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::access_mode::AccessMode;
259use crate::address::{BaseAddress, StoredAddress};
260use crate::attribute_info::AttributeInfoMessage;
261use crate::checksum::jenkins_lookup3;
262use crate::chunk_index_inplace::{Located, Store, apply_ea_append, plan_ea_append};
263use crate::chunked_read::{
264    chunk_index_spans_from_source, enumerate_chunks_from_source, plan_dense_grid,
265};
266use crate::chunked_write::{
267    ChunkMeta, ChunkOptions, ChunkProvider, StorageAllocation, WrittenChunk, assemble_chunked_at,
268    build_extensible_array_at, chunked_data_len, compress_chunks, emit_chunked_data_verbatim,
269    extensible_array_len, full_chunk_bytes, plan_chunked_data_verbatim,
270    serialize_v4_extensible_array, split_into_chunks,
271};
272use crate::convert::Narrow;
273use crate::data_layout::{ChunkIndexLayout, DataLayout};
274use crate::dataspace::{Dataspace, DataspaceType, Extent, MaxExtent};
275use crate::datatype::{
276    Datatype, datatype_holds_file_address, datatype_holds_object_address, embedded_reference_slots,
277    stored_object_references,
278};
279use crate::error::{Error, FormatError, OBJECT_HEADER_MESSAGE_MAX};
280use crate::extensible_array::ExtensibleArrayHeader;
281use crate::file_create_properties::FileCreateProperties;
282use crate::file_lock::{self, FileLocking};
283use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy, NUM_FILE_FSM_MANAGERS};
284use crate::file_writer::{
285    DenseAttrCreationOrder, LENGTH_SIZE, OFFSET_SIZE, build_chunked_dataset_oh, build_dataset_oh,
286    make_link,
287};
288use crate::filter_pipeline::{
289    FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZF, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
290    FilterPipeline,
291};
292use crate::filters::{ChunkContext, FilterScratch, compress_chunk_with, decompress_chunk};
293use crate::free_space::{FreeList, trailing_run_start};
294use crate::free_space_manager::{
295    self, FreeSection, FsmHeader, PageType, PagedManagerPlan, SECT_CLASS_SIMPLE, align_up,
296    file_fsm_blocks_len, fshd_len, plan_paged_managers, serialize_file_fsm,
297};
298use crate::group_v2::resolve_group_entries_from_source;
299use crate::image::{FileImage, HandleImage, MirrorImage, WriteBuffering};
300use crate::libver::LibVer;
301use crate::link_info::LinkInfoMessage;
302use crate::link_message::{LinkMessage, LinkTarget};
303use crate::message_flags::MessageFlags;
304use crate::message_type::MessageType;
305use crate::object_header::ObjectHeader;
306use crate::object_path::{LinkNameBuf, ObjectPathBuf};
307use crate::reader::FileAccessProperties;
308use crate::shared_message::DatatypeLocation;
309use crate::source::{BaseOffsetSource, BytesSource, MetadataCacheConfig, Source};
310use crate::superblock::Superblock;
311use crate::type_builders::{
312    AttrValue, DatasetBuilder, ObjectRefPatch, ObjectRefTarget, VlStringStaging,
313    build_attr_message, build_global_heap_collections, make_f32_type, make_f64_type, make_i8_type,
314    make_i16_type, make_i32_type, make_i64_type, make_u8_type, make_u16_type, make_u32_type,
315    make_u64_type, patch_vl_refs, patch_vl_refs_masked, write_reference_address,
316};
317use crate::width::UintWidth;
318use crate::{DatatypeByteOrder, signature};
319
320/// An undefined on-disk address (all bits set), HDF5's "no address" sentinel.
321const UNDEF: u64 = u64::MAX;
322
323/// The most free space an immediate in-place append takes out of a persisting
324/// file's on-disk managers in one draw, when the allocation in front of it needs
325/// less (issue #387).
326///
327/// Every draw costs a manager rewrite and a superblock repoint — a few kilobytes
328/// and two ordering barriers — so drawing per allocation would put a commit
329/// between every pair of chunks and make reuse cost more than the growth it
330/// avoids. A draw therefore takes as much as this of whatever holes can each hold
331/// the allocation, largest first, and a megabyte covers sixteen chunks at the
332/// 64 KiB scale the C library's own chunk-cache default is sized for, which puts
333/// the rewrite well outside the per-chunk path.
334///
335/// It is a cap, not a floor. A file whose holes are all smaller than this still
336/// has them drawn, one rewrite for however many of them fit under the cap: a
337/// floor here made every persisting file whose deleted objects were under a
338/// megabyte grow without bound, since no hole it left was ever spent
339/// (issue #413). What a draw refuses is only a hole too small for the allocation
340/// in hand, which nothing could be placed in. Every hole exists because a commit
341/// freed it, so the rewrites spent reusing them are bounded by the commits the
342/// caller already paid for.
343///
344/// It bounds one draw, not a session's total. A fresh draw is made whenever the
345/// largest reserved region is smaller than the allocation in hand, and the
346/// remainders of earlier draws are kept and still handed out, so what an abrupt
347/// end strands is the sum of every remainder still held: each one smaller than
348/// the allocation that forced the next draw, and one more of them possible per
349/// draw. Larger would amortize further and strand more; this is the point where
350/// the rewrite is already rare enough that making it rarer buys little.
351const APPEND_RESERVE_BYTES: u64 = 1 << 20;
352
353/// The refusal both address-side reference screens report: an object reference
354/// this commit writes names an object the same commit removes.
355///
356/// One constant because the screens differ only in where the address came from
357/// — [`WriteEngine::resolve_reference_target`] takes it from a builder's
358/// [`ObjectRefTarget::Raw`], [`screen_resolved_references`] reads it out of a
359/// dataset's or an attribute's element bytes — and a caller must not be able to
360/// tell them apart by the message. It is the address-side twin of the by-name refusal
361/// `resolve_reference_target` reports for a path, and shares its second clause.
362const REFERENCE_INTO_RECLAIMED_SPACE: &str = "a reference this commit writes holds the address of an object this commit deletes, or of \
363     one under it; the reference would be left pointing at storage the delete can reclaim";
364
365/// The refusal for the other half of [`InvalidatedAddresses`]: the object is
366/// still there, but not at that address any more.
367///
368/// It can offer a way out, which the removal refusal cannot: the target still
369/// exists, so a path names it. Whether the path *works* depends on which half of
370/// [`InvalidatedAddresses::moved`] the target came from, which is why the message
371/// names separate commits as well rather than promising the first.
372/// [`WriteEngine::resolve_reference_target`] resolves a **dirty group** once this
373/// commit has placed it, and reports "still writing" until then; a **relocating
374/// dataset write** it refuses outright, by exact match against `write_targets`,
375/// with no later point at which that changes.
376const REFERENCE_TO_A_MOVED_OBJECT: &str = "a reference this commit writes holds the pre-commit address of an object this commit \
377     rewrites elsewhere; name the target by path (`with_path_references`) so it resolves to \
378     where the object lands, or use separate commits";
379
380/// A shared (SOHM) attribute message is held in the file's shared-message table
381/// rather than in the object whose attribute it is, so rewriting the object's
382/// attributes would have to account for the table as well. Both attribute paths
383/// refuse it by the same name: the compact one in [`parse_compact_attr_name`],
384/// which walks the messages it is about to copy, and the dense one in
385/// [`plan_attr_ops`] — where the set is read through
386/// [`crate::attribute::extract_attributes_full_from_source`], which *resolves*
387/// such a message and would otherwise re-emit it into the new heap as a private
388/// one, leaving the table's reference count naming an attribute that no longer
389/// exists.
390///
391/// Since issue #417 the reader follows such a reference, so these are live
392/// refusals rather than backstops: what they hold back is the *table*, whose
393/// reference count this engine cannot yet decrement, not the read. `repack`
394/// rewrites the file with every shared message inline, which is the way through
395/// today.
396const SHARED_ATTRIBUTE_MESSAGE: &str =
397    "a target object has a shared attribute message (not editable in place yet)";
398
399/// A shared-message (SOHM) index record does not always name a heap entry: where
400/// the reference C library shares a message written into an object header it
401/// already holds open, the record names *that header* and the message stays
402/// where it is. Such a record is a stored address like any other, and it is the
403/// one no rewrite reaches — the index lives outside every object a commit
404/// rebuilds, and this engine writes no shared-message indexes. A commit that
405/// removed or moved the named header would leave the file's own index pointing
406/// at bytes that are no longer there, which the next library to share a message
407/// would follow.
408const SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT: &str = "this file's shared-message (SOHM) index names an object header this commit \
409     removes or rewrites elsewhere, and the index cannot be updated in place; \
410     rewrite the file with `repack` instead";
411
412/// Refusal for an attribute whose references a commit repoints today and could
413/// not repoint from a heap. See [`plan_attr_ops`].
414const REFERENCE_ATTRIBUTE_WOULD_LEAVE_THE_HEADER: &str = "an attribute holding an object reference cannot be moved to dense \
415     (fractal-heap) storage, where a later commit could no longer repoint it when \
416     its target moves; keep this object within compact attribute storage";
417
418/// Maximum number of attributes an object header keeps inline; past this, HDF5
419/// switches the object to dense (fractal-heap) attribute storage, which this
420/// engine emits through [`plan_attr_ops`] and [`WriteEngine::place_dense_attrs`].
421///
422/// Taken from the whole-file writer's threshold rather than restated beside it:
423/// the two decide the same thing about the same attribute set, and a file whose
424/// objects were written by one and edited by the other must not disagree about
425/// where the eighth attribute lives.
426const MAX_COMPACT_ATTRS: usize = crate::file_writer::DENSE_ATTR_THRESHOLD;
427
428/// Recursion-depth cap for object copy, guarding against a stack overflow on a
429/// pathological or cyclic hard-link graph (HDF5 hard links can form cycles).
430/// Far deeper than any real group hierarchy.
431const MAX_COPY_DEPTH: u32 = 1000;
432
433/// Upper bound on the number of object headers walked when counting hard links
434/// across the file (issue #77 / reclaim safety). Far beyond any real file; a
435/// graph larger than this aborts the count, and the commit then leaves deleted
436/// objects unreclaimed (a safe leak) rather than risk an unbounded walk.
437const MAX_LINK_GRAPH_NODES: u32 = 1 << 24;
438
439/// Maximum number of object-header chunks to follow when gathering a header that
440/// spans continuation blocks, guarding against a cyclic continuation chain.
441/// Matches the reader's continuation-depth cap.
442const MAX_OH_CHUNKS: usize = 256;
443
444/// Maximum length of a version 2 object header's fixed prefix: signature (4) +
445/// version (1) + flags (1) + optional access/modification/change/birth times
446/// (16) + optional attribute phase-change thresholds (4) + the chunk-0 size
447/// field (up to 8). Reading this many bytes always covers the prefix, so
448/// [`oh_region_at`] can be handed one bounded window instead of a whole-file
449/// image.
450const OH_PREFIX_MAX: usize = 34;
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<ObjectPathBuf>,
466}
467
468impl AppenderClaim {
469    /// Returns `true` if this claim covers the dataset `path` identifies, or if either side has
470    /// no path.
471    fn conflicts_with(&self, path: Option<&ObjectPathBuf>) -> bool {
472        match (&self.path, path) {
473            (Some(claimed), Some(candidate)) => claimed == candidate,
474            _ => true,
475        }
476    }
477}
478
479/// A variable-length group/root attribute staged by [`apply_compact_attr_ops`]
480/// and resolved in the apply loop.
481#[derive(Clone)]
482struct PendingVlAttr {
483    /// The attribute message, its global-heap references still placeholders.
484    msg: crate::attribute::AttributeMessage,
485    /// The collections whose real addresses those references need.
486    collections: Vec<Vec<u8>>,
487    /// The creation index this attribute keeps, taken from the message it
488    /// replaces on a header that tracks attribute creation order. `None` asks
489    /// for the object's next unused index when the message is appended — which
490    /// is every case on a header that does not track the order.
491    creation_index: Option<u16>,
492}
493
494/// Variable-length group/root attributes staged by [`apply_compact_attr_ops`].
495type PendingVlAttrs = Vec<PendingVlAttr>;
496
497/// Accumulates elements to append to an existing chunked, unlimited dataset via
498/// [`Dataset::append_staged`](crate::Dataset::append_staged), in call order along the dataset's first
499/// (axis-0) dimension.
500///
501/// It mirrors [`DatasetBuilder`]'s typed/generic vocabulary. Repeated typed or
502/// [`append_raw`](Self::append_raw) calls concatenate; each typed method also
503/// records the element datatype it implies, which `commit` checks against the
504/// dataset's on-disk datatype (a mismatch — including a mix of element types in
505/// one builder — is refused with [`Error::AppendUnsupported`], never written as
506/// garbage).
507pub struct AppendBuilder {
508    /// Accumulated little-endian element bytes to append, in call order.
509    raw: Vec<u8>,
510    /// The element datatype implied by the typed `append_*` calls, if any were
511    /// used. `None` when only [`append_raw`](Self::append_raw) was called (a raw
512    /// append is checked structurally — element-size alignment and little-endian
513    /// on-disk order — rather than by datatype equality).
514    elem_dt: Option<Datatype>,
515    /// Set when two typed calls implied different element datatypes; `commit`
516    /// refuses such a builder rather than write a mix of encodings.
517    dt_conflict: bool,
518}
519
520impl AppendBuilder {
521    pub(crate) fn new() -> Self {
522        Self {
523            raw: Vec::new(),
524            elem_dt: None,
525            dt_conflict: false,
526        }
527    }
528
529    /// Accumulated little-endian element bytes (for the general append writer,
530    /// which reuses this builder to gather typed/generic appends).
531    pub(crate) fn raw(&self) -> &[u8] {
532        &self.raw
533    }
534
535    /// The element datatype implied by typed appends, if any.
536    pub(crate) fn elem_dt(&self) -> Option<&Datatype> {
537        self.elem_dt.as_ref()
538    }
539
540    /// Whether two typed appends implied conflicting element datatypes.
541    pub(crate) fn dt_conflict(&self) -> bool {
542        self.dt_conflict
543    }
544
545    /// A builder holding a copy of this one's first `byte_len` bytes, carrying
546    /// the same element datatype so the prefix is type-checked exactly as the
547    /// whole would have been. Used by [`BufferedAppender`](crate::BufferedAppender)
548    /// to write out the chunk-aligned prefix of its buffer; it copies rather
549    /// than splits so a failed write leaves the buffer intact and the appender
550    /// can report precisely which elements did not land.
551    pub(crate) fn head(&self, byte_len: usize) -> Self {
552        Self {
553            raw: self.raw[..byte_len.min(self.raw.len())].to_vec(),
554            elem_dt: self.elem_dt.clone(),
555            dt_conflict: self.dt_conflict,
556        }
557    }
558
559    /// Discard the first `byte_len` buffered bytes (a prefix that reached the
560    /// file), keeping the element datatype.
561    pub(crate) fn drop_front(&mut self, byte_len: usize) {
562        self.raw.drain(..byte_len.min(self.raw.len()));
563    }
564
565    /// Consume the builder, yielding its accumulated element bytes. Used by
566    /// [`BufferedAppender::discard`](crate::BufferedAppender::discard) to hand
567    /// abandoned elements back to the caller.
568    pub(crate) fn into_raw(self) -> Vec<u8> {
569        self.raw
570    }
571
572    /// Cut the buffer back to `byte_len` bytes, keeping the element datatype.
573    /// [`BufferedAppender`](crate::BufferedAppender) uses this to undo a call
574    /// whose write was refused before it touched the file, so the refusal leaves
575    /// nothing buffered and a retry cannot append the same elements twice.
576    pub(crate) fn truncate(&mut self, byte_len: usize) {
577        self.raw.truncate(byte_len);
578    }
579
580    /// Record the datatype a typed append implies, flagging a conflict if an
581    /// earlier typed call implied a different one.
582    fn set_dt(&mut self, dt: Datatype) {
583        match &self.elem_dt {
584            Some(prev) if *prev != dt => self.dt_conflict = true,
585            Some(_) => {}
586            None => self.elem_dt = Some(dt),
587        }
588    }
589
590    /// Append already-little-endian element bytes verbatim. The concatenated
591    /// length must be a whole multiple of the dataset's on-disk element size, and
592    /// the dataset's element datatype must be little-endian; no datatype is
593    /// otherwise inferred. Prefer the typed methods when the element type is known.
594    pub fn append_raw(&mut self, bytes: &[u8]) -> &mut Self {
595        self.raw.extend_from_slice(bytes);
596        self
597    }
598
599    /// Generic append of a flat slice of any supported scalar type — the
600    /// counterpart of [`DatasetBuilder::with_data`](crate::DatasetBuilder::with_data).
601    pub fn append<T: crate::element::H5Element>(&mut self, data: &[T]) -> &mut Self {
602        T::append_into(self, data);
603        self
604    }
605}
606
607/// Generate the typed `append_*` methods: serialize each value little-endian and
608/// record the implied element datatype.
609macro_rules! append_typed {
610    ($($method:ident, $ty:ty, $make:ident;)*) => {
611        impl AppendBuilder {
612            $(
613                #[doc = concat!("Append `", stringify!($ty), "` values to the dataset.")]
614                pub fn $method(&mut self, data: &[$ty]) -> &mut Self {
615                    self.set_dt($make());
616                    // Reserved up front: serializing element by element into a
617                    // growing `Vec` re-allocated it ten times per call and copied
618                    // the batch twice over, which for an append loop is the whole
619                    // per-call cost (issue #228).
620                    self.raw.reserve(data.len() * core::mem::size_of::<$ty>());
621                    for &v in data {
622                        self.raw.extend_from_slice(&v.to_le_bytes());
623                    }
624                    self
625                }
626            )*
627        }
628    };
629}
630
631append_typed! {
632    append_f64, f64, make_f64_type;
633    append_f32, f32, make_f32_type;
634    append_i8, i8, make_i8_type;
635    append_i16, i16, make_i16_type;
636    append_i32, i32, make_i32_type;
637    append_i64, i64, make_i64_type;
638    append_u8, u8, make_u8_type;
639    append_u16, u16, make_u16_type;
640    append_u32, u32, make_u32_type;
641    append_u64, u64, make_u64_type;
642}
643
644/// Every edit a session has staged and not yet committed, as one value.
645///
646/// [`WriteEngine::commit`] takes the whole set out for the duration of an
647/// attempt and puts it back if that attempt refuses, so a refused commit costs
648/// the session no staged work — the same guarantee [`FreeSnapshot`] gives the
649/// free lists, for the same reason (issue #316). Keeping the vectors together
650/// rather than beside the engine's other fields is what makes that total: a
651/// staged kind added later participates by construction, where a tenth field
652/// would have to be remembered in a hand-written restore.
653#[derive(Default)]
654struct StagedEdits {
655    /// Datasets staged by `create_dataset`, as (parent group path, dataset).
656    ///
657    /// Flattened at staging rather than at commit: [`flatten_dataset`] is the
658    /// one step of the commit's preflight that *consumes* what it validates, so
659    /// running it here is what lets the preflight read the staged set without
660    /// destroying it (issue #316). It is a pure function of the builder, so the
661    /// guards it raises — a missing shape, data that does not match it, a
662    /// feature this engine cannot reproduce — are answered at the call that
663    /// stages the dataset, where the caller still has the context to fix them.
664    datasets: Vec<(ObjectPathBuf, FlatDataset)>,
665    /// Value overwrites staged by `write_dataset`, as (full dataset path,
666    /// dataset). Each replaces an existing dataset's values in place; the new
667    /// datatype and shape must match the on-disk ones byte-exactly (this is a
668    /// value overwrite, not a reshape/retype). Applied on the next `commit`.
669    /// Flattened at staging, for the reason given on [`datasets`](Self::datasets);
670    /// the match against the on-disk dataset is a commit-time check and stays
671    /// one, since it reads the file.
672    writes: Vec<(ObjectPathBuf, FlatDataset)>,
673    /// Appends staged by `append_dataset`, as (full dataset path, builder). Each
674    /// grows an existing chunked, unlimited, Extensible-Array-indexed dataset
675    /// along axis 0 by keeping its existing chunk data in place and rebuilding the
676    /// index over the kept plus newly-appended (and any rewritten trailing) chunks.
677    /// Applied on the next `commit`.
678    appends: Vec<(ObjectPathBuf, AppendBuilder)>,
679    /// New groups staged by `create_group`, as full paths.
680    groups: Vec<ObjectPathBuf>,
681    /// Group attribute edits staged as (group path, operation). The path may be
682    /// a group created in this same session.
683    group_attrs: Vec<(ObjectPathBuf, AttrOp)>,
684    /// Dataset attribute edits staged as (full dataset path, operation), applied
685    /// on the next `commit`. Each relocates the dataset's object header (like a
686    /// relocating overwrite): the header is rebuilt with the compact-attribute
687    /// change, its single naming link is patched, and the old header freed — the
688    /// dataset's data and chunk index stay in place. The target must be an existing,
689    /// single-hard-link dataset using compact (not dense fractal-heap) attributes.
690    dataset_attrs: Vec<(ObjectPathBuf, AttrOp)>,
691    /// Links staged for removal by `delete`, as full paths.
692    deletes: Vec<ObjectPathBuf>,
693    /// Object copies staged by `copy`, as (source path, destination full path).
694    copies: Vec<(ObjectPathBuf, ObjectPathBuf)>,
695    /// Cross-file object copies staged by `copy_from`, as (destination full path,
696    /// the source subtree already read out of the other file). The subtree is read
697    /// — and foreign-address-screened — eagerly in `copy_from` (the source file is
698    /// borrowed only for that call), then linked in at the next `commit`.
699    cross_copies: Vec<(ObjectPathBuf, CopyTree)>,
700    /// Where the creation at each full path sits in [`datasets`](Self::datasets)
701    /// / [`groups`](Self::groups), so a by-name question about the staged set
702    /// costs a hash rather than a scan of everything staged.
703    ///
704    /// The scan is what made a writer that stages one dataset per column and
705    /// then binds a handle to each quadratic in the number of columns (issue
706    /// #392). Both maps are derived from the vectors beside them and are kept in
707    /// step at the three places those change: the push that stages a creation,
708    /// the truncation [`rewind`](Self::rewind) makes, and the rebuild
709    /// [`withdraw_at`](Self::withdraw_at) makes after removing from the middle.
710    /// A path staged twice keeps its *first* position, which is the entry a scan
711    /// would have found; the commit refuses such a pair anyway.
712    dataset_at: HashMap<ObjectPathBuf, usize>,
713    group_at: HashMap<ObjectPathBuf, usize>,
714}
715
716/// What a session has staged at a path, for a handle addressing an object the
717/// commit has not written yet.
718#[derive(Clone, Copy, PartialEq, Eq, Debug)]
719pub(crate) enum StagedKind {
720    Group,
721    Dataset,
722}
723
724/// A creation this session has staged at a path, and whether it takes that path
725/// over from the file.
726#[derive(Clone, Copy, PartialEq, Eq, Debug)]
727pub(crate) struct StagedObject {
728    pub(crate) kind: StagedKind,
729    /// Whether this same commit removes the link the file holds at that path.
730    ///
731    /// True makes the creation a *replacement* (issue #305). False means the
732    /// file holds no link there at all — a creation colliding with one that
733    /// survives is refused where it is staged, and would not be reported here
734    /// either, since the file's own object is what the name means.
735    pub(crate) replaces_link: bool,
736}
737
738/// One direct child a group gains from this session's staged creations.
739pub(crate) struct StagedChild {
740    pub(crate) name: String,
741    pub(crate) kind: StagedKind,
742    /// Whether a staged deletion removes the link this name has in the file, so
743    /// the creation supersedes it rather than colliding with it. See
744    /// [`StagedObject::replaces_link`].
745    pub(crate) replaces_link: bool,
746}
747
748/// What a staged dataset can say about itself before it is written.
749///
750/// Everything here is settled when the dataset is staged — [`flatten_dataset`]
751/// has already validated the builder — so a handle answers from it without
752/// reading the file, and the answers are what the commit goes on to write.
753pub(crate) struct StagedMeta {
754    pub(crate) datatype: Datatype,
755    pub(crate) dimensions: Vec<u64>,
756    /// `None` for a fixed-shape dataset, matching `Dataset::maxshape`.
757    pub(crate) maxshape: Option<Vec<MaxExtent>>,
758    pub(crate) chunked: bool,
759    /// Each staged filter's registered id and its `H5Z_FLAG_OPTIONAL` flag, in
760    /// pipeline order.
761    pub(crate) filters: Vec<(u16, bool)>,
762}
763
764/// Where a [`StagedEdits`] stood before a batch of staging calls, so a batch
765/// that fails partway can be undone (see [`StagedEdits::rewind`]).
766#[derive(Clone, Copy, Default, PartialEq, Eq)]
767struct StagedMark {
768    datasets: usize,
769    writes: usize,
770    appends: usize,
771    groups: usize,
772    group_attrs: usize,
773    dataset_attrs: usize,
774    deletes: usize,
775    copies: usize,
776    cross_copies: usize,
777}
778
779impl StagedEdits {
780    /// The length of every staged vector right now, for
781    /// [`rewind`](Self::rewind).
782    ///
783    /// Destructured rather than read field by field, here and in
784    /// [`rewind`](Self::rewind), so that the "a staged kind added later
785    /// participates by construction" claim on [`StagedEdits`] is enforced rather
786    /// than hoped for: a struct pattern naming fewer fields than the struct has
787    /// does not compile, and the unused binding a tenth kind would leave in
788    /// `rewind` is an error under the crate's `-D warnings`.
789    fn mark(&self) -> StagedMark {
790        let Self {
791            datasets,
792            writes,
793            appends,
794            groups,
795            group_attrs,
796            dataset_attrs,
797            deletes,
798            copies,
799            cross_copies,
800            // Derived from `datasets` and `groups`, and restored with them.
801            dataset_at: _,
802            group_at: _,
803        } = self;
804        StagedMark {
805            datasets: datasets.len(),
806            writes: writes.len(),
807            appends: appends.len(),
808            groups: groups.len(),
809            group_attrs: group_attrs.len(),
810            dataset_attrs: dataset_attrs.len(),
811            deletes: deletes.len(),
812            copies: copies.len(),
813            cross_copies: cross_copies.len(),
814        }
815    }
816
817    /// Drop everything staged since `mark`.
818    ///
819    /// Staging inside a batch only ever appends — every `stage_*` entry point
820    /// validates and then pushes — so truncating to the recorded lengths is an
821    /// exact undo of the calls made in between, and leaves anything staged
822    /// before them alone. The two operations that instead *change* what is
823    /// already staged — the fold
824    /// [`WriteEngine::stage_dataset_append_pending`] makes into a staged
825    /// creation, and the withdrawal [`withdraw_at`](Self::withdraw_at) makes for
826    /// [`WriteEngine::delete`] — would leave that untrue, so
827    /// [`WriteEngine::refuse_mid_batch`] refuses them while a batch is open
828    /// rather than leaving the claim to be hoped for.
829    ///
830    /// The by-path index is truncated with the vectors it describes, so a
831    /// rewound creation stops answering as well as stops existing.
832    fn rewind(&mut self, mark: StagedMark) {
833        for i in mark.datasets..self.datasets.len() {
834            let key = {
835                let (parent, fd) = &self.datasets[i];
836                parent.join(&fd.name)
837            };
838            // Only where it still points at the entry being dropped: a second
839            // creation at one path never displaced the first.
840            if self.dataset_at.get(&key) == Some(&i) {
841                self.dataset_at.remove(&key);
842            }
843        }
844        for i in mark.groups..self.groups.len() {
845            let key = self.groups[i].clone();
846            if self.group_at.get(&key) == Some(&i) {
847                self.group_at.remove(&key);
848            }
849        }
850        let StagedMark {
851            datasets,
852            writes,
853            appends,
854            groups,
855            group_attrs,
856            dataset_attrs,
857            deletes,
858            copies,
859            cross_copies,
860        } = mark;
861        self.datasets.truncate(datasets);
862        self.writes.truncate(writes);
863        self.appends.truncate(appends);
864        self.groups.truncate(groups);
865        self.group_attrs.truncate(group_attrs);
866        self.dataset_attrs.truncate(dataset_attrs);
867        self.deletes.truncate(deletes);
868        self.copies.truncate(copies);
869        self.cross_copies.truncate(cross_copies);
870    }
871
872    /// Stage a dataset creation, indexing it by its full path.
873    fn push_dataset(&mut self, parent: ObjectPathBuf, fd: FlatDataset) {
874        self.dataset_at
875            .entry(parent.join(&fd.name))
876            .or_insert(self.datasets.len());
877        self.datasets.push((parent, fd));
878    }
879
880    /// Stage a group creation, indexing it by its path.
881    fn push_group(&mut self, path: ObjectPathBuf) {
882        self.group_at
883            .entry(path.clone())
884            .or_insert(self.groups.len());
885        self.groups.push(path);
886    }
887
888    /// Where the staged dataset at `path` sits, if there is one.
889    ///
890    /// The position the index gives is checked against the entry it names
891    /// rather than trusted: an index that fell behind the vector then reads as a
892    /// miss — the answer a scan would give — instead of as another dataset.
893    fn dataset_position(&self, path: &ObjectPathBuf) -> Option<usize> {
894        let i = *self.dataset_at.get(path)?;
895        let (parent, fd) = self.datasets.get(i)?;
896        path.covers_child(parent, &fd.name).then_some(i)
897    }
898
899    /// The staged dataset at `path`, if there is one.
900    fn dataset_at(&self, path: &ObjectPathBuf) -> Option<&FlatDataset> {
901        let i = self.dataset_position(path)?;
902        self.datasets.get(i).map(|(_, fd)| fd)
903    }
904
905    /// The staged dataset at `path`, mutably.
906    fn dataset_at_mut(&mut self, path: &ObjectPathBuf) -> Option<&mut FlatDataset> {
907        let i = self.dataset_position(path)?;
908        self.datasets.get_mut(i).map(|(_, fd)| fd)
909    }
910
911    /// Whether a group creation is staged at `path`. Checked against the entry
912    /// the index names, for the reason [`dataset_position`](Self::dataset_position)
913    /// gives.
914    fn has_group_at(&self, path: &ObjectPathBuf) -> bool {
915        match self.group_at.get(path) {
916            Some(&i) => self.groups.get(i).is_some_and(|p| p == path),
917            None => false,
918        }
919    }
920
921    /// Whether a staged deletion removes the link at `path`, or the link to an
922    /// ancestor that carries it away.
923    fn deletes_cover(&self, path: &ObjectPathBuf) -> bool {
924        self.deletes.iter().any(|d| path.starts_with(d))
925    }
926
927    /// Whether a staged deletion hands `path` over to a creation staged there —
928    /// which is what makes the pair a *replacement* rather than a collision
929    /// (issue #305).
930    ///
931    /// Stricter than [`deletes_cover`](Self::deletes_cover), and deliberately:
932    /// a deletion of an *ancestor* only carries the name over when this same
933    /// session builds that ancestor again, which is exactly what the commit
934    /// requires of everything at or under a replaced path. Deleting `g` and
935    /// creating `g/x` without recreating `g` is a batch the commit refuses, so
936    /// the file's own `g/x` is still what that name means — reporting it as a
937    /// replacement would make a live, readable dataset answer
938    /// [`Error::NotCommitted`] and vanish from its group's listing until the
939    /// refusal.
940    ///
941    /// The exact deletion is the empty case of the same rule: with `d == path`
942    /// there is no ancestor between them to have been recreated.
943    fn deletes_hand_over(&self, path: &ObjectPathBuf) -> bool {
944        self.deletes.iter().any(|d| {
945            path.starts_with(d) && (d.len()..path.len()).all(|n| self.has_group_at(&path.prefix(n)))
946        })
947    }
948
949    /// Withdraw every edit staged at or under `path`, as though the calls that
950    /// staged them had never been made, and report whether a *creation* was
951    /// among them.
952    ///
953    /// This is what lets [`WriteEngine::delete`] cancel an object this session
954    /// staged and has not written: there is nothing in the file to unlink, so a
955    /// deletion of it is a withdrawal. Deletions are deliberately left alone —
956    /// one under a withdrawn creation still names a link the file holds, and two
957    /// overlapping deletions are the commit's refusal to make rather than this
958    /// call's.
959    ///
960    /// Destructured for the reason [`mark`](Self::mark) is: a staged kind added
961    /// later has to be classified here rather than silently survive a
962    /// withdrawal.
963    fn withdraw_at(&mut self, path: &ObjectPathBuf) -> bool {
964        let before = {
965            let Self {
966                datasets,
967                writes,
968                appends,
969                groups,
970                group_attrs,
971                dataset_attrs,
972                deletes,
973                copies,
974                cross_copies,
975                dataset_at: _,
976                group_at: _,
977            } = self;
978            let before = datasets.len() + groups.len();
979            datasets.retain(|(parent, fd)| !path.covers_child(parent, &fd.name));
980            writes.retain(|(p, _)| !p.starts_with(path));
981            appends.retain(|(p, _)| !p.starts_with(path));
982            groups.retain(|p| !p.starts_with(path));
983            group_attrs.retain(|(p, _)| !p.starts_with(path));
984            dataset_attrs.retain(|(p, _)| !p.starts_with(path));
985            copies.retain(|(_, dst)| !dst.starts_with(path));
986            cross_copies.retain(|(dst, _)| !dst.starts_with(path));
987            let _ = &deletes;
988            before
989        };
990        let withdrew = self.datasets.len() + self.groups.len() < before;
991        if withdrew {
992            self.reindex();
993        }
994        withdrew
995    }
996
997    /// Rebuild the by-path index from the vectors, after a removal from the
998    /// middle shifted every position after it.
999    fn reindex(&mut self) {
1000        self.dataset_at.clear();
1001        self.group_at.clear();
1002        for (i, (parent, fd)) in self.datasets.iter().enumerate() {
1003            self.dataset_at.entry(parent.join(&fd.name)).or_insert(i);
1004        }
1005        for (i, path) in self.groups.iter().enumerate() {
1006            self.group_at.entry(path.clone()).or_insert(i);
1007        }
1008    }
1009
1010    /// Whether nothing at all is staged.
1011    ///
1012    /// Asked as "is the mark the zero mark", which is exact — nothing is staged
1013    /// exactly when every vector has length zero — and leaves [`mark`](Self::mark)
1014    /// as the single place that names every vector. The two callers,
1015    /// [`WriteEngine::has_staged_edits`] and the commit's own no-op return, then
1016    /// cannot come to disagree, and a staged kind added later reaches both. (The
1017    /// commit's *fast path* asks a narrower question and spells its own subset
1018    /// out.)
1019    fn is_empty(&self) -> bool {
1020        self.mark() == StagedMark::default()
1021    }
1022}
1023
1024/// The in-place write engine behind the owned read-write [`File`](crate::File)
1025/// (its `Backend::Edit`).
1026///
1027/// Reads and edits the file through a [`FileImage`], which owns the writable
1028/// handle and decides how much of the file is resident. It carries two commit
1029/// models: staged tree edits applied by [`commit`](Self::commit), and immediate
1030/// crash-atomic in-place appends ([`append_inplace_gathered`](Self::append_inplace_gathered)).
1031pub(crate) struct WriteEngine {
1032    /// The file bytes this session reads and edits, behind the [`FileImage`]
1033    /// abstraction: reads go through its [`Source`] impl, and the write side —
1034    /// the end-of-file cursor, `append`, `write_at`, `truncate`, and the
1035    /// durability barriers — through its own primitives.
1036    ///
1037    /// Nothing in the engine assumes the whole file is resident, so one engine
1038    /// serves both a whole-file mirror and a file-backed image that holds only
1039    /// what it is reading (issue #198). [`image_slice`](Self::image_slice)
1040    /// exposes the mirror's buffer where a caller can exploit it.
1041    image: Box<dyn FileImage>,
1042    /// Absolute offset of the superblock signature in the file.
1043    sb_sig_off: u64,
1044    /// Parsed superblock. On-disk addresses are stored relative to `base_address`;
1045    /// the in-memory `root_group_address` is normalized to an absolute file offset
1046    /// on open and converted back to a base-relative address when serialized on
1047    /// commit. `base_address` equals the superblock's file location (`sb_sig_off`):
1048    /// 0 for a plain file, the userblock size for one with a userblock.
1049    superblock: Superblock,
1050    /// Every edit this session has staged and not yet applied. Held as one
1051    /// value so that [`commit`](WriteEngine::commit) can take the whole set out
1052    /// for the duration of an attempt and put it back when that attempt
1053    /// refuses; see [`StagedEdits`].
1054    staged: StagedEdits,
1055    /// Datasets with a live [`BufferedAppender`](crate::BufferedAppender), which
1056    /// holds accepted elements only it can write. A staged edit that would stop
1057    /// that appender from flushing is refused while the claim stands, rather
1058    /// than left to fail in the appender's `Drop`, where there is no caller to
1059    /// report it to and the buffer is simply lost. See `refuse_if_claimed`.
1060    appender_claims: Vec<AppenderClaim>,
1061    /// Monotonic id for the next claim, so releasing one is exact even when two
1062    /// appenders on different datasets are live at once.
1063    next_appender_token: u64,
1064    /// Session-local free-space tracker (issue #21). Holds regions vacated by
1065    /// prior commits in this session — superseded object headers and the blocks
1066    /// of deleted objects — so later commits reuse them instead of growing the
1067    /// file, and so a freed run reaching end-of-file can be truncated away. It
1068    /// starts empty on `open` for a non-persisting file: holes already present
1069    /// from earlier sessions or other tools are not tracked. When the file
1070    /// persists its free space (`persist` is `Some`), `open` instead seeds it
1071    /// from the on-disk free-space managers, so reuse spans sessions.
1072    free: FreeList,
1073    /// Space this session has taken *out* of the on-disk free-space managers so
1074    /// that an immediate in-place append may spend it (issue #387).
1075    ///
1076    /// Only a file that persists its free space uses this. Such a file records
1077    /// its holes on disk, and an append has no superblock repoint of its own to
1078    /// publish an allocation with — so an append that simply drew from
1079    /// [`free`](Self::free) would leave a manager advertising bytes a live chunk
1080    /// now occupies, through a clean close as much as a crash. The reserve is
1081    /// the answer: [`reserve_for_immediate_append`](Self::reserve_for_immediate_append)
1082    /// moves a batch of bytes out of `free` (or, on a paged file, out of
1083    /// [`PagedEdit`]'s raw list) and rewrites the managers *without* them, under
1084    /// the same crash-atomic superblock repoint every persisting commit uses.
1085    /// Only then may an append write there, because by then no durable record
1086    /// calls those bytes free.
1087    ///
1088    /// What is left unspent goes back — into `free` or `PagedEdit` — before the
1089    /// managers are next rewritten, which is [`release_reserve`](Self::release_reserve)
1090    /// at the next commit and at close. A session that dies in between strands
1091    /// the unspent remainder: it is accounted to nobody, so it is never handed
1092    /// out twice, and a later `H5repack` or whole-file rewrite recovers it. That
1093    /// is the same trade the module header records for
1094    /// [`finalize_persist`](Self::finalize_persist), on a surface bounded per
1095    /// draw — see [`APPEND_RESERVE_BYTES`], which is the most one draw takes,
1096    /// and note that this list holds the unspent remainder of every draw the
1097    /// session has made, not just the last.
1098    ///
1099    /// Empty for a non-persisting session, which reuses out of `free` directly:
1100    /// its list is in memory alone, so nothing on the disk claims those bytes
1101    /// are free and nothing has to be published before they are spent.
1102    reserved: FreeList,
1103    /// Whether this file has been *proved* to hold no object reference at all —
1104    /// the one thing that lets a commit skip the walk that repoints them
1105    /// (issue #324).
1106    ///
1107    /// Set by a walk that reaches every object and finds no reference-holding
1108    /// datatype; every later commit in this session then skips the walk
1109    /// outright, which matters because the walk is linear in the file's object
1110    /// count and the file that needs it is the exception.
1111    ///
1112    /// One bit rather than three states, because "not yet walked" and "walked
1113    /// and found one" are the same instruction to the caller: walk. Only the
1114    /// proof is worth carrying.
1115    ///
1116    /// Cleared by any commit that could introduce a reference the walk has not
1117    /// seen. Exactly one of [`StagedEdits`]' nine collections can: `datasets`,
1118    /// which carries a datatype and attributes the caller chose. The check
1119    /// covers four, and the other three are belt-and-braces rather than
1120    /// load-bearing — recorded here because a reason that does not hold is worse
1121    /// than no reason:
1122    ///
1123    /// - `writes` is a *value* overwrite, whose datatype must match the on-disk
1124    ///   dataset's exactly. A reference-typed one therefore overwrites a dataset
1125    ///   that already held references, which a file proved free of them does not
1126    ///   have. Checked anyway, because that match is a commit-time check running
1127    ///   after this one, so `fd.dt` here is still whatever the caller supplied.
1128    /// - `copies` re-emits an object *from this same file* — one the walk that
1129    ///   granted the proof had already visited and found reference-free.
1130    /// - `cross_copies` reads from another file, where `reject_foreign_addresses`
1131    ///   refuses a reference datatype outright.
1132    ///
1133    /// The remaining five cannot, and these reasons do hold:
1134    ///
1135    /// - `groups` stages a bare path: no datatype, no data, no attributes.
1136    /// - `group_attrs` and `dataset_attrs` carry an [`AttrValue`], which has no
1137    ///   reference variant — the reason a reference-typed attribute can only
1138    ///   have come from the reference C library.
1139    /// - `appends` grows an *existing* dataset along its own datatype, so it can
1140    ///   only add references to a dataset that already held them.
1141    /// - `deletes` removes objects and adds nothing.
1142    ///
1143    /// The commit fast path returns before the check and needs no reason of its
1144    /// own: it is taken only when every staged edit is a same-length in-place
1145    /// overwrite, which is the `writes` case above.
1146    ///
1147    /// A tenth collection would have to be classified the same way; the check
1148    /// sits beside the reference screen in `commit`, which iterates the same
1149    /// datatype-carrying collections.
1150    proved_free_of_references: bool,
1151    /// Free-space persistence read from the file's superblock extension on
1152    /// `open` (the file-creation `H5Pset_file_space_strategy(persist = true)`
1153    /// setting). `None` for the default non-persisting file; when `Some`, every
1154    /// [`commit`](Self::commit) rewrites the on-disk free-space managers so the
1155    /// free list survives close/reopen.
1156    persist: Option<PersistState>,
1157    /// Per-dataset geometry cache for the immediate O(1) in-place append
1158    /// ([`append_inplace_gathered`](Self::append_inplace_gathered)), keyed by the dataset's resolved
1159    /// **object-header address** (not its path, so two hard links to one dataset
1160    /// share one entry). Populated on the first append to a dataset and maintained
1161    /// across appends; cleared wholesale at the entry of every non-trivial
1162    /// [`commit`](Self::commit), since a commit can relocate a cached header or
1163    /// free the region it points into (see `commit`).
1164    located: HashMap<u64, LocatedState>,
1165    /// Global heap collections **this session placed** for a variable-length
1166    /// value overwrite, by the overwritten dataset's path, as absolute
1167    /// `(address, length)` extents.
1168    ///
1169    /// This is what lets the *next* overwrite of the same dataset reclaim the
1170    /// strings the previous one left, instead of growing the file by its whole
1171    /// payload on every commit forever (issue #321). Nothing else in the editor
1172    /// reclaims a collection, and the reason is sound: a collection can be
1173    /// shared between objects, and **nothing in the format proves otherwise**.
1174    /// The per-object reference count does not: an in-file
1175    /// [`copy`](Self::copy) of a variable-length dataset re-emits its
1176    /// element references verbatim, so two datasets name one collection with
1177    /// every object's count still 1 — and files with that shape are already in
1178    /// the wild.
1179    ///
1180    /// Provenance is therefore the whole proof: these collections were placed by
1181    /// this session, for this dataset, and were named by nothing else at the
1182    /// moment they were written. `invalidate_heap_provenance` is what keeps that
1183    /// true afterwards — every entry is dropped as soon as this session does
1184    /// anything that could name one of them a second time.
1185    ///
1186    /// The collections a dataset held when the session *opened* are never in
1187    /// here and are never reclaimed: their provenance is whatever wrote the
1188    /// file. `repack` is what recovers those.
1189    vl_overwrite_heaps: HashMap<ObjectPathBuf, Vec<(u64, u64)>>,
1190    /// Heap collections superseded by a value overwrite this commit is applying,
1191    /// freed once the commit's superblock repoint has landed — never before, so
1192    /// a mid-commit crash leaves the prior root reaching bytes that are still
1193    /// there. Drained into the commit's `to_free` list; cleared at commit entry,
1194    /// so an attempt that fails partway frees nothing on the next one.
1195    superseded_heaps: Vec<(u64, u64)>,
1196    /// The bytes each same-length in-place overwrite of the commit in flight
1197    /// wrote over, so a commit that fails before its linearization point can put
1198    /// the dataset's values back (issue #344).
1199    ///
1200    /// Everything else a commit places is written where nothing reaches it until
1201    /// the superblock is repointed, so an attempt that stops short of the
1202    /// repoint is invisible. A same-length value overwrite is the exception:
1203    /// [`WritePlan::InPlace`] writes straight over the dataset's existing data
1204    /// block, which the *current* root already reaches, so there is no repoint
1205    /// to withhold and the new values are live the moment they land. This
1206    /// journal is how they are withheld anyway.
1207    ///
1208    /// Written only by
1209    /// [`write_inplace_journaled`](Self::write_inplace_journaled) and drained
1210    /// only by [`undo_inplace_writes`](Self::undo_inplace_writes). Every
1211    /// [`commit`](Self::commit) that returns leaves it empty, and one that
1212    /// unwinds does not, which is why the next commit clears it on entry rather
1213    /// than trusting it.
1214    ///
1215    /// **It costs a full copy of every value being replaced, read back off the
1216    /// file first.** Measured on the bounded backing, whose contract is not to
1217    /// hold the file in memory: a same-length overwrite of a 4 MB dataset made
1218    /// its commit read 4,000,197 bytes where the same commit without the
1219    /// journal read 197, and held the 4 MB until the commit ended. That is the
1220    /// price of the guarantee and it is charged on the success path too, since
1221    /// whether the bytes will be needed is not known until the attempt ends.
1222    /// `a_bounded_commit_reads_the_value_it_is_replacing` states the rule so
1223    /// the number is in the repository rather than a surprise.
1224    ///
1225    /// The alternative — planning a same-length overwrite as a relocating write,
1226    /// as a variable-length one already is — would cost nothing here and was
1227    /// rejected for a harder reason than cost: a relocating write is refused for
1228    /// a dataset with more than one hard link
1229    /// ([`count_incoming_hard_links`](Self::count_incoming_hard_links)), so it
1230    /// would start refusing overwrites this engine accepts today.
1231    inplace_undo: Vec<(u64, Vec<u8>)>,
1232    /// True when this engine was opened for SWMR writing
1233    /// ([`open_swmr_writer`](Self::open_swmr_writer)): the append engine then
1234    /// enforces the SWMR subset (unfiltered, chunk-aligned) so a concurrent
1235    /// reader never observes a torn view. `false` for an ordinary edit session.
1236    swmr_mode: bool,
1237    /// Paged-file state (`H5F_FSPACE_STRATEGY_PAGE`), read from the superblock
1238    /// extension at `open` regardless of the persist flag; `None` for the common
1239    /// non-paged file. When `Some`, [`commit`](Self::commit) takes a page-aware
1240    /// tail that keeps pages homogeneous and rewrites the per-page-type managers
1241    /// (issue #198). A paged file that does not *persist* its free space is still
1242    /// refused: see [`PagedEdit`].
1243    paged: Option<PagedEdit>,
1244    /// Set by the first [`commit`](Self::commit) that does any work. A commit can
1245    /// relocate an object header, and nothing on disk distinguishes a relocated
1246    /// header from the intact bytes it vacated — the old header still parses, and
1247    /// its data-layout message still points at the live chunk index. An
1248    /// [`AppendTarget::Header`] captured before that commit would therefore append
1249    /// successfully *into the dead header*, growing its dataspace while the live
1250    /// dataset stayed put, and report `Ok`. A path is re-resolved on every append
1251    /// and so survives a commit; a raw address does not, so it is refused once one
1252    /// has run.
1253    committed: bool,
1254    /// Object-header address for each path an in-place append has resolved in
1255    /// this session. A single `Dataset::append` asks for the target's geometry
1256    /// and then appends to it, and a loop of appends repeats that; without this
1257    /// the path would be walked from the root on every one of those steps.
1258    ///
1259    /// An in-place append never moves an object header — that is why
1260    /// [`located`](Self::located) can be keyed by address and survive appends —
1261    /// so only a commit can stale an entry, and it clears both together.
1262    resolved: HashMap<ObjectPathBuf, u64>,
1263    /// Whether this session splits a large in-place append into batches, trading
1264    /// whole-call crash atomicity for a peak memory that does not scale with the
1265    /// call. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy); see
1266    /// [`batch_elems`](Self::batch_elems).
1267    batched_appends: bool,
1268    /// Whether this session reads through a handle rather than a whole-file
1269    /// mirror. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy), and reported by
1270    /// [`File::edit_backing`](crate::File::edit_backing) so a caller who
1271    /// asked for [`MemoryStrategy::Auto`] can tell which one it got. Distinct
1272    /// from [`batched_appends`](Self::batched_appends), which is a crash-atomicity
1273    /// trade the bounded engine happens to make, not a statement about memory.
1274    bounded: bool,
1275    /// The on-disk format this session may write, resolved from the fapl's
1276    /// [`FileAccessProperties::with_libver_bounds`]. `None` — the default —
1277    /// means unconstrained: the session adds whatever the content needs, which
1278    /// is what lets a file the C library wrote under its own bounds be edited at
1279    /// all.
1280    ///
1281    /// Set below [`LibVer::V110`] it refuses content the older format cannot
1282    /// carry, the way the whole-file writer does. The file's *own* superblock
1283    /// version cannot stand in for this: a version 2 superblock says the file is
1284    /// 1.8-readable today, not that the caller wants it to stay that way, and
1285    /// deriving the ceiling from it would refuse the C-library-file edits of
1286    /// issue #101.
1287    ///
1288    /// [`FileAccessProperties::with_libver_bounds`]: crate::FileAccessProperties::with_libver_bounds
1289    libver_ceiling: Option<LibVer>,
1290    /// The file length when the on-disk free-space managers were last written,
1291    /// for a file that persists them. Every immediate in-place append grows the
1292    /// file past those managers and leaves them mid-file, so a session that ends
1293    /// with `image.len() != fsm_len` owes a rewrite; that is what
1294    /// [`finalize_persist`](Self::finalize_persist) settles at close. Meaningless
1295    /// (and untouched) when `persist` is `None`.
1296    fsm_len: u64,
1297    /// Whether the commit currently running has *asked* the disk to repoint the
1298    /// superblock at its new root. Set by whichever tail performs that write,
1299    /// immediately **before** issuing it, and read only by
1300    /// [`commit`](Self::commit) to decide whether a failure may roll anything
1301    /// back — the free lists (see [`FreeSnapshot`]), the staged set, and the
1302    /// values in [`inplace_undo`](Self::inplace_undo).
1303    ///
1304    /// Set before rather than after for the reason the whole rollback exists: a
1305    /// write that alters the file and then reports failure is a real device
1306    /// behaviour, so a write call returning an error does not mean its bytes
1307    /// are not on the disk. Once the publish has been issued, whether this
1308    /// commit is live is unknowable from here, and every rollback is unsound —
1309    /// undoing the values would tear a commit that did land, and re-offering
1310    /// the regions would hand out space that commit is using. Doing nothing is
1311    /// the only answer that is never actively wrong, and the error the caller
1312    /// gets is the write failure that says so (issue #344).
1313    ///
1314    /// It is a property of one publish, not of the session, so every publisher —
1315    /// `commit`, and the manager rewrite an append's draw makes — clears it on
1316    /// entry rather than trusting the previous run to have left it false.
1317    publish_attempted: bool,
1318    /// Whether a [`stage_atomically`](Self::stage_atomically) batch is open, so
1319    /// [`refuse_mid_batch`](Self::refuse_mid_batch) can keep
1320    /// [`StagedEdits::rewind`]'s truncation an exact undo.
1321    staging_batch: bool,
1322    /// How many times this session's staged set has been *consumed* by a commit
1323    /// rather than handed back to it.
1324    ///
1325    /// It is what tells a handle born onto a staged creation
1326    /// ([`staged_generation`](Self::staged_generation)) which of two things has
1327    /// happened since: the object it names was published, so it addresses the
1328    /// file now — or the staging was withdrawn under it, and it names nothing.
1329    /// Both leave the staged set without an entry at its path, and reading them
1330    /// the same way is what let such a handle silently retarget onto whatever
1331    /// the file held at that path.
1332    ///
1333    /// Advanced by [`commit`](Self::commit) alone, on exactly the path where the
1334    /// set does not go back: a commit that succeeds, and one that fails past its
1335    /// publish (whose objects are in the file). A commit refused before its
1336    /// first write restores the set and leaves this alone, so the handles it
1337    /// concerns stay pending and the batch can be committed again.
1338    staged_generation: u64,
1339    /// Superblock status flags this session raised on the file and holds for its
1340    /// lifetime — the SWMR pair, or the page buffer's crash mark — or `0` when it
1341    /// holds none.
1342    ///
1343    /// A clean commit publishes *this* rather than a literal zero. The zero was
1344    /// there to scrub a flag the file arrived carrying (one a crashed writer left
1345    /// behind), and it still does: this is `0` unless the session raised
1346    /// something itself. What it must not do is scrub a mark that is still
1347    /// standing for a reason, which a page-buffered session's is for every
1348    /// commit it makes (issue #308).
1349    ///
1350    /// Maintained only by [`set_consistency_flags`](Self::set_consistency_flags),
1351    /// which is the one place this crate writes that byte, so the field cannot
1352    /// drift from what is on the disk.
1353    held_status_flags: u32,
1354    /// Who owns this session's `fsync` cadence, from the fapl's
1355    /// [`FileAccessProperties::with_sync_policy`]. Consulted by
1356    /// [`barrier`](Self::barrier) and [`barrier_data`](Self::barrier_data) —
1357    /// every durability point the write paths define — and by nothing else, so
1358    /// [`force_sync`](Self::force_sync) can serve an explicit
1359    /// [`File::sync`](crate::File::sync) whatever it says.
1360    ///
1361    /// [`FileAccessProperties::with_sync_policy`]: crate::FileAccessProperties::with_sync_policy
1362    sync_policy: SyncPolicy,
1363}
1364
1365/// The free lists as they stood before a commit's apply loop drew from them.
1366///
1367/// A commit allocates as it goes — a free region it hands to an object header or
1368/// a chunk blob leaves the list immediately, so no two objects in the same commit
1369/// can be handed the same region. If the commit then fails, those regions are as
1370/// dead as they were before it started: nothing the commit wrote is reachable,
1371/// because the superblock still names the old root — and the one write that does
1372/// not wait for the repoint, a same-length value overwrite, is one
1373/// [`WriteEngine::undo_inplace_writes`] has already tried to put back by the
1374/// time this runs (issue #344). Restoring this snapshot gives them back instead
1375/// of leaking them for the rest of the session.
1376///
1377/// The restore is sound only *before* the repoint. After it, the objects written
1378/// into those regions are live, and handing the same addresses out again would
1379/// overwrite the tree the commit just published — which is why
1380/// [`WriteEngine::publish_attempted`] gates it.
1381struct FreeSnapshot {
1382    free: FreeList,
1383    /// The append reserve, which the persisting commit tail drains back into the
1384    /// lists above before it rewrites the managers. A commit that then fails
1385    /// before publishing leaves those managers as they were — still not listing
1386    /// the reserve — so restoring it is what keeps the session's picture of the
1387    /// disk exact (issue #387).
1388    reserved: FreeList,
1389    paged: Option<(FreeList, FreeList)>,
1390    /// The heap-collection provenance, rolled back with the free lists.
1391    ///
1392    /// [`resolve_overwrite_bytes`](WriteEngine::resolve_overwrite_bytes) records
1393    /// a placement in the *apply* phase, so a commit that fails after that and
1394    /// before its repoint hands the space back to the free list while leaving a
1395    /// record naming it. The next overwrite of that path would then free a
1396    /// region already handed out — the one piece of engine state naming file
1397    /// addresses that this rollback used to miss (issue #321).
1398    vl_overwrite_heaps: HashMap<ObjectPathBuf, Vec<(u64, u64)>>,
1399}
1400
1401/// How much memory a read-write open may use to hold the file being edited.
1402///
1403/// The two read-write backends differ in memory, not in what they can express: a
1404/// *bounded* session reads through a handle and holds only what a commit is
1405/// building, while a *mirrored* session materializes the whole file in memory.
1406/// Bounded is the better default when it applies, but it cannot yet edit every
1407/// file — a pre-v2 (non-latest-format) superblock or a userblock still needs the
1408/// mirror.
1409///
1410/// This is what a caller says about that trade-off, on
1411/// [`FileAccessProperties::with_memory_strategy`](crate::FileAccessProperties::with_memory_strategy).
1412/// Leaving it unset lets the entry point decide: [`File::open_rw`](crate::File::open_rw)
1413/// prefers the bounded engine and falls back to the mirror ([`Auto`](Self::Auto)).
1414/// Stating [`Bounded`](Self::Bounded) refuses instead of falling back.
1415///
1416/// This is a *request*, so it is deliberately not the type a file answers with:
1417/// [`File::edit_backing`](crate::File::edit_backing) returns an [`EditBacking`],
1418/// which cannot express [`Auto`](Self::Auto).
1419///
1420/// Sealed: unlike [`FileLocking`] or [`FileSpaceStrategy`], whose variant sets
1421/// mirror a closed C-library enum, this is a policy this crate invented, so a
1422/// fourth strategy must not be a breaking change.
1423#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1424#[non_exhaustive]
1425pub enum MemoryStrategy {
1426    /// Never build a whole-file mirror. A file the bounded engine cannot edit is
1427    /// refused at open with [`Error::EditUnsupported`], before anything is
1428    /// staged.
1429    Bounded,
1430    /// Prefer the bounded engine, but fall back to the whole-file mirror for a
1431    /// file it cannot edit, rather than refusing. Memory then scales with the
1432    /// file, which is the cost of the file opening at all. What
1433    /// [`File::open_rw`](crate::File::open_rw) uses when nothing is asked for.
1434    ///
1435    /// Two files reach that fallback, and no others: one with a pre-v2
1436    /// (non-latest-format) superblock, and one with a userblock (a non-zero base
1437    /// address). Every other file a read-write open accepts is edited bounded,
1438    /// whatever its [`FileSpaceStrategy`], whether or
1439    /// not it persists its free space, and however large it is. A paged file with
1440    /// no persisted free space is refused by *both* backings rather than
1441    /// mirrored, so it is not a third case. The list being exhaustive is what
1442    /// makes "prefer bounded" a guarantee to budget against: wherever
1443    /// [`Bounded`](Self::Bounded) opens a file, `Auto` gives the same backing.
1444    Auto,
1445    /// Always build the whole-file mirror, whatever the file looks like. What
1446    /// [`File::open_rw`](crate::File::open_rw) did before it learned to dispatch.
1447    Mirrored,
1448}
1449
1450/// Which of the two read-write backends a file's editing session is actually
1451/// using, from [`File::edit_backing`](crate::File::edit_backing).
1452///
1453/// Deliberately a different type from [`MemoryStrategy`]: that one is what a
1454/// caller *asks* for and includes [`Auto`](MemoryStrategy::Auto), which is a
1455/// preference between these two rather than a third thing a session can be. A
1456/// single shared type would make `file.backing() == Auto` a comparison that
1457/// compiles and is false forever.
1458///
1459/// The two also evolve at different rates. A future `MemoryStrategy` may name a
1460/// new *policy* — a byte budget, a size threshold — without the set of backends
1461/// changing at all. Sealed for the rarer case that a third backend does appear.
1462#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1463#[non_exhaustive]
1464pub enum EditBacking {
1465    /// Reads through a file handle, holding only what a commit is building.
1466    /// Memory does not scale with the file.
1467    Bounded,
1468    /// Holds the whole file in memory for the life of the session.
1469    Mirrored,
1470}
1471
1472impl From<EditBacking> for MemoryStrategy {
1473    /// Turns an outcome back into the request that pins it, so a caller can
1474    /// reopen a file onto the backing it got the first time:
1475    /// `with_memory_strategy(file.edit_backing().unwrap().into())`.
1476    fn from(backing: EditBacking) -> Self {
1477        match backing {
1478            EditBacking::Bounded => Self::Bounded,
1479            EditBacking::Mirrored => Self::Mirrored,
1480        }
1481    }
1482}
1483
1484/// When a read-write session forces its writes to durable storage — who owns the
1485/// `fsync` cadence, this crate or the application.
1486///
1487/// This is *not* about whether a write reaches the file. Every commit and every
1488/// append has gone to the operating system by the time it returns, whatever this
1489/// policy says, so a committed edit is visible to any other process on the same
1490/// machine and survives this process crashing. What it governs is the `fsync` on
1491/// top of that, which is what makes those bytes survive the *machine* losing
1492/// power.
1493///
1494/// A session does gather the many small writes *inside* one such operation and
1495/// issue them a page at a time (issue #288), and an explicit
1496/// [page buffer](crate::FileAccessProperties::with_page_buffer_size) extends that
1497/// across operations — trading exactly the guarantee in the paragraph above,
1498/// which is why it is opt-in and this is not.
1499///
1500/// The reference C library does not `fsync` on its normal path either: the
1501/// default `sec2` driver installs no flush callback at all, so `H5Fflush` drains
1502/// libhdf5's own caches with `write` and stops there, leaving power-loss
1503/// durability to the application. [`OnClose`](Self::OnClose) is that behavior;
1504/// [`Always`](Self::Always), the default here, is the stronger one.
1505///
1506/// The whole-file writer ([`FileBuilder`](crate::FileBuilder)) and
1507/// [`repack`](crate::repack()) are outside this: they never `fsync` under either
1508/// policy, since each writes a file and hands it over rather than holding an
1509/// editing session.
1510///
1511/// Sealed: a future policy (syncing on a timer, or once per N bytes) must not be
1512/// a breaking change.
1513#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1514#[non_exhaustive]
1515pub enum SyncPolicy {
1516    /// Force durability at every point the write paths define one: after each
1517    /// immediate [`Dataset::append`](crate::Dataset::append) batch, at each
1518    /// ordering barrier inside a [`File::commit`](crate::File::commit), and when
1519    /// the file is closed or dropped.
1520    ///
1521    /// This is what makes an append crash-atomic and a commit all-or-nothing
1522    /// against power loss: the barriers order the appended data before the
1523    /// superblock repoint that publishes it, so an interrupted write leaves the
1524    /// previous state rather than a torn one.
1525    #[default]
1526    Always,
1527    /// No `fsync` during the session; one when the file is finished. Nothing an
1528    /// append or a commit does is forced to durable storage — writes still reach
1529    /// the operating system immediately, so another process on the same machine
1530    /// sees them and a crash of *this* process loses nothing — and
1531    /// [`File::close`](crate::File::close), or dropping the last handle, issues
1532    /// a single barrier at the end. [`File::sync`](crate::File::sync) adds a
1533    /// checkpoint wherever the application wants one.
1534    ///
1535    /// One setting suspends the "reach the operating system immediately" half,
1536    /// and it is the one that requires this policy:
1537    /// [`with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size)
1538    /// holds dirty pages in this process until the budget is spent, a
1539    /// [`File::sync`](crate::File::sync) is issued, or the session closes. Off by
1540    /// default, and it marks the file on disk for its lifetime so a session that
1541    /// dies holding pages leaves one every reader refuses.
1542    ///
1543    /// The terminal barrier is not an exception grafted onto "never sync": it is
1544    /// the point past which the application *cannot* act. `close` and `drop`
1545    /// both write — they apply staged edits, re-home the free-space managers of
1546    /// a file that persists them, and clear a SWMR writer's flag — and they
1547    /// destroy the handle that would have ordered those writes. A policy that
1548    /// skipped the barrier there would not hand the caller a cadence; it would
1549    /// take one away. The cost is one `fsync` per session against the five per
1550    /// append batch and two or three per commit that this policy removes.
1551    ///
1552    /// Three things are given up in exchange. **Power-loss ordering** during the
1553    /// session: with the barriers gone, a machine that loses power mid-commit
1554    /// can have the superblock repoint on disk without the data it points at.
1555    /// **Deferred write errors**: on a filesystem that allocates late, a write
1556    /// that will fail at writeback still returns success, and `fsync` is where
1557    /// the `ENOSPC`/`EIO` surfaces, so a commit the filesystem cannot complete
1558    /// returns `Ok` until the terminal barrier reports it. **Cross-host
1559    /// visibility**: "another process sees it" is the page cache, so under NFS's
1560    /// close-to-open semantics a reader on another host — a SWMR reader
1561    /// included — may not see writes a client is holding.
1562    OnClose,
1563}
1564
1565/// Why the bounded engine cannot edit a file, when the whole-file mirror can.
1566///
1567/// Kept separate from the refusals that apply to *both* engines: a fallback is
1568/// only ever worth taking for a limitation the mirror does not share. A paged
1569/// file with no persisted free-space managers, for instance, is refused by the
1570/// staged commit as well, so mirroring it would trade a clear error at open for
1571/// the same error later with work already staged.
1572fn bounded_only_limitation(session: &WriteEngine) -> Option<&'static str> {
1573    if session.superblock.version < 2 {
1574        return Some(
1575            "bounded read-write access requires a latest-format file (v2/v3 superblock); \
1576             leave MemoryStrategy unset, or pass MemoryStrategy::Auto, to fall back to \
1577             the whole-file mirror here",
1578        );
1579    }
1580    if !session.superblock.base_address.is_zero() {
1581        return Some(
1582            "bounded read-write access does not support a file with a userblock \
1583             (non-zero base address); leave MemoryStrategy unset, or pass \
1584             MemoryStrategy::Auto, to fall back to the whole-file mirror here",
1585        );
1586    }
1587    None
1588}
1589
1590/// Whether a file built from `create` could not then be opened read-write under
1591/// `access`, and why.
1592///
1593/// [`File::create_with_options`](crate::File::create_with_options) writes a file
1594/// and hands back an open read-write handle, so a creation/access pair that
1595/// cannot survive that second half must be caught *before* the write — otherwise
1596/// the call leaves a file on disk and returns `Err`, which reads like a failed
1597/// create but is not one.
1598///
1599/// This mirrors the open-time refusals above and must be kept in step with them:
1600/// [`bounded_only_limitation`] for the userblock, and the shared paged check in
1601/// [`open_rw_with_strategy`](WriteEngine::open_rw_with_strategy) for a paged file
1602/// with no persisted free space. Both are stated here in terms of the properties
1603/// that *cause* them, because the open-time wording tells the caller to recreate
1604/// the file — advice that is circular when the caller is creating it.
1605pub(crate) fn create_would_refuse_reopen(
1606    create: &FileCreateProperties,
1607    access: &FileAccessProperties,
1608) -> Option<&'static str> {
1609    if let Some((FileSpaceStrategy::Page, false, _)) = create.file_space_strategy() {
1610        return Some(
1611            "a paged file (FileSpaceStrategy::Page) with persist = false cannot be reopened \
1612             read-write, so creating one this way would write the file and then fail to open \
1613             it; pass persist = true to with_file_space_strategy, or build the file with \
1614             FileBuilder if it is only ever going to be read",
1615        );
1616    }
1617    if create.userblock() != 0 && access.memory_strategy() == Some(MemoryStrategy::Bounded) {
1618        return Some(
1619            "a userblock cannot be combined with MemoryStrategy::Bounded: the bounded engine \
1620             cannot edit a file with a non-zero base address, so creating one this way would \
1621             write the file and then refuse to open it; drop the userblock, or leave \
1622             MemoryStrategy unset to mirror this file",
1623        );
1624    }
1625    // A userblock on a *paged* file reaches the same open-time refusal as
1626    // `persist = false` above, by a different route: persisted free space is
1627    // declined for a non-zero base address, which leaves a paged file with no
1628    // seeded managers however its creation properties were written.
1629    if create.userblock() != 0
1630        && matches!(
1631            create.file_space_strategy(),
1632            Some((FileSpaceStrategy::Page, _, _))
1633        )
1634    {
1635        return Some(
1636            "a userblock cannot be combined with FileSpaceStrategy::Page: free space is not \
1637             persisted for a file with a non-zero base address, and a paged file without it \
1638             cannot be opened read-write — so creating one this way would write the file and \
1639             then fail to open it; drop the userblock, or choose another file-space strategy",
1640        );
1641    }
1642    // The page-buffer refusals `set_page_buffer_size` makes at open, restated as
1643    // the creation pair that would walk into them. They are phrased as something
1644    // to *add* to the properties in hand rather than as something to recreate the
1645    // file with, since the file does not exist yet (issues #288 and #308).
1646    if access.page_buffer_size() != 0 {
1647        if access.sync_policy() == SyncPolicy::Always {
1648            return Some(
1649                "a page buffer does nothing under the default SyncPolicy::Always, whose every \
1650                 barrier is an fsync that flushes it, so creating one this way would write the \
1651                 file and then fail to open it; add \
1652                 with_sync_policy(SyncPolicy::OnClose) to the access properties, or drop the \
1653                 page buffer",
1654            );
1655        }
1656        // The version-3 counterpart. It used to need none: a page buffer required
1657        // a paged file, and `FileSpaceStrategy::Page` below the 1.10 format is
1658        // refused by the builder itself before anything is written, so that pair
1659        // never reached an open. Issue #357 lifted the paged requirement, and an
1660        // *unpaged* file at `LibVer::V18` is an ordinary, buildable file with a
1661        // version-2 superblock — one whose status-flags byte no library reads
1662        // back, so the mark could not be raised on it. Without this the pair
1663        // would write the file and fail the open it promised.
1664        //
1665        // Unsatisfiable bounds are left to the builder, which reports which
1666        // format it can write; this is not the place to restate that.
1667        if matches!(
1668            crate::libver::LibVer::resolve_writable(create.libver_bounds()),
1669            Ok(v) if v < crate::libver::LibVer::V110
1670        ) {
1671            return Some(
1672                "a page buffer marks the file in a version-3 superblock, and these bounds \
1673                 write the 1.8 format, so creating one this way would write the file and then \
1674                 fail to open it; raise with_libver_bounds to admit LibVer::V110, or drop the \
1675                 page buffer",
1676            );
1677        }
1678        // An unpaged file has no page size of its own and merges within
1679        // `DEFAULT_GATHER_PAGE`, which is the same figure the format defaults
1680        // `H5Pset_file_space_page_size` to. This arm decides on its own now that
1681        // a budget below the session's own gather budget is honored rather than
1682        // refused (issue #391): a 2 KiB buffer on an unpaged create clears every
1683        // other check and is caught only here. It is written out rather than
1684        // folded into the paged arm's `unwrap_or` because the two constants are
1685        // free to move apart, and because a reader comparing this function
1686        // against `set_page_buffer_size` should find the same rule in both.
1687        let page_size = match create.file_space_strategy() {
1688            Some((FileSpaceStrategy::Page, _, _)) => create
1689                .file_space_page_size()
1690                .unwrap_or(crate::file_space_info::DEFAULT_PAGE_SIZE),
1691            _ => DEFAULT_GATHER_PAGE,
1692        };
1693        if (access.page_buffer_size() as u64) < page_size {
1694            return Some(
1695                "a page buffer smaller than the file's file-space page size would be refused \
1696                 at open — so creating one this way would write the file and then fail to \
1697                 open it; raise with_page_buffer_size",
1698            );
1699        }
1700    }
1701    None
1702}
1703
1704/// What a commit knows about a region it is vacating, which is what decides
1705/// where the region may be recorded.
1706///
1707/// A flat file ignores the distinction — every vacated region is simply free —
1708/// and a paged one cannot, because a byte handed out for the wrong page type
1709/// mixes the page it lands in.
1710#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1711enum FreeClass {
1712    /// The region sits in pages of this type, so it becomes free space of that
1713    /// type and may serve the next allocation of it.
1714    Page(PageType),
1715    /// The region provably holds nothing live, but which page type surrounds it
1716    /// is not provable — a chunk index this crate cannot place
1717    /// ([`WriteEngine::index_is_provably_raw`]) is the case that arises.
1718    ///
1719    /// It may not be handed out as either type, so it is recorded as *dead*
1720    /// rather than free: bytes that are no longer in use and are not yet
1721    /// reusable. Once every other byte of the page around it is free or dead
1722    /// too, the page holds nothing of either type and
1723    /// [`PagedEdit::promote_whole_free_pages`] turns the whole page into free
1724    /// space, which is the same rule that lets one page type claim a wholly free
1725    /// page from the other ([`PagedEdit::alloc_typed`]).
1726    Dead,
1727}
1728
1729impl From<PageType> for FreeClass {
1730    fn from(ty: PageType) -> Self {
1731        FreeClass::Page(ty)
1732    }
1733}
1734
1735/// Paged-file bookkeeping for the whole-file editor (issue #198, step 1).
1736///
1737/// A paged file never mixes metadata and raw data within one page, so this tracks
1738/// free space per page *type* — the one distinction that governs where a byte may
1739/// be placed — and keeps the commit's appends homogeneous by padding a tail page
1740/// whenever the page type changes.
1741///
1742/// One list per type, not one per on-disk manager. A paged file records its free
1743/// space in three managers (metadata-small, raw-small, and a generic-large one for
1744/// whole free pages), but for space this session can place, that split is a size
1745/// classification of the same raw or metadata space, and [`plan_paged_managers`]
1746/// recomputes it from scratch at every commit. Tracking it here as well would only
1747/// stop a freed chunk-data run and the freed index that abuts it from coalescing —
1748/// leaving two neighboring holes neither of which fits the dataset that just
1749/// vacated both (issue #261).
1750///
1751/// The free lists are seeded only when the file *persists* its free space. A paged
1752/// non-persisting file has no on-disk record of which pages hold metadata and
1753/// which hold raw data, so there is nothing to seed and no way to stay segregated;
1754/// [`commit`](WriteEngine::commit) rejects it outright, exactly as the bounded
1755/// backend does.
1756struct PagedEdit {
1757    page_size: u64,
1758    /// Free space inside metadata pages, plus whole free pages this session last
1759    /// saw as metadata. A page holding nothing belongs to no type, so
1760    /// [`alloc_typed`](Self::alloc_typed) lets raw data claim one from here.
1761    meta: FreeList,
1762    /// Free space inside raw-data pages, plus whole free pages this session last
1763    /// saw as raw — including every one seeded from the generic-large manager,
1764    /// which records no type. Metadata claims from here on the same terms.
1765    raw: FreeList,
1766    /// Free space this session may record but must never hand out, because the
1767    /// page it sits in is of unknown type. Seeded at open — nothing this engine
1768    /// frees is ever of unknown type, so it only ever shrinks, and only where a
1769    /// commit released the end of the file over it (issue #418) — and written
1770    /// back to the generic-large manager verbatim, so the space stays available
1771    /// to the writer that recorded it.
1772    ///
1773    /// It exists because the reference C library's generic-large manager is
1774    /// exactly that: `H5F_MEM_PAGE_GENERIC` is *"large-sized generic: meta and
1775    /// raw"*, and under paged aggregation on a contiguous-address driver every
1776    /// allocation of a page or more lands there whatever its type. Its
1777    /// page-alignment tail (`H5MF__alloc_pagefs`) is then recorded as a free
1778    /// section in the same manager — a sub-page fragment sitting in a page whose
1779    /// earlier bytes are live. Handing such a fragment to raw data would put raw
1780    /// bytes in a metadata page, so a section that is not a whole run of aligned
1781    /// pages is kept here rather than guessed at.
1782    unclassified: FreeList,
1783    /// Space this session has vacated whose page type it could not prove
1784    /// ([`FreeClass::Dead`]): no longer in use, and not reusable as either type.
1785    ///
1786    /// It is held rather than discarded so that the page around it can still be
1787    /// reclaimed: a page every one of whose bytes is free or dead holds nothing
1788    /// of either type, and [`promote_whole_free_pages`](Self::promote_whole_free_pages)
1789    /// moves it into the typed lists whole. Without this list those bytes would
1790    /// simply be lost, and the page they sit in could never be shown to be empty
1791    /// — which is what made a paged file leak a chunk index per deleted empty
1792    /// resizable dataset (issue #388).
1793    ///
1794    /// Unlike the free lists this one is not written to disk: a dead sub-page
1795    /// fragment is not free space by the file's account, and recording it in the
1796    /// only manager that takes untyped space would offer it to the reference
1797    /// library for either type. A fragment that never completes a page is
1798    /// therefore forgotten at close, which wastes its bytes and nothing more.
1799    dead: FreeList,
1800    /// Page type of the current tail page. `None` until this session's first
1801    /// typed append; the file is page-aligned at open, so the first append never
1802    /// needs to pad regardless of this.
1803    last: Option<PageType>,
1804    /// Free tails left by padding a metadata page before a raw append.
1805    meta_pad: Vec<(u64, u64)>,
1806    /// Free tails left by padding a raw page before a metadata append.
1807    raw_pad: Vec<(u64, u64)>,
1808}
1809
1810impl PagedEdit {
1811    /// Ensure the next allocation on `image` begins in a page holding page type
1812    /// `ty`: when the tail page holds the *other* type and is only partially
1813    /// filled, pad it to a page boundary and record the padding as free space of
1814    /// the outgoing type.
1815    ///
1816    /// This is the whole of the paged-append rule, and it lives here so the two
1817    /// places that grow a paged file — the staged commit through
1818    /// [`WriteEngine::begin_page`](WriteEngine::begin_page), and the shared
1819    /// Extensible-Array append engine through [`EditStore`] — cannot drift. They
1820    /// used to keep separate copies of this state, one per engine, which is what
1821    /// made an in-place append to a paged file unsafe from the whole-file editor
1822    /// (issue #198).
1823    ///
1824    /// Call it **before** reading the image's end-of-file to compute an address
1825    /// that will be embedded in the bytes being built: several callers build
1826    /// content whose interior addresses assume it lands at the current
1827    /// end-of-file, and padding inserted after that read would shift the landing
1828    /// address out from under them.
1829    fn begin(&mut self, image: &mut dyn FileImage, ty: PageType) -> Result<(), Error> {
1830        let len = image.len();
1831        if len % self.page_size != 0 {
1832            let pad_len = self.page_size - len % self.page_size;
1833            // `prev` is the outgoing page type to record the padding under, or
1834            // `None` for a crash-recovery pad whose tail-page type is unknown.
1835            let pad = match self.last {
1836                // Normal case: the tail page holds a known type; pad only on a
1837                // type switch, recording the tail as free of the outgoing type.
1838                Some(prev) if prev != ty => Some(Some(prev)),
1839                Some(_) => None, // same type: keep packing the tail page
1840                // A previous session grew this paged file and was killed before
1841                // its tail was page-aligned, so the file opened non-page-aligned
1842                // with no known tail type. Pad it up (extending whatever the tail
1843                // page holds, so the page stays homogeneous) and leave the padding
1844                // untracked, since recording it under the wrong page type could
1845                // let a reader reuse it and mix the page.
1846                None => Some(None),
1847            };
1848            if let Some(prev) = pad {
1849                let pad_at = len;
1850                image.append(&vec![0u8; pad_len.to_usize()?])?;
1851                match prev {
1852                    Some(PageType::Meta) => self.meta_pad.push((pad_at, pad_len)),
1853                    Some(PageType::Raw) => self.raw_pad.push((pad_at, pad_len)),
1854                    None => {} // crash-recovery pad: untracked (tail type unknown)
1855                }
1856            }
1857        }
1858        self.last = Some(ty);
1859        Ok(())
1860    }
1861
1862    fn new(page_size: u64) -> Self {
1863        PagedEdit {
1864            page_size,
1865            meta: FreeList::new(),
1866            raw: FreeList::new(),
1867            unclassified: FreeList::new(),
1868            dead: FreeList::new(),
1869            last: None,
1870            meta_pad: Vec::new(),
1871            raw_pad: Vec::new(),
1872        }
1873    }
1874
1875    /// Which list a persisted section from File Space Info `slot` belongs in.
1876    ///
1877    /// Slot 0 (SUPER) is small metadata and slot 2 (DRAW) is small raw: both name
1878    /// a page type outright. Slot 6 is the generic-large manager, which holds
1879    /// space of *either* type (see [`unclassified`](Self::unclassified)), so a
1880    /// section from it is only safe to reuse when it covers whole aligned pages —
1881    /// and such a section belongs to no type at all, since nothing lives in those
1882    /// pages to be mixed with. It is filed under raw and reached from either side
1883    /// through [`alloc_typed`](Self::alloc_typed), rather than kept in a third
1884    /// list, so that a run of free pages still coalesces with the sub-page
1885    /// fragment beside it. Everything else, including the per-type large managers
1886    /// a multi/split driver would populate, is left unclassified rather than
1887    /// guessed at.
1888    fn slot_list(slot: usize, addr: u64, size: u64, page_size: u64) -> Option<PageType> {
1889        match slot {
1890            0 => Some(PageType::Meta),
1891            2 => Some(PageType::Raw),
1892            6 if addr % page_size == 0 && size % page_size == 0 => Some(PageType::Raw),
1893            _ => None,
1894        }
1895    }
1896
1897    /// Record `(addr, size)` in the list its class names: the free space of one
1898    /// page type, or the dead list for space whose page type is unproven.
1899    /// [`plan_paged_managers`] splits and classes the free lists into the on-disk
1900    /// managers at serialization time, so this only has to route.
1901    ///
1902    /// Takes the lists rather than `&mut self` because a commit routes into
1903    /// *copies* of them — nothing is free until the superblock repoint — and the
1904    /// rule must not be restated at that call site.
1905    fn route_free(
1906        meta: &mut FreeList,
1907        raw: &mut FreeList,
1908        dead: &mut FreeList,
1909        addr: u64,
1910        size: u64,
1911        class: FreeClass,
1912    ) {
1913        match class {
1914            FreeClass::Page(PageType::Meta) => meta.free(addr, size),
1915            FreeClass::Page(PageType::Raw) => raw.free(addr, size),
1916            FreeClass::Dead => dead.free(addr, size),
1917        }
1918    }
1919
1920    /// Move every page that is *wholly* free or dead out of the three lists and
1921    /// into the raw list as one whole free page.
1922    ///
1923    /// A paged file may only place a byte in a page of its own type, which is why
1924    /// free space is tracked per type at all. A page holding nothing belongs to no
1925    /// type, so it may be opened for whichever type asks — the rule
1926    /// [`alloc_typed`](Self::alloc_typed) already applies to a whole page inside
1927    /// the other type's list, and [`slot_list`](Self::slot_list) to a whole page
1928    /// read back from the untyped manager. This applies the same rule one level
1929    /// up, to a page whose emptiness only the three lists *together* establish:
1930    /// part free metadata, part free raw data, part space vacated by an object
1931    /// whose page type could not be proven ([`FreeClass::Dead`]).
1932    ///
1933    /// That last part is what makes it necessary rather than a tidy-up. Dead
1934    /// space is never handed out on its own, so without this step a deleted chunk
1935    /// index this crate cannot place is lost for good, and a paged file under
1936    /// delete-and-recreate churn spends a page every few cycles that it can never
1937    /// take back (issue #388). Promoting the page is not a guess about what once
1938    /// sat in it: nothing live is left there to be mixed with.
1939    ///
1940    /// Filed under raw, which is where a whole free page belongs by the same
1941    /// convention [`slot_list`](Self::slot_list) reads them back under, and
1942    /// reachable from either side through `alloc_typed`.
1943    fn promote_whole_free_pages(
1944        meta: &mut FreeList,
1945        raw: &mut FreeList,
1946        dead: &mut FreeList,
1947        page_size: u64,
1948    ) {
1949        // The lists are individually coalesced and pairwise disjoint (a byte is
1950        // vacated once), so their union is a plain merge: sort by address and join
1951        // runs that touch. A page is promotable exactly when it lies inside one of
1952        // those runs, which is what a page split across two of the lists needs.
1953        let mut all = meta.sections();
1954        all.extend(raw.sections());
1955        all.extend(dead.sections());
1956        all.sort_unstable_by_key(|&(addr, _)| addr);
1957        let mut runs: Vec<(u64, u64)> = Vec::with_capacity(all.len());
1958        for (addr, len) in all {
1959            match runs.last_mut() {
1960                Some(run) if run.0 + run.1 >= addr => {
1961                    let end = (run.0 + run.1).max(addr + len);
1962                    run.1 = end - run.0;
1963                }
1964                _ => runs.push((addr, len)),
1965            }
1966        }
1967        for (addr, len) in runs {
1968            // The whole pages inside the run: its aligned interior, exactly as
1969            // `FreeList::alloc_whole_units` takes one. The partial edges sit in
1970            // pages whose other bytes may be live, so they keep whatever they are.
1971            let first = addr.next_multiple_of(page_size);
1972            let last = (addr + len) / page_size * page_size;
1973            if last <= first {
1974                continue;
1975            }
1976            let span = last - first;
1977            meta.take_range(first, span);
1978            raw.take_range(first, span);
1979            dead.take_range(first, span);
1980            raw.free(first, span);
1981        }
1982    }
1983
1984    /// Draw `len` bytes of page type `ty` from this file's free space, or `None`
1985    /// when nothing can serve it.
1986    ///
1987    /// Its own list first, which is the only place a *partly* free page can serve
1988    /// it. Failing that, whole pages inside the other type's free space: a page
1989    /// with nothing in it belongs to neither type, so opening it for `ty` cannot
1990    /// mix it. Enough whole pages to cover `len` are claimed, and the remainder
1991    /// joins `ty`'s list, since those pages now hold `ty`.
1992    ///
1993    /// The cross-type claim is what lets space survive a close. A whole free page
1994    /// is written to the generic-large manager, which records no page type, so
1995    /// every one of them comes back from disk as raw ([`slot_list`](Self::slot_list));
1996    /// without this, metadata could never reuse a page again after a reopen, and
1997    /// each session's commits would append past all of them (issue #286).
1998    fn alloc_typed(&mut self, len: u64, ty: PageType) -> Option<u64> {
1999        let (own, other) = match ty {
2000            PageType::Meta => (&mut self.meta, &mut self.raw),
2001            PageType::Raw => (&mut self.raw, &mut self.meta),
2002        };
2003        if let Some(addr) = own.alloc(len) {
2004            return Some(addr);
2005        }
2006        let span = align_up(len, self.page_size);
2007        let addr = other.alloc_whole_units(span, self.page_size)?;
2008        if span > len {
2009            own.free(addr + len, span - len);
2010        }
2011        Some(addr)
2012    }
2013
2014    /// The longest contiguous run [`alloc_typed`](Self::alloc_typed) could serve
2015    /// `ty` from in one call, or `0` when nothing could: the larger of its own
2016    /// list's longest region and the longest run of whole free pages in the
2017    /// other's.
2018    ///
2019    /// The same two sources in the same terms, so a caller sizing a draw on this
2020    /// figure gets exactly the run `alloc_typed` then hands it.
2021    fn largest_typed(&self, ty: PageType) -> u64 {
2022        let (own, other) = match ty {
2023            PageType::Meta => (&self.meta, &self.raw),
2024            PageType::Raw => (&self.raw, &self.meta),
2025        };
2026        own.largest().max(other.largest_whole_units(self.page_size))
2027    }
2028
2029    /// Every free region this session could still hand out, ascending by address.
2030    /// Used for space accounting, where the caller wants one total rather than a
2031    /// per-page-type breakdown.
2032    ///
2033    /// [`unclassified`](Self::unclassified) is excluded: it is free space, and
2034    /// [`File::persisted_free_space`](crate::File::persisted_free_space) reports
2035    /// it as such, but this session will never place anything in it, which is
2036    /// what the accounting field it feeds is about.
2037    fn reusable_sections(&self) -> Vec<(u64, u64)> {
2038        let mut out = self.meta.sections();
2039        out.extend(self.raw.sections());
2040        out.sort_unstable_by_key(|&(addr, _)| addr);
2041        debug_assert!(
2042            out.windows(2)
2043                .all(|w| w[0].0.saturating_add(w[0].1) <= w[1].0),
2044            "a region is free in both the metadata and the raw list, so the two \
2045             page-type lists have stopped being disjoint"
2046        );
2047        out
2048    }
2049}
2050
2051/// The free space a paged commit is about to write to disk, as
2052/// [`WriteEngine::paged_post_free`] computes it: the session's lists with this
2053/// commit's frees folded in, held apart from the session until the repoint makes
2054/// them true.
2055struct PagedPostFree {
2056    meta: FreeList,
2057    raw: FreeList,
2058    /// The session's dead space plus this commit's, with every page the three
2059    /// lists together empty already promoted out of it
2060    /// ([`PagedEdit::promote_whole_free_pages`]).
2061    dead: FreeList,
2062    /// Nothing in a commit adds to or draws from this one.
2063    unclassified: FreeList,
2064}
2065
2066impl PagedPostFree {
2067    /// Give the run of unreferenced space that reaches `eof` back to the
2068    /// filesystem: drop it from every list here and return the end-of-allocation
2069    /// the commit should publish, which the caller truncates the file to
2070    /// (issue #418). `eof` itself when nothing at the end of the file is free.
2071    ///
2072    /// The cut is rounded **up** to a page boundary, so a paged file's
2073    /// end-of-allocation stays a whole number of pages: a run that begins
2074    /// mid-page begins in a page whose earlier bytes are live, and only the whole
2075    /// pages above it can go. That is the same granularity the reference C
2076    /// library shrinks a paged file at — its `H5MF__sect_large_can_shrink` sees
2077    /// only the large (whole-page) sections, the small per-type managers having
2078    /// no shrink at all — so a partial page at the end is kept and recorded,
2079    /// exactly as it is there.
2080    ///
2081    /// Every list is cut, including the space this session may record but never
2082    /// place ([`PagedEdit::unclassified`]) and the space it has vacated but
2083    /// cannot type ([`PagedEdit::dead`]). Bytes past the end-of-allocation are
2084    /// not in the file at all, so a section describing them would point outside
2085    /// it — the one thing the persisted managers must never do.
2086    ///
2087    /// Whole pages holding [`TRAILING_RESERVE_TAILS`] tails stay in the file, and
2088    /// stay recorded, for the reason [`release_trailing_run`] gives on a flat
2089    /// file — with the same refusal to release a run that gives back less than it
2090    /// keeps.
2091    fn release_trailing(&mut self, eof: u64, page_size: u64, tail_len: u64) -> u64 {
2092        let start =
2093            trailing_run_start([&self.meta, &self.raw, &self.dead, &self.unclassified], eof);
2094        let cut = align_up(start, page_size);
2095        if cut >= eof {
2096            return eof;
2097        }
2098        // Whole pages are this file's unit, so the reserve is one too, and the
2099        // same "give back more than you keep" condition applies.
2100        let keep = align_up(TRAILING_RESERVE_TAILS * tail_len, page_size);
2101        if eof - cut < 2 * keep {
2102            return eof;
2103        }
2104        let eoa = cut + keep;
2105        let span = eof - eoa;
2106        // A section straddling the cut keeps the part that is still inside the
2107        // file, which is what `take_range` leaves of it.
2108        self.meta.take_range(eoa, span);
2109        self.raw.take_range(eoa, span);
2110        self.dead.take_range(eoa, span);
2111        self.unclassified.take_range(eoa, span);
2112        eoa
2113    }
2114}
2115
2116/// How much free space a released trailing run leaves behind, as a multiple of
2117/// the manager tail a persisting commit writes.
2118///
2119/// A persisting file rewrites those blocks on *every* commit, and a rewrite can
2120/// never land in its own predecessor's extent — the on-disk superblock still
2121/// points at it until the repoint, so overwriting it would destroy the file a
2122/// crash there falls back on. The end of the file therefore has to keep room for
2123/// the tails that follow this one, and a tail is not a fixed size: every
2124/// abandoned one adds a section to the managers, which makes the *next* tail
2125/// longer again.
2126///
2127/// Measured rather than chosen, on the shipped rule. At one,
2128/// `a_released_file_holds_its_length_across_many_tail_rewrites` leaves the flat
2129/// file alternating between 5,992 and 6,303 bytes instead of holding one length,
2130/// because a tail a section longer than the one that sized the reserve does not
2131/// fit it. At two, `persisting_churn_reaches_a_steady_size` has not settled by
2132/// its sixteenth round — 15,095 bytes after a middle third that held 14,685.
2133/// Three is the smallest value both accept; four is one tail of margin, the tail
2134/// length being itself a function of a section count that varies with the file.
2135const TRAILING_RESERVE_TAILS: u64 = 4;
2136
2137/// Release the run of free space reaching `eof` from a flat file's post-commit
2138/// free list, and return the end-of-allocation the commit should publish
2139/// (issue #418).
2140///
2141/// Not the whole run: [`TRAILING_RESERVE_TAILS`] tails' worth of it stays in the
2142/// file, and stays recorded, so the commits after this one still have somewhere
2143/// to write their own manager blocks.
2144///
2145/// And nothing at all unless the run gives back at least as much as it keeps.
2146/// Trimming the top off a hole that is *about* to be reused is the worst of both:
2147/// it returns a few hundred bytes and leaves the hole too small for the object
2148/// that would have filled it, which then goes past end-of-file instead, so the
2149/// file grows by a whole object to give back a fraction of one.
2150///
2151/// So `eof` — the file's length, unchanged — when it ends in live bytes and when
2152/// the run is not worth releasing; the list is then left exactly as it came in.
2153fn release_trailing_run(post: &mut FreeList, eof: u64, tail_len: u64) -> u64 {
2154    let run_start = trailing_run_start([&*post], eof);
2155    let keep = TRAILING_RESERVE_TAILS * tail_len;
2156    if eof - run_start < 2 * keep {
2157        return eof;
2158    }
2159    post.take_range(run_start + keep, eof - run_start - keep);
2160    run_start + keep
2161}
2162
2163/// Whether a persisting file's tail rewrite may grow the file to place its
2164/// manager blocks.
2165///
2166/// The two callers want opposite things from a tail that will not fit in free
2167/// space, and the difference is not a preference: a commit publishing a tree
2168/// *must* record its free space somewhere, while the shrink pass exists only to
2169/// make the file smaller and would defeat itself by appending.
2170#[derive(Clone, Copy, Debug, PartialEq, Eq)]
2171enum TailPlacement {
2172    /// Place the tail in free space where it fits and past end-of-file where it
2173    /// does not. Every commit that publishes a tree.
2174    Anywhere,
2175    /// Place the tail in free space or not at all: when nothing fits, the
2176    /// rewrite is abandoned before it writes a byte and the file is left exactly
2177    /// as the commit before it published.
2178    ReuseOnly,
2179}
2180
2181/// Where a commit has decided to put one allocation's bytes, handed out by
2182/// [`WriteEngine::reserve`] and consumed by [`WriteEngine::place`].
2183///
2184/// The two arms differ in how the bytes are written — over a dead interior region
2185/// or past the end of the file — and in nothing else the caller sees, so a caller
2186/// that needs the address before the bytes (a relocatable blob) never has to know
2187/// which it got.
2188#[derive(Clone, Copy, Debug)]
2189enum Placement {
2190    /// A region an earlier commit freed, already removed from its free list.
2191    Reused { addr: u64, len: u64 },
2192    /// The end-of-file, with the page for the requested type already open.
2193    Appended { addr: u64, len: u64 },
2194}
2195
2196impl Placement {
2197    /// The absolute file address the bytes will occupy.
2198    fn address(self) -> u64 {
2199        match self {
2200            Placement::Reused { addr, .. } | Placement::Appended { addr, .. } => addr,
2201        }
2202    }
2203
2204    /// The reserved byte count, which the placed bytes must match exactly.
2205    fn len(self) -> u64 {
2206        match self {
2207            Placement::Reused { len, .. } | Placement::Appended { len, .. } => len,
2208        }
2209    }
2210}
2211
2212/// What an in-place append names its target by.
2213///
2214/// A handle with a resolvable path uses it, which lets the session compare the
2215/// target against its own staged edits. A handle reached by object reference has
2216/// no path, so it names the dataset by the object-header address it was reached
2217/// through — the same key the geometry cache uses.
2218#[derive(Clone, Copy)]
2219pub(crate) enum AppendTarget<'a> {
2220    Path(&'a ObjectPathBuf),
2221    Header(u64),
2222}
2223
2224/// Byte budget for one append batch on a session that batches: a large append is
2225/// split into whole-chunk batches of at most this many raw bytes (always at least
2226/// one chunk), each applied as its own crash-atomic fsync-barriered sequence, so
2227/// peak append memory never scales with the caller's slice.
2228const APPEND_BATCH_BYTES: u64 = 1 << 20;
2229
2230/// The data-only durability barrier: an ordering point always, and an `fsync`
2231/// only when this session's [`SyncPolicy`] keeps the cadence.
2232///
2233/// One function rather than one per caller because the commit path
2234/// ([`WriteEngine::barrier_data`]) and the append engine's phase boundaries
2235/// ([`EditStore::sync`]) are the same point reached from two owners of the
2236/// image, and a barrier that orders at one and not the other is a
2237/// crash-consistency defect no test on the default policy can see (issue #288).
2238///
2239/// The match is exhaustive on purpose, as at [`WriteEngine::barrier`]:
2240/// `SyncPolicy` is sealed to the outside but not to this crate, so a policy
2241/// added later fails to compile here until someone decides what it means.
2242fn barrier_data(image: &mut dyn FileImage, sync_policy: SyncPolicy) -> Result<(), Error> {
2243    match sync_policy {
2244        SyncPolicy::Always => image.sync_data(),
2245        SyncPolicy::OnClose => image.ordering_barrier(),
2246    }
2247}
2248
2249/// Byte budget for the writes one operation may gather before they are issued
2250/// (see [`WriteBuffering::Operation`]): the default every exclusively locked
2251/// read-write session opens under.
2252///
2253/// A megabyte, the same figure as [`APPEND_BATCH_BYTES`] and for a related
2254/// reason: that is already what a bounded session spends holding one batch of
2255/// raw append data, so this is a familiar quantum for the write path rather than
2256/// a new one. The two are not derived from each other and either may be tuned
2257/// alone. An operation larger than this is flushed part-way, which costs it
2258/// little — its writes are long and contiguous by then.
2259///
2260/// # Choosing a page-buffer budget
2261///
2262/// An explicit [page buffer](crate::FileAccessProperties::with_page_buffer_size)
2263/// *replaces* this budget rather than adding to it, and holds what it gathers
2264/// across operations rather than releasing it at every ordering barrier. The two
2265/// figures are therefore not comparable as memory: this one is a per-operation
2266/// peak, a page buffer's is continuous residency. A budget below it asks for less
2267/// of that residency and pays in writes, wherever one long contiguous run has to
2268/// be flushed and restarted. Writes issued on a 4 KiB-paged file:
2269///
2270/// ```text
2271/// workload                          unset   4 KiB   64 KiB   1 MiB
2272/// 32 chunk appends into 8 + commit    188      25        4       4
2273/// one 4 MiB append                    131    1094       74      10
2274/// ```
2275///
2276/// Nothing here bypasses the gatherer the way `H5PB_write` bypasses the C page
2277/// buffer for any I/O of a page or more (issue #357), so the second row is what
2278/// a small budget actually costs; `a_smaller_budget_issues_more_writes` asserts
2279/// that ordering rather than these counts. Which side of it to want is the
2280/// caller's trade, not this crate's (issue #391).
2281const WRITE_GATHER_BYTES: usize = 1 << 20;
2282
2283/// Page the write gatherer merges within on a file that is not paged.
2284///
2285/// A non-paged file has no page size of its own, and this is the same figure
2286/// HDF5 defaults `H5Pset_file_space_page_size` to, so the merge quantum matches
2287/// what the file *would* have used had it been paged.
2288const DEFAULT_GATHER_PAGE: u64 = crate::file_space_info::DEFAULT_PAGE_SIZE;
2289
2290/// One dataset's append geometry, handed to the public append path so it can
2291/// slice a large call into aligned batches without materializing the whole
2292/// call's bytes first.
2293pub(crate) struct AppendGeometry {
2294    /// Elements per chunk along axis 0 (>= 1).
2295    pub(crate) chunk_elems: u64,
2296    /// Bytes per on-disk element, proven non-zero.
2297    pub(crate) element_size: NonZeroUsize,
2298    /// Current length along the unlimited dimension.
2299    pub(crate) current_dim: u64,
2300    /// Whether this dataset's filter pipeline is **lossy** — ZFP, or float
2301    /// D-scale scale-offset. Such a dataset cannot have a partial trailing chunk
2302    /// grown, because that decodes and re-encodes already-committed values (see
2303    /// [`pipeline_lossless`]), so an append onto one has to start chunk-aligned.
2304    pub(crate) lossy_filters: bool,
2305    /// Whole-chunk elements in one full batch (>= one chunk's worth), or
2306    /// [`u64::MAX`] when the session does not batch.
2307    pub(crate) full_batch_elems: u64,
2308}
2309
2310/// Superblock consistency-flag bits raised while a SWMR writer is active: bit 0
2311/// (write access) | bit 2 (SWMR write access). Cleared on a clean close. Matches
2312/// the reference C library, h5py, and [`crate::File::open_swmr_writer`]. These
2313/// are the bits every open path checks — see [`file_lock::check_status_flags`].
2314const SWMR_WRITE_FLAGS: u32 = file_lock::WRITE_ACCESS | file_lock::SWMR_WRITE_ACCESS;
2315
2316/// A dataset located once for [`Dataset::append`](crate::Dataset::append) (or the bounded
2317/// backend's immediate append), then maintained across appends. Mirrors the
2318/// append writer's per-dataset state.
2319pub(crate) struct LocatedState {
2320    pub(crate) loc: Located,
2321    /// The dataset's on-disk element datatype (for the append type check and the
2322    /// filter chunk context).
2323    pub(crate) datatype: Datatype,
2324    /// Spatial (rank-length) chunk dimensions in elements: `[chunk_elems]`.
2325    pub(crate) spatial: Vec<u64>,
2326    /// Bytes per element (datatype size), proven non-zero.
2327    pub(crate) element_size: NonZeroUsize,
2328    /// The re-encodable filter pipeline, when the dataset is filtered.
2329    pub(crate) pipeline: Option<FilterPipeline>,
2330    /// What the slots past the new dimension must hold when an append completes
2331    /// a partial chunk (issue #296).
2332    pub(crate) fill: crate::fill_value::PaddingFill,
2333}
2334
2335/// State for a file that persists its free space on disk. Carries the file's
2336/// fixed file-space parameters and the extents of the free-space-manager blocks
2337/// (and superblock extension) the *current* on-disk file uses, so the next
2338/// persisting commit can reclaim them when it writes fresh ones.
2339struct PersistState {
2340    strategy: FileSpaceStrategy,
2341    threshold: u64,
2342    page_size: u64,
2343    /// `(addr, len)` of the on-disk superblock-extension header and every
2344    /// free-space-manager `FSHD`/`FSSE` block currently in use. Superseded — and
2345    /// therefore freed — by the next persisting commit.
2346    old_blocks: Vec<(u64, u64)>,
2347}
2348
2349/// A snapshot of a writable file's live space usage (issue #150).
2350///
2351/// This is the mutating-session counterpart of the read-only accounting on
2352/// [`File`](crate::File) ([`file_size`](crate::File::file_size) and
2353/// [`persisted_free_space`](crate::File::persisted_free_space)): it describes the
2354/// file *as the session currently holds it*, taken atomically at the moment of
2355/// the [`space_accounting`](crate::File::space_accounting) call.
2356///
2357/// It reflects the committed file plus any immediate in-place appends
2358/// ([`append`](crate::Dataset::append)), but **not** edits still
2359/// staged for the next [`commit`](crate::File::commit) — `create_group`,
2360/// `create_dataset`, `write_dataset`, `append_dataset`, `delete`, `copy`,
2361/// `copy_from`, and attribute edits change these figures only when they are
2362/// applied at commit. Use [`has_staged_edits`](crate::File::has_staged_edits) to
2363/// tell whether such pending work exists.
2364#[derive(Debug, Clone, PartialEq, Eq)]
2365#[non_exhaustive]
2366pub struct SpaceAccounting {
2367    /// The session's current logical size in bytes: the byte length of the file
2368    /// as the session holds it. It equals what
2369    /// [`File::file_size`](crate::File::file_size) reports for the file on disk
2370    /// right now (the HDF5 `H5Fget_filesize` value), because the session keeps its
2371    /// in-memory mirror byte-for-byte identical to the file — every committed
2372    /// write and every immediate in-place append
2373    /// ([`append`](crate::Dataset::append)) updates both together.
2374    ///
2375    /// It is not monotonic: [`commit`](crate::File::commit) can reclaim trailing
2376    /// free space and *shrink* the file. It can also exceed the superblock's
2377    /// recorded end-of-file address when the file was opened carrying unaccounted
2378    /// trailing bytes (the same slack [`File::file_size`](crate::File::file_size)
2379    /// surfaces), since opening does not rewrite that address.
2380    pub logical_size: u64,
2381    /// Total reusable free bytes this session can draw from before the file has to
2382    /// grow — the summed length of
2383    /// [`reusable_free_space`](Self::reusable_free_space).
2384    ///
2385    /// Counts holes left inside [`logical_size`](Self::logical_size) by this
2386    /// session's earlier commits (superseded object headers, the blocks of
2387    /// deleted objects) and, for a file created with
2388    /// `H5Pset_file_space_strategy(persist = true)` and no userblock, the regions
2389    /// seeded from the on-disk free-space managers when the session was opened (so
2390    /// reuse spans sessions). A fresh non-persisting session reports `0` even if
2391    /// the file contains holes left by other tools — those are never tracked. It
2392    /// is neither a lower bound on the next write's growth nor a promise of
2393    /// shrinkage: a region counted here may be truncated away at commit — rather
2394    /// than reused — if adjacent space is later freed and the coalesced run
2395    /// reaches end-of-file.
2396    ///
2397    /// **Which write can spend it is not uniform on a persisting file.** Such a
2398    /// session may write into a hole only once it has taken that hole *out* of
2399    /// the on-disk managers, so an in-place [`append`](crate::Dataset::append)
2400    /// holds a reserve of up to a megabyte per draw that this figure counts and
2401    /// the next [`commit`](crate::File::commit) cannot place into: the commit
2402    /// gives the unspent part back in the same tail that rewrites the managers,
2403    /// which runs *after* it has placed everything. The commit after that one
2404    /// can. So on a
2405    /// persisting file read this as "space this session will reuse rather than
2406    /// grow for", not as "space the very next commit can fill".
2407    ///
2408    /// It is also not the on-disk view. That reserve is deliberately absent from
2409    /// the free-space managers for as long as the session holds it, so
2410    /// [`File::persisted_free_space`](crate::File::persisted_free_space) — which
2411    /// reads those managers — reports less than this by whatever is reserved,
2412    /// and is the figure to compare against what another tool would find.
2413    pub reusable_free_bytes: u64,
2414    /// The reusable free regions as `(offset, length)` pairs, sorted ascending by
2415    /// offset and fully coalesced (no two regions touch or overlap).
2416    ///
2417    /// The offsets are **absolute** file offsets (from byte 0, including any
2418    /// userblock prefix), matching [`logical_size`](Self::logical_size). This
2419    /// differs from [`File::persisted_free_space`](crate::File::persisted_free_space),
2420    /// whose pairs are relative to the superblock base address; the two coincide
2421    /// for a file with no userblock (base address 0), which is the only kind whose
2422    /// persisted free space a session seeds. Empty when nothing is reusable.
2423    pub reusable_free_space: Vec<(u64, u64)>,
2424}
2425
2426impl WriteEngine {
2427    /// Open an existing HDF5 file for in-place editing under an explicit
2428    /// file-locking policy.
2429    ///
2430    /// Reads the file into memory and retains a read/write handle. Under
2431    /// [`FileLocking::Enabled`] it takes an exclusive OS advisory lock so the file
2432    /// cannot be opened concurrently by another writer or reader; the lock is
2433    /// released automatically when the session is dropped or the process exits
2434    /// (including on a crash). Fails with [`Error::FileLocked`] if the file is
2435    /// already locked, or [`Error::EditUnsupported`] if the file is not a
2436    /// supported target; its documentation enumerates the exact requirements.
2437    /// `HDF5_USE_FILE_LOCKING` overrides the requested policy, as in the C
2438    /// library.
2439    pub fn open_with_locking<P: AsRef<Path>>(path: P, locking: FileLocking) -> Result<Self, Error> {
2440        Self::open_inner(path.as_ref(), Some(locking))
2441    }
2442
2443    /// Open exactly as [`open_with_locking`](Self::open_with_locking) does, but
2444    /// behind an image that withholds its whole-file slice, so every read takes
2445    /// the [`Source`] path rather than the slice fast path.
2446    ///
2447    /// Distinct from [`open_rw_with_strategy`](Self::open_rw_with_strategy), which withholds the
2448    /// slice *and* the residency: this one still mirrors the file, so a test can
2449    /// compare the two read forms on a file the bounded open would refuse.
2450    #[cfg(test)]
2451    pub(crate) fn open_source_only(path: &Path) -> Result<Self, Error> {
2452        Self::open_imaged(path, Some(FileLocking::Enabled), |handle, _len| {
2453            Ok(Box::new(crate::image::SourceOnlyImage::new(
2454                Self::read_mirror(handle)?,
2455            )))
2456        })
2457    }
2458
2459    /// Open exactly as [`open_with_locking`](Self::open_with_locking) does, but
2460    /// behind an image whose writes into `fails` behave like a dying device:
2461    /// the first alters the file and then errors, the rest are refused
2462    /// outright. See [`crate::image::TornWriteImage`] for why it takes both to
2463    /// reach the state a refused commit cannot repair.
2464    #[cfg(test)]
2465    pub(crate) fn open_torn_writes(
2466        path: &Path,
2467        fails: core::ops::Range<u64>,
2468    ) -> Result<Self, Error> {
2469        Self::open_imaged(path, Some(FileLocking::Enabled), |handle, _len| {
2470            Ok(Box::new(crate::image::TornWriteImage::new(
2471                Self::read_mirror(handle)?,
2472                fails,
2473            )))
2474        })
2475    }
2476
2477    /// Open a bounded session whose image counts the bytes read through it, so a
2478    /// test can assert that an operation touches only a small part of the file.
2479    /// The counter is shared with the caller.
2480    #[cfg(test)]
2481    pub(crate) fn open_bounded_counting(
2482        path: &Path,
2483        read_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
2484    ) -> Result<Self, Error> {
2485        let mut session = Self::open_imaged(path, Some(FileLocking::Enabled), |handle, len| {
2486            Ok(Box::new(crate::image::CountingImage::new(
2487                Box::new(HandleImage::new(
2488                    handle,
2489                    len,
2490                    crate::source::MetadataCacheConfig::disabled(),
2491                )),
2492                read_bytes,
2493                std::sync::Arc::default(),
2494            )))
2495        })?;
2496        session.batched_appends = true;
2497        Ok(session)
2498    }
2499
2500    /// Open a session under `policy` whose image counts the `fsync`s issued
2501    /// through it, so a test can assert what a write path actually costs. The
2502    /// counter is shared with the caller.
2503    ///
2504    /// It takes the bounded backing and its flags, so the session matches what
2505    /// [`File::open_rw`](crate::File::open_rw) builds for a latest-format file;
2506    /// the barrier sites live on the engine, above the choice of image, so one
2507    /// backing exercises them all.
2508    #[cfg(test)]
2509    pub(crate) fn open_sync_counting(
2510        path: &Path,
2511        policy: SyncPolicy,
2512        syncs: std::sync::Arc<std::sync::atomic::AtomicU64>,
2513    ) -> Result<Self, Error> {
2514        let mut session = Self::open_imaged(path, Some(FileLocking::Enabled), |handle, len| {
2515            Ok(Box::new(crate::image::CountingImage::new(
2516                Box::new(HandleImage::new(
2517                    handle,
2518                    len,
2519                    crate::source::MetadataCacheConfig::disabled(),
2520                )),
2521                std::sync::Arc::default(),
2522                syncs,
2523            )))
2524        })?;
2525        session.batched_appends = true;
2526        session.bounded = true;
2527        session.set_sync_policy(policy);
2528        Ok(session)
2529    }
2530
2531    /// Open an existing file for read-write editing under `strategy`: the one
2532    /// place that picks between the bounded backing (a [`HandleImage`] keeping no
2533    /// whole-file mirror, so resident memory is the metadata-cache budget plus
2534    /// whatever is being parsed) and the whole-file mirror. Backs
2535    /// [`File::open_rw`](crate::File::open_rw), which selects between them by
2536    /// the strategy it is given.
2537    ///
2538    /// The eligibility rules are checked here rather than deferred, because a
2539    /// caller who asked for bounded memory cannot be silently given the mirror
2540    /// instead. Under [`MemoryStrategy::Bounded`] a file the bounded engine
2541    /// cannot edit is refused up front; [`MemoryStrategy::Auto`] opts in to
2542    /// falling back to the mirror instead (issue #198, steps 3 and 4).
2543    ///
2544    /// Only a *bounded-only* limitation is worth falling back for — see
2545    /// [`bounded_only_limitation`]. Non-8-byte offsets or lengths are refused by
2546    /// [`open_imaged`](Self::open_imaged) for both engines, and so is an
2547    /// unsupported superblock version; a paged file without persisted free space
2548    /// is refused below for both.
2549    pub(crate) fn open_rw_with_strategy(
2550        path: &Path,
2551        cache: MetadataCacheConfig,
2552        locking: FileLocking,
2553        strategy: MemoryStrategy,
2554    ) -> Result<Self, Error> {
2555        if strategy == MemoryStrategy::Mirrored {
2556            return Self::open_with_locking(path, locking);
2557        }
2558        let mut session = Self::open_imaged(path, Some(locking), |handle, len| {
2559            Ok(Box::new(HandleImage::new(handle, len, cache)))
2560        })?;
2561        session.batched_appends = true;
2562        session.bounded = true;
2563        // Refusals that apply to *both* backings come first, or falling back for a
2564        // bounded-only limitation would skip them and hand back a session that
2565        // cannot commit. A paged file with no persisted managers has no on-disk
2566        // record of which pages hold metadata and which hold raw data, so nothing
2567        // can keep the pages segregated; the staged commit refuses it too, so
2568        // deferring would only trade this error for the same one later, with work
2569        // already staged. A userblock is one way to reach this state without the
2570        // file saying `persist = false`: persisted free space is declined for a
2571        // non-zero base address, which leaves the managers unseeded all the same.
2572        if session.paged.is_some() && session.persist.is_none() {
2573            return Err(Error::EditUnsupported(
2574                "read-write access to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
2575                 persisted free space; recreate the file with \
2576                 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to grow it in place",
2577            ));
2578        }
2579        if let Some(reason) = bounded_only_limitation(&session) {
2580            if strategy == MemoryStrategy::Bounded {
2581                return Err(Error::EditUnsupported(reason));
2582            }
2583            // Release the handle and its exclusive lock before reopening, or the
2584            // mirrored open would contend with the probe we are discarding —
2585            // fatally so on Windows, where the OS lock is mandatory. Dropping a
2586            // bare `WriteEngine` writes nothing: the free-space finalize that a
2587            // dropped writer owes lives on `FileInner`, which this is not yet.
2588            // Another writer can take the lock in that window; the reopen then
2589            // reports `Error::FileLocked`, which is the truthful answer.
2590            drop(session);
2591            return Self::open_with_locking(path, locking);
2592        }
2593        Ok(session)
2594    }
2595
2596    /// Open an existing file for SWMR (single-writer/multiple-reader) writing:
2597    /// take **no** OS lock at all and raise the superblock's SWMR-write
2598    /// consistency flag. Backs [`File::open_swmr_writer`](crate::File::open_swmr_writer).
2599    ///
2600    /// The no-lock is unconditional — `lock = None` never reaches
2601    /// `acquire_exclusive`, so `HDF5_USE_FILE_LOCKING` cannot reintroduce a lock
2602    /// that would block the concurrent readers SWMR exists to permit (fatally so
2603    /// on Windows, where OS locks are mandatory). Requires a latest-format
2604    /// (version-3 superblock) file with no userblock and no persisted
2605    /// free-space, so the superblock can be rewritten in place.
2606    ///
2607    /// The version-3 requirement is the C library's (`H5F__super_read`: "superblock
2608    /// version for SWMR is less than 3"), and it is what keeps the SWMR-write flag
2609    /// meaningful: neither library reads the status-flags byte back on an older
2610    /// superblock, so a flag raised there would announce a live writer to nobody.
2611    /// This crate's writer emits version 3, so no file it produces is affected.
2612    pub(crate) fn open_swmr_writer<P: AsRef<Path>>(
2613        path: P,
2614        sync_policy: SyncPolicy,
2615    ) -> Result<Self, Error> {
2616        let mut session = Self::open_inner(path.as_ref(), None)?;
2617        // Before the flag write below, which is a durability point like any
2618        // other: a caller who asked for no `fsync` gets none, and the flag still
2619        // reaches every other process, which reads it from the operating system.
2620        session.set_sync_policy(sync_policy);
2621        if session.superblock.version < 3
2622            || !session.superblock.base_address.is_zero()
2623            || session.persist.is_some()
2624        {
2625            return Err(Error::SwmrAppendUnsupported(
2626                "SWMR writing requires a latest-format file (v3 superblock) with no userblock \
2627                 and no persisted free-space",
2628            ));
2629        }
2630        session.swmr_mode = true;
2631        session.set_consistency_flags(SWMR_WRITE_FLAGS)?;
2632        Ok(session)
2633    }
2634
2635    /// Set the superblock's consistency flags in the mirror and on disk, then
2636    /// flush. The one place this crate writes that byte: the SWMR-write flag on
2637    /// open and off on close, and the page buffer's crash mark
2638    /// ([`raise_crash_mark`](Self::raise_crash_mark)).
2639    ///
2640    /// Requires a version-2/3 superblock, since [`Superblock::serialize`] emits
2641    /// that layout; `open_swmr_writer` and
2642    /// [`set_page_buffer_size`](Self::set_page_buffer_size) each check it.
2643    ///
2644    /// The root address is stored on disk *relative to the base address* while
2645    /// the session holds it absolute (see the normalization in `open_imaged`), so
2646    /// the serialized clone converts it back — as the commit tail does at the
2647    /// only other place this crate serializes a superblock. Without it, writing
2648    /// the absolute address here repoints the root past the end of the file while
2649    /// claiming only to have touched a flag, and the file stops reading.
2650    ///
2651    /// `open_swmr_writer` happens to hold a base of zero, since it turns a
2652    /// userblock away outright. Its sibling no longer does:
2653    /// [`set_page_buffer_size`](Self::set_page_buffer_size) once required
2654    /// persisted free space, which is declined for a non-zero base, and issue
2655    /// #357 scoped that refusal to paged files — so an unpaged userblock file
2656    /// now reaches here with a base to convert. It was never a property to build
2657    /// on, which is why `a_status_flag_write_leaves_a_userblock_files_root_alone`
2658    /// pins the conversion directly rather than through a caller.
2659    fn set_consistency_flags(&mut self, flags: u32) -> Result<(), Error> {
2660        self.superblock.consistency_flags = flags;
2661        self.held_status_flags = flags;
2662        let mut on_disk = self.superblock.clone();
2663        // `open_imaged` built the in-memory address by adding the base to the
2664        // stored one, so this cannot be below the base — but it is a subtraction
2665        // over two file-derived numbers, and `relative` reports rather than wraps.
2666        on_disk.root_group_address = on_disk
2667            .base_address
2668            .relative(on_disk.root_group_address)?
2669            .get();
2670        let bytes = on_disk.serialize();
2671        self.write_at(self.sb_sig_off, &bytes)?;
2672        self.barrier_data()?;
2673        Ok(())
2674    }
2675
2676    /// Raise the page buffer's crash mark: superblock status-flag bit 0
2677    /// (`H5F_SUPER_WRITE_ACCESS`), the byte the C library raises for any writer
2678    /// and this crate otherwise raises only for a SWMR one.
2679    ///
2680    /// Why a page buffer is not offered without one is
2681    /// [`with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size)'s
2682    /// to explain; what matters here is that two properties of *this* function
2683    /// are load-bearing, and neither is the [`SyncPolicy`]'s to skip:
2684    ///
2685    /// - It runs **before** the buffering mode changes, so no buffered write can
2686    ///   reach the disk ahead of it. Its caller states why nothing can fail in
2687    ///   between.
2688    /// - It is **fsynced**, unconditionally — the half no test here can see.
2689    ///   Reaching the operating system already covers a process that dies, and
2690    ///   this suite reproduces no power loss, so deleting the `sync_all` fails
2691    ///   nothing. It is there because power loss is exactly the case where the
2692    ///   buffer's held writes may be partly on the platter while a mark still in
2693    ///   the page cache is not. One `fsync` per session buys it.
2694    ///
2695    /// A failure rolls the byte back, best-effort. The alternative is a file
2696    /// marked in use by a session that never opened, which nothing can read until
2697    /// [`File::clear_swmr_flag`](crate::File::clear_swmr_flag) — a steep price
2698    /// for an open that failed. Best-effort because the rollback writes through
2699    /// the same handle that just failed; it is a chance, not a guarantee.
2700    fn raise_crash_mark(&mut self) -> Result<(), Error> {
2701        let raised = self
2702            .set_consistency_flags(file_lock::WRITE_ACCESS)
2703            .and_then(|()| self.image.sync_all());
2704        if raised.is_err() {
2705            let _ = self.set_consistency_flags(0);
2706            let _ = self.image.sync_all();
2707        }
2708        raised
2709    }
2710
2711    /// Take down whatever status flags this session raised, once everything they
2712    /// stood for is durable — the SWMR pair, or a page buffer's crash mark. A
2713    /// no-op for a session that raised none, which is every ordinary
2714    /// [`File::open_rw`](crate::File::open_rw) session.
2715    ///
2716    /// The whole byte goes to zero, so this cannot leave the half-set state
2717    /// [`check_status_flags`](crate::file_lock::check_status_flags) reports as
2718    /// flags in disagreement; that is why the guard asks whether anything is held
2719    /// rather than which bits are.
2720    ///
2721    /// Called from `File::close` and `FileInner::drop`, **after** their
2722    /// `force_sync`. The order is the whole point: clearing first and flushing
2723    /// second would leave a window in which the file's writes are still in memory
2724    /// and nothing on the disk says so, which is the state the flags exist to
2725    /// make unreachable. Nothing in this suite can tell the two orders apart —
2726    /// it takes a crash inside the window — so the argument is the reason, not a
2727    /// test.
2728    pub(crate) fn release_status_flags(&mut self) -> Result<(), Error> {
2729        if self.held_status_flags == 0 {
2730            return Ok(());
2731        }
2732        self.set_consistency_flags(0)?;
2733        self.image.sync_all()
2734    }
2735
2736    /// Shared open path. `lock = Some(policy)` acquires an exclusive OS lock under
2737    /// that policy (the ordinary read-write session); `lock = None` takes no lock
2738    /// at all (the SWMR writer — see [`open_swmr_writer`](Self::open_swmr_writer)).
2739    fn open_inner(path: &Path, lock: Option<FileLocking>) -> Result<Self, Error> {
2740        Self::open_imaged(path, lock, |handle, _len| {
2741            Ok(Box::new(Self::read_mirror(handle)?))
2742        })
2743    }
2744
2745    /// Read `handle` whole into a [`MirrorImage`]. The one place the engine
2746    /// still assumes it can hold the file, kept behind a named constructor so
2747    /// the mirrorless opens visibly do not call it.
2748    ///
2749    /// `read_to_end` reads from the handle's *current* cursor, and the open path
2750    /// has already read the superblock through it, so this rewinds first. Reading
2751    /// from wherever the last read landed would mirror a truncated file — with no
2752    /// error to say so, since a short mirror is a valid `Vec<u8>`.
2753    fn read_mirror(mut handle: fs::File) -> Result<MirrorImage, Error> {
2754        handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
2755        let mut data = Vec::new();
2756        handle.read_to_end(&mut data).map_err(Error::Io)?;
2757        Ok(MirrorImage::new(handle, data))
2758    }
2759
2760    /// Shared open path over any backing: acquire the handle (and, when asked,
2761    /// the exclusive lock), parse and validate the superblock through a borrowed
2762    /// view of the handle, and only then let `build` decide how the bytes are
2763    /// held.
2764    ///
2765    /// Every refusal comes before `build`, because `build` may read the whole
2766    /// file: reaching a refusal after it would spend `O(file size)` on a file
2767    /// that is then rejected — a 20 GB flagged file read into memory and thrown
2768    /// away. The superblock reads themselves are a few bounded windows either
2769    /// way, so nothing is read twice.
2770    ///
2771    /// `build` receives the file's length as well as the handle because a
2772    /// mirrorless image has to be told its end-of-file — it has no buffer whose
2773    /// length implies it.
2774    ///
2775    /// Nothing below this point reads the file as a slice, which is what lets
2776    /// one engine open a whole-file mirror, a mirrorless handle, and (in tests)
2777    /// a mirror that withholds its slice.
2778    fn open_imaged(
2779        path: &Path,
2780        lock: Option<FileLocking>,
2781        build: impl FnOnce(fs::File, u64) -> Result<Box<dyn FileImage>, Error>,
2782    ) -> Result<Self, Error> {
2783        let handle = fs::OpenOptions::new()
2784            .read(true)
2785            .write(true)
2786            .open(path)
2787            .map_err(Error::Io)?;
2788        // Acquire the exclusive lock before reading or mutating; the retained
2789        // `handle` holds it for the session's life. A `None` policy (SWMR) never
2790        // reaches `acquire_exclusive`, so no lock is ever taken.
2791        if let Some(policy) = lock {
2792            file_lock::acquire_exclusive(&handle, policy, path)?;
2793        }
2794        let len = handle.metadata().map_err(Error::Io)?.len();
2795        // Read the superblock through the handle itself, before any image owns
2796        // it. `probe` borrows, so it is gone by the time `build` takes the
2797        // handle; it leaves the handle's cursor wherever its last read ended,
2798        // which is why the mirror positions the handle before reading it whole.
2799        let probe = crate::image::BorrowedHandle::new(&handle, len);
2800        let sb_sig_off = signature::find_signature_in(&probe)?;
2801        let mut superblock = Superblock::parse_from_source(&probe, sb_sig_off)?;
2802
2803        if superblock.version > 3 {
2804            return Err(Error::EditUnsupported("unsupported superblock version"));
2805        }
2806        // Refuse a file a writer already holds, before anything is mutated. This
2807        // is the one exclusion the OS lock above cannot make: a SWMR writer
2808        // takes no lock, so its file is lock-free but flagged (issue #245).
2809        file_lock::check_status_flags(
2810            &superblock,
2811            file_lock::OpenIntent::Write,
2812            file_lock::OpenTarget::Path(path),
2813        )?;
2814        if superblock.offset_size != OFFSET_SIZE || superblock.length_size != LENGTH_SIZE {
2815            return Err(Error::EditUnsupported(
2816                "only 8-byte offsets and lengths are supported for in-place editing",
2817            ));
2818        }
2819        // A userblock shifts the whole HDF5 image forward by `base_address`: the
2820        // superblock sits at the base address and every stored address is relative
2821        // to it (the end-of-file address is the sole absolute field). The editor
2822        // supports this by reading at `stored + base` and writing back
2823        // `file_offset - base`. Only the canonical layout — superblock located
2824        // exactly at the base address (e.g. a MATLAB v7.3 `.mat` file's 512-byte
2825        // userblock) — is accepted; a base address that disagrees with the
2826        // superblock's location is a relocated or malformed file we will not rewrite.
2827        if superblock.base_address != BaseAddress::new(sb_sig_off) {
2828            return Err(Error::EditUnsupported(
2829                "a file whose superblock is not located at its base address is not editable in place",
2830            ));
2831        }
2832        // Normalize the root group address to an absolute file offset, exactly as
2833        // the reader does (`reader::parse_superblock`), so `resolve_path_any` and
2834        // the link-graph walk index the image correctly. It is converted back to a
2835        // stored (base-relative) address only when the superblock is serialized on
2836        // commit.
2837        superblock.root_group_address = superblock
2838            .base_address
2839            .absolute(StoredAddress::new(superblock.root_group_address))?;
2840
2841        // Everything that can refuse this file has run; only now is it worth
2842        // holding the bytes.
2843        let image = build(handle, len)?;
2844
2845        let mut session = Self {
2846            image,
2847            sb_sig_off,
2848            superblock,
2849            staged: StagedEdits::default(),
2850            appender_claims: Vec::new(),
2851            next_appender_token: 0,
2852            free: FreeList::new(),
2853            reserved: FreeList::new(),
2854            proved_free_of_references: false,
2855            persist: None,
2856            located: HashMap::new(),
2857            vl_overwrite_heaps: HashMap::new(),
2858            superseded_heaps: Vec::new(),
2859            inplace_undo: Vec::new(),
2860            swmr_mode: false,
2861            paged: None,
2862            committed: false,
2863            resolved: HashMap::new(),
2864            batched_appends: false,
2865            bounded: false,
2866            libver_ceiling: None,
2867            fsm_len: len,
2868            publish_attempted: false,
2869            staging_batch: false,
2870            staged_generation: 0,
2871            held_status_flags: 0,
2872            sync_policy: SyncPolicy::Always,
2873        };
2874        // If the file persists its free space, seed the free list from the
2875        // on-disk managers and arm persistence for future commits. Best-effort:
2876        // an unreadable or non-persisting extension simply leaves the session in
2877        // the default, non-persisting mode.
2878        session.load_persisted_free_space();
2879        // Gather this session's writes, now that the page size the file was laid
2880        // out on is known. Only an exclusively locked session: `lock = None` is
2881        // the SWMR writer, whose concurrent readers observe the order its ordered
2882        // phases become visible in, and so must see every write as it is made
2883        // (issue #288).
2884        //
2885        // `lock.is_some()` is a proxy for "not the SWMR writer", used because
2886        // `swmr_mode` is not set until after this. It is exact only while
2887        // `open_swmr_writer` is the sole lock-free entry point: a future one that
2888        // took a lock would silently *gain* gathering, which is the direction
2889        // that costs a reader. The other direction — a lock-free non-SWMR open
2890        // losing gathering — costs only writes. Whoever adds such an entry point
2891        // owns this condition.
2892        if lock.is_some() {
2893            let page_size = session.gather_page_size();
2894            session
2895                .image
2896                .set_write_buffering(WriteBuffering::Operation {
2897                    page_size,
2898                    max_bytes: WRITE_GATHER_BYTES,
2899                })?;
2900        }
2901        Ok(session)
2902    }
2903
2904    /// The page this session's writes are merged within: the file's own
2905    /// file-space page size when it is paged, and the format's default otherwise.
2906    fn gather_page_size(&self) -> u64 {
2907        self.paged
2908            .as_ref()
2909            .map_or(DEFAULT_GATHER_PAGE, |pg| pg.page_size)
2910    }
2911
2912    /// Let this session's writes span operations, up to `max_bytes` of them: the
2913    /// `H5Pset_page_buffer_size` analogue, requested through
2914    /// [`FileAccessProperties::with_page_buffer_size`](crate::FileAccessProperties::with_page_buffer_size).
2915    ///
2916    /// Refused for a budget below the page this session merges within — the
2917    /// file's own file-space page size when it is paged and
2918    /// [`DEFAULT_GATHER_PAGE`] when it is not, which is what
2919    /// [`gather_page_size`](Self::gather_page_size) answers. A buffer that
2920    /// cannot hold one page flushes on every page it touches. The C library
2921    /// reaches the same conclusion and acts on it *silently*, rounding the budget
2922    /// up at `H5Fopen`, which is the one part of its behavior not worth copying.
2923    ///
2924    /// Every larger budget is honored, including one below the
2925    /// [`WRITE_GATHER_BYTES`] this session was already gathering under. That was
2926    /// refused until issue #391; see the constant for what a small one costs.
2927    ///
2928    /// It does **not** require a paged file, where `H5Pset_page_buffer_size`
2929    /// does. The C page buffer is a page *cache*, and `H5PB_create` rejects an
2930    /// unpaged file because its `min_meta_perc`/`min_raw_perc` reservations are
2931    /// counted in pages that the paged allocator keeps segregated by kind. This
2932    /// is a write gatherer instead: it merges runs within a page-sized window and
2933    /// flushes whole, so the window is all it needs, and `gather_page_size`
2934    /// supplies one on an unpaged file already — it is what every read-write
2935    /// session's default [`Operation`](WriteBuffering::Operation) gathering runs
2936    /// under. Lifting the requirement leaves the file byte-identical, which is
2937    /// what the unpaged arm of `a_page_buffer_holds_dirty_pages_across_operations`
2938    /// asserts (issue #357).
2939    ///
2940    /// A zero budget leaves the session's default gathering alone, matching the
2941    /// property's own "unset" value.
2942    pub(crate) fn set_page_buffer_size(&mut self, max_bytes: usize) -> Result<(), Error> {
2943        if max_bytes == 0 {
2944            return Ok(());
2945        }
2946        // A lock-free session's readers observe the order its writes become
2947        // visible in. `File::open_swmr_writer` refuses this property before it
2948        // raises the on-disk flag, which is the refusal a caller sees; this one is
2949        // here because the guarantee belongs to the engine that would break it,
2950        // and a SWMR session can otherwise clear every check below — an unpaged
2951        // version-3 file with an ample budget is exactly what SWMR writing wants.
2952        if self.swmr_mode {
2953            return Err(Error::EditUnsupported(
2954                "a SWMR writer cannot buffer its writes: its readers observe the order they \
2955                 become visible in",
2956            ));
2957        }
2958        // One page is the whole of the size rule, and a buffer that cannot hold
2959        // one drains on every page it touches.
2960        //
2961        // A budget below `WRITE_GATHER_BYTES` was refused alongside it until
2962        // issue #391, on the grounds that a page buffer *replaces* the byte
2963        // budget the session was already gathering under rather than adding to
2964        // it, so a smaller one is also the point at which one long run is flushed
2965        // and restarted. That cost is real — nothing here bypasses the gatherer
2966        // the way `H5PB_write` bypasses the C page buffer for I/O of a page or
2967        // more (issue #357) — but it is not this crate's trade to make. The
2968        // budget it replaces is a per-operation peak released at every barrier,
2969        // while this one is held across operations, so a smaller number lowers
2970        // both the peak and the residency, and a writer inside a tight memory cap
2971        // is entitled to spend the writes for that. See `WRITE_GATHER_BYTES` for
2972        // the measurement.
2973        let page_size = self.gather_page_size();
2974        if (max_bytes as u64) < page_size {
2975            return Err(Error::EditUnsupported(
2976                "a page buffer must be at least the file's file-space page size",
2977            ));
2978        }
2979        // A page buffer holds dirty pages across ordering barriers, and under
2980        // `Always` every barrier is an `fsync` that flushes it — so it would hold
2981        // nothing, and the caller would have paid the mark below for nothing in
2982        // return. It lives here with its three siblings rather than at the fapl,
2983        // because a refusal kept apart from the others is one
2984        // `create_would_refuse_reopen` forgets to restate, which is how this one
2985        // came to write the file before refusing it.
2986        if self.sync_policy == SyncPolicy::Always {
2987            return Err(Error::EditUnsupported(
2988                "a page buffer does nothing under SyncPolicy::Always, whose every barrier is \
2989                 an fsync that flushes it; pair with_page_buffer_size with \
2990                 with_sync_policy(SyncPolicy::OnClose), or drop it",
2991            ));
2992        }
2993        // A paged file whose free space is not persisted can neither commit nor
2994        // append — both are refused — so a page buffer would have nothing to
2995        // hold, while its mark blocked every reader for the session's life.
2996        //
2997        // Scoped to a paged file: an *unpaged* one commits and appends whether it
2998        // persists its free space or not, so there is nothing here to refuse. The
2999        // base address the mark's superblock rewrite needs is not this check's to
3000        // settle — `set_consistency_flags` converts the root address itself, and
3001        // is tested for a userblock file directly rather than through a caller.
3002        if self.paged.is_some() && self.persist.is_none() {
3003            return Err(Error::EditUnsupported(
3004                "a page buffer on a paged file needs its free space persisted: without it \
3005                 this session can neither commit nor append, so the buffer would hold nothing \
3006                 while marking the file against every reader; recreate the file with \
3007                 with_file_space_strategy(FileSpaceStrategy::Page, true, ..), or drop the \
3008                 page buffer",
3009            ));
3010        }
3011        // The crash mark below is the whole reason this property is offered at
3012        // all, and only a version-3 superblock carries one that anybody reads:
3013        // `check_status_flags` is gated there, matching where the C library gates
3014        // it. A version-2 paged file is reachable — `LibVer::V18` writes a
3015        // version-2 superblock and nothing about paged file space rules it out —
3016        // so this is a refusal rather than an assertion (issue #308).
3017        if self.superblock.version < 3 {
3018            return Err(Error::EditUnsupported(
3019                "a page buffer marks the file for the life of the session, so that a session \
3020                 that crashes leaves one every reader refuses rather than one that reads \
3021                 clean; only a version-3 superblock carries a status-flags byte any library \
3022                 reads back, and this file's is older. Rewrite it at the 1.10 format \
3023                 (repack, or FileBuilder's default bounds), or drop the page buffer",
3024            ));
3025        }
3026        // Before the mode change, and durable: from here on a write may sit in
3027        // memory across a barrier, and the file has to say so on the disk first.
3028        //
3029        // Nothing can fail between the two and leave the file marked with no
3030        // session behind it. `raise_crash_mark` ends in a `sync_all`, which
3031        // flushes, so the gatherer is empty when `set_write_buffering` flushes it
3032        // again — the one fallible step it takes.
3033        self.raise_crash_mark()?;
3034        self.image.set_write_buffering(WriteBuffering::Session {
3035            page_size,
3036            max_bytes,
3037        })
3038    }
3039
3040    /// Read the superblock-extension File Space Info message; if it requests
3041    /// persistence, seed [`self.free`](Self::free) from the on-disk free-space
3042    /// managers and record the manager/extension block extents for reclamation on
3043    /// the next commit. Silent on any malformed or absent metadata — persistence
3044    /// is then simply off for this session.
3045    fn load_persisted_free_space(&mut self) {
3046        if self.superblock.version < 2 {
3047            return; // no superblock extension exists before v2
3048        }
3049        let Some(ext_rel) = self.superblock.superblock_extension_address else {
3050            return;
3051        };
3052        let ext_rel = StoredAddress::new(ext_rel);
3053        if ext_rel.is_undefined(self.superblock.offset_size) {
3054            return;
3055        }
3056        // The header is read at the extension's absolute file offset. That is the
3057        // same offset on the base-0 file every path below the userblock check
3058        // sees, but the check itself needs the strategy of a *userblock* file.
3059        let Ok(ext_addr) = self.superblock.base_address.absolute(ext_rel) else {
3060            return;
3061        };
3062        let Some(info) = self.extension_fsinfo(ext_addr) else {
3063            return;
3064        };
3065        // Free-space reuse and persistence are not yet base-address aware: the
3066        // persisted section addresses (and the extension/manager block walk below)
3067        // are read as absolute, so on a userblock file they would seed `self.free`
3068        // with wrong regions that a later allocation could hand out into live
3069        // data. Leave persistence off for such a file — the on-disk managers stay
3070        // untouched and valid, this session simply appends rather than reusing.
3071        //
3072        // A *paged* userblock file is a different matter: appending without page
3073        // awareness would mix metadata and raw data in its pages and leave its end
3074        // of allocation unaligned, quietly producing a file that still claims the
3075        // paged strategy but no longer satisfies it. Install the paged marker
3076        // without persistence so the commit refusal below catches it, which is the
3077        // same rule a paged non-persisting file already takes.
3078        if !self.superblock.base_address.is_zero() {
3079            if info.strategy == FileSpaceStrategy::Page && info.page_size > 0 {
3080                self.paged = Some(PagedEdit::new(info.page_size));
3081            }
3082            return;
3083        }
3084        // Record the paged strategy regardless of the persist flag: a paged commit
3085        // needs page-aware bookkeeping, and a paged file that does not persist its
3086        // free space is refused outright (see `PagedEdit` and the commit refusal).
3087        //
3088        // A zero page size is refused rather than installed: every page calculation
3089        // divides by it, so a corrupt or hostile file declaring `Page` with a page
3090        // size of 0 would panic the editor. Leaving `paged` unset makes the file
3091        // take the ordinary flat path, which needs no page geometry.
3092        let paged = info.strategy == FileSpaceStrategy::Page && info.page_size > 0;
3093        if paged {
3094            self.paged = Some(PagedEdit::new(info.page_size));
3095        }
3096        if !info.persist {
3097            return;
3098        }
3099        let os = self.superblock.offset_size;
3100        let file_len = self.image.len();
3101
3102        // Seed the free list(s) with every persisted section (addresses are stored
3103        // relative to the base address, which this editor requires to be 0).
3104        // Defensive against a malformed or corrupt manager: skip a section that is
3105        // empty, runs past end-of-file, or overlaps one already taken. A
3106        // well-formed file (this crate's or the C library's) has none of these;
3107        // tolerating them keeps a bad file from seeding a bogus or double-counted
3108        // free region that a later commit would hand out into live data.
3109        if paged {
3110            // A paged file's free space is segregated across per-page-type
3111            // managers, so read each slot on its own and keep the page type its
3112            // slot implies ([`PagedEdit::slot_list`]). Flattening them (as the
3113            // non-paged path below does) would lose exactly the distinction the
3114            // commit has to preserve. A section whose slot does not settle its page
3115            // type is recorded but never handed out.
3116            let page_size = info.page_size;
3117            let mut tagged: Vec<(FreeSection, Option<PageType>)> = Vec::new();
3118            for (slot, &m) in info.manager_addrs.iter().enumerate() {
3119                if StoredAddress::new(m).is_undefined(os) {
3120                    continue;
3121                }
3122                let Ok(sections) = free_space_manager::read_persisted_sections_source(
3123                    &self.image(),
3124                    &[m],
3125                    BaseAddress::ZERO,
3126                    os,
3127                )
3128                .map(|(sections, _)| sections) else {
3129                    continue;
3130                };
3131                for s in sections {
3132                    let ty = PagedEdit::slot_list(slot, s.addr.get(), s.size, page_size);
3133                    tagged.push((s, ty));
3134                }
3135            }
3136            // Distinct sections have distinct addresses in any well-formed file,
3137            // so the tie-break never arises; only a malformed manager can
3138            // advertise one address twice, and the overlap guard below already
3139            // discards the second of any such pair. No `debug_assert` here: this
3140            // parses untrusted bytes, which must not panic a debug build.
3141            tagged.sort_unstable_by_key(|(s, _)| s.addr);
3142            let mut prev_end = 0u64;
3143            for (s, ty) in tagged {
3144                let Some(end) = s.addr.get().checked_add(s.size) else {
3145                    continue;
3146                };
3147                if s.size == 0 || end > file_len || s.addr.get() < prev_end {
3148                    continue;
3149                }
3150                prev_end = end;
3151                let pg = self
3152                    .paged
3153                    .as_mut()
3154                    .expect("the paged state was just installed");
3155                match ty {
3156                    Some(ty) => PagedEdit::route_free(
3157                        &mut pg.meta,
3158                        &mut pg.raw,
3159                        &mut pg.dead,
3160                        s.addr.get(),
3161                        s.size,
3162                        ty.into(),
3163                    ),
3164                    None => pg.unclassified.free(s.addr.get(), s.size),
3165                }
3166            }
3167            // A page the seeded lists empty between them belongs to no type and
3168            // may serve either, so fold it in now rather than leaving it to the
3169            // first commit. Nothing this crate writes produces one — it files a
3170            // whole free page under a single manager — but a file another writer
3171            // laid out can, and the rule is the same wherever the page came from.
3172            let pg = self
3173                .paged
3174                .as_mut()
3175                .expect("the paged state was just installed");
3176            PagedEdit::promote_whole_free_pages(&mut pg.meta, &mut pg.raw, &mut pg.dead, page_size);
3177        } else if let Ok(mut sections) = free_space_manager::read_persisted_sections_source(
3178            &self.image(),
3179            &info.manager_addrs,
3180            BaseAddress::ZERO,
3181            os,
3182        )
3183        .map(|(sections, _)| sections)
3184        {
3185            // Unique addresses in any well-formed file; see the paged branch
3186            // above for why a duplicate is harmless and unasserted here.
3187            sections.sort_unstable_by_key(|s| s.addr);
3188            let mut prev_end = 0u64;
3189            for s in sections {
3190                let Some(end) = s.addr.get().checked_add(s.size) else {
3191                    continue;
3192                };
3193                if s.size == 0 || end > file_len || s.addr.get() < prev_end {
3194                    continue;
3195                }
3196                prev_end = end;
3197                self.free.free(s.addr.get(), s.size);
3198            }
3199        }
3200
3201        // Record the byte extents of the blocks the live file uses so the next
3202        // persisting commit frees them when it writes replacements: the
3203        // extension header, and each defined manager's FSHD + FSSE.
3204        let mut old_blocks = Vec::new();
3205        if let Ok(spans) = self.oh_chunk_spans(ext_addr) {
3206            old_blocks.extend(spans);
3207        }
3208        for &m in &info.manager_addrs {
3209            if StoredAddress::new(m).is_undefined(os) {
3210                continue;
3211            }
3212            let Ok(hdr_len) = fshd_len(os).to_usize() else {
3213                continue;
3214            };
3215            let Ok(fshd) = self.image().read_metadata_at(m, hdr_len) else {
3216                continue;
3217            };
3218            if let Ok(h) = FsmHeader::parse(&fshd, os) {
3219                // `FsmHeader::parse` succeeding guarantees the header's own bytes
3220                // are present, so the FSHD extent is in-bounds; validate the
3221                // section-info extent before recording it, so a malformed
3222                // `fsse_used` can't later free a region running past end-of-file.
3223                old_blocks.push((m, fshd_len(os)));
3224                if !h.fsse_addr.is_undefined(os)
3225                    && h.fsse_addr
3226                        .get()
3227                        .checked_add(h.fsse_used)
3228                        .is_some_and(|end| end <= file_len)
3229                {
3230                    old_blocks.push((h.fsse_addr.get(), h.fsse_used));
3231                }
3232            }
3233        }
3234
3235        self.persist = Some(PersistState {
3236            strategy: info.strategy,
3237            threshold: info.threshold,
3238            page_size: info.page_size,
3239            old_blocks,
3240        });
3241    }
3242
3243    /// Parses the File Space Info message out of the superblock-extension object
3244    /// header at `ext_addr`, if present and readable.
3245    fn extension_fsinfo(&self, ext_addr: u64) -> Option<FileSpaceInfo> {
3246        let os = self.superblock.offset_size;
3247        let ls = self.superblock.length_size;
3248        let base = self.superblock.base_address;
3249        let oh = ObjectHeader::parse_from_source(
3250            &self.image(),
3251            AccessMode::ReadWrite,
3252            ext_addr,
3253            os,
3254            ls,
3255            base,
3256        )
3257        .ok()?;
3258        let msg = oh
3259            .messages
3260            .iter()
3261            .find(|m| m.msg_type == MessageType::FILE_SPACE_INFO)?;
3262        FileSpaceInfo::parse(&msg.data, os, ls).ok()
3263    }
3264
3265    /// Stage a new dataset, added on the next [`commit`](Self::commit).
3266    ///
3267    /// `path` is the dataset's full path; everything before the last component
3268    /// names the parent group, which must exist (or be created in this session).
3269    /// `builder` is the same [`DatasetBuilder`] [`FileBuilder`](crate::FileBuilder)
3270    /// uses, configured by the caller; its name is taken from `path`, so the two
3271    /// cannot disagree.
3272    ///
3273    /// The builder is passed in finished rather than handed out as a `&mut` into
3274    /// this engine. That is deliberate: a borrow into the engine forces the
3275    /// caller to hold it — and, above this layer, its lock — for as long as the
3276    /// builder is being configured, which is what let a user closure deadlock
3277    /// against the same file it was reading (issue #200).
3278    ///
3279    /// The dataset may be contiguous or chunked, and chunked datasets may be
3280    /// filtered (`with_deflate`, `with_shuffle`, `with_fletcher32`,
3281    /// `with_scale_offset`, `with_zfp`) and/or extensible (`with_maxshape`). An
3282    /// empty (zero-element) dataset is supported under either storage, a
3283    /// provenance dataset (`with_provenance`) is supported, and a
3284    /// contiguous dataset may carry variable-length attributes, a
3285    /// variable-length-string payload (`with_vlen_strings`), or path-resolved
3286    /// object-reference elements (`with_path_references`; chunking any of
3287    /// these is not supported). An attribute set too large for the object header
3288    /// is written to a fractal heap, as the whole-file writer does.
3289    pub(crate) fn stage_created_dataset(
3290        &mut self,
3291        path: &ObjectPathBuf,
3292        builder: DatasetBuilder,
3293    ) -> Result<(), Error> {
3294        self.refuse_if_claimed(path)?;
3295        let Some((parent, leaf)) = path.split_leaf() else {
3296            return Err(Error::EditUnsupported("dataset path has an empty name"));
3297        };
3298        self.refuse_creation_collision(path, StagedKind::Dataset)?;
3299        self.staged
3300            .push_dataset(parent, flatten_dataset(builder, leaf)?);
3301        Ok(())
3302    }
3303
3304    /// Stage an in-place overwrite of an **existing** dataset's values (the HDF5
3305    /// `H5Dwrite` whole-dataset write), applied on the next
3306    /// [`commit`](Self::commit).
3307    ///
3308    /// `path` is the full path of a dataset that must already exist; `builder`
3309    /// supplies the replacement data and is named from `path`, as in
3310    /// [`stage_created_dataset`](Self::stage_created_dataset).
3311    ///
3312    /// This is a *value* overwrite, not a reshape or retype: the new data's
3313    /// datatype and shape must match the on-disk dataset's exactly (byte-for-byte
3314    /// after serialization, so endianness and compound layout must agree), or
3315    /// `commit` reports [`Error::EditUnsupported`]. Contiguous, compact, and
3316    /// chunked (including filtered) datasets are all supported; the dataset's
3317    /// existing chunk geometry, filter pipeline, and chunk index are taken from the
3318    /// on-disk header. A chunk index this engine cannot enumerate (a version-2
3319    /// B-tree) is refused. Partial / sub-region writes are out of scope — the
3320    /// whole dataset is replaced.
3321    ///
3322    /// What the builder alone can rule out —
3323    /// [`refuse_unsupported_overwrite`](Self::refuse_unsupported_overwrite) —
3324    /// is refused *here* rather than at `commit`.
3325    ///
3326    /// When the new data is the same length as the existing contiguous data block
3327    /// (the common case), the bytes are written straight into that block: no
3328    /// object header is rewritten and the superblock root is not flipped, so the
3329    /// commit's linearization point is the synced data write itself. A chunked
3330    /// dataset is handled the same way when every (re-encoded) chunk is the same
3331    /// byte length as the slot it replaces — an unfiltered overwrite (chunk sizes
3332    /// are fixed by the unchanged shape) or a filtered one whose re-encoded chunks
3333    /// match — so it too writes straight into the existing chunk slots. When the
3334    /// length differs (a resized contiguous block, or a filtered chunk that no
3335    /// longer fits), the dataset's storage is rebuilt at end-of-file (or in
3336    /// reusable freed space), the old extent is freed, the data-layout message is
3337    /// repointed, the object header is rewritten, and the parent group's link is
3338    /// patched — exactly like an addition relocates the path up to the root. A
3339    /// relocating overwrite moves the object header, so it is refused unless the
3340    /// dataset has a single hard link.
3341    pub(crate) fn stage_dataset_write(
3342        &mut self,
3343        path: &ObjectPathBuf,
3344        builder: DatasetBuilder,
3345    ) -> Result<(), Error> {
3346        self.refuse_if_claimed(path)?;
3347        // The root group has no link to overwrite: the superblock holds its address.
3348        let Some((_, leaf)) = path.split_leaf() else {
3349            return Err(Error::EditUnsupported("cannot overwrite the root group"));
3350        };
3351        let fd = flatten_dataset(builder, leaf)?;
3352        Self::refuse_unsupported_overwrite(&fd)?;
3353        self.staged.writes.push((path.clone(), fd));
3354        Ok(())
3355    }
3356
3357    /// Stage an append of new elements to an **existing** chunked, unlimited
3358    /// dataset, applied on the next [`commit`](Self::commit).
3359    ///
3360    /// `path` names a dataset that must already exist; `builder` supplies the
3361    /// elements to add via its typed / generic / raw `append_*` methods.
3362    ///
3363    /// Unlike [`stage_dataset_write`](Self::stage_dataset_write) (a value
3364    /// overwrite that forbids any shape change) this **grows** the dataset along
3365    /// its first (axis-0) dimension. It works on **filtered** datasets: the
3366    /// appended chunks are compressed through the dataset's own on-disk filter
3367    /// pipeline (deflate / shuffle / fletcher32 / scale-offset / LZF, and ZFP
3368    /// with the `zfp` feature), and the pipeline, datatype, fill value, and attributes are
3369    /// preserved verbatim. Appends of any length are supported — when the
3370    /// dataset's current length is not a whole multiple of the chunk length, the
3371    /// single trailing partial chunk is read, extended, and re-encoded; every
3372    /// other existing chunk is carried by metadata alone, so the existing data is
3373    /// not rewritten and the file does not grow by the whole dataset per append.
3374    ///
3375    /// This does **not** use SWMR and sets no consistency flag. Like every other
3376    /// staged edit it commits by appending the new chunks and a rebuilt
3377    /// index at end-of-file and repointing the superblock last (under the
3378    /// session's exclusive lock), so a crash leaves either the original dataset or
3379    /// the fully-grown one, never a torn state.
3380    ///
3381    /// The first release supports the Extensible-Array chunk index (the index the
3382    /// reference C library and h5py select for a single unlimited dimension under
3383    /// the latest format, and the one this crate writes for every unlimited
3384    /// dataset), rank-1 datasets, and datasets with a single hard link. A dataset
3385    /// that is not chunked, not unlimited along axis 0, not Extensible-Array
3386    /// indexed, higher than rank 1, uses a filter this engine cannot re-encode,
3387    /// has a sparse chunk grid, or (for [`append_raw`](AppendBuilder::append_raw))
3388    /// has a big-endian element datatype is refused with
3389    /// [`Error::AppendUnsupported`]. Use [`Dataset::is_chunked`](crate::Dataset::is_chunked),
3390    /// [`maxshape`](crate::Dataset::maxshape), and [`filters`](crate::Dataset::filters)
3391    /// to check eligibility up front.
3392    pub(crate) fn stage_dataset_append(
3393        &mut self,
3394        path: &ObjectPathBuf,
3395        builder: AppendBuilder,
3396    ) -> Result<(), Error> {
3397        self.refuse_if_claimed(path)?;
3398        if self.staged.dataset_at(path).is_some() {
3399            // This call came through a handle onto the object *in the file*: a
3400            // handle onto the staged creation goes to
3401            // [`stage_dataset_append_pending`] instead. So the elements could
3402            // only be meant for the object the same commit replaces, which is
3403            // never what the caller wants and is what `commit` refuses two calls
3404            // later. Said here, where the handle that asked is still in hand.
3405            return Err(Error::EditUnsupported(
3406                "a dataset is staged at this path in the same commit, so an append through a \
3407                 handle onto the object the file holds there could only grow the object being \
3408                 replaced; append through the handle that staged the creation, or commit first",
3409            ));
3410        }
3411        self.refuse_lossy_partial_tail(path, &builder)?;
3412        self.staged.appends.push((path.clone(), builder));
3413        Ok(())
3414    }
3415
3416    /// Refuse, at the call that stages an append, a dataset whose lossy pipeline
3417    /// sits on a partial trailing chunk — the refusal
3418    /// [`prepare_append`](Self::prepare_append) raises during the commit's
3419    /// preflight, brought forward to where the caller can act on it (issue #407).
3420    ///
3421    /// Waiting for the commit makes that refusal permanent for the session and
3422    /// costs every edit staged beside it: a preflight failure restores the staged
3423    /// set, so the offending append is still there at the next
3424    /// [`commit`](crate::File::commit), and [`File::close`](crate::File::close)
3425    /// runs one and returns its `Err` — discarding the whole staged set on the way
3426    /// out. `BufferedAppender::new` refuses the same dataset eagerly for the same
3427    /// reason; this puts the staged path on the same footing.
3428    ///
3429    /// **Positive proof only.** Every reason the geometry cannot be read here — a
3430    /// path that does not resolve, a header this engine does not parse, a layout
3431    /// that is not chunked, a rank this release does not append to — leaves the
3432    /// answer to the preflight, which names each of them. Nothing about the bytes
3433    /// being appended enters into it: the on-disk length is what decides whether a
3434    /// trailing chunk has to be re-encoded, and staged appends never change that
3435    /// before the commit reads it. The commit-time check stays as the backstop,
3436    /// and is the only one that runs for an append staged through some other
3437    /// entry point.
3438    fn refuse_lossy_partial_tail(
3439        &self,
3440        path: &ObjectPathBuf,
3441        builder: &AppendBuilder,
3442    ) -> Result<(), Error> {
3443        // A zero-length append is dropped by the preflight before it ever reaches
3444        // `prepare_append`, so it stays a no-op here rather than becoming the one
3445        // append this refusal would newly reject.
3446        if builder.raw().is_empty() {
3447            return Ok(());
3448        }
3449        if self.appends_onto_a_lossy_partial_tail(path) {
3450            return Err(Error::AppendUnsupported(LOSSY_TAIL_REFUSAL));
3451        }
3452        Ok(())
3453    }
3454
3455    /// The geometry question behind [`refuse_lossy_partial_tail`](Self::refuse_lossy_partial_tail):
3456    /// does the file hold, at `path`, a rank-1 chunked dataset under a lossy
3457    /// filter pipeline whose length is not a whole multiple of its chunk length?
3458    /// `false` whenever that cannot be established, for whatever reason.
3459    fn appends_onto_a_lossy_partial_tail(&self, path: &ObjectPathBuf) -> bool {
3460        let Ok(addr) = crate::group_v2::resolve_path_any_from_source(
3461            &self.image(),
3462            AccessMode::ReadWrite,
3463            &self.superblock,
3464            &path.as_path(),
3465        ) else {
3466            return false;
3467        };
3468        let Ok(region) =
3469            Self::gather_oh_messages(&self.image(), addr, self.superblock.base_address)
3470        else {
3471            return false;
3472        };
3473        let mut datatype: Option<(usize, usize)> = None;
3474        let mut dataspace: Option<(usize, usize)> = None;
3475        let mut layout: Option<(usize, usize)> = None;
3476        let mut filter: Option<(usize, usize)> = None;
3477        let mut p = 0;
3478        while let Ok(Some((msg_type, body, body_end))) = region.next_message(p) {
3479            match msg_type {
3480                MessageType::DATATYPE => datatype = Some((body, body_end)),
3481                MessageType::DATASPACE => dataspace = Some((body, body_end)),
3482                MessageType::DATA_LAYOUT => layout = Some((body, body_end)),
3483                MessageType::FILTER_PIPELINE => filter = Some((body, body_end)),
3484                _ => {}
3485            }
3486            p = body_end;
3487        }
3488        // No pipeline at all is nothing to re-encode, and an unparseable one is
3489        // refused by the preflight on its own terms.
3490        let (Some((fb, fe)), Some((dt_b, dt_e)), Some((ds_b, ds_e)), Some((lb, le))) =
3491            (filter, datatype, dataspace, layout)
3492        else {
3493            return false;
3494        };
3495        let Ok(pipeline) = FilterPipeline::parse(&region[fb..fe]) else {
3496            return false;
3497        };
3498        if pipeline_lossless(&pipeline) {
3499            return false;
3500        }
3501        let Ok((disk_dt, _)) = Datatype::parse(&region[dt_b..dt_e]) else {
3502            return false;
3503        };
3504        let Ok(disk_ds) = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE) else {
3505            return false;
3506        };
3507        let Ok(dl) = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE) else {
3508            return false;
3509        };
3510        // Rank 1 is the shape the preflight's `current_dim0 % chunk_elems`
3511        // arithmetic is about; anything else it refuses before reaching that test.
3512        if disk_ds.dimensions.len() != 1 {
3513            return false;
3514        }
3515        let Ok(geometry) = chunked_geometry(&disk_dt, &disk_ds, &dl) else {
3516            return false;
3517        };
3518        match (geometry.spatial.first(), disk_ds.dimensions.first()) {
3519            (Some(&chunk_elems), Some(&dim0)) if chunk_elems != 0 => dim0 % chunk_elems != 0,
3520            _ => false,
3521        }
3522    }
3523
3524    /// Stage an append made through a handle onto a dataset **this session
3525    /// staged and has not committed**, folding the elements into the pending
3526    /// creation.
3527    ///
3528    /// The caller establishes that its handle is the pending one;
3529    /// [`stage_dataset_append`](Self::stage_dataset_append) is the entry point
3530    /// for a handle onto an object the file already holds, and refuses the same
3531    /// path. Between the caller's check and this lock another thread can commit,
3532    /// which leaves the dataset in the file and nothing staged at its path — so
3533    /// the append becomes an ordinary staged one rather than an error.
3534    pub(crate) fn stage_dataset_append_pending(
3535        &mut self,
3536        path: &ObjectPathBuf,
3537        builder: AppendBuilder,
3538    ) -> Result<(), Error> {
3539        self.refuse_if_claimed(path)?;
3540        if self.staged.dataset_at(path).is_none() {
3541            // Nothing staged here after all, so these elements grow the object the
3542            // file holds — including its trailing chunk, which the same eager
3543            // refusal applies to. The fold below reaches no on-disk chunk at all
3544            // and is untouched by it.
3545            self.refuse_lossy_partial_tail(path, &builder)?;
3546            self.staged.appends.push((path.clone(), builder));
3547            return Ok(());
3548        }
3549        self.refuse_mid_batch()?;
3550        self.extend_staged_dataset(path, &builder)
3551    }
3552
3553    /// Refuse an edit that changes or withdraws something already staged while a
3554    /// [`stage_atomically`](Self::stage_atomically) batch is open.
3555    ///
3556    /// [`StagedEdits::rewind`] undoes a batch by truncating the staged vectors,
3557    /// which is exact only while staging appends to them. The two operations
3558    /// that do otherwise — the fold
3559    /// [`stage_dataset_append_pending`](Self::stage_dataset_append_pending)
3560    /// makes, and the withdrawal [`delete`](Self::delete) makes — would leave a
3561    /// refused batch holding half of one. No caller reaches this today: a batch
3562    /// replays [`StagedOp`](crate::StagedGroup)s, which only ever create. It is
3563    /// here so the claim `rewind` rests on is enforced rather than remembered.
3564    fn refuse_mid_batch(&self) -> Result<(), Error> {
3565        if self.staging_batch {
3566            return Err(Error::EditUnsupported(
3567                "an edit that changes or withdraws an already-staged object cannot be made \
3568                 inside an atomic staging batch, whose undo drops additions only",
3569            ));
3570        }
3571        Ok(())
3572    }
3573
3574    /// Fold an append onto a dataset this same session staged and has not
3575    /// committed into the pending creation: the elements are added to the
3576    /// bytes the commit will write and the leading dimension grows to match.
3577    ///
3578    /// There is no dataset in the file to grow, so this is the same edit as
3579    /// having handed the elements to the builder in the first place — which is
3580    /// why it asks for neither an unlimited dimension nor a chunked layout,
3581    /// where an append onto a *committed* dataset needs both. Anything the
3582    /// staged creation must resolve at commit time per element — a
3583    /// variable-length string payload, an object reference — is refused: those
3584    /// carry a side table indexed by element that this cannot extend.
3585    ///
3586    /// A provenance dataset (`with_provenance`) is grown rather than refused:
3587    /// its attributes are derived from the bytes, so they are rebuilt from the
3588    /// grown ones ([`FlatDataset::rebuild_provenance`]) and the digest the
3589    /// commit writes is the digest of what it writes.
3590    fn extend_staged_dataset(
3591        &mut self,
3592        path: &ObjectPathBuf,
3593        builder: &AppendBuilder,
3594    ) -> Result<(), Error> {
3595        if builder.dt_conflict() {
3596            return Err(Error::AppendUnsupported(
3597                "this append mixes element datatypes; use one element type per append",
3598            ));
3599        }
3600        let fd = self
3601            .staged
3602            .dataset_at_mut(path)
3603            .expect("caller checked a dataset is staged at this path");
3604        if fd.vl_string_staging.is_some() || fd.reference_targets.is_some() {
3605            return Err(Error::AppendUnsupported(
3606                "a staged variable-length-string or object-reference dataset cannot be appended \
3607                 to before it is committed: its per-element side table is built with the \
3608                 dataset. Supply every element through the builder that creates it",
3609            ));
3610        }
3611        if let Some(dt) = builder.elem_dt() {
3612            if *dt != fd.dt {
3613                return Err(Error::AppendUnsupported(
3614                    "appended element datatype does not match the staged dataset's datatype",
3615                ));
3616            }
3617        }
3618        let Some((lead, inner)) = fd.ds.dimensions.split_first() else {
3619            return Err(Error::AppendUnsupported(
3620                "a scalar dataset has no dimension to append along",
3621            ));
3622        };
3623        // One row of the leading dimension, in bytes: the element size times
3624        // every trailing extent. A rank-1 dataset's row is one element.
3625        let mut row_bytes = u64::from(fd.dt.type_size());
3626        for &d in inner {
3627            row_bytes = row_bytes.checked_mul(d).ok_or(Error::AppendUnsupported(
3628                "staged dataset row size overflows",
3629            ))?;
3630        }
3631        if row_bytes == 0 {
3632            return Err(Error::AppendUnsupported(
3633                "a staged dataset whose rows hold no bytes cannot be appended to",
3634            ));
3635        }
3636        let bytes = builder.raw();
3637        if bytes.len() as u64 % row_bytes != 0 {
3638            return Err(Error::AppendUnsupported(
3639                "appended byte length is not a whole number of rows of the staged dataset",
3640            ));
3641        }
3642        let grown = lead
3643            .checked_add(bytes.len() as u64 / row_bytes)
3644            .ok_or(Error::AppendUnsupported("staged dataset length overflows"))?;
3645        // A finite maximum is a promise the commit would refuse to break.
3646        if let Some(max) = fd
3647            .ds
3648            .max_dimensions
3649            .as_ref()
3650            .and_then(|m| m.first().copied())
3651            && !max.admits(grown)
3652        {
3653            return Err(Error::AppendUnsupported(
3654                "appending would grow the staged dataset past its maximum shape",
3655            ));
3656        }
3657        fd.raw.extend_from_slice(bytes);
3658        fd.ds.dimensions[0] = grown;
3659        // The digest covers the bytes, so it is recomputed over the grown ones
3660        // rather than left describing the shorter dataset that was staged. Doing
3661        // it here, on the record the commit writes, is what keeps
3662        // `verify_provenance` passing on the file this produces.
3663        #[cfg(feature = "provenance")]
3664        fd.rebuild_provenance();
3665        Ok(())
3666    }
3667
3668    /// Register a live [`BufferedAppender`](crate::BufferedAppender) on `path`
3669    /// (`None` for a handle reached by object reference), returning the token
3670    /// that releases it.
3671    ///
3672    /// Refused when the claim could not be honored: a second appender on the
3673    /// same dataset would interleave the two buffers a chunk at a time, and a
3674    /// staged edit already pending on that path is one the appender's own flush
3675    /// would later refuse.
3676    pub(crate) fn claim_for_appender(
3677        &mut self,
3678        path: Option<&ObjectPathBuf>,
3679    ) -> Result<u64, Error> {
3680        if self
3681            .appender_claims
3682            .iter()
3683            .any(|claim| claim.conflicts_with(path))
3684        {
3685            return Err(Error::EditUnsupported(
3686                "this dataset already has a live buffered appender; two of them would interleave \
3687                 their buffers a chunk at a time",
3688            ));
3689        }
3690        let blocked = match path {
3691            Some(p) => self.append_conflicts_with_pending(p),
3692            None => self.has_staged_edits() || self.committed,
3693        };
3694        if blocked {
3695            return Err(Error::EditUnsupported(
3696                "this session holds staged edits that would stop a buffered appender from \
3697                 flushing; commit or discard them before opening one",
3698            ));
3699        }
3700        let token = self.next_appender_token;
3701        self.next_appender_token += 1;
3702        self.appender_claims.push(AppenderClaim {
3703            token,
3704            path: path.cloned(),
3705        });
3706        Ok(token)
3707    }
3708
3709    /// Drop a claim. Called from the appender's `Drop`, after its final flush.
3710    pub(crate) fn release_appender_claim(&mut self, token: u64) {
3711        self.appender_claims.retain(|c| c.token != token);
3712    }
3713
3714    /// Refuse a staged edit at `path` that a live appender could not survive.
3715    ///
3716    /// An appender holds elements a caller has already been told were accepted,
3717    /// and only its own flush can write them; that flush goes through the
3718    /// immediate append path, which refuses a dataset with a staged edit on it or
3719    /// an ancestor. Left unchecked, staging such an edit turns accepted data into
3720    /// data lost silently in `Drop`. Refusing here moves the failure to the call
3721    /// that creates the conflict, where there is someone to report it to.
3722    fn refuse_if_claimed(&self, path: &ObjectPathBuf) -> Result<(), Error> {
3723        let conflicts = self.appender_claims.iter().any(|c| match &c.path {
3724            Some(p) => p.overlaps(path),
3725            None => true,
3726        });
3727        if conflicts {
3728            return Err(Error::EditUnsupported(
3729                "this dataset has a live buffered appender holding elements only its own flush \
3730                 can write, and this edit would stop that flush; finish or discard the appender \
3731                 first",
3732            ));
3733        }
3734        Ok(())
3735    }
3736
3737    /// Whether this session is the SWMR writer, whose append rules are a strict
3738    /// subset of the ordinary ones (see `append_inplace_gathered`).
3739    pub(crate) fn is_swmr(&self) -> bool {
3740        self.swmr_mode
3741    }
3742
3743    /// Whether any staged tree edit is still uncommitted. In-place appends
3744    /// ([`append_inplace_gathered`](Self::append_inplace_gathered)) are applied immediately and are
3745    /// never staged, so they never affect this; it reflects only edits awaiting
3746    /// [`commit`](Self::commit) — `create_group`, `create_dataset`,
3747    /// `write_dataset`, `append_dataset`, group and dataset attribute edits,
3748    /// `delete`, `copy`, and `copy_from`. Dropping the session silently discards
3749    /// any staged edits.
3750    ///
3751    /// A [`commit`](Self::commit) that refuses leaves this answering `true`: the
3752    /// staged set it declined to apply is put back exactly as it was.
3753    pub fn has_staged_edits(&self) -> bool {
3754        !self.staged.is_empty()
3755    }
3756
3757    /// Run `f`, and if it fails, drop whatever it managed to stage.
3758    ///
3759    /// One caller-facing call can stage many edits — `create_group_with` stages
3760    /// a group, its attributes and its whole subtree — and each is validated as
3761    /// it is staged, so the fifth dataset can be refused after four have been
3762    /// recorded. Without this, such a call would return `Err` having changed the
3763    /// session anyway, which is the half of issue #316 that lives one level
3764    /// above `commit`: a refused operation must cost the session nothing.
3765    pub(crate) fn stage_atomically<R>(
3766        &mut self,
3767        f: impl FnOnce(&mut Self) -> Result<R, Error>,
3768    ) -> Result<R, Error> {
3769        let mark = self.staged.mark();
3770        // Saved and restored rather than simply cleared, so a nested batch does
3771        // not lift the outer one's guard on its way out.
3772        let outer = std::mem::replace(&mut self.staging_batch, true);
3773        let result = f(self);
3774        self.staging_batch = outer;
3775        if result.is_err() {
3776            self.staged.rewind(mark);
3777        }
3778        result
3779    }
3780
3781    /// This session's file image as one slice, when its backing holds the whole
3782    /// file in memory; `None` for a file-backed image.
3783    ///
3784    /// The slice reflects committed state plus immediate in-place appends, not
3785    /// edits still staged for `commit`. The owned read-write
3786    /// [`File`](crate::File) uses it to serve reads by borrowing rather than
3787    /// copying, and falls back to [`image`](Self::image) when it is absent.
3788    pub(crate) fn image_slice(&self) -> Option<&[u8]> {
3789        self.image.as_slice()
3790    }
3791
3792    /// A random-access [`Source`] view of this session's file image, for the
3793    /// parsers the edit engine drives.
3794    ///
3795    /// Every read the engine performs against the file goes through this, so the
3796    /// image needs to be no more than a source of bytes — whole-file mirror or
3797    /// not — without the parsers knowing which (issue #198).
3798    pub(crate) fn image(&self) -> &dyn Source {
3799        self.image.as_ref()
3800    }
3801
3802    /// This session's parsed superblock, with `root_group_address` normalized to
3803    /// an absolute file offset (the open-time convention) and `base_address` the
3804    /// userblock size. A relocating commit updates it, so a caller holding a
3805    /// clone from an earlier moment may be reading a stale root.
3806    pub(crate) fn superblock(&self) -> &Superblock {
3807        &self.superblock
3808    }
3809
3810    /// The file's shared-message (SOHM) master table, or `None` for a file that
3811    /// shares no messages — which is every file this crate writes and nearly
3812    /// every file it is handed.
3813    ///
3814    /// Costs one small object-header parse per commit on a file that has a
3815    /// superblock extension at all, and nothing on one that does not.
3816    fn shared_message_table(&self) -> Result<Option<crate::sohm::SohmTable>, Error> {
3817        let os = self.superblock.offset_size;
3818        let rel = match self
3819            .superblock
3820            .superblock_extension_address
3821            .map(StoredAddress::new)
3822        {
3823            Some(rel) if !rel.is_undefined(os) => rel,
3824            _ => return Ok(None),
3825        };
3826        let base = self.superblock.base_address;
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(
3838            &self.image(),
3839            AccessMode::ReadWrite,
3840            abs,
3841            os,
3842            ls,
3843            base,
3844        ) else {
3845            return Ok(None);
3846        };
3847        let Some(msg) = header
3848            .messages
3849            .iter()
3850            .find(|m| m.msg_type == MessageType::SHARED_MESSAGE_TABLE)
3851        else {
3852            return Ok(None);
3853        };
3854        let message = crate::sohm::SharedMessageTableMessage::parse(&msg.data, os)?;
3855        let framed = BaseOffsetSource {
3856            inner: self.image(),
3857            base,
3858        };
3859        Ok(Some(crate::sohm::SohmTable::read_from_source(
3860            &framed, &message, os,
3861        )?))
3862    }
3863
3864    /// Refuse a commit that would strand a shared-message index record naming an
3865    /// object header. See [`SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT`].
3866    fn screen_shared_message_index(&self, invalidated: &InvalidatedAddresses) -> Result<(), Error> {
3867        if invalidated.is_empty() {
3868            return Ok(());
3869        }
3870        let Some(table) = self.shared_message_table()? else {
3871            return Ok(());
3872        };
3873        let base = self.superblock.base_address;
3874        let os = self.superblock.offset_size;
3875        let ls = self.superblock.length_size;
3876        let framed = BaseOffsetSource {
3877            inner: self.image(),
3878            base,
3879        };
3880        for index in &table.indexes {
3881            let records = crate::sohm::read_index_records_from_source(&framed, index, os, ls)?;
3882            screen_shared_message_records(&records, invalidated)?;
3883        }
3884        Ok(())
3885    }
3886
3887    /// The on-disk format this session writes, read from the file it opened
3888    /// rather than chosen fresh, since a commit preserves the superblock version
3889    /// it found.
3890    ///
3891    /// This decides the *contiguous* data-layout message version, where the
3892    /// older number costs nothing: versions 3 and 4 have identical bodies there,
3893    /// so a dataset added to a 1.8 file stays readable by a 1.8 library.
3894    ///
3895    /// It does not gate chunked storage. A chunked dataset needs the version 4
3896    /// layout and a 1.10 chunk index whatever the superblock says, so adding one
3897    /// to an older file does make that file need 1.10 — deliberately, since the
3898    /// alternative is a version 1 B-tree index this crate does not write, and
3899    /// refusing instead would take away in-place editing of every file the C
3900    /// library wrote with its own default bounds. `crates/crosscheck/tests/main/edit.rs`
3901    /// covers exactly that case (issue #101).
3902    ///
3903    /// That is a default, not a verdict: a caller who needs the file to stay
3904    /// loadable by an old reader asks for it with
3905    /// [`FileAccessProperties::with_libver_bounds`], which sets
3906    /// [`libver_ceiling`](Self::libver_ceiling) and turns the addition into a
3907    /// refusal at commit rather than a silent format bump.
3908    ///
3909    /// [`FileAccessProperties::with_libver_bounds`]: crate::FileAccessProperties::with_libver_bounds
3910    pub(crate) fn libver(&self) -> LibVer {
3911        LibVer::from_superblock_version(self.superblock.version)
3912    }
3913
3914    /// Constrain what this session may add, from the fapl's library-version
3915    /// bounds. See [`libver_ceiling`](Self::libver_ceiling).
3916    ///
3917    /// Resolved through [`LibVer::resolve_writable`], the same rule the
3918    /// whole-file writer applies, so bounds admitting no format this crate
3919    /// writes are refused here as they are there rather than silently ignored on
3920    /// the editing path.
3921    pub(crate) fn set_libver_bounds(
3922        &mut self,
3923        bounds: Option<(LibVer, LibVer)>,
3924    ) -> Result<(), Error> {
3925        self.libver_ceiling = match bounds {
3926            Some(_) => Some(LibVer::resolve_writable(bounds).map_err(Error::Format)?),
3927            None => None,
3928        };
3929        Ok(())
3930    }
3931
3932    /// Refuse staged content the session's [`libver_ceiling`](Self::libver_ceiling)
3933    /// cannot express, before the commit writes anything.
3934    ///
3935    /// Only chunked storage is at stake: `build_chunked_dataset_oh` writes a
3936    /// version 4 data-layout message and a 1.10 chunk index unconditionally,
3937    /// while everything else this session adds is expressible in both formats
3938    /// (`libver` already picks the contiguous layout version). A filter or an
3939    /// unlimited dimension arrives as chunked storage, so they are covered here
3940    /// too.
3941    fn check_libver_admits<'a>(
3942        &self,
3943        datasets: impl IntoIterator<Item = &'a FlatDataset>,
3944    ) -> Result<(), Error> {
3945        let Some(ceiling) = self.libver_ceiling else {
3946            return Ok(());
3947        };
3948        if ceiling >= LibVer::V110 {
3949            return Ok(());
3950        }
3951        let chunked = datasets
3952            .into_iter()
3953            .any(|fd| fd.chunk_options.is_chunked() || fd.maxshape.is_some());
3954        if chunked {
3955            return Err(Error::Format(FormatError::LibverTooOldForContent {
3956                content: "a chunked, filtered, or resizable dataset",
3957                needs: LibVer::V110.name(),
3958                writing: ceiling.name(),
3959            }));
3960        }
3961        Ok(())
3962    }
3963
3964    /// Which backend this session resolved to: [`Bounded`] when it reads through
3965    /// a handle, [`Mirrored`] when it holds a whole-file image.
3966    ///
3967    /// [`Bounded`]: EditBacking::Bounded
3968    /// [`Mirrored`]: EditBacking::Mirrored
3969    pub(crate) fn edit_backing(&self) -> EditBacking {
3970        if self.bounded {
3971            EditBacking::Bounded
3972        } else {
3973            EditBacking::Mirrored
3974        }
3975    }
3976
3977    /// A snapshot of this session's live space usage — the current file size and
3978    /// the free space it can reuse — as a [`SpaceAccounting`].
3979    ///
3980    /// This is the mutating-session analogue of the read-only accounting on
3981    /// [`File`](crate::File): it answers "how big is the file right now, and how
3982    /// much space can be reused before it must grow?" from the session's own live
3983    /// state. The snapshot reflects the committed file plus any immediate in-place
3984    /// appends ([`append_inplace_gathered`](Self::append_inplace_gathered)) but excludes edits still
3985    /// staged for the next [`commit`](Self::commit); see [`SpaceAccounting`] for
3986    /// the field-by-field semantics and [`has_staged_edits`](Self::has_staged_edits)
3987    /// for detecting pending work.
3988    ///
3989    /// On a paged file (`H5F_FSPACE_STRATEGY_PAGE`) the reported regions are the
3990    /// union of the per-page-type managers, which a commit draws on under the rule
3991    /// paging exists for: an allocation takes a hole of the page type it is placing,
3992    /// or whole pages that are free of both, so reuse never re-mixes a page
3993    /// (issue #261). Space whose page type this file does not settle is recorded
3994    /// and handed back to the reference library but never spent, and is excluded
3995    /// from the reusable figure.
3996    ///
3997    /// ```no_run
3998    /// use hdf5_pure::File;
3999    ///
4000    /// let file = File::open_rw("existing.h5")?;
4001    /// let acct = file.space_accounting()?;
4002    /// println!(
4003    ///     "{} bytes on disk, {} reusable in {} free region(s)",
4004    ///     acct.logical_size,
4005    ///     acct.reusable_free_bytes,
4006    ///     acct.reusable_free_space.len(),
4007    /// );
4008    /// # Ok::<(), hdf5_pure::Error>(())
4009    /// ```
4010    #[expect(
4011        rustdoc::private_doc_tests,
4012        reason = "rustdoc's test mode collects a private item, so this example is compiled and run by `cargo test --doc`"
4013    )]
4014    #[must_use]
4015    pub fn space_accounting(&self) -> SpaceAccounting {
4016        // The append reserve is free space this session can still spend — it is
4017        // out of the on-disk managers, not out of the file — so leaving it out
4018        // would make an in-place append look like it had consumed bytes it has
4019        // not yet placed anything in (issue #387).
4020        //
4021        // It is folded back through `FreeList::free` rather than appended and
4022        // sorted, because a reserve span very often *abuts* what is left of the
4023        // hole it was drawn from — and the spent end of the reserve walks toward
4024        // that remainder until the two touch exactly. Reported as two regions
4025        // they would break the field's documented contract in the one way that
4026        // misleads: a caller sizing an allocation against the largest region
4027        // would be told nothing that big fits when it does.
4028        //
4029        // It goes back into the list it came from and no other. On a paged file
4030        // that is the raw list, which is what `take_raw_span` drew it out of;
4031        // the two page-type lists stay reported side by side as they always
4032        // were, because a caller cannot allocate across a page-type boundary and
4033        // merging them would claim it could. `FreeList::free`'s debug assertion
4034        // comes along and is welcome: the reserve is drawn out of exactly this
4035        // list, so an overlap here would mean that invariant had already broken.
4036        //
4037        // A paged file tracks its free space per page type; report the union of
4038        // the two, since the caller wants one total rather than a per-manager
4039        // breakdown.
4040        let reusable_free_space = match (&self.paged, self.reserved.is_empty()) {
4041            // The common case, and every non-persisting session: no reserve to
4042            // fold, so nothing is cloned.
4043            (Some(pg), true) => pg.reusable_sections(),
4044            (None, true) => self.free.sections(),
4045            (Some(pg), false) => {
4046                let mut raw = pg.raw.clone();
4047                for (addr, len) in self.reserved.sections() {
4048                    raw.free(addr, len);
4049                }
4050                let mut out = pg.meta.sections();
4051                out.extend(raw.sections());
4052                out.sort_unstable_by_key(|&(addr, _)| addr);
4053                debug_assert!(
4054                    out.windows(2).all(|w| w[0].0 < w[1].0),
4055                    "the same address is free in both the metadata and the raw \
4056                     list, so the two page-type lists have stopped being disjoint"
4057                );
4058                out
4059            }
4060            (None, false) => {
4061                let mut free = self.free.clone();
4062                for (addr, len) in self.reserved.sections() {
4063                    free.free(addr, len);
4064                }
4065                free.sections()
4066            }
4067        };
4068        let reusable_free_bytes = reusable_free_space.iter().map(|(_, len)| len).sum();
4069        SpaceAccounting {
4070            logical_size: self.image.len(),
4071            reusable_free_bytes,
4072            reusable_free_space,
4073        }
4074    }
4075
4076    /// The shared Extensible-Array append engine's view of this session: the
4077    /// image, the superblock, and the paged-file state, paired as [`EditStore`].
4078    ///
4079    /// Takes `&mut self`, so a caller that also needs [`located`](Self::located)
4080    /// borrowed at the same time must destructure the fields itself rather than
4081    /// call this.
4082    fn store(&mut self) -> EditStore<'_> {
4083        EditStore {
4084            image: self.image.as_mut(),
4085            superblock: &mut self.superblock,
4086            sb_sig_off: self.sb_sig_off,
4087            paged: self.paged.as_mut(),
4088            // This adapter's one caller only reads; nothing is allocated here.
4089            free: None,
4090            sync_policy: self.sync_policy,
4091        }
4092    }
4093
4094    /// Whether an immediate in-place append may allocate out of already-free
4095    /// space instead of growing the file (issue #349, issue #387).
4096    ///
4097    /// The staged [`commit`](Self::commit) always may: it publishes its
4098    /// allocations with the superblock repoint, so a crash before that leaves a
4099    /// file whose root never named them. An immediate append has no repoint, so
4100    /// what has to hold instead is that the bytes it overwrites are dead *on the
4101    /// disk as it stands*. Which list satisfies that depends on the session:
4102    ///
4103    /// - An ordinary [`File::open_rw`](crate::File::open_rw) session on a
4104    ///   default-strategy file holds its free space in memory alone. A region in
4105    ///   [`free`](Self::free) was freed by a commit this session already
4106    ///   published, so nothing on the disk points at it and nothing on the disk
4107    ///   claims it is free; a crash at any point of the append leaves it as dead
4108    ///   as it was. It is spent directly.
4109    /// - A file that **persists** its free-space managers records its holes on
4110    ///   disk, so a hole in `free` is one a durable manager still advertises and
4111    ///   spending it would leave that manager describing bytes a live chunk
4112    ///   occupies — through a clean close as much as a crash, since only a commit
4113    ///   or [`finalize_persist`](Self::finalize_persist) rewrites the record.
4114    ///   Such a session therefore spends [`reserved`](Self::reserved) instead:
4115    ///   space [`reserve_for_immediate_append`](Self::reserve_for_immediate_append)
4116    ///   has already taken *out* of the published managers.
4117    /// - A **paged** file keeps its free space per page type in [`PagedEdit`]
4118    ///   rather than in [`free`](Self::free), so drawing from that list would be
4119    ///   drawing from the wrong one and would put a byte of one kind in a page of
4120    ///   the other. It draws its reserve through [`PagedEdit::alloc_typed`] with
4121    ///   [`PageType::Raw`] instead, which is what keeps a page holding one kind.
4122    ///   Named as its own term at the call site rather than left to the argument
4123    ///   that the persist term already covers it (`append_prepare` refuses a
4124    ///   paged file that does not persist): a page-type invariant proved from
4125    ///   another function's refusal is exactly the shape that has gone wrong here
4126    ///   before (issue #261). It costs nothing to name, because `reserved` is
4127    ///   empty on a session that never reserved — the term declines reuse rather
4128    ///   than misdirecting it.
4129    ///
4130    /// The **SWMR** writer is the one absolute bar, and it is not about
4131    /// publication at all: its concurrent readers may still be inside a region
4132    /// this session freed, and the format forbids reusing one while they are,
4133    /// which is why the C library's own SWMR writer allocates append-only. No
4134    /// test can reach this term today, because a SWMR session refuses every
4135    /// staged edit ([`Error::SwmrStagedUnsupported`]) and so never frees
4136    /// anything. It is here so that lifting that refusal cannot silently enable
4137    /// reuse.
4138    ///
4139    /// One window is inherited rather than introduced. A commit whose superblock
4140    /// write itself fails leaves [`publish_attempted`](Self::publish_attempted)
4141    /// set, which withholds the free-list rollback, so the regions that commit
4142    /// freed stay in the list although whether it published is unknowable
4143    /// (issue #344). An append can now spend them where only a later commit could
4144    /// before. That is the same trade, on a larger surface: doing nothing is
4145    /// still the only answer that is never actively wrong.
4146    fn immediate_reuse_allowed(&self) -> bool {
4147        !self.swmr_mode
4148    }
4149
4150    /// Take a batch of free space out of the on-disk free-space managers so an
4151    /// immediate in-place append may spend it, for a session that persists them
4152    /// (issue #387). Afterwards [`reserved`](Self::reserved) holds whatever could
4153    /// be drawn; the allocator that spends it is what says whether that serves
4154    /// the append, since the rewrite's own tail may have taken part of it.
4155    ///
4156    /// The draw and the publication are one step and in that order: the bytes
4157    /// leave [`free`](Self::free) (or [`PagedEdit`]'s raw list) first, and the
4158    /// managers are then rewritten through the ordinary persisting commit tail
4159    /// with nothing to free and the root unchanged — the same call
4160    /// [`finalize_persist`](Self::finalize_persist) makes, whose superblock
4161    /// repoint is the crash-atomic linearization point. Before that repoint the
4162    /// old managers stand and still list the batch, which is correct because
4163    /// nothing has been written into it; after it, no durable record calls those
4164    /// bytes free and the append may have them.
4165    ///
4166    /// Nothing here moves a live object: `to_free` is empty and the new root is
4167    /// the old one, so the tail places only itself, in a hole or past the live
4168    /// data. The [`located`](Self::located) append-geometry cache is therefore
4169    /// still valid across this call, which is what lets it run between an
4170    /// append's plan and its apply.
4171    ///
4172    /// Draws nothing — not an error — when the session persists nothing to draw
4173    /// from, or when no hole can hold the allocation in hand; the caller simply
4174    /// grows the file, as it always did. An error means the manager rewrite
4175    /// itself failed, and the lists are put back so they agree with whatever the
4176    /// disk now holds, on the rule [`commit`](Self::commit) applies: a rewrite that
4177    /// never reached its superblock write left the managers as they were, so the
4178    /// lists go back to exactly what they were, tail placement included; one that
4179    /// did may have published, so the draw is handed back — those bytes are
4180    /// genuinely free, nothing having been written into them, and the next commit
4181    /// records them again — while the span its tail took stays out, since that
4182    /// tail may now be the live one.
4183    fn reserve_for_immediate_append(&mut self, want: u64) -> Result<(), Error> {
4184        if want == 0 || self.persist.is_none() || self.swmr_mode || self.reserved.largest() >= want
4185        {
4186            return Ok(());
4187        }
4188        let snapshot = self.snapshot_free();
4189        if !self.take_raw_spans(want, APPEND_RESERVE_BYTES) {
4190            return Ok(());
4191        }
4192        self.publish_attempted = false;
4193        match self.commit_persisting(
4194            self.superblock.root_group_address,
4195            Vec::new(),
4196            TailPlacement::Anywhere,
4197        ) {
4198            Ok(()) => Ok(()),
4199            Err(e) => {
4200                if self.publish_attempted {
4201                    self.release_reserve();
4202                } else {
4203                    self.restore_free(snapshot);
4204                }
4205                Err(e)
4206            }
4207        }
4208    }
4209
4210    /// Move raw-appendable free space into [`reserved`](Self::reserved) for one
4211    /// manager rewrite to publish: runs that can each hold `want` bytes, until
4212    /// `cap` bytes are held or no hole that large is left. Reports whether
4213    /// anything was drawn, which is exactly whether the first hole could hold
4214    /// `want`.
4215    ///
4216    /// The draw is sized on what the file has rather than on a fixed batch, and
4217    /// that is the whole of the fix for issue #413 — [`APPEND_RESERVE_BYTES`]
4218    /// says why the cap is a cap and not a floor. The rewrite that publishes a
4219    /// draw is the expensive part, so one draw gathers as many holes as the cap
4220    /// allows and the rewrite is paid once for all of them: a file fragmented
4221    /// into chunk-sized holes costs one rewrite per cap's worth of them, not one
4222    /// per chunk.
4223    ///
4224    /// Each run is sized on the largest hole and taken best fit, so it is a whole
4225    /// hole or a `cap`-bounded prefix of one, and the reserve never holds a
4226    /// fragment too small for the allocation that drew it. `want` above `cap`
4227    /// draws one run of exactly `want`: the cap bounds the amortization, not
4228    /// what a single allocation may need.
4229    fn take_raw_spans(&mut self, want: u64, cap: u64) -> bool {
4230        debug_assert!(want > 0, "a draw is sized on a real allocation");
4231        // The cap is the budget, except that a single allocation larger than it
4232        // still draws one run for itself.
4233        let budget = cap.max(want);
4234        let mut drawn = 0u64;
4235        while budget - drawn >= want {
4236            let available = self.largest_raw_run();
4237            if available < want {
4238                break;
4239            }
4240            let len = available.min(budget - drawn);
4241            if !self.take_raw_span(len) {
4242                // Sized on the allocator's own figure, so it serves; a refusal
4243                // here means the two disagree, and drawing nothing further is
4244                // the answer that leaves the file merely larger.
4245                debug_assert!(false, "a run of {len} bytes was reported and not served");
4246                break;
4247            }
4248            drawn += len;
4249        }
4250        drawn > 0
4251    }
4252
4253    /// The longest contiguous run [`take_raw_span`](Self::take_raw_span) could
4254    /// move into the reserve right now, or `0` when nothing could.
4255    fn largest_raw_run(&self) -> u64 {
4256        match self.paged.as_ref() {
4257            Some(pg) => pg.largest_typed(PageType::Raw),
4258            None => self.free.largest(),
4259        }
4260    }
4261
4262    /// Move `len` bytes of raw-appendable free space into
4263    /// [`reserved`](Self::reserved), reporting whether a contiguous run that
4264    /// large was available.
4265    ///
4266    /// A paged file draws through [`PagedEdit::alloc_typed`] with
4267    /// [`PageType::Raw`], the same call the staged commit makes for a dataset's
4268    /// data, so the reserve is raw-typed space and an append spending it cannot
4269    /// mix a page. A flat file has one list and draws from it.
4270    fn take_raw_span(&mut self, len: u64) -> bool {
4271        let addr = match self.paged.as_mut() {
4272            Some(pg) => pg.alloc_typed(len, PageType::Raw),
4273            None => self.free.alloc(len),
4274        };
4275        match addr {
4276            Some(addr) => {
4277                self.reserved.free(addr, len);
4278                true
4279            }
4280            None => false,
4281        }
4282    }
4283
4284    /// Give the unspent append reserve back to the lists the managers are written
4285    /// from, so the next rewrite records it as the free space it is.
4286    ///
4287    /// Called immediately before every rewrite of the on-disk managers — the
4288    /// persisting commit tail and [`finalize_persist`](Self::finalize_persist) —
4289    /// and nowhere else, so a commit that does *not* rewrite them (the
4290    /// same-length-overwrite fast path) leaves the reserve intact for the appends
4291    /// that follow it.
4292    ///
4293    /// The reserve is raw-typed on a paged file, so it goes back to the raw list.
4294    /// A run that was claimed whole from the metadata list is raw space now, and
4295    /// a page of it that is wholly free can be claimed back by either type
4296    /// through [`PagedEdit::alloc_typed`], exactly as any other free page can.
4297    fn release_reserve(&mut self) {
4298        if self.reserved.is_empty() {
4299            return;
4300        }
4301        let spans = std::mem::replace(&mut self.reserved, FreeList::new()).sections();
4302        match self.paged.as_mut() {
4303            Some(pg) => {
4304                for (addr, len) in spans {
4305                    pg.raw.free(addr, len);
4306                }
4307            }
4308            None => {
4309                for (addr, len) in spans {
4310                    self.free.free(addr, len);
4311                }
4312            }
4313        }
4314    }
4315
4316    /// Rewrite the on-disk free-space managers for a persisting commit, giving
4317    /// the unspent append reserve back first so the managers record it, and then
4318    /// give any trailing free space the rewrite exposed back to the filesystem.
4319    ///
4320    /// Every caller that publishes a *tree* takes this rather than
4321    /// [`commit_persisting`](Self::commit_persisting) directly. The one caller
4322    /// that does not is [`reserve_for_immediate_append`](Self::reserve_for_immediate_append),
4323    /// whose whole purpose is to publish managers that leave the reserve out.
4324    fn commit_persisting_releasing_reserve(
4325        &mut self,
4326        new_root: u64,
4327        to_free: Vec<(u64, u64, FreeClass)>,
4328    ) -> Result<(), Error> {
4329        self.release_reserve();
4330        self.commit_persisting(new_root, to_free, TailPlacement::Anywhere)?;
4331        self.shrink_to_the_trailing_free_run(new_root)
4332    }
4333
4334    /// Move a commit's manager blocks down into the space that commit freed, and
4335    /// truncate the file to them (issue #418).
4336    ///
4337    /// A commit that frees a run reaching end-of-file cannot land its own tail in
4338    /// that run: the regions it is vacating are still referenced by the *on-disk*
4339    /// root until the superblock repoint, so writing over them would destroy the
4340    /// pre-commit file if the process died in between. The tail therefore goes
4341    /// past end-of-file, above the very space that should have been given back,
4342    /// and the file cannot shrink — the delete-then-commit case of issue #418.
4343    ///
4344    /// Once that repoint is durable those regions are genuinely free, so a second
4345    /// tail-only rewrite can place the blocks inside them. It publishes through
4346    /// the same crash-atomic tail as any other commit, with nothing to free and
4347    /// the root unchanged, and the truncation follows its superblock write.
4348    ///
4349    /// It runs under [`TailPlacement::ReuseOnly`], so it places the tail in free
4350    /// space or writes nothing at all. That is the safety property: a pass taken
4351    /// for the sake of a smaller file can never leave a larger one.
4352    ///
4353    /// Skipped unless the last commit's tail is the highest thing in the file and
4354    /// the free run reaching it is long enough for [`release_trailing_run`] to
4355    /// release any of — the shape a delete leaves, and the condition that bounds
4356    /// this to one extra rewrite on the commits that can actually use it. A
4357    /// non-persisting file never gets here (its commit truncates directly), and
4358    /// neither does the SWMR writer, which
4359    /// [`open_swmr_writer`](crate::File::open_swmr_writer) refuses to open on a
4360    /// file that persists its free space at all.
4361    fn shrink_to_the_trailing_free_run(&mut self, new_root: u64) -> Result<(), Error> {
4362        let Some(persist) = self.persist.as_ref() else {
4363            return Ok(());
4364        };
4365        let (Some(tail_start), Some(tail_end)) = (
4366            persist.old_blocks.iter().map(|&(a, _)| a).min(),
4367            persist.old_blocks.iter().map(|&(a, l)| a + l).max(),
4368        ) else {
4369            return Ok(());
4370        };
4371        // Only a tail at the very end of the file is standing in the way of one.
4372        if tail_end != self.image.len() {
4373            return Ok(());
4374        }
4375        // The free run reaching up to it, plus the tail the rewrite frees, is what
4376        // the rewrite would have to release from. Held to the same threshold
4377        // `release_trailing_run` applies, since below it that call returns the
4378        // file's length unchanged and the pass would cost a rewrite to publish
4379        // exactly what the commit before it did.
4380        let run_start = match self.paged.as_ref() {
4381            Some(pg) => {
4382                trailing_run_start([&pg.meta, &pg.raw, &pg.dead, &pg.unclassified], tail_start)
4383            }
4384            None => trailing_run_start([&self.free], tail_start),
4385        };
4386        if tail_end - run_start < 2 * TRAILING_RESERVE_TAILS * (tail_end - tail_start) {
4387            return Ok(());
4388        }
4389        self.commit_persisting(new_root, Vec::new(), TailPlacement::ReuseOnly)
4390    }
4391
4392    /// Adopt the fapl's `fsync` cadence. Called once, on the funnel every
4393    /// read-write open passes through, before the session is handed out.
4394    pub(crate) fn set_sync_policy(&mut self, policy: SyncPolicy) {
4395        self.sync_policy = policy;
4396    }
4397
4398    /// The cadence this session adopted. Nothing in the crate branches on this —
4399    /// [`barrier`](Self::barrier) does that — but the fapl reaching the engine is
4400    /// otherwise invisible from outside, and an entry point that forgot to pass
4401    /// it on would look exactly like one that did.
4402    #[cfg(test)]
4403    pub(crate) fn sync_policy(&self) -> SyncPolicy {
4404        self.sync_policy
4405    }
4406
4407    /// Override how long this session's writes may sit in memory.
4408    ///
4409    /// The image is private to this module, and the only caller outside it is
4410    /// [`crate::crash_replay`], which sweeps the *same* workload under gathering
4411    /// and without it. That comparison is the answer to the obvious worry about
4412    /// making writes larger, so it is worth an accessor; nothing outside a test
4413    /// build has one.
4414    #[cfg(test)]
4415    pub(crate) fn set_write_buffering(&mut self, mode: WriteBuffering) -> Result<(), Error> {
4416        self.image.set_write_buffering(mode)
4417    }
4418
4419    /// Every write this session issued, as `(offset, length)` in the order it
4420    /// went out. Same reasoning as [`set_write_buffering`](Self::set_write_buffering):
4421    /// the image is private, and a test that asks whether a publish left the
4422    /// engine as one write has nowhere else to look.
4423    #[cfg(test)]
4424    pub(crate) fn issued_write_order(&self) -> Vec<(u64, u64)> {
4425        self.image.issued_write_order()
4426    }
4427
4428    /// The durability barrier a write path calls for, data and metadata both —
4429    /// issued unless this session's [`SyncPolicy`] leaves the `fsync` cadence to
4430    /// the application.
4431    ///
4432    /// Every such point routes through here rather than touching the image, so
4433    /// the policy is honored by construction at sites yet to be written.
4434    ///
4435    /// A barrier is an **ordering** point first and a durability point second,
4436    /// and only the second half is the policy's to skip. The writes gathered
4437    /// before it are issued here whatever the policy says, because the image
4438    /// issues gathered writes in address order: leave them gathered across a
4439    /// barrier and a commit's superblock — address 0 — goes to the disk *before*
4440    /// the content it names, which is the one order the whole tail is built to
4441    /// avoid. A write that then fails, or a process that dies mid-flush, would
4442    /// leave a superblock naming bytes that are not in the file, where before it
4443    /// left the previous file intact (issue #288).
4444    ///
4445    /// [`WriteBuffering::Session`] — an explicit page buffer — is the deliberate
4446    /// exception, and [`ordering_barrier`](crate::image::FileImage::ordering_barrier)
4447    /// is what encodes that: it does nothing under that mode, which is precisely
4448    /// the guarantee such a caller is trading away. What that caller gets back is
4449    /// a file that *says* so — see [`raise_crash_mark`](Self::raise_crash_mark).
4450    ///
4451    /// The teardown barrier is deliberately *not* one of these points:
4452    /// [`File::close`](crate::File::close) and `FileInner::drop` write after the
4453    /// last barrier a caller could have asked for, so they take
4454    /// [`force_sync`](Self::force_sync) instead.
4455    fn barrier(&mut self) -> Result<(), Error> {
4456        // Exhaustive on purpose, here and at the two sites below: `SyncPolicy` is
4457        // sealed to the outside but not to this crate, so a policy added later
4458        // fails to compile at every durability point until someone decides what
4459        // it means there.
4460        match self.sync_policy {
4461            // `sync_all` issues the gathered writes on its way to the disk.
4462            SyncPolicy::Always => self.image.sync_all(),
4463            SyncPolicy::OnClose => self.image.ordering_barrier(),
4464        }
4465    }
4466
4467    /// The data-only counterpart to [`barrier`](Self::barrier), for a write that
4468    /// does not move end-of-file and so needs no metadata flush. It orders the
4469    /// gathered writes for the same reason.
4470    fn barrier_data(&mut self) -> Result<(), Error> {
4471        barrier_data(self.image.as_mut(), self.sync_policy)
4472    }
4473
4474    /// Force this session's writes to durable storage *whatever* the policy
4475    /// says. The counterpart to [`barrier`](Self::barrier), which the policy may
4476    /// skip.
4477    ///
4478    /// Two callers: [`File::sync`](crate::File::sync), the application naming
4479    /// its own cadence, and the teardown path — `File::close` and
4480    /// `FileInner::drop` — whose own writes no caller can order, because both
4481    /// destroy the handle that would have done it.
4482    pub(crate) fn force_sync(&mut self) -> Result<(), Error> {
4483        self.image.sync_all()
4484    }
4485
4486    /// Rewrite the on-disk free-space managers for a file that persists them, if
4487    /// this session left them stale.
4488    ///
4489    /// Immediate in-place appends grow the file past the managers, leaving them
4490    /// describing a length the file no longer has, or spend a reserve those
4491    /// managers no longer list. A staged
4492    /// [`commit`](Self::commit) rewrites them as part of its tail, but a session
4493    /// that only appends never runs one, so [`File::close`](crate::File::close)
4494    /// and `FileInner::drop` call this instead. It is the same tail the commit
4495    /// writes — placed in space an earlier commit freed, or appended past
4496    /// everything live when none fits, with the superblock repoint as the
4497    /// crash-atomic linearization point — with nothing to free and the root
4498    /// unchanged.
4499    ///
4500    /// Where those blocks end up is not something a reader has to care about, and
4501    /// after this change usually is not end-of-file: a file with free space to
4502    /// spend has its managers inside it, above and below live data alike. What has
4503    /// to hold is that they describe the file as it now stands, which is what makes
4504    /// this call's own writes the last the session issues.
4505    ///
4506    /// It is also where a session's last release of trailing free space happens,
4507    /// since it takes the same tail as a commit (issue #418).
4508    ///
4509    /// A no-op for a non-persisting file, and skipped when the file has not grown
4510    /// past the managers since they were last written *and* the session holds no
4511    /// unspent append reserve, so an unchanged session never grows the file. The
4512    /// reserve is the second condition because an append that spent a hole rather
4513    /// than the end of the file leaves the length alone, and what it did not
4514    /// spend is space the managers must be told about again (issue #387).
4515    ///
4516    /// If a session that grew the file ends without `close` or `drop` running (a
4517    /// true crash — `SIGKILL`, power loss), the managers are left describing the
4518    /// shorter file, and an unspent reserve is left out of them altogether. Every
4519    /// append was durable and crash-atomic, so no data is lost, and both this
4520    /// crate and the reference C library reopen the file and read it correctly;
4521    /// the managers simply under-report until a clean rewrite, which wastes those
4522    /// bytes and never hands them out twice.
4523    pub(crate) fn finalize_persist(&mut self) -> Result<(), Error> {
4524        if self.persist.is_none() || (self.image.len() == self.fsm_len && self.reserved.is_empty())
4525        {
4526            return Ok(());
4527        }
4528        self.commit_persisting_releasing_reserve(self.superblock.root_group_address, Vec::new())
4529    }
4530
4531    /// Resolve and locate an in-place append target, applying every rule that
4532    /// does not depend on the bytes being appended: the file-level eligibility
4533    /// guards, the staged-edit conflict check, and the geometry lookup that
4534    /// populates [`located`](Self::located). Returns the dataset's object-header
4535    /// address, which is that cache's key.
4536    ///
4537    /// Split out from the append itself so [`append_geometry`](Self::append_geometry)
4538    /// can report a dataset's batching geometry under exactly the same rules the
4539    /// append will apply — a caller slicing a large append into batches must be
4540    /// refused before the first batch, not part-way through.
4541    fn append_prepare(&mut self, target: AppendTarget<'_>) -> Result<u64, Error> {
4542        // The fast in-place append is only sound on a base-0 latest-format file:
4543        // the slot math assumes absolute addresses and the superblock is patched in
4544        // place per call. A userblock or pre-v2 file falls back to the staged
4545        // `append_dataset`, which rebuilds the index and repoints the superblock
4546        // last.
4547        if !self.superblock.base_address.is_zero() {
4548            return Err(Error::AppendInPlaceUnsupported(
4549                "in-place append does not support a file with a userblock (non-zero base \
4550                 address); use Dataset::append_staged",
4551            ));
4552        }
4553        if self.superblock.version < 2 {
4554            return Err(Error::AppendInPlaceUnsupported(
4555                "in-place append requires a latest-format file (v2/v3 superblock); use \
4556                 Dataset::append_staged",
4557            ));
4558        }
4559        // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
4560        // space has no on-disk record of which pages hold metadata and which hold
4561        // raw data, so neither this immediate append nor the staged commit can keep
4562        // the two segregated; refuse it outright. A paged *persisting* file appends
4563        // through the page-aware `EditStore`, which pads a tail page whenever the
4564        // page type changes, and has its managers rewritten at the next commit or
4565        // at close (issue #198).
4566        if self.paged.is_some() && self.persist.is_none() {
4567            return Err(Error::AppendInPlaceUnsupported(
4568                "in-place append is not supported on a paged file \
4569                 (H5F_FSPACE_STRATEGY_PAGE) without persisted free space; recreate the \
4570                 file with with_file_space_strategy(FileSpaceStrategy::Page, true, ..)",
4571            ));
4572        }
4573
4574        // Refuse an append against a dataset (or a subtree) that a still-staged edit
4575        // in this same session will relocate, replace, or delete — which would
4576        // strand the durably-appended rows or plan against a header the commit
4577        // moves. The caller must commit those edits first.
4578        //
4579        // A target named by object-header address cannot be compared against the
4580        // staged paths, so any staged edit at all disqualifies it. That is a
4581        // superset of the path check, and the remedy is the same one.
4582        match target {
4583            AppendTarget::Path(dataset) => {
4584                if self.append_conflicts_with_pending(dataset) {
4585                    return Err(Error::AppendInPlaceUnsupported(
4586                        "the dataset or an ancestor has a staged edit pending in this session; \
4587                         commit the staged edits before appending in place, or use \
4588                         Dataset::append_staged",
4589                    ));
4590                }
4591            }
4592            AppendTarget::Header(_) if self.has_staged_edits() || self.committed => {
4593                return Err(Error::AppendInPlaceUnsupported(
4594                    "this append target was reached by object reference, so it names a dataset \
4595                     by object-header address, and this session has staged or committed edits \
4596                     that can move that header; re-open the dataset by path to append to it",
4597                ));
4598            }
4599            AppendTarget::Header(_) => {}
4600        }
4601
4602        // Resolve the dataset's object-header address — the geometry cache key.
4603        // base == 0 here, so a resolved address is absolute; two hard links to
4604        // one dataset share the one entry.
4605        let oh_addr = match target {
4606            AppendTarget::Path(dataset) => match self.resolved.get(dataset) {
4607                Some(&addr) => addr,
4608                None => {
4609                    let addr = crate::group_v2::resolve_path_any_from_source(
4610                        &self.image(),
4611                        AccessMode::ReadWrite,
4612                        &self.superblock,
4613                        &dataset.as_path(),
4614                    )?;
4615                    self.resolved.insert(dataset.clone(), addr);
4616                    addr
4617                }
4618            },
4619            AppendTarget::Header(addr) => addr,
4620        };
4621
4622        // Locate the dataset on the first append (cache miss) against the session's
4623        // own image — no second lock, no second view of the file, no re-read.
4624        if !self.located.contains_key(&oh_addr) {
4625            let store = self.store();
4626            let state = locate_dataset_state(&store, oh_addr)?;
4627            self.located.insert(oh_addr, state);
4628        }
4629        Ok(oh_addr)
4630    }
4631
4632    /// The append geometry of the dataset `target` names, so a caller can slice a
4633    /// large append into aligned batches *before* materializing each batch's
4634    /// bytes — which is what keeps a bounded session's peak memory at one batch
4635    /// rather than the whole call.
4636    pub(crate) fn append_geometry(
4637        &mut self,
4638        target: AppendTarget<'_>,
4639    ) -> Result<AppendGeometry, Error> {
4640        let oh_addr = self.append_prepare(target)?;
4641        let st = &self.located[&oh_addr];
4642        let chunk_elems = st.loc.chunk_elems.max(1);
4643        Ok(AppendGeometry {
4644            chunk_elems,
4645            element_size: st.element_size,
4646            current_dim: st.loc.current_dim,
4647            lossy_filters: st.pipeline.as_ref().is_some_and(|p| !pipeline_lossless(p)),
4648            full_batch_elems: self.batch_elems(st.loc.chunk_bytes, chunk_elems),
4649        })
4650    }
4651
4652    /// Whole-chunk elements in one append batch.
4653    ///
4654    /// A bounded session caps a batch at [`APPEND_BATCH_BYTES`] of raw data (at
4655    /// least one chunk) so peak memory is independent of the call size, at the
4656    /// cost of splitting one crash-atomic append into several: a crash between
4657    /// batches leaves a valid shorter dataset, exactly as if the caller had
4658    /// looped. A mirror session already holds the whole file, so bounding the
4659    /// call buys nothing there and would trade that atomicity away for free;
4660    /// it takes the whole append as one batch.
4661    fn batch_elems(&self, chunk_bytes: usize, chunk_elems: u64) -> u64 {
4662        if !self.batched_appends {
4663            return u64::MAX;
4664        }
4665        (APPEND_BATCH_BYTES / (chunk_bytes.max(1) as u64)).max(1) * chunk_elems
4666    }
4667
4668    /// Apply a gathered in-place append (typed / generic / raw bytes) to the
4669    /// dataset `target` names, immediately and crash-atomically, driving the
4670    /// shared Extensible-Array engine against the session's own image through an
4671    /// [`EditStore`] adapter. Runs only the first `max_phase` durability phases;
4672    /// production callers pass 4, the crash-consistency tests stop at a boundary
4673    /// to simulate a crash.
4674    ///
4675    /// A bounded session splits the call into whole-chunk batches (see
4676    /// [`batch_elems`](Self::batch_elems)), each its own crash-atomic apply.
4677    /// Every predictable refusal is raised before the first batch, so a rejected
4678    /// append leaves the file untouched rather than partly grown.
4679    ///
4680    /// `Dataset::append` slices its own call the same way before it reaches here,
4681    /// so through the public API this loop runs once per call; it batches for the
4682    /// benefit of a caller that hands the engine one large builder directly, whose
4683    /// bytes are already materialized but whose plan need not be.
4684    pub(crate) fn append_inplace_gathered(
4685        &mut self,
4686        target: AppendTarget<'_>,
4687        b: &AppendBuilder,
4688        max_phase: u8,
4689    ) -> Result<(), Error> {
4690        if b.dt_conflict() {
4691            return Err(Error::AppendInPlaceUnsupported(
4692                "append mixes element types in one call; use one element type per append",
4693            ));
4694        }
4695        let oh_addr = self.append_prepare(target)?;
4696
4697        // Validate the appended bytes against the on-disk datatype.
4698        let raw = b.raw();
4699        let new_elems = validate_gathered_append(&self.located[&oh_addr], b)?;
4700        if new_elems == 0 {
4701            return Ok(());
4702        }
4703
4704        // In SWMR mode, hold to the subset a concurrent reader can follow safely:
4705        // unfiltered and chunk-aligned, so an append only ever inserts new,
4706        // not-yet-visible elements and never rewrites a visible trailing chunk
4707        // out from under a reader. Outside SWMR the session's exclusive lock is
4708        // what makes that rewrite safe; see `plan_ea_append`.
4709        if self.swmr_mode {
4710            let st = &self.located[&oh_addr];
4711            if st.pipeline.is_some() {
4712                return Err(Error::SwmrAppendUnsupported(
4713                    "filtered datasets are not supported for SWMR append",
4714                ));
4715            }
4716            let chunk_elems = st.loc.chunk_elems;
4717            if chunk_elems == 0
4718                || st.loc.current_dim % chunk_elems != 0
4719                || new_elems % chunk_elems != 0
4720            {
4721                return Err(Error::SwmrAppendUnsupported(
4722                    "SWMR append must be chunk-aligned: the current length and the appended \
4723                     length must both be whole multiples of the chunk length",
4724                ));
4725            }
4726        }
4727
4728        let (chunk_elems, elem_bytes, full_batch_elems) = {
4729            let st = &self.located[&oh_addr];
4730            (
4731                st.loc.chunk_elems.max(1),
4732                st.element_size.get() as u64,
4733                self.batch_elems(st.loc.chunk_bytes, st.loc.chunk_elems.max(1)),
4734            )
4735        };
4736        // Outside SWMR the session holds an exclusive lock, so re-encoding a
4737        // filtered partial trailing chunk into a fresh allocation and repointing
4738        // its index element is a change no other reader is crossing; see
4739        // `plan_ea_append`, which owns that argument and keeps the refusal for
4740        // the SWMR writer.
4741        let grow_visible_tail = !self.swmr_mode;
4742
4743        let mut done = 0u64;
4744        while done < new_elems {
4745            // Fill the trailing partial chunk first (so later batches start
4746            // chunk-aligned and never rewrite it again), then whole-chunk batches.
4747            // That first batch is the only one that can re-encode a filtered
4748            // trailing chunk; every batch after it starts on a boundary.
4749            let current_dim = self.located[&oh_addr].loc.current_dim;
4750            let to_boundary = (chunk_elems - current_dim % chunk_elems) % chunk_elems;
4751            let take = (new_elems - done).min(to_boundary.saturating_add(full_batch_elems));
4752            let batch =
4753                &raw[(done * elem_bytes).to_usize()?..((done + take) * elem_bytes).to_usize()?];
4754
4755            // Read/plan phase (immutable borrows only, nothing published yet), then
4756            // the ordered, fsync-barriered write phase — both shared with
4757            // `Dataset::append` through the chunk-index engine. `EditStore` borrows
4758            // only the image-carrying fields, so `self.located` stays independently
4759            // borrowable.
4760            let plan_result = {
4761                let Self {
4762                    image,
4763                    superblock,
4764                    sb_sig_off,
4765                    paged,
4766                    located,
4767                    sync_policy,
4768                    ..
4769                } = self;
4770                let st = &located[&oh_addr];
4771                let store = EditStore {
4772                    image: image.as_mut(),
4773                    superblock,
4774                    sb_sig_off: *sb_sig_off,
4775                    paged: paged.as_mut(),
4776                    // Planning only reads; nothing is allocated here.
4777                    free: None,
4778                    sync_policy: *sync_policy,
4779                };
4780                plan_ea_append(
4781                    &store,
4782                    &st.loc,
4783                    &st.datatype,
4784                    &st.spatial,
4785                    st.element_size,
4786                    st.pipeline.as_ref(),
4787                    grow_visible_tail,
4788                    batch,
4789                    take,
4790                    st.fill.pattern(st.element_size),
4791                )
4792            };
4793            let plan = plan_result.map_err(as_inplace_error)?;
4794            // A persisting file's holes are advertised on disk, so this batch's
4795            // bytes have to be taken out of the published managers before any of
4796            // them is written into. Sized on the largest single blob, since the
4797            // allocator serves each one from a contiguous run: a reserve that
4798            // cannot hold the biggest is a reserve the biggest goes past. A
4799            // refusal here (nothing left to draw, or nothing to draw from) is not
4800            // an error — the append grows the file, as it always did. Placed
4801            // between the plan and the apply because the manager rewrite it makes
4802            // publishes a superblock, and nothing may be half-applied across that.
4803            let largest_blob = plan
4804                .new_chunk_bytes
4805                .iter()
4806                .map(|b| b.len() as u64)
4807                .max()
4808                .unwrap_or(0);
4809            self.reserve_for_immediate_append(largest_blob)?;
4810            {
4811                let reuse = self.immediate_reuse_allowed();
4812                // Two terms, not one. A **paged** session must never be handed
4813                // `free`: it keeps its space per page type in `PagedEdit`, so
4814                // that list is the wrong one to draw from and placing a byte out
4815                // of it would mix a page. Every paged session is a persisting one
4816                // (`append_prepare` refuses a paged file without persistence), so
4817                // the first term alone would pick `reserved` for it anyway — and
4818                // that is exactly the shape issue #261 was: a page-type invariant
4819                // proved from a refusal in another function. Named here so the
4820                // safety is by construction, and so deleting that refusal cannot
4821                // silently turn a paged append loose on the flat list. `reserved`
4822                // is empty on any session that never reserved, which makes the
4823                // extra term cost nothing but the reuse it correctly declines.
4824                let from_reserve = self.persist.is_some() || self.paged.is_some();
4825                let Self {
4826                    image,
4827                    superblock,
4828                    sb_sig_off,
4829                    paged,
4830                    located,
4831                    free,
4832                    reserved,
4833                    sync_policy,
4834                    ..
4835                } = self;
4836                let st = located.get_mut(&oh_addr).expect("dataset located above");
4837                let mut store = EditStore {
4838                    image: image.as_mut(),
4839                    superblock,
4840                    sb_sig_off: *sb_sig_off,
4841                    paged: paged.as_mut(),
4842                    // A persisting or paged session spends only what the reserve
4843                    // took out of the managers; every other session spends its
4844                    // in-memory list directly. See `immediate_reuse_allowed`.
4845                    free: reuse.then_some(if from_reserve { reserved } else { free }),
4846                    sync_policy: *sync_policy,
4847                };
4848                apply_ea_append(&mut store, &mut st.loc, &plan, max_phase)
4849                    .map_err(as_inplace_error)?;
4850            }
4851            if max_phase < 4 {
4852                // Crash-consistency hook: the caller asked to stop inside the
4853                // first batch's durability sequence, so there is no next batch.
4854                // Each phase's own barrier has already issued the writes up to it
4855                // under either policy, which is what leaves a phase boundary a
4856                // real one for the tests that stop at it.
4857                return Ok(());
4858            }
4859            done += take;
4860        }
4861        Ok(())
4862    }
4863
4864    /// Test-only phased in-place append (stops after `max_phase` durability phases)
4865    /// used by the crash-consistency tests, mirroring `Dataset::append`'s harness.
4866    #[cfg(test)]
4867    fn append_inplace_i32_phased(
4868        &mut self,
4869        dataset: &str,
4870        values: &[i32],
4871        max_phase: u8,
4872    ) -> Result<(), Error> {
4873        let mut b = AppendBuilder::new();
4874        b.append_i32(values);
4875        self.append_inplace_gathered(
4876            AppendTarget::Path(&ObjectPathBuf::parse(dataset)),
4877            &b,
4878            max_phase,
4879        )
4880    }
4881
4882    /// Whether `target` (an [`append_inplace_gathered`](Self::append_inplace_gathered) dataset path)
4883    /// or any of its ancestors is named by a staged edit that a later
4884    /// [`commit`](Self::commit) would relocate, replace, or delete. `create_group`
4885    /// and group-attribute edits are excluded: they rewrite a group header without
4886    /// moving a descendant dataset's header or freeing its storage, so they cannot
4887    /// stale the append geometry cache.
4888    fn append_conflicts_with_pending(&self, target: &ObjectPathBuf) -> bool {
4889        let hits = |p: &ObjectPathBuf| target.overlaps(p);
4890        self.staged.writes.iter().any(|(p, _)| hits(p))
4891            || self.staged.appends.iter().any(|(p, _)| hits(p))
4892            || self.staged.deletes.iter().any(&hits)
4893            || self.staged.copies.iter().any(|(_, dst)| hits(dst))
4894            || self.staged.cross_copies.iter().any(|(dst, _)| hits(dst))
4895            || self.staged.dataset_attrs.iter().any(|(p, _)| hits(p))
4896            || self
4897                .staged
4898                .datasets
4899                .iter()
4900                .any(|(parent, fd)| target.overlaps(&parent.join(&fd.name)))
4901    }
4902
4903    /// What this session has staged at `path`, for a handle that wants to
4904    /// address a not-yet-committed object by name.
4905    ///
4906    /// Only a *creation* answers, and only when it owns the path — when the file
4907    /// holds no link there, or when this same commit removes the one it does.
4908    /// Both halves matter:
4909    ///
4910    /// - A staged **deletion** on its own hides nothing. The object is in the
4911    ///   file and still readable, and a lookup that pretended otherwise would
4912    ///   refuse a read the file can serve.
4913    /// - A creation **colliding** with a link the commit does not remove is one
4914    ///   [`refuse_creation_collision`](Self::refuse_creation_collision) turns
4915    ///   away where it is staged, so this arm is the same rule asked a second
4916    ///   time rather than a state a caller can reach. Reporting such a creation
4917    ///   would shadow a live, readable object with a handle that never becomes
4918    ///   valid, so the file's own object stays the meaning of the name.
4919    /// - The two together are a **replacement** (issue #305), and the creation
4920    ///   is what the path names from the moment it is staged, before the commit
4921    ///   as after. "The two together" is
4922    ///   [`StagedEdits::deletes_hand_over`]'s rule: the deletion names this path,
4923    ///   or an ancestor this session builds again.
4924    ///
4925    /// Only a path this session staged is reported, and every group above a
4926    /// staged object must be one the file already holds or one this session
4927    /// stages at its own path. [`commit`](Self::commit) rejects the whole batch
4928    /// with [`Error::EditUnsupported`] when a group above a creation is neither,
4929    /// the state `a/b` is in when [`stage_created_dataset`](Self::stage_created_dataset) stages
4930    /// `a/b/col` alone.
4931    pub(crate) fn staged_object(&self, path: &ObjectPathBuf) -> Option<StagedObject> {
4932        if self.stages_no_creations() {
4933            return None;
4934        }
4935        if path.is_empty() {
4936            // The root always exists; nothing can stage it.
4937            return None;
4938        }
4939        let kind = if self.staged.dataset_at(path).is_some() {
4940            StagedKind::Dataset
4941        } else if self.staged.has_group_at(path) {
4942            StagedKind::Group
4943        } else {
4944            return None;
4945        };
4946        let replaces_link = self.staged.deletes_hand_over(path);
4947        // The file is asked only about a path a creation actually names, so an
4948        // ordinary open of an object nothing is staged at never pays for this.
4949        if !replaces_link && self.path_in_file(path) {
4950            return None;
4951        }
4952        Some(StagedObject {
4953            kind,
4954            replaces_link,
4955        })
4956    }
4957
4958    /// Refuse a creation at a path something already owns — a link the file
4959    /// holds and this session does not remove, or a creation this session
4960    /// already staged — at the call that stages it.
4961    ///
4962    /// The commit refuses the same batch (`a link with this name already
4963    /// exists`), and the file arm raises that refusal's own error so the two
4964    /// cannot disagree about what a collision is. Refusing here is what lets
4965    /// [`Group::create_dataset`](crate::Group::create_dataset) and its two
4966    /// siblings hand back a handle onto the object they stage: a creation that
4967    /// merely collides is not what its path names — the file's object still is
4968    /// — so a handle onto it would have addressed the *old* object while
4969    /// claiming to address the new one.
4970    ///
4971    /// A creation this session already stages at the path is refused for the
4972    /// same reason, one the commit's own refusal cannot stand in for. The staged
4973    /// set is indexed by path and the index keeps the *first* record at each one
4974    /// (`or_insert`), so a second creation is staged behind the first and every
4975    /// handle onto that path — the one this call would hand back included —
4976    /// answers `shape`, `dtype`, `filters` and `append_staged` from the record
4977    /// the caller did not just describe. No committed object is misaddressed,
4978    /// since the commit refuses the batch, but the window between the two is one
4979    /// where a handle reports another object's geometry as its own.
4980    ///
4981    /// Two *group* creations at one path are the exception and stay allowed:
4982    /// they name one node, the commit builds one group from them, and a handle
4983    /// onto either addresses that group. Re-staging a group is how a caller adds
4984    /// attributes or children to one it already staged.
4985    ///
4986    /// A [`copy`](Self::copy) destination is still the commit's collision to
4987    /// refuse: it hands back no handle, so nothing can be misaddressed by it.
4988    fn refuse_creation_collision(
4989        &self,
4990        path: &ObjectPathBuf,
4991        kind: StagedKind,
4992    ) -> Result<(), Error> {
4993        // An empty path identifies the root group, which no link reaches and
4994        // nothing creates. Both callers reject it with a message of their own
4995        // before this is reached, so this returns `Ok` and leaves the collision
4996        // below to a path that identifies a link.
4997        if path.is_empty() {
4998            return Ok(());
4999        }
5000        // Asked before the file, so that staging twice over a link this session
5001        // deletes — a replacement, which the file arm below lets through — is
5002        // caught by the same rule as staging twice over nothing.
5003        if self.staged.dataset_at(path).is_some()
5004            || (kind == StagedKind::Dataset && self.staged.has_group_at(path))
5005        {
5006            return Err(Error::EditUnsupported(
5007                "this session already stages an object with this name in the target group; \
5008                 delete it to withdraw that staging before staging another in its place",
5009            ));
5010        }
5011        if self.staged.deletes_hand_over(path) || !self.path_in_file(path) {
5012            return Ok(());
5013        }
5014        Err(Error::EditUnsupported(
5015            "a link with this name already exists in the target group",
5016        ))
5017    }
5018
5019    /// Whether the file this session is editing holds a link at `path`, as of
5020    /// its committed state.
5021    ///
5022    /// Resolved against a borrowed slice where the backing can lend one, as the
5023    /// reader's own path resolution does: this is asked once per by-name lookup
5024    /// of a path a creation is staged at, so the copy a `Source` read would make
5025    /// per link block is worth avoiding.
5026    fn path_in_file(&self, path: &ObjectPathBuf) -> bool {
5027        match self.image.as_slice() {
5028            Some(data) => crate::group_v2::resolve_path_any(
5029                data,
5030                AccessMode::ReadWrite,
5031                &self.superblock,
5032                &path.as_path(),
5033            )
5034            .is_ok(),
5035            None => crate::group_v2::resolve_path_any_from_source(
5036                &self.image(),
5037                AccessMode::ReadWrite,
5038                &self.superblock,
5039                &path.as_path(),
5040            )
5041            .is_ok(),
5042        }
5043    }
5044
5045    /// Whether this session has staged no creations at all, which is the common
5046    /// case on the by-name lookup path. Checked before the path is owned, so a
5047    /// lookup of an object already in the file returns on this flag alone.
5048    fn stages_no_creations(&self) -> bool {
5049        self.staged.groups.is_empty() && self.staged.datasets.is_empty()
5050    }
5051
5052    /// How many times a commit has consumed this session's staged set, rather
5053    /// than handing it back to it. See the
5054    /// [`staged_generation`](Self::staged_generation) field for the rule, and
5055    /// [`crate::Error::StagingWithdrawn`] for what a handle does with it.
5056    pub(crate) fn staged_generation(&self) -> u64 {
5057        self.staged_generation
5058    }
5059
5060    /// What a staged dataset at `path` says about itself, for the introspection
5061    /// a handle onto it can answer before the commit writes any bytes.
5062    ///
5063    /// Cloned out of the staged [`FlatDataset`], which is the whole record of
5064    /// what the commit will write, so these answers cannot drift from the
5065    /// dataset that lands. Answers only for a creation that owns its path, on
5066    /// the terms [`staged_object`](Self::staged_object) sets out.
5067    pub(crate) fn staged_dataset_meta(&self, path: &ObjectPathBuf) -> Option<StagedMeta> {
5068        if self.staged_object(path)?.kind != StagedKind::Dataset {
5069            return None;
5070        }
5071        let fd = self.staged.dataset_at(path)?;
5072        Some(StagedMeta {
5073            datatype: fd.dt.clone(),
5074            dimensions: fd.ds.dimensions.clone(),
5075            // Reported the way `Dataset::maxshape` reports an on-disk one: a
5076            // maximum equal to the current shape is a fixed-shape dataset.
5077            maxshape: fd.ds.extensible_max_dimensions().map(<[MaxExtent]>::to_vec),
5078            // The rule the commit applies: chunk options or an extensible shape
5079            // select chunked storage, anything else contiguous.
5080            chunked: fd.chunk_options.is_chunked() || fd.maxshape.is_some(),
5081            filters: fd
5082                .chunk_options
5083                .filters
5084                .iter()
5085                .map(|f| (f.kind.filter_id(), f.optional))
5086                .collect(),
5087        })
5088    }
5089
5090    /// The direct children `parent` gains from this session's staged creations:
5091    /// the groups first and then the datasets, each in the order it was staged.
5092    ///
5093    /// `parent` may itself be staged, which is what lets a listing on a
5094    /// not-yet-committed group report its members. Only children the session
5095    /// named answer, for the reason [`staged_object`](Self::staged_object)
5096    /// gives.
5097    ///
5098    /// Unlike [`staged_object`](Self::staged_object) this does not ask the file
5099    /// whether each name is already taken: the caller is enumerating a group and
5100    /// already holds its on-disk links, so `replaces_link` is all it needs to
5101    /// apply the same rule without a path resolution per child.
5102    pub(crate) fn staged_children(&self, parent: &ObjectPathBuf) -> Vec<StagedChild> {
5103        if self.stages_no_creations() {
5104            return Vec::new();
5105        }
5106        // Whether this group is itself a replacement staged here — its on-disk
5107        // links go with the object being removed, so every creation under it
5108        // owns its name — and which links directly under it are removed by
5109        // name: the two ways a creation here is a replacement rather than a
5110        // collision. A deletion of the group that this session does *not* build
5111        // again is neither: the commit refuses that batch, so the file's own
5112        // children still own their names (see [`StagedEdits::deletes_hand_over`]).
5113        let base_deleted =
5114            self.staged.deletes_hand_over(parent) && self.staged.has_group_at(parent);
5115        let deleted_here: HashSet<&str> = self
5116            .staged
5117            .deletes
5118            .iter()
5119            .filter_map(|d| d.name_under(parent))
5120            .map(LinkNameBuf::as_str)
5121            .collect();
5122
5123        let mut seen: HashSet<&str> = HashSet::new();
5124        let mut out: Vec<StagedChild> = Vec::new();
5125        for path in &self.staged.groups {
5126            if let Some(name) = path.name_under(parent) {
5127                let name = name.as_str();
5128                if seen.insert(name) {
5129                    out.push(StagedChild {
5130                        name: name.to_string(),
5131                        kind: StagedKind::Group,
5132                        replaces_link: base_deleted || deleted_here.contains(name),
5133                    });
5134                }
5135            }
5136        }
5137        for (p, fd) in &self.staged.datasets {
5138            if p == parent && seen.insert(fd.name.as_str()) {
5139                out.push(StagedChild {
5140                    name: fd.name.as_str().to_string(),
5141                    kind: StagedKind::Dataset,
5142                    replaces_link: base_deleted || deleted_here.contains(fd.name.as_str()),
5143                });
5144            }
5145        }
5146        out
5147    }
5148
5149    /// Stage a new (empty) group at `path`, created on the next
5150    /// [`commit`](Self::commit). The parent must already exist or be created in
5151    /// the same session; populate the group with datasets via
5152    /// [`stage_created_dataset`](Self::stage_created_dataset) using a path under it.
5153    ///
5154    /// # Errors
5155    ///
5156    /// Returns [`Error::EditUnsupported`] if `path` is [`ObjectPathBuf::root`], which no link
5157    /// reaches, if a live buffered appender covers `path`, or if the name is taken on the terms
5158    /// [`refuse_creation_collision`](Self::refuse_creation_collision) sets out.
5159    pub fn create_group(&mut self, path: &ObjectPathBuf) -> Result<(), Error> {
5160        // Nothing links to the root group, and every file has one, so there is
5161        // no link here to create. Rejected here, as a deletion of the root is:
5162        // a creation staged at the root would mark it a node this commit builds
5163        // fresh, until the commit rejected the batch.
5164        if path.is_empty() {
5165            return Err(Error::EditUnsupported("cannot create the root group"));
5166        }
5167        self.refuse_if_claimed(path)?;
5168        self.refuse_creation_collision(path, StagedKind::Group)?;
5169        self.staged.push_group(path.clone());
5170        Ok(())
5171    }
5172
5173    /// Stage an attribute add or replacement on a group, applied on the next
5174    /// [`commit`](Self::commit).
5175    ///
5176    /// `path` identifies the group to edit, and [`ObjectPathBuf::root`] the root group. The
5177    /// group may already exist or may be created earlier in the same session
5178    /// with [`create_group`](Self::create_group). Attributes — fixed-size or
5179    /// variable-length — are stored compactly in the rebuilt group header while
5180    /// they fit it, and in a fractal heap past that, on the terms
5181    /// [`plan_attr_ops`] sets out.
5182    pub fn set_group_attr(
5183        &mut self,
5184        path: &ObjectPathBuf,
5185        name: &str,
5186        value: AttrValue,
5187    ) -> Result<(), Error> {
5188        self.refuse_if_claimed(path)?;
5189        self.staged.group_attrs.push((
5190            path.clone(),
5191            AttrOp::Set {
5192                name: name.to_string(),
5193                value,
5194            },
5195        ));
5196        Ok(())
5197    }
5198
5199    /// Stage removal of a compact attribute from a group, applied on the next
5200    /// [`commit`](Self::commit).
5201    ///
5202    /// `path` identifies the group to edit, and [`ObjectPathBuf::root`] the root group. The
5203    /// named attribute must exist in the committed group state after any earlier
5204    /// staged attribute operations for the same group have been applied.
5205    pub fn remove_group_attr(&mut self, path: &ObjectPathBuf, name: &str) -> Result<(), Error> {
5206        self.refuse_if_claimed(path)?;
5207        self.staged.group_attrs.push((
5208            path.clone(),
5209            AttrOp::Remove {
5210                name: name.to_string(),
5211            },
5212        ));
5213        Ok(())
5214    }
5215
5216    /// Stage an attribute add or replacement on an **existing dataset**, applied on
5217    /// the next [`commit`](Self::commit).
5218    ///
5219    /// `path` names the dataset to edit. Attributes — fixed-size or
5220    /// variable-length — are stored in the rebuilt dataset header
5221    /// while they fit it, and in a fractal heap past that, on the terms
5222    /// [`plan_attr_ops`] sets out. Applying it relocates the dataset's object
5223    /// header (the header is rewritten and its single naming link repointed; the
5224    /// dataset's data and chunk index stay in place), so it is supported only when
5225    /// the dataset has a **single hard link**. To set attributes on a dataset being *created* in this
5226    /// session, use the builder's [`set_attr`](crate::DatasetBuilder::set_attr)
5227    /// instead.
5228    pub fn set_dataset_attr(
5229        &mut self,
5230        path: &ObjectPathBuf,
5231        name: &str,
5232        value: AttrValue,
5233    ) -> Result<(), Error> {
5234        self.refuse_if_claimed(path)?;
5235        self.staged.dataset_attrs.push((
5236            path.clone(),
5237            AttrOp::Set {
5238                name: name.to_string(),
5239                value,
5240            },
5241        ));
5242        Ok(())
5243    }
5244
5245    /// Stage removal of a compact attribute from an **existing dataset**, applied on
5246    /// the next [`commit`](Self::commit).
5247    ///
5248    /// `path` names the dataset to edit; the named attribute must exist in the
5249    /// committed dataset state after any earlier staged attribute operations for the
5250    /// same dataset have been applied. Like [`set_dataset_attr`](Self::set_dataset_attr)
5251    /// it relocates the dataset header and requires a single hard link.
5252    pub fn remove_dataset_attr(&mut self, path: &ObjectPathBuf, name: &str) -> Result<(), Error> {
5253        self.refuse_if_claimed(path)?;
5254        self.staged.dataset_attrs.push((
5255            path.clone(),
5256            AttrOp::Remove {
5257                name: name.to_string(),
5258            },
5259        ));
5260        Ok(())
5261    }
5262
5263    /// Stage removal of the link at `path` (the HDF5 `H5Ldelete`), applied on the
5264    /// next [`commit`](Self::commit). The link's object — and, for a group, its
5265    /// whole subtree — becomes unreachable. The bytes it occupied are returned to
5266    /// this session's free list (issue #21): a later commit reuses them for new
5267    /// objects instead of growing the file, and if a freed run reaches
5268    /// end-of-file the file is truncated. Contiguous and chunked datasets (their
5269    /// chunk index and chunk data blocks) and whole group subtrees are all
5270    /// reclaimed. Reclaim is best-effort — an object whose blocks this engine
5271    /// cannot enumerate exhaustively (variable-length global-heap storage, dense
5272    /// attribute/link heaps, a version 2 B-tree chunk index) is left as dead
5273    /// bytes rather than risk freeing a region that is still in use. Freed space is
5274    /// reused within the open session; for a file created with
5275    /// `H5Pset_file_space_strategy(persist = true)` it is also recorded on disk so
5276    /// it survives reopen, otherwise it is forgotten
5277    /// on close. After reuse, an object reference to a deleted object may resolve
5278    /// to an unrelated object (deleting a referenced object is undefined in HDF5).
5279    ///
5280    /// The path must exist. The link's parent group must itself be editable in
5281    /// place (compact links, single-chunk header); the target being removed has
5282    /// no such restriction.
5283    ///
5284    /// # Replacing an object
5285    ///
5286    /// Deleting a path and creating a new object at that same path in one commit
5287    /// is a *replacement*, and is accepted (issue #305): the removal is applied
5288    /// before the addition, and the commit's single superblock write publishes
5289    /// both, so the path is occupied at every instant a crash could interrupt.
5290    /// The new object need not resemble the old one — a dataset may replace a
5291    /// group, or the reverse — and replacing a group discards its whole subtree
5292    /// rather than inheriting it.
5293    ///
5294    /// Everything this commit adds *below* a replaced path lands in the
5295    /// replacement, whatever order the calls were made in: staging
5296    /// `create_dataset("g/x")` and then replacing `g` puts `x` in the new group,
5297    /// not the one being removed. A staged edit that could only mean the
5298    /// *original* is refused instead — a group under the replaced path that this
5299    /// commit does not itself create, an attribute set on the object being
5300    /// removed, a value overwrite inside it, or a [`copy`](crate::File::copy)
5301    /// reading from it.
5302    ///
5303    /// See [`Group::delete`](crate::Group::delete) for the public form and a
5304    /// worked example.
5305    ///
5306    /// A deletion may not overlap a staged change at a *different* path
5307    /// (deleting `/a` while adding `/a/b`, unless `/a` is itself replaced in the
5308    /// same commit), nor an edit to the object being removed (an attribute set
5309    /// on it, or a value overwrite of something inside it); split those into
5310    /// separate commits.
5311    /// Deleting something this session **staged** and has not committed
5312    /// withdraws that staging instead: there is no link in the file to unlink,
5313    /// so the session is left as though the creation had never been made — its
5314    /// attributes, appends and staged children go with it. When the file *also*
5315    /// holds a link at the path (the creation was replacing it), the withdrawal
5316    /// leaves the plain deletion of the file's own object behind.
5317    ///
5318    /// The root group, [`ObjectPathBuf::root`], is rejected with [`Error::EditUnsupported`]:
5319    /// no link reaches it, so there is no link to remove.
5320    pub fn delete(&mut self, path: &ObjectPathBuf) -> Result<(), Error> {
5321        // The root is not linked from anywhere, so there is no link to remove —
5322        // and an empty path is a prefix of every other, so a deletion staged
5323        // here would make every staged creation in the session look like a
5324        // replacement of a file object until the commit refused the batch.
5325        // Refused by name, as creating the root is.
5326        if path.is_empty() {
5327            return Err(Error::EditUnsupported(
5328                "cannot delete the root group; delete its members instead",
5329            ));
5330        }
5331        self.refuse_if_claimed(path)?;
5332        if self.staged.dataset_at(path).is_some() || self.staged.has_group_at(path) {
5333            self.refuse_mid_batch()?;
5334            self.staged.withdraw_at(path);
5335            // Withdrawing is the whole deletion unless the file holds a link
5336            // here too — and if it does, a deletion of it may already be staged,
5337            // which is what made this a replacement in the first place. A second
5338            // one would be an overlapping deletion the commit refuses.
5339            if self.staged.deletes_cover(path) || !self.path_in_file(path) {
5340                return Ok(());
5341            }
5342        }
5343        self.staged.deletes.push(path.clone());
5344        Ok(())
5345    }
5346
5347    /// Stage a deep copy of the object at `src` to a new link at `dst` (the HDF5
5348    /// `H5Ocopy`), applied on the next [`commit`](Self::commit). The source — a
5349    /// dataset or a whole group subtree — is duplicated: fresh copies of every
5350    /// object's data and header are written, internal links and the contiguous
5351    /// data address are repointed to the copies, and a link named by `dst`'s last
5352    /// component is added to `dst`'s parent group. The original is untouched.
5353    ///
5354    /// The copy reflects the file's on-disk state at commit time. `src` must
5355    /// exist and `dst` must not (and may not lie inside `src`). A chunked (and
5356    /// filtered) dataset is copied with its chunk payloads and filter pipeline
5357    /// preserved byte-for-byte (the index is rebuilt at the new location, so a
5358    /// source using a B-tree-v1 or implicit index is reproduced with an equivalent
5359    /// v4 index). The source subtree must otherwise be copyable in place: compact
5360    /// links and attributes, single-chunk headers, and a chunk index this engine
5361    /// can enumerate (a version-2 B-tree, or a sparse/unallocated chunk grid, is
5362    /// refused) — otherwise `commit` reports [`Error::EditUnsupported`].
5363    ///
5364    /// A *contiguous* dataset whose storage was never allocated is copied as the
5365    /// storage it has — none — rather than as the fill value a read answers with,
5366    /// which is what keeps a schema-only source from arriving materialized at the
5367    /// size its shape declares. A dataset storing its elements in external files
5368    /// (`H5Pset_external`) carries that same empty storage over data this crate
5369    /// does not read, and is refused by name so the two do not share an answer
5370    /// (issue #336).
5371    pub fn copy(&mut self, src: &ObjectPathBuf, dst: &ObjectPathBuf) -> Result<(), Error> {
5372        // Both ends matter: the source is read and the destination is written,
5373        // and a commit relocates headers along either path.
5374        self.refuse_if_claimed(src)?;
5375        self.refuse_if_claimed(dst)?;
5376        self.staged.copies.push((src.clone(), dst.clone()));
5377        Ok(())
5378    }
5379
5380    /// Stage a deep copy of the object at `src` in another open file `source` to a
5381    /// new link at `dst` in this file — a *cross-file* HDF5 `H5Ocopy` — applied on
5382    /// the next [`commit`](Self::commit). Like [`copy`](Self::copy) but the source
5383    /// lives in a separate, independently-opened [`File`](crate::File) reader
5384    /// rather than the file being edited.
5385    ///
5386    /// The source — a dataset or a whole group subtree — is duplicated faithfully:
5387    /// fresh, byte-identical copies of every object's header and data are appended
5388    /// to this file, internal links repointed, and a link named by `dst`'s last
5389    /// component added to `dst`'s parent group (which must already exist or be
5390    /// created earlier in this session). Both files are left otherwise untouched;
5391    /// the destination only changes on `commit`.
5392    ///
5393    /// Unlike the same-file [`copy`](Self::copy), the source is read **eagerly**
5394    /// here (the `source` borrow need not outlive the call), so this returns
5395    /// `Result`: the source subtree is resolved, validated, and read out before
5396    /// returning, and only an already-validated copy is queued for `commit`.
5397    ///
5398    /// # Errors
5399    ///
5400    /// Returns [`Error::EditUnsupported`] if the copy cannot be reproduced exactly
5401    /// in another file. Because the copy is byte-for-byte verbatim, anything that
5402    /// embeds a *source-file* absolute address is refused (it would dangle here):
5403    /// **variable-length** or **reference** datasets and attributes (including a
5404    /// chunked dataset whose elements are variable-length or references, whose
5405    /// chunk payloads embed such addresses), and any **shared header message** (a
5406    /// committed datatype, or an SOHM-shared dataspace, fill value, or filter
5407    /// pipeline). As with [`copy`](Self::copy) a chunked/filtered source is copied
5408    /// with its chunk payloads and pipeline preserved (index rebuilt at the new
5409    /// location); the source must use compact links and attributes, single-chunk
5410    /// version-2 headers, and a chunk index this engine can enumerate (a
5411    /// version-2 B-tree, or a sparse chunk grid, is refused). The
5412    /// `source` must be a buffered file ([`File::open`](crate::File::open) or
5413    /// [`File::from_bytes`](crate::File::from_bytes), not
5414    /// [`open_streaming`](crate::File::open_streaming)) using 8-byte offsets and no
5415    /// userblock, and `src` must exist in it and not be the root group.
5416    ///
5417    /// # Note
5418    ///
5419    /// The source is read under the mode its own file is open under, read-only for
5420    /// every source this copy accepts, so an object header message of a type this
5421    /// crate cannot name that a decoder with write access must understand is read
5422    /// past. The copy reproduces an object's header records verbatim, so such a
5423    /// message on `src`'s own header reaches the destination. `H5Ocopy` copies a
5424    /// message of an unknown type into the destination header the same way, since a
5425    /// message without a `copy_file` callback is copied raw (`H5Ocopy.c`,
5426    /// HDF5 2.2.0).
5427    pub fn copy_from(
5428        &mut self,
5429        source: &crate::reader::File,
5430        src: &ObjectPathBuf,
5431        dst: &ObjectPathBuf,
5432    ) -> Result<(), Error> {
5433        // The source bytes must be addressable: a streaming file is refused.
5434        let src_data = source.in_memory_image().ok_or(Error::EditUnsupported(
5435            "cross-file copy requires a buffered source file (File::open or File::from_bytes), not a streaming one",
5436        ))?;
5437        let src_sb = source.superblock();
5438        if src_sb.offset_size != OFFSET_SIZE || src_sb.length_size != LENGTH_SIZE {
5439            return Err(Error::EditUnsupported(
5440                "cross-file copy requires the source file to use 8-byte offsets and lengths",
5441            ));
5442        }
5443        if !source.base_address().is_zero() {
5444            return Err(Error::EditUnsupported(
5445                "cross-file copy requires the source file to have no userblock (base address 0)",
5446            ));
5447        }
5448
5449        if src.is_empty() {
5450            return Err(Error::EditUnsupported("cannot copy the root group"));
5451        }
5452        if dst.is_empty() {
5453            return Err(Error::EditUnsupported("copy destination path is empty"));
5454        }
5455
5456        let src_addr = crate::group_v2::resolve_path_any(
5457            src_data,
5458            source.access_mode(),
5459            src_sb,
5460            &src.as_path(),
5461        )?;
5462        // Read (and foreign-address-screen) the whole subtree now, while `source`
5463        // is borrowed; the owned tree carries every byte the commit will write. The
5464        // source is gated to base 0 above, so its stored addresses are absolute.
5465        let tree = Self::read_copy_subtree(
5466            &BytesSource::new(src_data),
5467            source.access_mode(),
5468            src_addr,
5469            0,
5470            true,
5471            BaseAddress::ZERO,
5472        )?;
5473        self.refuse_if_claimed(dst)?;
5474        self.staged.cross_copies.push((dst.clone(), tree));
5475        Ok(())
5476    }
5477
5478    /// Apply all staged additions and deletions to the file in place and flush.
5479    ///
5480    /// Appends each new dataset (its data — a contiguous blob, or the chunk data
5481    /// and index for a chunked/filtered dataset — plus its object header) and
5482    /// each new group, then appends rewritten object headers for every touched
5483    /// group and its ancestors up to the root (omitting any deleted links), then
5484    /// repoints the superblock at the new root. On success the staged set is
5485    /// cleared and the session can be reused. On any [`Error::EditUnsupported`]
5486    /// the file on disk is left untouched: the checks that raise it — including
5487    /// each dataset's filter-pipeline and chunk-geometry validation — all run
5488    /// before the first byte is written. Should a later step fail mid-apply (an
5489    /// I/O error, or a residual build error), the superblock — repointed last —
5490    /// still names the prior root, so the file stays valid and the appended bytes
5491    /// are unreferenced slack.
5492    ///
5493    /// **A refused commit costs the session nothing it had staged.** The
5494    /// staged set is whole afterwards — so the batch can be committed again,
5495    /// and refuses again identically rather than applying the part of itself
5496    /// the refusal was not about — and the free regions the attempt drew from
5497    /// are given back, so an attempt that never became visible costs the
5498    /// session neither staged work nor reusable space (issue #316; see
5499    /// [`StagedEdits`] and [`FreeSnapshot`]).
5500    ///
5501    /// A failure *past* the first write is the other case, and it clears the
5502    /// staged set rather than restoring it: the file is valid but some of the
5503    /// batch is in it as slack, and a later `commit` must not re-issue the rest
5504    /// as if nothing had happened.
5505    ///
5506    /// **What the file holds is a separate promise: a commit that fails before
5507    /// its repoint leaves every dataset reading what it read before.** "Slack"
5508    /// describes all but one of the edits such a commit applies. The exception
5509    /// is a same-length value overwrite, which writes straight over the
5510    /// dataset's existing data block and so is live the moment it lands, with
5511    /// no repoint to withhold; the prior bytes are written back over every one
5512    /// of them on the way out (issue #344).
5513    ///
5514    /// Past the repoint the promise is the other one, and it has to be: the
5515    /// commit has published, so its overwrites are the file's values and are
5516    /// left standing. A failure there — `repoint_stored_references` is the step
5517    /// that can produce one — returns an error over a file that holds the whole
5518    /// batch, which is what
5519    /// `a_commit_that_fails_past_its_repoint_keeps_the_value_it_published`
5520    /// pins.
5521    ///
5522    /// # Errors
5523    ///
5524    /// Returns [`Error::CommitPartiallyApplied`] — carrying the write failure,
5525    /// not the refusal — when that restore itself failed, which is the one
5526    /// refusal after which a dataset the batch named may hold either value.
5527    /// Every other refusal leaves them all as they were.
5528    pub fn commit(&mut self) -> Result<(), Error> {
5529        let snapshot = self.snapshot_free();
5530        self.publish_attempted = false;
5531        // Cleared at entry, like `superseded_heaps` and for the same reason: a
5532        // previous attempt that unwound past this function — a panic caught by
5533        // the session lock, which recovers from poisoning — would otherwise
5534        // leave entries behind, and this commit's rollback would replay bytes
5535        // captured before it over whatever lives at those addresses now. The
5536        // clear on the way out is a separate duty: it stops a journal being
5537        // held across the idle time between commits.
5538        self.inplace_undo.clear();
5539        // The staged set comes out for the duration of the attempt and goes back
5540        // if the attempt refuses, so a refusal costs the session no staged work
5541        // (issue #316). What goes back is whatever the attempt did not take: its
5542        // preflight only reads, and its apply phase takes the whole set in one
5543        // move at the point of no return, so a preflight refusal restores every
5544        // edit and a failure past that point restores nothing.
5545        let mut staged = std::mem::take(&mut self.staged);
5546        let mut result = self.commit_inner(&mut staged);
5547        if result.is_err() && !self.publish_attempted {
5548            // The three legs of the rollback: the file's values, the free
5549            // lists, and the staged set. Nothing observes the order — the first
5550            // touches the file and the others only in-memory state, inside one
5551            // synchronous call — so it is written the way it reads: put the
5552            // file back, then re-offer what the attempt drew from (issue #344).
5553            let restored = self.undo_inplace_writes();
5554            self.restore_free(snapshot);
5555            self.staged = staged;
5556            // The set is back, so nothing this session staged has been consumed
5557            // and every handle onto one of those creations is still pending.
5558            if let Err(restore) = restored {
5559                // Both are worth carrying: the refusal is what the caller has to
5560                // fix, and the write failure is what proves the file changed.
5561                let refusal = result.expect_err("only a failed attempt is rolled back");
5562                result = Err(Error::CommitPartiallyApplied {
5563                    refusal: Box::new(refusal),
5564                    restore: Box::new(restore),
5565                });
5566            }
5567        } else {
5568            // The set is gone — published, or in the file as slack a later
5569            // commit must not re-issue — so a handle onto one of its creations
5570            // stops being pending and addresses the file from here on.
5571            self.staged_generation += 1;
5572        }
5573        // For the paths that took no rollback — a commit that succeeded, whose
5574        // overwrites are now the file's values, and one that failed with the
5575        // publish already issued, where undoing anything is unsound. A rollback
5576        // drained the journal on its way through, so it owes nothing here.
5577        self.inplace_undo.clear();
5578        result
5579    }
5580
5581    /// The free lists as they stand now, for [`commit`](Self::commit) to restore
5582    /// if its apply loop draws from them and then fails.
5583    fn snapshot_free(&self) -> FreeSnapshot {
5584        FreeSnapshot {
5585            free: self.free.clone(),
5586            reserved: self.reserved.clone(),
5587            paged: self
5588                .paged
5589                .as_ref()
5590                .map(|pg| (pg.meta.clone(), pg.raw.clone())),
5591            vl_overwrite_heaps: self.vl_overwrite_heaps.clone(),
5592        }
5593    }
5594
5595    /// Put the free lists back as [`snapshot_free`](Self::snapshot_free) found
5596    /// them. Called only for a commit that failed before publishing anything, so
5597    /// every region it restores is dead again.
5598    ///
5599    /// That rests on nothing the live tree reaches naming a span the attempt
5600    /// allocated, which takes two rules rather than one. Every object a commit
5601    /// *places* is unreachable until the superblock repoint; and the one write
5602    /// that does not wait for the repoint — a same-length value overwrite — is
5603    /// planned only over bytes that carry no allocated address, because
5604    /// [`prepare_write`](Self::prepare_write) relocates a staged
5605    /// variable-length overwrite instead of writing it in place, which is
5606    /// exactly the counterexample (issue #321).
5607    ///
5608    /// [`undo_inplace_writes`](Self::undo_inplace_writes) has nevertheless run
5609    /// by the time this does, so the file's values are back before its regions
5610    /// are offered again (issue #344).
5611    fn restore_free(&mut self, snapshot: FreeSnapshot) {
5612        self.vl_overwrite_heaps = snapshot.vl_overwrite_heaps;
5613        self.free = snapshot.free;
5614        self.reserved = snapshot.reserved;
5615        if let (Some(pg), Some((meta, raw))) = (self.paged.as_mut(), snapshot.paged) {
5616            pg.meta = meta;
5617            pg.raw = raw;
5618        }
5619    }
5620
5621    /// Overwrites `raw` at `at`, keeping the bytes it replaces so a commit that
5622    /// fails before its repoint can put them back.
5623    ///
5624    /// The read covers exactly the range about to be written, which the write
5625    /// preflight sized and bounds-checked against the on-disk dataset — a
5626    /// same-length overwrite by construction.
5627    fn write_inplace_journaled(&mut self, at: u64, raw: &[u8]) -> Result<(), Error> {
5628        let prior = self.image().read_exact_at(at, raw.len())?;
5629        self.inplace_undo.push((at, prior));
5630        self.write_at(at, raw)
5631    }
5632
5633    /// Put back the values the refused commit's same-length in-place overwrites
5634    /// replaced, and drop the journal.
5635    ///
5636    /// The third leg of a refused commit's rollback, beside
5637    /// [`restore_free`](Self::restore_free) and the staged set: those two give
5638    /// back what the attempt *consumed*, this gives back what it *changed*. It
5639    /// is the only one of the three that has to touch the file, because a
5640    /// same-length overwrite is the only edit a commit writes over bytes the
5641    /// live root already reaches (issue #344).
5642    ///
5643    /// # Errors
5644    ///
5645    /// Every entry is attempted even after one fails, so the file is restored as
5646    /// far as it can be, and the first failure met is what comes back. That is the
5647    /// one outcome leaving values from a batch that was refused, which is why
5648    /// [`commit`](Self::commit) reports it as
5649    /// [`Error::CommitPartiallyApplied`] rather than as the refusal.
5650    fn undo_inplace_writes(&mut self) -> Result<(), Error> {
5651        let journal = core::mem::take(&mut self.inplace_undo);
5652        if journal.is_empty() {
5653            return Ok(());
5654        }
5655        let mut failed = None;
5656        // Newest first, and load-bearing rather than conventional. The commit
5657        // refuses to overwrite the same *path* twice, which is not the same as
5658        // the same dataset: an object reachable through two hard links has two
5659        // paths and one data block, and a same-length overwrite through either
5660        // is allowed precisely because it rewrites the block every link sees.
5661        // Two such writes in one commit therefore journal the same address
5662        // twice, the second entry holding what the first write put there.
5663        // Replaying oldest-first would finish on that value — one from the very
5664        // batch being rolled back.
5665        for (at, prior) in journal.into_iter().rev() {
5666            if let Err(e) = self.write_at(at, &prior) {
5667                failed.get_or_insert(e);
5668            }
5669        }
5670        match failed {
5671            Some(e) => Err(e),
5672            // A restore that is not a barrier can be gathered and reordered
5673            // past a later write, which is the whole hazard write gathering
5674            // introduced (issue #288); order it here instead. The data-only
5675            // barrier is the one that fits, because putting bytes back into
5676            // blocks that already existed moves no end-of-file — it is a
5677            // narrower barrier than the `barrier()` the commit itself uses
5678            // after these writes, deliberately.
5679            None => self.barrier_data(),
5680        }
5681    }
5682
5683    fn commit_inner(&mut self, staged: &mut StagedEdits) -> Result<(), Error> {
5684        if staged.is_empty() {
5685            return Ok(());
5686        }
5687
5688        // An attempt that failed partway may have recorded superseded collections
5689        // it never freed; this one must not free them on its behalf, since it is
5690        // not the commit that replaced what they hold.
5691        self.superseded_heaps.clear();
5692        // Drop every heap-collection provenance record this batch could falsify,
5693        // before any of them is read below.
5694        self.invalidate_heap_provenance(staged);
5695
5696        // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
5697        // space has no on-disk record of which pages hold metadata and which hold
5698        // raw data, so this commit could not keep the two segregated and would
5699        // silently degrade the paging. Refuse up front, before any writes, exactly
5700        // as the bounded backend does. A paged *persisting* file is committed
5701        // through the page-aware tail below (issue #198).
5702        if self.paged.is_some() && self.persist.is_none() {
5703            return Err(Error::EditUnsupported(
5704                "committing an edit to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
5705                 persisted free space; recreate the file with \
5706                 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to edit it in place",
5707            ));
5708        }
5709
5710        // Invalidate the in-place-append geometry cache before doing any work. A
5711        // commit that reaches here rewrites and relocates object headers, frees
5712        // vacated regions into `self.free`, and may truncate the file — any of
5713        // which can leave a cached `Located` pointing at a moved header or into a
5714        // now-free-eligible region. Clearing at *entry* (rather than the success
5715        // tail) means a later failure — including one after the durable root flip,
5716        // which leaves the session reusable — never strands a stale cache. The
5717        // no-op fast return above does no such work, so it keeps the cache. The
5718        // The next in-place append re-locates against the fresh file.
5719        self.located.clear();
5720        self.resolved.clear();
5721        // Past this point the commit may relocate object headers, so every address
5722        // a caller captured earlier is suspect; see `committed`.
5723        self.committed = true;
5724
5725        // On a file with a userblock, stored addresses are relative to this base
5726        // and the editor converts at every disk boundary (read `stored + base`,
5727        // write `file_offset - base`). Userblock support covers value overwrites,
5728        // additions of contiguous and chunked/filtered datasets, in-place and
5729        // relocating overwrites of every layout (chunked, contiguous, compact) with
5730        // reclaim, object deletion (with base-aware subtree reclaim), object copy
5731        // (in-file, and cross-file into a userblock destination), group creation,
5732        // and compact group attributes. Cross-file copy still requires a base-0
5733        // *source* (see [`copy_from`](Self::copy_from)).
5734        let base = self.superblock.base_address;
5735
5736        // --- Preflight value overwrites (`write_dataset`) before any write, under
5737        // the same all-or-nothing contract as additions. Each is resolved,
5738        // validated (datatype and shape must match the on-disk dataset exactly),
5739        // and classified: a same-length contiguous overwrite is applied straight
5740        // in place (no header rewrite, no superblock flip), while a resize or
5741        // compact rewrite relocates the header and is staged against its parent
5742        // group so the commit below rebuilds it and patches the link. ---
5743        let mut inplace_writes: Vec<(u64, OverwriteBytes)> = Vec::new();
5744        let mut moving_writes: Vec<(ObjectPathBuf, LinkNameBuf, u64, MovingWrite)> = Vec::new();
5745        let mut write_targets: Vec<ObjectPathBuf> = Vec::new();
5746        // The file-wide hard-link count, computed lazily the first time a commit
5747        // relocates a header: such a write moves the object's header and patches
5748        // only the one parent link that names it, so an object reachable through
5749        // more than one hard link would have its other links left pointing at the
5750        // stale header. Refuse that rather than silently diverge the aliases (a
5751        // same-length in-place overwrite is unaffected — it rewrites the shared
5752        // data block, which every link sees).
5753        //
5754        // Read by four places, which is every way a header moves: a relocating
5755        // overwrite, a staged append, a dataset attribute edit, and — since
5756        // issue #327 — a rebuilt group, which had the same defect and none of
5757        // the guard.
5758        let mut incoming_links: Option<Option<HashMap<u64, u32>>> = None;
5759        for (full, fd) in &staged.writes {
5760            // A path named twice in one commit would write it twice (and double-
5761            // free a resized extent); require separate commits.
5762            if write_targets.contains(full) {
5763                return Err(Error::EditUnsupported(
5764                    "the same dataset is overwritten twice in one commit; use separate commits",
5765                ));
5766            }
5767            let addr = crate::group_v2::resolve_path_any_from_source(
5768                &self.image(),
5769                AccessMode::ReadWrite,
5770                &self.superblock,
5771                &full.as_path(),
5772            )?;
5773            match Self::prepare_write(&self.image(), addr, fd, base, full)? {
5774                WritePlan::InPlace { data_addr, bytes } => {
5775                    inplace_writes.push((data_addr, bytes));
5776                }
5777                // A chunked in-place overwrite never carries staging — a staged
5778                // one is a `ChunkPayload::Deferred`, which relocates.
5779                WritePlan::InPlaceChunks { writes } => inplace_writes.extend(
5780                    writes
5781                        .into_iter()
5782                        .map(|(at, raw)| (at, OverwriteBytes::ready(raw))),
5783                ),
5784                WritePlan::Moving(mw) => {
5785                    // A relocating overwrite rewrites the dataset's header and data
5786                    // address. Every variant is base-aware on a userblock file: the
5787                    // chunked one rebuilds the chunk blob with stored addresses and
5788                    // reclaims the old storage base-relative, the contiguous one
5789                    // stores the relocated data address base-relative (and frees the
5790                    // old extent at its absolute offset), and the compact one carries
5791                    // its data inline. The parent link to the rewritten header is
5792                    // patched base-relative below.
5793                    //
5794                    // A relocating overwrite is safe only when this is the
5795                    // dataset's sole hard link. Compute the link graph once.
5796                    let counts = incoming_links
5797                        .get_or_insert_with(|| self.count_incoming_hard_links())
5798                        .as_ref();
5799                    match counts.and_then(|c| c.get(&addr)) {
5800                        Some(&1) => {}
5801                        _ => {
5802                            return Err(Error::EditUnsupported(
5803                                "overwriting a dataset that resizes or relocates its header is \
5804                                 only supported when it has a single hard link",
5805                            ));
5806                        }
5807                    }
5808                    let Some((parent, leaf)) = full.split_leaf() else {
5809                        return Err(Error::EditUnsupported("cannot overwrite the root group"));
5810                    };
5811                    moving_writes.push((parent, leaf, addr, mw));
5812                }
5813            }
5814            write_targets.push(full.clone());
5815        }
5816
5817        // --- Preflight appends (`append_dataset`) under the same all-or-nothing,
5818        // single-hard-link contract. Each plans a relocating append — existing
5819        // chunk data stays in place; the appended (and any rewritten trailing)
5820        // chunks and a rebuilt Extensible-Array index are staged, and the whole is
5821        // treated like a relocating overwrite of the dataset's header (staged
5822        // against its parent group so the commit patches the link). A zero-length
5823        // append is a no-op and is dropped here. ---
5824        for (full, ab) in &staged.appends {
5825            let Some((parent, leaf)) = full.split_leaf() else {
5826                return Err(Error::AppendUnsupported("cannot append to the root group"));
5827            };
5828            if ab.raw.is_empty() {
5829                continue; // nothing to append
5830            }
5831            // A dataset overwritten or appended earlier in this commit would be
5832            // planned against a stale header and its old storage double-freed;
5833            // require separate commits.
5834            if write_targets.contains(full) {
5835                return Err(Error::AppendUnsupported(
5836                    "the same dataset is edited more than once in one commit; use separate commits",
5837                ));
5838            }
5839            let addr = crate::group_v2::resolve_path_any_from_source(
5840                &self.image(),
5841                AccessMode::ReadWrite,
5842                &self.superblock,
5843                &full.as_path(),
5844            )?;
5845            let mw = Self::prepare_append(&self.image(), addr, ab, base)?;
5846            // A relocating append moves the dataset's object header and patches only
5847            // the one parent link that names it, so it is safe only when this is the
5848            // dataset's sole hard link (same rule as a relocating overwrite).
5849            let counts = incoming_links
5850                .get_or_insert_with(|| self.count_incoming_hard_links())
5851                .as_ref();
5852            match counts.and_then(|c| c.get(&addr)) {
5853                Some(&1) => {}
5854                _ => {
5855                    return Err(Error::AppendUnsupported(
5856                        "appending relocates the dataset header; only supported when it \
5857                         has a single hard link",
5858                    ));
5859                }
5860            }
5861            moving_writes.push((parent, leaf, addr, mw));
5862            write_targets.push(full.clone());
5863        }
5864
5865        // --- Preflight dataset attribute edits (`set_dataset_attr` /
5866        // `remove_dataset_attr`) under the same all-or-nothing, single-hard-link
5867        // contract. Each gathers the dataset's verbatim object-header region,
5868        // applies the compact attribute ops to it, and stages a relocating
5869        // `AttrEdit` header rewrite against the parent group — like a value
5870        // overwrite, but the data-layout message (and thus the chunk data and index)
5871        // is preserved verbatim, so only the header moves. ---
5872        if !staged.dataset_attrs.is_empty() {
5873            // Collect the ops per dataset in first-seen path order, so multiple edits
5874            // to one dataset produce a single relocating header rewrite.
5875            let mut order: Vec<ObjectPathBuf> = Vec::new();
5876            let mut ops_by_path: HashMap<&ObjectPathBuf, Vec<&AttrOp>> = HashMap::new();
5877            for (path, op) in &staged.dataset_attrs {
5878                if !ops_by_path.contains_key(path) {
5879                    order.push(path.clone());
5880                }
5881                ops_by_path.entry(path).or_default().push(op);
5882            }
5883            for full in order {
5884                let ops = ops_by_path.remove(&full).unwrap();
5885                let Some((parent, leaf)) = full.split_leaf() else {
5886                    return Err(Error::EditUnsupported(
5887                        "cannot set a dataset attribute on the root group; use set_group_attr",
5888                    ));
5889                };
5890                // A dataset already overwritten or appended in this commit would be
5891                // planned against a stale header; require separate commits.
5892                if write_targets.contains(&full) {
5893                    return Err(Error::EditUnsupported(
5894                        "the same dataset is edited more than once in one commit (an attribute \
5895                         edit plus another edit); use separate commits",
5896                    ));
5897                }
5898                let addr = crate::group_v2::resolve_path_any_from_source(
5899                    &self.image(),
5900                    AccessMode::ReadWrite,
5901                    &self.superblock,
5902                    &full.as_path(),
5903                )?;
5904                // An attribute edit relocates the dataset's object header and patches
5905                // only the one naming link, so it is safe only when this is the
5906                // dataset's sole hard link (same rule as a relocating overwrite).
5907                let counts = incoming_links
5908                    .get_or_insert_with(|| self.count_incoming_hard_links())
5909                    .as_ref();
5910                match counts.and_then(|c| c.get(&addr)) {
5911                    Some(&1) => {}
5912                    _ => {
5913                        return Err(Error::EditUnsupported(
5914                            "editing a dataset attribute relocates its header; only supported \
5915                             when it has a single hard link",
5916                        ));
5917                    }
5918                }
5919                let region = Self::gather_oh_messages(&self.image(), addr, base)?;
5920                let edits = plan_attr_ops(&self.image(), base, Some(addr), &region, &ops)?;
5921
5922                moving_writes.push((
5923                    parent,
5924                    leaf,
5925                    addr,
5926                    MovingWrite::AttrEdit {
5927                        region: edits.region,
5928                        attrs: edits.attrs,
5929                    },
5930                ));
5931                write_targets.push(full);
5932            }
5933        }
5934
5935        // Pre-commit object-header addresses this commit rewrites elsewhere, for
5936        // `InvalidatedAddresses` below: a reference naming one of these keeps an
5937        // address the commit is vacating (issue #317). Read off the one list every
5938        // relocating write is staged on, so a write added later cannot be left out
5939        // of the screen without failing to compile.
5940        let mut moved_headers: Vec<u64> =
5941            moving_writes.iter().map(|&(_, _, addr, _)| addr).collect();
5942
5943        // Fast path: when the only staged edits are same-length in-place
5944        // overwrites, apply them straight to their data blocks and return without
5945        // rebuilding any header or flipping the superblock root. The commit's
5946        // linearization point is the synced data write — there is no tree to
5947        // repoint, so each overwrite stands alone. (A persisting file takes the
5948        // same path: no free-space change occurs.)
5949        //
5950        // Because this path never rewrites the superblock, it deliberately leaves
5951        // it untouched — including a pre-existing stale consistency flag (e.g. one
5952        // left by a crashed SWMR writer). A lone same-length value overwrite does
5953        // not introduce any inconsistency, so it does not clear one either; an edit
5954        // that takes the full path below (any header/root change) clears the flag
5955        // as usual.
5956        //
5957        // A staged variable-length overwrite cannot reach here at all: it
5958        // relocates, so `moving_writes` is non-empty for it. That is enforced
5959        // where the plan is chosen (`prepare_write`) rather than re-tested here,
5960        // and it has to hold for a second reason besides the one that made it
5961        // relocate — such an overwrite places a global heap collection, and an
5962        // append moves end-of-file, which only the superblock this path leaves
5963        // untouched records (issue #321).
5964        debug_assert!(
5965            inplace_writes.iter().all(|(_, b)| b.vlen.is_none()),
5966            "a staged variable-length overwrite must relocate, not write in place"
5967        );
5968        if moving_writes.is_empty()
5969            && staged.datasets.is_empty()
5970            && staged.groups.is_empty()
5971            && staged.group_attrs.is_empty()
5972            && staged.deletes.is_empty()
5973            && staged.copies.is_empty()
5974            && staged.cross_copies.is_empty()
5975        {
5976            // This path's own point of no return, and it takes the staged set
5977            // for the same reason the main one below does (issue #316): every
5978            // refusal that applies to these overwrites has already run, and a
5979            // batch some of which has been written is not one a later `commit`
5980            // may re-issue. It is also what this path already did, back when the
5981            // write preflight drained the staged set on its way here.
5982            //
5983            // Leaving the set in place would make a half-written batch
5984            // retryable — these overwrites are same-length writes to fixed
5985            // addresses, so repeating them is idempotent — but that reads the
5986            // rule the other way round for one path, on an argument nothing
5987            // enforces. What the file holds is settled separately and the same
5988            // way on both paths: a `write_at` that fails partway is undone
5989            // through the journal below, so the batch is dropped but the
5990            // dataset's values are the ones it had (issue #344).
5991            drop(std::mem::take(staged));
5992            for (data_addr, bytes) in &inplace_writes {
5993                // Every entry here stages nothing (the guard above), so this
5994                // hands back the staged bytes unchanged and unallocated.
5995                let raw = self.resolve_overwrite_bytes(bytes)?;
5996                self.write_inplace_journaled(*data_addr, &raw)?;
5997            }
5998            self.barrier()?;
5999            return Ok(());
6000        }
6001
6002        // --- Plan: build the tree of "dirty" groups (root plus every group on a
6003        // path to an addition or deletion), validating every target before any
6004        // write. `add_targets` records the full paths created this commit, used
6005        // to reject a deletion that overlaps an addition. ---
6006        let mut nodes: BTreeMap<ObjectPathBuf, Node> = BTreeMap::new();
6007        nodes.entry(ObjectPathBuf::root()).or_default(); // root is always dirty
6008        let mut add_targets: Vec<ObjectPathBuf> = Vec::new();
6009        // Where each in-file `copy` reads from. A copy takes its bytes from the
6010        // *pre-commit* file, so a source this same commit replaces would copy the
6011        // object being removed while the replacement lands at the same path — see
6012        // the delete-staging loop, which refuses that. Cross-file copies are not
6013        // tracked: their source is in another file, so no path here can name it.
6014        let mut copy_sources: Vec<ObjectPathBuf> = Vec::new();
6015        let mut attr_targets: Vec<ObjectPathBuf> = Vec::new();
6016
6017        // Mark explicitly-created new groups, ensuring their ancestor chain.
6018        for path in &staged.groups {
6019            if path.is_empty() {
6020                return Err(Error::EditUnsupported("cannot create the root group"));
6021            }
6022            ensure_ancestors(&mut nodes, path);
6023            nodes.entry(path.clone()).or_default().is_new = true;
6024            add_targets.push(path.clone());
6025        }
6026
6027        // Make each staged dataset's parent group a node (with its ancestor
6028        // chain). The datasets themselves stay in the staged set, which the
6029        // preflight reads but must not empty (issue #316); `datasets_by_group`
6030        // below is the grouped view both the preflight and the apply loop use.
6031        for (parent, fd) in &staged.datasets {
6032            add_targets.push(parent.join(&fd.name));
6033            ensure_ancestors(&mut nodes, parent);
6034        }
6035
6036        // Attach relocating value overwrites (resized contiguous or compact) to
6037        // their parent group nodes: the new header is written below and the
6038        // parent's existing link patched to it, like an existing child group.
6039        for (parent, leaf, old_oh, mw) in moving_writes {
6040            ensure_ancestors(&mut nodes, &parent);
6041            nodes
6042                .entry(parent)
6043                .or_default()
6044                .writes
6045                .push((leaf, old_oh, mw));
6046        }
6047
6048        // Stage group attribute edits against their target groups. A target may
6049        // be a newly-created group from this same commit, but not a copied
6050        // destination or a dataset being added in the same commit. The ops
6051        // themselves stay in the staged set and are looked up by path where they
6052        // are applied, so a refusal below still gives them back (issue #316).
6053        for (path, _) in &staged.group_attrs {
6054            ensure_ancestors(&mut nodes, path);
6055            attr_targets.push(path.clone());
6056        }
6057
6058        // Stage copies: validate the source subtree is copyable (read-only),
6059        // then treat the destination like an addition to its parent group.
6060        for (src, dst) in &staged.copies {
6061            if src.is_empty() {
6062                return Err(Error::EditUnsupported("cannot copy the root group"));
6063            }
6064            let Some((parent, leaf)) = dst.split_leaf() else {
6065                return Err(Error::EditUnsupported("copy destination path is empty"));
6066            };
6067            if dst.starts_with(src) {
6068                return Err(Error::EditUnsupported(
6069                    "cannot copy an object into itself or its own subtree",
6070                ));
6071            }
6072            let src_addr = crate::group_v2::resolve_path_any_from_source(
6073                &self.image(),
6074                AccessMode::ReadWrite,
6075                &self.superblock,
6076                &src.as_path(),
6077            )?;
6078            // Read the source subtree from this file's own mirror (`cross_file`
6079            // false: same address space, so verbatim addresses stay valid). On a
6080            // userblock file the stored addresses are base-relative, so pass this
6081            // session's base for the read to absolutize them.
6082            let tree = Self::read_copy_subtree(
6083                &self.image(),
6084                AccessMode::ReadWrite,
6085                src_addr,
6086                0,
6087                false,
6088                base,
6089            )?;
6090            copy_sources.push(src.clone());
6091            add_targets.push(dst.clone());
6092            ensure_ancestors(&mut nodes, &parent);
6093            nodes.entry(parent).or_default().copies.push((leaf, tree));
6094        }
6095
6096        // Stage cross-file copies: their subtrees were already read out of the
6097        // source file (with foreign-address screening) when `copy_from` was
6098        // called, so here they are simply linked into the destination parent like
6099        // any other addition.
6100        for (dst, _) in &staged.cross_copies {
6101            let Some((parent, leaf)) = dst.split_leaf() else {
6102                return Err(Error::EditUnsupported("copy destination path is empty"));
6103            };
6104            add_targets.push(dst.clone());
6105            ensure_ancestors(&mut nodes, &parent);
6106            nodes.entry(parent).or_default().cross_copies.push(leaf);
6107        }
6108
6109        // Stage deletions: each must exist, must not overlap any other staged
6110        // change *unless* this commit replaces what it removes, and is recorded
6111        // against its parent group (which becomes dirty). `deleted_addrs` keeps
6112        // each removed object's header address so its owned blocks can be
6113        // reclaimed after the commit lands (issue #21).
6114        let delete_targets = &staged.deletes;
6115        let mut deleted_addrs: Vec<u64> = Vec::new();
6116        for (i, d) in delete_targets.iter().enumerate() {
6117            let Some((parent, leaf)) = d.split_leaf() else {
6118                return Err(Error::EditUnsupported("cannot delete the root group"));
6119            };
6120            let del_addr = crate::group_v2::resolve_path_any_from_source(
6121                &self.image(),
6122                AccessMode::ReadWrite,
6123                &self.superblock,
6124                &d.as_path(),
6125            )?;
6126            deleted_addrs.push(del_addr);
6127            // A deletion may overlap other staged work when this commit
6128            // *replaces* what it removes: an addition names exactly `d`, so the
6129            // removal and the new object at the same path are one rotation
6130            // rather than an edit of something being deleted (issue #305).
6131            let recreated = add_targets.iter().any(|t| t == d);
6132            // A replacement also requires that every group node at or below `d`
6133            // is one this commit builds fresh. A node that is *not* new is
6134            // rebuilt from the old object's on-disk header — the object being
6135            // replaced — and both ways that lands are wrong, in the two shapes
6136            // this guard was measured against:
6137            //
6138            // * At `d` itself (a group attribute set on a path this commit
6139            //   replaces with a *dataset*), the node and the replacement share a
6140            //   `path_addr` key. The replacement's address overwrites the group's,
6141            //   the parent's link is then patched to the address it already had,
6142            //   and the rebuilt group header is left orphaned — a commit that
6143            //   returns `Ok` having **silently discarded** the staged attribute.
6144            // * Strictly below `d`, the parent is a freshly built region with no
6145            //   existing link to patch, so `patch_link_target` reports a missing
6146            //   child link partway through the apply. That leaves a valid file
6147            //   (the superblock is never repointed) but appends dead bytes and
6148            //   reports the wrong thing.
6149            //
6150            // Refusing here keeps both in the preflight, where the commit is
6151            // all-or-nothing and the message can name the actual conflict.
6152            if recreated
6153                && !nodes
6154                    .iter()
6155                    .all(|(key, node)| !key.starts_with(d) || node.is_new)
6156            {
6157                return Err(Error::EditUnsupported(
6158                    "a staged edit names a group at or under a replaced path that this \
6159                     commit does not itself create; create it in the same commit, or use \
6160                     separate commits",
6161                ));
6162            }
6163            // A copy reading from a path this commit replaces is the same conflict
6164            // seen from the other side: `read_copy_subtree` already took its bytes
6165            // from the pre-commit file, so the commit would place the *original*
6166            // at the copy's destination while placing something else at `d` — two
6167            // different objects from one path, in one commit, with no error. A
6168            // source that is merely deleted and not replaced is unambiguous (it is
6169            // a move) and stays allowed.
6170            if recreated {
6171                for t in &copy_sources {
6172                    if t.starts_with(d) {
6173                        return Err(Error::EditUnsupported(
6174                            "a copy in this commit reads from a path the same commit \
6175                             replaces; use separate commits",
6176                        ));
6177                    }
6178                }
6179            }
6180            // Past that return `recreated` means the whole touched subtree is
6181            // fresh, so an addition at or below `d` lands in the replacement. The
6182            // apply loop already removes a group's deleted links before appending
6183            // any new one (`remove_link_from_region` runs first), so a
6184            // replacement needs no further sequencing here.
6185            for t in &add_targets {
6186                if recreated && t.starts_with(d) {
6187                    continue;
6188                }
6189                if t.starts_with(d) || d.starts_with(t) {
6190                    return Err(Error::EditUnsupported(
6191                        "a deletion overlaps an addition in the same commit; \
6192                         replace the path instead, or use separate commits",
6193                    ));
6194                }
6195            }
6196            for t in &attr_targets {
6197                if recreated && t.starts_with(d) {
6198                    continue;
6199                }
6200                if t.starts_with(d) {
6201                    return Err(Error::EditUnsupported(
6202                        "a deletion overlaps a group-attribute edit in the same commit; use separate commits",
6203                    ));
6204                }
6205            }
6206            for t in &write_targets {
6207                if t.starts_with(d) {
6208                    // `write_targets` holds three kinds — a value overwrite, a
6209                    // dataset-attribute edit, and a staged append — so the
6210                    // message names what they have in common rather than only
6211                    // the first of them.
6212                    return Err(Error::EditUnsupported(
6213                        "a deletion overlaps a staged edit to a dataset in the same \
6214                         commit; use separate commits",
6215                    ));
6216                }
6217            }
6218            for (j, d2) in delete_targets.iter().enumerate() {
6219                if i != j && d2.starts_with(d) {
6220                    return Err(Error::EditUnsupported(
6221                        "overlapping deletions in one commit; delete the common parent only",
6222                    ));
6223                }
6224            }
6225            ensure_ancestors(&mut nodes, &parent);
6226            nodes.entry(parent).or_default().deletes.push(leaf);
6227        }
6228
6229        // Resolve / validate each node's base object-header region up front.
6230        // Every existing dirty group is rewritten to a freshly-appended header,
6231        // so its old header becomes dead bytes once the superblock is repointed;
6232        // `superseded_addrs` records those old headers for reclamation (#21),
6233        // paired with the path whose rebuilt header replaces each — the two
6234        // halves of one relocation, which is what an object reference stored
6235        // elsewhere in the file has to be repointed across (issue #324).
6236        let keys: Vec<ObjectPathBuf> = nodes.keys().cloned().collect();
6237        let mut superseded_addrs: Vec<(ObjectPathBuf, u64)> = Vec::new();
6238        for key in &keys {
6239            let is_new = nodes[key].is_new;
6240            if is_new {
6241                nodes.get_mut(key).unwrap().base_region = fresh_group_region();
6242            } else {
6243                let addr = crate::group_v2::resolve_path_any_from_source(
6244                    &self.image(),
6245                    AccessMode::ReadWrite,
6246                    &self.superblock,
6247                    &key.as_path(),
6248                )?;
6249                // Rebuilding this group moves its header and patches only the
6250                // link this commit resolved it through, so every other hard link
6251                // to it would be left naming the old header — which this commit
6252                // then frees. The aliases show the pre-commit group until
6253                // something reuses the span, and are unreadable after (issue
6254                // #327). Same rule, and the same lazily-computed count, as the
6255                // three relocating dataset writes above.
6256                //
6257                // The root group is exempt because it is named by the superblock
6258                // rather than by a link, so it has no entry in the count and
6259                // nothing to strand. Every other group was resolved by path and
6260                // therefore has at least one.
6261                if !key.is_empty() {
6262                    let counts = incoming_links
6263                        .get_or_insert_with(|| self.count_incoming_hard_links())
6264                        .as_ref();
6265                    match counts.and_then(|c| c.get(&addr).copied()) {
6266                        Some(1) => {}
6267                        // Known, and more than one: the aliases are the problem.
6268                        Some(_) => {
6269                            return Err(Error::EditUnsupported(
6270                                "editing a group relocates its object header; only supported \
6271                                 when it has a single hard link",
6272                            ));
6273                        }
6274                        // Not known: the file-wide walk that counts links gave up
6275                        // — an object header it could not parse, a group it could
6276                        // not enumerate, or a link graph past its bound. A
6277                        // different refusal because it is a different problem, and
6278                        // "it has more than one hard link" would send the reader
6279                        // looking for a second link that may not exist.
6280                        None => {
6281                            return Err(Error::EditUnsupported(
6282                                "editing a group relocates its object header, and this file's \
6283                                 links could not be walked to establish that nothing else \
6284                                 names it",
6285                            ));
6286                        }
6287                    }
6288                }
6289                let info = self.inspect_group(addr)?;
6290                superseded_addrs.push((key.clone(), addr));
6291                let node = nodes.get_mut(key).unwrap();
6292                node.base_region = info.region;
6293                node.existing_links = info.link_names;
6294            }
6295        }
6296
6297        // A rebuilt group's old header is vacated just as a relocated dataset's is,
6298        // so the two join one list for the screen below.
6299        moved_headers.extend(superseded_addrs.iter().map(|&(_, a)| a));
6300
6301        // Apply and validate group attribute edits before any writes. This keeps
6302        // unsupported attribute edits under the same all-or-nothing preflight
6303        // contract as unsupported dataset additions. A variable-length attribute
6304        // is not fully resolved here — its global heap collection is built (it
6305        // is self-contained, no address needed yet) but placed and patched into
6306        // `base_region` only in the apply loop below, once its address is known.
6307        let attrs_by_group = group_by_parent(staged.group_attrs.iter().map(|(p, op)| (p, op)));
6308        // The pre-commit header address of each group this commit rewrites. An
6309        // edit that sends a group's attributes to a fractal heap reads the set it
6310        // rebuilds back out of the file, which needs the address; a group this
6311        // commit *creates* has neither an address nor any stored attribute, and
6312        // its `None` says so.
6313        let existing_group_addrs: HashMap<ObjectPathBuf, u64> =
6314            superseded_addrs.iter().cloned().collect();
6315        for key in &keys {
6316            if let Some(ops) = attrs_by_group.get(key) {
6317                let region = std::mem::take(&mut nodes.get_mut(key).unwrap().base_region);
6318                let edits = plan_attr_ops(
6319                    &self.image(),
6320                    base,
6321                    existing_group_addrs.get(key).copied(),
6322                    &region,
6323                    ops,
6324                )?;
6325                let node = nodes.get_mut(key).unwrap();
6326                node.base_region = edits.region;
6327                node.attrs = edits.attrs;
6328            }
6329        }
6330
6331        // Map each node to its direct child group nodes, each with the link name
6332        // it has in that parent (for link wiring). The root group has no name in
6333        // a parent, so `split_leaf` gives `None` for it and the loop skips it.
6334        let mut children: BTreeMap<ObjectPathBuf, Vec<(ObjectPathBuf, LinkNameBuf)>> =
6335            BTreeMap::new();
6336        for key in &keys {
6337            if let Some((parent, leaf)) = key.split_leaf() {
6338                children
6339                    .entry(parent)
6340                    .or_default()
6341                    .push((key.clone(), leaf));
6342            }
6343        }
6344
6345        // Each group's added datasets, in the order the apply loop will place
6346        // them, for the guards below to read. Borrowed from the staged set:
6347        // every one of those guards may still refuse, and a refusal gives the
6348        // caller back every edit it was holding (issue #316). The apply loop
6349        // rebuilds the same grouping, by the same rule, once it owns the set.
6350        let datasets_by_group = group_by_parent(staged.datasets.iter().map(|(p, fd)| (p, fd)));
6351
6352        // A group that tracks *link* creation order takes an addition — the
6353        // apply loop numbers each added link from the running maximum its Link
6354        // Info message records — up to the point the addition would send its
6355        // links dense, which is refused before anything is written. The count
6356        // this screens is the one the group would hold afterwards: the links it
6357        // has now, less the ones this commit deletes, plus the ones it adds.
6358        for key in &keys {
6359            let node = &nodes[key];
6360            let added = node.copies.len()
6361                + node.cross_copies.len()
6362                + datasets_by_group.get(key).map_or(0, Vec::len)
6363                + children.get(key).map_or(0, |kids| {
6364                    kids.iter().filter(|(child, _)| nodes[child].is_new).count()
6365                });
6366            if added > 0 {
6367                let kept = node
6368                    .existing_links
6369                    .iter()
6370                    .filter(|name| !node.deletes.iter().any(|d| d.as_str() == *name))
6371                    .count();
6372                reject_dense_link_creation_order(&node.base_region, kept + added)?;
6373            }
6374        }
6375
6376        // Validate names: no addition may collide with an existing link or with
6377        // another addition under the same parent. A link this same commit
6378        // deletes is not one of the existing ones — the apply loop removes it
6379        // from the region before any addition is appended, so replacing an
6380        // object at its own path is a rotation, not a collision (issue #305).
6381        for key in &keys {
6382            let node = &nodes[key];
6383            let mut adding: Vec<&str> = Vec::new();
6384            for fd in datasets_by_group.get(key).into_iter().flatten() {
6385                adding.push(fd.name.as_str());
6386            }
6387            for (child, leaf) in children.get(key).into_iter().flatten() {
6388                if nodes[child].is_new {
6389                    adding.push(leaf.as_str());
6390                }
6391            }
6392            for (leaf, _) in &node.copies {
6393                adding.push(leaf.as_str());
6394            }
6395            for leaf in &node.cross_copies {
6396                adding.push(leaf.as_str());
6397            }
6398            for (i, name) in adding.iter().enumerate() {
6399                let survives = node.existing_links.iter().any(|n| n == name)
6400                    && !node.deletes.iter().any(|n| n.as_str() == *name);
6401                if survives || adding[..i].contains(name) {
6402                    return Err(Error::EditUnsupported(
6403                        "a link with this name already exists in the target group",
6404                    ));
6405                }
6406            }
6407        }
6408
6409        // Content the caller's library-version bound cannot carry is refused
6410        // here, before any write, so a rejected addition leaves the commit
6411        // unapplied.
6412        self.check_libver_admits(staged.datasets.iter().map(|(_, fd)| fd))?;
6413
6414        // Enumerate what this commit's deletions reclaim, from the current
6415        // on-disk layout and before any byte moves. It is read here, ahead of
6416        // every remaining refusal, because two of them screen against it: an
6417        // object reference this commit writes must not name space the same
6418        // commit frees (issue #317). The spans are carried to `to_free` below
6419        // rather than walked a second time, so the screen and the allocator
6420        // always mean the same thing by "removed".
6421        //
6422        // An object's storage is reclaimed only when the link being removed is
6423        // its LAST hard link: HDF5 objects can have several hard links, and one
6424        // reachable through a surviving link is still live (freeing it would
6425        // corrupt the survivor). Count every hard link in the pre-commit file
6426        // and reclaim a deleted object only when its count is exactly 1.
6427        // `deleted_addrs` is de-duplicated first so two delete paths that are
6428        // hard links to the same object are not visited (and freed) twice. If
6429        // the link graph cannot be walked in full, no deleted object is
6430        // reclaimed (a safe leak) — and none is screened either, which is sound
6431        // in the same direction: nothing is freed, so no reference dangles.
6432        // Superseded group headers and relocated dataset headers are vacated too,
6433        // and are screened separately: `moved_headers` holds them, and they are
6434        // addresses rather than spans.
6435        let mut deleted_free: Vec<(u64, u64, FreeClass)> = Vec::new();
6436        deleted_addrs.sort_unstable();
6437        deleted_addrs.dedup();
6438        if !deleted_addrs.is_empty() {
6439            if let Some(incoming) = self.count_incoming_hard_links() {
6440                for &a in &deleted_addrs {
6441                    self.collect_free_spans(a, 0, &incoming, &mut deleted_free);
6442                }
6443            }
6444        }
6445        // An in-file copy re-emits its source's element bytes verbatim, so a
6446        // copied object reference keeps naming whatever it named in the source —
6447        // including an object this same commit is removing (issue #317). The page
6448        // types the walk records are for the allocator, not for this.
6449        //
6450        // Removals only, and this is the one place the two halves part company. A
6451        // *supplied* address is a fresh claim about the file, and a commit that
6452        // moves the object falsifies it. A *copied* one repeats a claim the file
6453        // already made, and since issue #324 a move no longer falsifies either
6454        // one: the commit's last act walks the tree it published and repoints
6455        // every reachable stored address, the copy's among them
6456        // ([`crate::reference_patch`], and `a_copy_made_in_the_same_commit_that_
6457        // moves_its_target_is_repointed`). A removal is the case that stays,
6458        // because there is no new address to point at.
6459        //
6460        // Refusing on `moved` here would also cost what it cannot buy: every
6461        // commit that reaches here rebuilds its root group, so `moved` is never
6462        // empty, and a chunked object-reference dataset is refused on a non-empty
6463        // screen alone rather than on a matching address — so every such copy
6464        // would be refused, in every commit.
6465        let for_copied = InvalidatedAddresses {
6466            removed: deleted_free
6467                .iter()
6468                .map(|&(off, len, _)| (off, len))
6469                .collect(),
6470            moved: Vec::new(),
6471            base: self.superblock.base_address,
6472        };
6473        {
6474            // Framed at the base address: a shared-message address is stored
6475            // relative to it, and `SourceResolver` reads its references as
6476            // absolute within the view it is given.
6477            let image = self.image();
6478            let framed = BaseOffsetSource {
6479                inner: image,
6480                base: self.superblock.base_address,
6481            };
6482            for key in &keys {
6483                for (_, tree) in &nodes[key].copies {
6484                    screen_copied_references(tree, &for_copied, &framed)?;
6485                }
6486            }
6487        }
6488
6489        // The same screen the copies just got, plus the half a supplied address
6490        // earns and a copied one does not.
6491        let for_supplied = InvalidatedAddresses {
6492            moved: moved_headers,
6493            ..for_copied
6494        };
6495
6496        // A shared-message index record can hold an object-header address too,
6497        // and it is the one stored address the commit's closing repoint walk
6498        // cannot reach. Screen it here, against the same two lists.
6499        self.screen_shared_message_index(&for_supplied)?;
6500
6501        // References a builder already resolved to addresses never reach
6502        // `resolve_reference_target`, so they are screened out of the element
6503        // bytes instead — additions and value overwrites alike, since neither
6504        // form's bytes are touched again before they are written. A slot still
6505        // holding a placeholder screens as the zero it is, and is screened for
6506        // real by `resolve_reference_target` when it resolves.
6507        //
6508        // Cross-file copies need no screen for the opposite reason:
6509        // `reject_foreign_addresses` refuses a reference datatype outright on
6510        // that path.
6511        for (_, fd) in staged.datasets.iter().chain(&staged.writes) {
6512            screen_resolved_references(&fd.dt, &fd.raw, &for_supplied)?;
6513        }
6514
6515        // Anything this commit adds could be a reference container the last walk
6516        // did not see, so it retires that walk's "no references in this file"
6517        // finding (issue #324). See `proved_free_of_references` for which of
6518        // these four clauses is load-bearing and which are belt-and-braces, and
6519        // for why the other five staged collections need no clause at all.
6520        //
6521        // A staged dataset's *attributes* are screened beside its element
6522        // datatype. Neither can carry a reference today — a committed one is
6523        // refused by `flatten_dataset`, and `AttrValue` has no reference variant
6524        // — but that is a refusal in another function rather than a property of
6525        // this collection, and the check that means what it says is the one that
6526        // looks.
6527        if staged.datasets.iter().chain(&staged.writes).any(|(_, fd)| {
6528            datatype_holds_object_address(&fd.dt)
6529                || fd
6530                    .attrs
6531                    .iter()
6532                    .any(|a| datatype_holds_object_address(&a.datatype))
6533        }) || !staged.copies.is_empty()
6534            || !staged.cross_copies.is_empty()
6535        {
6536            self.proved_free_of_references = false;
6537        }
6538
6539        // Prove every object-reference target resolves before any write (see
6540        // `preflight_reference_targets`'s doc comment): otherwise a reference
6541        // resolution failure discovered mid-apply-loop would leave every
6542        // earlier-processed group's real writes (headers, data, copied
6543        // subtrees) orphaned in the file despite `commit()` returning `Err`.
6544        Self::preflight_reference_targets(
6545            &keys,
6546            &datasets_by_group,
6547            &nodes,
6548            &add_targets,
6549            &write_targets,
6550            delete_targets,
6551            &for_supplied,
6552            &self.image(),
6553            &self.superblock,
6554        )?;
6555
6556        // --- The point of no return. Every refusal is behind us, so the staged
6557        // set is taken here rather than at the top of this function: a refusal
6558        // above restores *every* edit the caller staged, and a failure below —
6559        // an I/O error mid-apply, which leaves the file valid because the
6560        // superblock is repointed last — restores none of them, so no later
6561        // `commit` can finish a batch this one abandoned (issue #316). Nothing
6562        // above this line may consume from `staged`. ---
6563        let mut taken = std::mem::take(staged);
6564        // The same paths as the borrow above, owned now that the set has moved.
6565        let delete_targets = std::mem::take(&mut taken.deletes);
6566        // The same grouping as `datasets_by_group` above, by the same rule and
6567        // so in the same order — which is what keeps the apply loop's placement
6568        // order the one `preflight_reference_targets` proved.
6569        let mut flat = group_by_parent(taken.datasets.drain(..));
6570        // A cross-file copy's subtree can move now, so it joins its destination
6571        // group's in-file copies. The parent node exists: the preflight made one
6572        // for every destination.
6573        for (dst, tree) in taken.cross_copies.drain(..) {
6574            // A destination that identifies no link is rejected above.
6575            let (parent, leaf) = dst.split_leaf().expect("a copy destination names a link");
6576            nodes.get_mut(&parent).unwrap().copies.push((leaf, tree));
6577        }
6578
6579        // Gather the regions this commit will vacate, read from the current
6580        // on-disk layout before any byte moves: every deleted object's owned
6581        // blocks plus every superseded group header. They stay out of the free
6582        // list until every append this commit makes is behind it, so none of
6583        // them is reused while it is still live. (On the non-persisting tail
6584        // that point is a few lines *before* the superblock write rather than
6585        // after it — the apply loop is where the appends happen, and it is long
6586        // done by then.) Enumeration is
6587        // best-effort — `collect_free_spans` simply omits anything it cannot
6588        // account for exhaustively, so the worst case is unreclaimed dead bytes,
6589        // never a freed-but-live region.
6590        // It starts as the deleted objects' blocks, enumerated above the preflight
6591        // because a refusal there screens against them.
6592        let mut to_free: Vec<(u64, u64, FreeClass)> = deleted_free;
6593
6594        // A superseded group header is dead once the root is repointed. Its chunk
6595        // spans are enumerated base-aware (`oh_chunk_spans` shifts continuation
6596        // addresses by the userblock base and returns absolute file offsets), as is
6597        // the delete path (`collect_free_spans`), so all of this reclamation works
6598        // on userblock files too.
6599        for &(_, a) in &superseded_addrs {
6600            if let Ok(spans) = self.oh_chunk_spans(a) {
6601                to_free.extend(meta_spans(spans));
6602            }
6603        }
6604
6605        // A relocating overwrite (`write_dataset` resize, or any compact rewrite)
6606        // vacates the dataset's old object header, and a resized contiguous one
6607        // also vacates its old data block: both become dead once the parent's
6608        // relinked header lands. `superseded_addrs` covers only the rebuilt group
6609        // headers, not the relocated dataset's own header, so record that here too.
6610        // The pre-commit dataset-header address rides on the write plan (`old_oh`),
6611        // recorded where the plan was made; its chunks and old data extent are
6612        // freed only after the superblock repoint.
6613
6614        // The single-hard-link guard in the write preflight makes freeing the old
6615        // header safe (no surviving link still points at it).
6616        for key in &keys {
6617            for (_leaf, old_oh, mw) in &nodes[key].writes {
6618                match mw {
6619                    MovingWrite::Contiguous {
6620                        old_extent: Some(extent),
6621                        ..
6622                    } => to_free.push((extent.0, extent.1, FreeClass::Page(PageType::Raw))),
6623                    // A relocated chunked dataset vacates its old chunk index and
6624                    // chunk data blocks. `chunked_storage_spans` returns `None` for
6625                    // anything it cannot enumerate exhaustively (leaving dead bytes
6626                    // rather than freeing a region still in use); the old header
6627                    // chunks are freed generically below.
6628                    MovingWrite::Chunked { old_addr, .. } => {
6629                        if let Some(spans) = self.chunked_storage_spans(*old_addr) {
6630                            to_free.extend(spans);
6631                        }
6632                    }
6633                    // A relocating append keeps the existing chunk *data* in place
6634                    // (shared by both indexes during the commit), so only the old
6635                    // index structure and the relocated old trailing chunk are dead.
6636                    // The old header chunks are freed by the generic path below.
6637                    MovingWrite::AppendedChunks {
6638                        old_addr,
6639                        old_tail_extent,
6640                        kept_chunks,
6641                        ..
6642                    } => {
6643                        if let Some(spans) = self.chunked_index_spans(*old_addr) {
6644                            // The old index is reclaimed as raw only where it
6645                            // provably sits in a raw page, and recorded as dead
6646                            // otherwise. See `index_is_provably_raw`. The
6647                            // dataset's old chunk data is the kept chunks
6648                            // (base-relative) plus the trailing partial chunk
6649                            // this append relocated, both already in hand, so
6650                            // the proof needs no second walk of the index.
6651                            let data: Vec<(u64, u64)> = kept_chunks
6652                                .iter()
6653                                .filter_map(|c| {
6654                                    Some((base.absolute(c.address).ok()?, c.compressed_size))
6655                                })
6656                                .chain(*old_tail_extent)
6657                                .collect();
6658                            let class = if self.index_is_provably_raw(&data, &spans) {
6659                                FreeClass::Page(PageType::Raw)
6660                            } else {
6661                                FreeClass::Dead
6662                            };
6663                            to_free.extend(spans.into_iter().map(|(a, l)| (a, l, class)));
6664                        }
6665                        if let Some(ext) = old_tail_extent {
6666                            // The relocated old trailing chunk is raw data.
6667                            to_free.push((ext.0, ext.1, FreeClass::Page(PageType::Raw)));
6668                        }
6669                    }
6670                    _ => {}
6671                }
6672                // The relocated dataset's old header chunks are dead too.
6673                if let Ok(spans) = self.oh_chunk_spans(*old_oh) {
6674                    to_free.extend(meta_spans(spans));
6675                }
6676            }
6677        }
6678
6679        // Defense in depth: never hand the free list an out-of-bounds or
6680        // overlapping span. The last-link guard plus the per-object checks
6681        // should already make the accumulated spans disjoint; this enforces it
6682        // as a whole-commit invariant against the pre-commit end-of-file. Any
6683        // dropped span (which should not occur for a well-formed file) only
6684        // leaks, never corrupts.
6685        retain_disjoint_in_bounds(&mut to_free, self.image.len());
6686
6687        // --- Apply: process deepest groups first so each parent sees its
6688        // children's new addresses, then repoint the superblock last.
6689        // `path_addr` accumulates every group's and dataset's address as it is
6690        // placed — read by `resolve_reference_target` to resolve a same-commit
6691        // object-reference target (see the dataset-placement loop below for the
6692        // group/dataset key convention: a group's own path, or a dataset's
6693        // full parent+name path). ---
6694        let mut path_addr: BTreeMap<ObjectPathBuf, u64> = BTreeMap::new();
6695        // Where each object-header address this commit vacates has been rewritten
6696        // to, for repointing the object references the rest of the file already
6697        // stores (issue #324). Filled from the two places a header moves: a
6698        // relocating value overwrite, recorded in the loop below as its plan is
6699        // applied, and a rebuilt group, joined from `superseded_addrs` once every
6700        // group's new address is known.
6701        let mut relocations: BTreeMap<u64, u64> = BTreeMap::new();
6702        let mut by_depth = keys.clone();
6703        // Stable on purpose: `keys` comes from a `BTreeMap`, so equal-depth
6704        // groups arrive in path order, and this order is the order they are
6705        // placed in — which fixes every address the commit writes. An unstable
6706        // sort may permute a depth group, and the file's layout with it.
6707        by_depth.sort_by_key(|k| std::cmp::Reverse(k.len())); // deepest first
6708        for key in &by_depth {
6709            let (mut region, deletes, copies, writes, attrs) = {
6710                let node = nodes.get_mut(key).unwrap();
6711                (
6712                    std::mem::take(&mut node.base_region),
6713                    std::mem::take(&mut node.deletes),
6714                    std::mem::take(&mut node.copies),
6715                    std::mem::take(&mut node.writes),
6716                    std::mem::take(&mut node.attrs),
6717                )
6718            };
6719
6720            // Remove deleted links first (verbatim-preserving the rest).
6721            //
6722            // First is a correctness requirement rather than a convention, and
6723            // has been since a replacement became one commit (issue #305):
6724            // `remove_link_from_region` matches by *name*, so a removal running
6725            // after the additions below would take the replacement's link with
6726            // the original's and leave the path gone. Every link this loop could
6727            // collide with — a copy's, a dataset's, a new child group's — is
6728            // appended after it, which is what keeps that unreachable. (A
6729            // relocating write and an existing child group *patch* a link rather
6730            // than appending one, so neither can be a replacement's.)
6731            for name in &deletes {
6732                region = remove_link_from_region(&region, name.as_str())?;
6733            }
6734
6735            // Every link appended below takes the next creation index from this
6736            // group's running maximum, on the groups that track link creation
6737            // order and on no others; `link_order.record` writes the bumped
6738            // maximum back once the last of them is placed. Read after the
6739            // deletions above because a deletion leaves the maximum alone — the
6740            // gap it opens is never handed out again.
6741            let mut link_order = LinkCreationOrder::for_region(&region)?;
6742
6743            // Write each staged source subtree and link its root into this group.
6744            for (leaf, tree) in copies {
6745                let root = self.write_copy_subtree(&tree)?;
6746                region.push_link(leaf.as_str(), base.relative(root)?, link_order.take()?);
6747            }
6748
6749            // Datasets directly under this group. Appended addresses are absolute
6750            // file offsets, and the contiguous data-layout address is stored
6751            // relative to the base address. 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                        StoredAddress::new(UNDEF)
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(
6843                    fd.name.as_str(),
6844                    base.relative(oh_addr)?,
6845                    link_order.take()?,
6846                );
6847                path_addr.insert(key.join(&fd.name), oh_addr);
6848            }
6849
6850            // Relocating value overwrites under this group: write the new data and
6851            // rewritten header, then patch this group's existing link to it. On a
6852            // userblock file only the chunked variant reaches here (contiguous and
6853            // compact resizes are refused in the write preflight).
6854            for (leaf, old_oh, mw) in &writes {
6855                let new_oh = self.write_moving(mw)?;
6856                patch_link_target(&mut region, leaf.as_str(), base.relative(new_oh)?)?;
6857                relocations.insert(*old_oh, new_oh);
6858            }
6859
6860            // Wire links to dirty child groups (new → add a link; existing →
6861            // patch the existing link to the child's new address).
6862            for (child, leaf) in children.get(key).into_iter().flatten() {
6863                let child_name = leaf.as_str();
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, 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[&ObjectPathBuf::root()];
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)?.get();
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)?.get(), 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        let base = self.superblock.base_address;
7125
7126        // The extension address is stored relative to the base address, and every
7127        // reader of the extension below indexes the image, so it is shifted once
7128        // here for all of them.
7129        let old_ext_addr = base.absolute(
7130            self.superblock
7131                .superblock_extension_address
7132                .map(StoredAddress::new)
7133                .filter(|a| !a.is_undefined(os))
7134                .ok_or(Error::EditUnsupported(
7135                    "a persisting file has no superblock extension to update",
7136                ))?,
7137        )?;
7138
7139        // The persist File Space Info message is fixed-size, so the rewritten
7140        // extension's length is independent of the addresses it will carry: size
7141        // it with a placeholder to place the FSM blocks that follow it.
7142        let placeholder =
7143            FileSpaceInfo::persistent_single_manager(strategy, threshold, page_size, 0, 0);
7144        let ext_len =
7145            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)?
7146                .len() as u64;
7147
7148        // Place the tail — the rewritten extension and the manager blocks — in a
7149        // hole an *earlier* commit freed where one fits, and at end-of-file where
7150        // none does. `self.free` holds only durable free space (this commit's own
7151        // frees stay in `to_free` until the repoint), so a reused span holds bytes
7152        // already unreachable from the on-disk root, which is the guarantee
7153        // [`reserve`](Self::reserve) rests on too.
7154        let (post, placed_at, tail_len, eoa) =
7155            self.flat_tail_layout(&to_free, &old_blocks, ext_len, os);
7156        if placed_at.is_none() && placement == TailPlacement::ReuseOnly {
7157            // The shrink pass appends nothing: growing the file by a tail is the
7158            // opposite of what it was called for. Nothing has been written and the
7159            // layout handed its reservation back, so the session is exactly as the
7160            // commit before it left it.
7161            return Ok(());
7162        }
7163        let reused = placed_at.is_some();
7164        let ext_addr = placed_at.unwrap_or_else(|| self.image.len());
7165        let sections = self.persisted_sections(&post);
7166        let fshd_addr = self.persisted_address(ext_addr + ext_len);
7167        let fsse_addr = self.persisted_address(ext_addr + ext_len + fshd_len(os));
7168        // A reused tail sits inside the file, which ends it at the end-of-allocation
7169        // the layout settled on — the current end-of-file, less any run of free
7170        // space reaching it, which `post` no longer records and the truncation
7171        // below gives back to the filesystem (issue #418). An appended tail ends
7172        // the file itself.
7173        let final_eof = if reused { eoa } else { ext_addr + tail_len };
7174        debug_assert!(
7175            ext_addr + tail_len <= final_eof,
7176            "the tail must end at or below the end-of-allocation it was placed under"
7177        );
7178
7179        // Build the real extension and the FSM blocks. With no free space to
7180        // record we still refresh the extension (persist on, managers undefined).
7181        let (ext_oh, fsm_blocks) = if sections.is_empty() {
7182            let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
7183            let ext_oh =
7184                build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?;
7185            (ext_oh, None)
7186        } else {
7187            // `eoa_pre_fsm` is the end-of-allocation before the free-space-manager
7188            // section blocks (`FSHD`/`FSSE`) were allocated: a consumer may shrink
7189            // back to here and rebuild them. For an appended tail that is the FSHD,
7190            // not the extension — the extension sits below it and persists, so
7191            // shrinking leaves the superblock and its extension pointer valid (only
7192            // the manager blocks, which are rewritten every commit, are discarded).
7193            // A tail placed *inside* the file gave the allocation back nothing to
7194            // shrink, so it records the end-of-allocation itself. Either way it is
7195            // defined, which a persisting file's message must be or an
7196            // assertion-enabled libhdf5 aborts on open (issue #178), and is the
7197            // value `H5Fget_freespace` accounts for correctly (verified in the
7198            // crosscheck).
7199            let eoa_pre_fsm = if reused { final_eof } else { fshd_addr.get() };
7200            let info = FileSpaceInfo::persistent_single_manager(
7201                strategy,
7202                threshold,
7203                page_size,
7204                fshd_addr.get(),
7205                eoa_pre_fsm,
7206            );
7207            let ext_oh =
7208                build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?;
7209            let (fshd, fsse) =
7210                serialize_file_fsm(&sections, fshd_addr, fsse_addr, os, SECT_CLASS_SIMPLE);
7211            (ext_oh, Some((fshd, fsse)))
7212        };
7213        // Both forms of the message carry the same twelve manager slots, so either
7214        // one measures what the placeholder did. The tail was reserved for that
7215        // length, and the manager blocks are placed after it.
7216        debug_assert_eq!(
7217            ext_oh.len() as u64,
7218            ext_len,
7219            "extension length must be stable across the placeholder and real messages"
7220        );
7221
7222        // Write the extension, then the FSM blocks. Both forms are safe against a
7223        // crash here: an appended tail is past everything live, a reused one sits
7224        // in space an earlier commit freed, and neither is referenced until the
7225        // repoint below.
7226        let region = (ext_addr, tail_len);
7227        self.write_tail_block(region, ext_addr, &ext_oh)?;
7228        if let Some((fshd, fsse)) = fsm_blocks {
7229            self.write_tail_block(region, base.absolute(fshd_addr)?, &fshd)?;
7230            self.write_tail_block(region, base.absolute(fsse_addr)?, &fsse)?;
7231        }
7232        // Exactly the bytes written, contiguous from the extension, which is what
7233        // the next commit supersedes.
7234        let new_old_blocks = vec![(ext_addr, tail_len)];
7235
7236        // Barrier, then repoint the superblock (root, eof, and the new extension)
7237        // — the linearization point — and sync it.
7238        self.barrier()?;
7239        let mut new_sb = self.superblock.clone();
7240        new_sb.root_group_address = new_root;
7241        new_sb.eof_address = final_eof;
7242        new_sb.superblock_extension_address = Some(ext_addr);
7243        // As above, and zero for the same reason: this is the *unpaged* persisting
7244        // tail, which no page-buffered session reaches (issue #73, issue #308).
7245        new_sb.consistency_flags = self.held_status_flags;
7246        let sb_bytes = new_sb.serialize();
7247        self.publish_attempted = true;
7248        self.write_at(self.sb_sig_off, &sb_bytes)?;
7249        self.barrier()?;
7250        self.superblock = new_sb;
7251
7252        // Physically shrink the file only after the superblock — now carrying the
7253        // smaller end-of-file — is durable, exactly as the non-persisting tail
7254        // does: a crash between the two leaves trailing bytes the next open
7255        // ignores, where the reverse order would advertise an end-of-file past
7256        // the file's actual length. The managers just written describe `post`,
7257        // which no longer names anything above this cut.
7258        if final_eof < self.image.len() {
7259            self.image.truncate(final_eof)?;
7260            self.barrier()?;
7261        }
7262
7263        // The repoint is durable: the prior free list plus this commit's vacated
7264        // regions are now genuinely free, and the freshly written blocks become
7265        // the ones a future commit will supersede.
7266        self.free = post;
7267        self.persist = Some(PersistState {
7268            strategy,
7269            threshold,
7270            page_size,
7271            old_blocks: new_old_blocks,
7272        });
7273        // The managers describe the file at this length, so nothing is owed until
7274        // it grows past them again.
7275        self.fsm_len = self.image.len();
7276        Ok(())
7277    }
7278
7279    /// The free regions of `free` as the sections a free-space manager records.
7280    fn persisted_sections(&self, free: &FreeList) -> Vec<FreeSection> {
7281        free.sections()
7282            .into_iter()
7283            .map(|(addr, size)| FreeSection {
7284                addr: self.persisted_address(addr),
7285                size,
7286            })
7287            .collect()
7288    }
7289
7290    /// The address a persisting commit records for the image position `at`.
7291    ///
7292    /// The free lists hold positions in this session's image, and a manager block
7293    /// records the addresses the file stores. The two are the same number here,
7294    /// since [`load_persisted_free_space`](Self::load_persisted_free_space) arms
7295    /// persistence for a file whose image begins at byte zero alone, so this
7296    /// changes the frame and not the value.
7297    ///
7298    /// # Panics
7299    ///
7300    /// Panics if the file has a userblock, where the two frames differ and a
7301    /// manager block would record every free region a userblock's length below
7302    /// where it lies.
7303    fn persisted_address(&self, at: u64) -> StoredAddress {
7304        assert!(
7305            self.superblock.base_address.is_zero(),
7306            "a persisting commit requires a file whose image begins at byte zero"
7307        );
7308        StoredAddress::new(at)
7309    }
7310
7311    /// The free space a flat persisting commit is about to record: the session's
7312    /// durable list plus the regions this commit vacated and the superseded
7313    /// extension and manager blocks, which are dead once the superblock is
7314    /// repointed.
7315    ///
7316    /// Returned as a temporary rather than folded into the session, for the reason
7317    /// [`paged_post_free`](Self::paged_post_free) does the same: every region
7318    /// gathered here is still *live* until that repoint, and everything in between
7319    /// can fail, so a session that believed them free after a failed commit would
7320    /// hand them out over the objects still occupying them.
7321    ///
7322    /// Called once to *size* the tail and again for each round its placement takes
7323    /// to settle, since what remains free depends on the space the tail has drawn
7324    /// for itself ([`flat_tail_layout`](Self::flat_tail_layout)).
7325    fn flat_post_free(
7326        &self,
7327        to_free: &[(u64, u64, FreeClass)],
7328        old_blocks: &[(u64, u64)],
7329    ) -> FreeList {
7330        let mut post = self.free.clone();
7331        // A flat file keeps one list for the whole of it, so the class each freed
7332        // region carries for the paged path is not consulted here — and a flat
7333        // file produces no [`FreeClass::Dead`] region to begin with, since the
7334        // proof that classes one short-circuits where there are no page types to
7335        // keep apart.
7336        for &(a, l, _) in to_free {
7337            post.free(a, l);
7338        }
7339        for &(a, l) in old_blocks {
7340            post.free(a, l);
7341        }
7342        post
7343    }
7344
7345    /// Reserve free space for a flat persisting commit's tail, returning the free
7346    /// list the tail leaves behind, the address it got — `None` when nothing
7347    /// reusable fits, which is the caller's cue to append — its length, which the
7348    /// caller needs either way, and the end-of-allocation the commit should
7349    /// publish, which is the current end-of-file less any run of free space that
7350    /// reaches it (issue #418).
7351    ///
7352    /// The reservation and the length define each other, exactly as they do for the
7353    /// paged tail ([`tail_layout`](Self::tail_layout)): drawing bytes out of the
7354    /// free list changes the very sections the manager blocks record, and a
7355    /// different section set is a different length. This proposes a length, sizes
7356    /// the blocks against the list as it stands once that length is taken, and
7357    /// accepts any plan that *fits*. A longer plan is refused — it would write past
7358    /// the reservation into live bytes — and the proposal becomes the length that
7359    /// plan asked for, which is the natural next candidate.
7360    ///
7361    /// A plan **shorter** than its reservation is accepted, and the reservation
7362    /// rather than the plan is what the tail extent covers, so those spare bytes
7363    /// are freed with the rest of the tail when the next commit supersedes it.
7364    /// Rejecting them instead is what the paged tail does, and it can afford to:
7365    /// its fallback is a fresh page, recorded in full. Here the fallback is
7366    /// end-of-file, and the two lengths genuinely need not meet — best fit picks a
7367    /// different hole as the proposal crosses a hole's size, and the pair can
7368    /// oscillate by one section record indefinitely. Measured on the workload
7369    /// `persisting_churn_reaches_a_steady_size` runs, insisting on an exact fit sent
7370    /// about one commit in eight to end-of-file, which is the growth this exists to
7371    /// stop.
7372    ///
7373    /// What that costs is bounded and small: a commit that takes spare bytes and is
7374    /// then the last of its session leaves them behind, since a reopened session
7375    /// reconstructs the tail extent from the blocks themselves
7376    /// ([`load_persisted_free_space`](Self::load_persisted_free_space)) and cannot
7377    /// see a reservation that outlived the process. The spare is a section record
7378    /// and, where the reservation emptied a size group, that group's own header
7379    /// too — tens of bytes, once per session, against a whole tail on one commit in
7380    /// eight.
7381    ///
7382    /// Iteration is capped rather than trusted to converge: the length is a step
7383    /// function of the section set, so a proposal can in principle keep growing.
7384    /// Appending is always available and always correct, so giving up costs space
7385    /// rather than a guarantee — and the cap bounds the work, since each round
7386    /// clones the free list and sizes the section list for real.
7387    ///
7388    /// Draws from `self.free` before anything is written, which
7389    /// [`commit`](Self::commit) puts back if the attempt then fails. The one caller
7390    /// that does not snapshot is [`finalize_persist`](Self::finalize_persist), whose
7391    /// failure leaves this session's list short by the reservation — in memory only,
7392    /// on a session already being torn down, with the on-disk managers unchanged.
7393    ///
7394    /// When `self.free` has no hole for it, the tail may take one out of
7395    /// [`reserved`](Self::reserved) instead. Only the rewrite that publishes an
7396    /// append's draw ([`reserve_for_immediate_append`](Self::reserve_for_immediate_append))
7397    /// reaches this with anything there — every other caller releases the reserve
7398    /// first — and for that rewrite the fallback is what keeps the draw from
7399    /// growing the file: a draw takes every hole the append fits in, and on a
7400    /// flat file that is the one list the tail is placed from, so without it the
7401    /// rewrite appended its tail at end-of-file every time and the file grew by a
7402    /// tail per draw (issue #413). Space in the reserve is exactly what this
7403    /// rewrite publishes as *not* free, so a tail placed there is live in bytes
7404    /// the managers do not advertise, which is the same standing a tail placed
7405    /// from `self.free` has once the repoint lands. `self.free` first, so that an
7406    /// ordinary hole is spent before the append's own space is; best fit within
7407    /// the reserve then takes its smallest run, leaving the largest for the
7408    /// append that drew it. The paged tail ([`tail_layout`](Self::tail_layout))
7409    /// has no such fallback: it opens a page when nothing fits, and
7410    /// `paged_group_churn_with_populated_datasets_reaches_a_steady_size` holds it
7411    /// to not doing so once per draw.
7412    fn flat_tail_layout(
7413        &mut self,
7414        to_free: &[(u64, u64, FreeClass)],
7415        old_blocks: &[(u64, u64)],
7416        ext_len: u64,
7417        os: u8,
7418    ) -> (FreeList, Option<u64>, u64, u64) {
7419        /// Enough rounds for the section set to settle after a reservation shrinks
7420        /// it, without letting a proposal that keeps growing spin.
7421        const ROUNDS: usize = 4;
7422
7423        let eof = self.image.len();
7424        // The first proposal: what the tail would measure with nothing reserved.
7425        // A manager block's length depends only on the sections it records, never
7426        // on where it sits. It is also the answer for a tail that ends up appended,
7427        // since every round hands its reservation back before this returns.
7428        let probe = self.flat_post_free(to_free, old_blocks);
7429        let appended_len = ext_len + file_fsm_blocks_len(&self.persisted_sections(&probe), os);
7430        let mut proposed = appended_len;
7431
7432        for _ in 0..ROUNDS {
7433            let (at, from_reserve) = match self.free.alloc(proposed) {
7434                Some(at) => (at, false),
7435                None => match self.reserved.alloc(proposed) {
7436                    Some(at) => (at, true),
7437                    None => break,
7438                },
7439            };
7440            let mut post = self.flat_post_free(to_free, old_blocks);
7441            // Free space that reaches end-of-file is released rather than
7442            // recorded: the file is truncated to where the run starts, so the
7443            // sections these blocks are sized from must already leave it out
7444            // (issue #418). The reservation was taken before this list was built,
7445            // so the run can only begin at or above the tail's own end.
7446            let eoa = release_trailing_run(&mut post, eof, proposed);
7447            let len = ext_len + file_fsm_blocks_len(&self.persisted_sections(&post), os);
7448            if len <= proposed {
7449                debug_assert!(
7450                    at + proposed <= eoa,
7451                    "a reused tail at {at} of {proposed} bytes runs past the end-of-allocation \
7452                     {eoa} it was placed under"
7453                );
7454                return (post, Some(at), proposed, eoa);
7455            }
7456            if from_reserve {
7457                self.reserved.free(at, proposed);
7458            } else {
7459                self.free.free(at, proposed);
7460            }
7461            proposed = len;
7462        }
7463        // Nothing reusable fits, so the tail is appended past end-of-file and no
7464        // trailing run is released: whatever free space reaches end-of-file was
7465        // too small for the tail, and stays recorded below it.
7466        (probe, None, appended_len, eof)
7467    }
7468
7469    /// Commit tail for a genuine paged file (`H5F_FSPACE_STRATEGY_PAGE`, issue
7470    /// #198). The paged counterpart of [`commit_persisting`](Self::commit_persisting).
7471    ///
7472    /// Two things differ from the flat tail. Free space is recorded in *per-page-type*
7473    /// managers — SUPER (slot 0) for metadata, DRAW (slot 2) for small raw, and the
7474    /// generic-large manager (slot 6) for whole free pages and large-raw fragments —
7475    /// rather than one generic manager, so a paged file reopened by the reference
7476    /// library still finds its free space segregated. And the file is padded to a
7477    /// page before the tail is laid down, so the end-of-allocation stays a whole
7478    /// number of pages, matching the paged file the from-scratch writer produces.
7479    ///
7480    /// The tail itself is placed like any other run of metadata, in free space
7481    /// where some fits ([`tail_layout`](Self::tail_layout)), and only opens a page
7482    /// at end-of-file when none does. It used to always append into a page of its
7483    /// own and pad the remainder out untracked, which cost a page per commit and
7484    /// recorded none of it — a paged file under delete-and-recreate churn grew
7485    /// without bound while reporting a fraction of that as reusable (issue #286).
7486    /// The reference library does not page-align these blocks either: its manager
7487    /// headers sit at whatever offset within a metadata page they are allocated at.
7488    ///
7489    /// Crash atomicity is identical to the flat path: the tail is either past the
7490    /// live file or inside space an earlier commit freed, and is unreferenced
7491    /// either way until the superblock repoint, which is the linearization point.
7492    fn commit_persisting_paged(
7493        &mut self,
7494        new_root: u64,
7495        to_free: Vec<(u64, u64, FreeClass)>,
7496        placement: TailPlacement,
7497    ) -> Result<(), Error> {
7498        let os = self.superblock.offset_size;
7499        let (strategy, threshold, page_size, old_blocks) = {
7500            let ps = self
7501                .persist
7502                .as_ref()
7503                .expect("commit_persisting is only called when persistence is armed");
7504            (
7505                ps.strategy,
7506                ps.threshold,
7507                ps.page_size,
7508                ps.old_blocks.clone(),
7509            )
7510        };
7511
7512        let base = self.superblock.base_address;
7513
7514        // Page-align the file before anything else, so the rewritten extension and
7515        // the manager blocks begin on a page boundary and stay in metadata pages.
7516        // The padded tail becomes free space of whatever type that page held.
7517        self.pad_to_page()?;
7518
7519        // As on the flat path: the extension address is stored relative to the base
7520        // address, and every reader of the extension below indexes the image.
7521        let old_ext_addr = base.absolute(
7522            self.superblock
7523                .superblock_extension_address
7524                .map(StoredAddress::new)
7525                .filter(|a| !a.is_undefined(os))
7526                .ok_or(Error::EditUnsupported(
7527                    "a persisting file has no superblock extension to update",
7528                ))?,
7529        )?;
7530
7531        // The 12-slot persist message is fixed-size, so a placeholder sizes the
7532        // rewritten extension before its manager addresses are known — and before
7533        // its address is, which is what lets the tail be sized before it is placed.
7534        let placeholder = FileSpaceInfo::persistent_managers(
7535            strategy,
7536            threshold,
7537            page_size,
7538            [UNDEF; NUM_FILE_FSM_MANAGERS],
7539            0,
7540        );
7541        let ext_len =
7542            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)?
7543                .len() as u64;
7544
7545        // Place the tail — the rewritten extension and the manager blocks — as an
7546        // ordinary run of metadata, in a hole an *earlier* commit freed where one
7547        // fits. That is what stops a file under delete-and-recreate churn from
7548        // growing: every commit frees its predecessor's tail exactly, so in the
7549        // steady state a tail lands in a hole an earlier one vacated — the one two
7550        // commits back, since a tail is freed into `pg`'s lists only at the repoint
7551        // that follows its successor — and the file never has to open a page for it
7552        // (issue #286). `pg`'s lists hold only
7553        // durable free space — this commit's own frees stay in `to_free` until the
7554        // repoint — so the bytes overwritten are already unreachable from the
7555        // on-disk root, the guarantee [`reserve`](Self::reserve) relies on.
7556        //
7557        // The tail is sized from the very lists it draws on, so its length and its
7558        // placement define each other: reserving space removes sections, and fewer
7559        // sections need fewer bytes to record. `tail_layout` settles that by
7560        // proposing a length, planning against it, and accepting the proposal only
7561        // when the plan comes out exactly that long — no shorter, which would leave
7562        // untracked bytes inside the reservation, and no longer, which would run
7563        // past it into whatever lives next. A few rounds settle it; a proposal that
7564        // will not converge falls through to the append below, which has no length
7565        // to satisfy.
7566        let placed = self.tail_layout(&to_free, &old_blocks, ext_len, page_size, os);
7567        if placed.is_none() && placement == TailPlacement::ReuseOnly {
7568            // As on the flat path: the shrink pass opens no page of its own.
7569            return Ok(());
7570        }
7571        let reused = placed.is_some();
7572        let (post, plan, ext_addr, blocks_len, eoa) = match placed {
7573            Some(layout) => layout,
7574            None => {
7575                // Nothing fits: open a metadata page at end-of-file. `pad_to_page`
7576                // above already left the file page-aligned, so this only records the
7577                // tail page's type. No trailing run is released here — whatever
7578                // free space reaches end-of-file was too small for the tail, and
7579                // stays recorded below it.
7580                self.begin_page(PageType::Meta)?;
7581                let at = self.image.len();
7582                let post = self.paged_post_free(&to_free, &old_blocks);
7583                let plan = plan_paged_managers(
7584                    &self.persisted_sections(&post.meta),
7585                    &self.persisted_sections(&post.raw),
7586                    &self.persisted_sections(&post.unclassified),
7587                    page_size,
7588                    self.persisted_address(at + ext_len),
7589                    os,
7590                );
7591                let blocks_len = plan.end_of_managers.get().max(at + ext_len) - at;
7592                (post, plan, at, blocks_len, at)
7593            }
7594        };
7595        let final_eof = if reused {
7596            // The tail landed inside the file; the end-of-allocation is where this
7597            // commit's appends left it — page-aligned by `pad_to_page`, less the
7598            // whole pages of free space reaching it, which `post` no longer
7599            // records and the truncation below gives back (issue #418).
7600            eoa
7601        } else {
7602            align_up(ext_addr + blocks_len, page_size)
7603        };
7604        debug_assert!(
7605            ext_addr + blocks_len <= final_eof,
7606            "the tail must end at or below the end-of-allocation it was placed under"
7607        );
7608
7609        let ext_oh = if plan.is_empty() {
7610            // No free space to track: an empty persist message, page-aligned.
7611            let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
7612            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?
7613        } else {
7614            // Paged convention (matching the from-scratch writer): the managers are
7615            // ordinary metadata below a page-aligned end-of-allocation.
7616            let info = FileSpaceInfo::persistent_managers(
7617                strategy,
7618                threshold,
7619                page_size,
7620                plan.slots.map(StoredAddress::get),
7621                final_eof,
7622            );
7623            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?)?
7624        };
7625        debug_assert_eq!(
7626            ext_oh.len() as u64,
7627            ext_len,
7628            "extension length must be stable across the placeholder and real messages"
7629        );
7630
7631        // Write the extension, then every manager block. Both forms are safe against
7632        // a crash here: an appended tail is past everything live, and a reused one
7633        // sits in space an earlier commit freed. Neither is referenced until the
7634        // repoint below.
7635        let region = (ext_addr, blocks_len);
7636        self.write_tail_block(region, ext_addr, &ext_oh)?;
7637        for b in &plan.blocks {
7638            let (fshd, fsse) =
7639                serialize_file_fsm(&b.sections, b.fshd_addr, b.fsse_addr, os, b.class);
7640            self.write_tail_block(region, base.absolute(b.fshd_addr)?, &fshd)?;
7641            self.write_tail_block(region, base.absolute(b.fsse_addr)?, &fsse)?;
7642        }
7643        // An appended tail ends mid-page; pad it out, so the end-of-allocation stays
7644        // a whole number of pages. A reused tail is already inside the file, and
7645        // matched its reservation exactly, so there is nothing to pad — and where
7646        // it released a trailing run its end-of-allocation is *below* the current
7647        // end-of-file, which the truncation after the repoint gives back.
7648        if !reused {
7649            self.pad_zeros_to(final_eof)?;
7650        }
7651        // Exactly the bytes written, contiguous from the extension. A session that
7652        // reopens this file records the same extents from the message and the
7653        // manager headers, so nothing depends on remembering this across a close.
7654        let new_old_blocks = vec![(ext_addr, blocks_len)];
7655
7656        // Barrier, then repoint the superblock (root, eof, and the new extension)
7657        // — the linearization point — and sync it.
7658        self.barrier()?;
7659        let mut new_sb = self.superblock.clone();
7660        new_sb.root_group_address = new_root;
7661        new_sb.eof_address = final_eof;
7662        new_sb.superblock_extension_address = Some(ext_addr);
7663        // The one publish site a page-buffered session actually reaches, and so
7664        // the one where this must not be a literal zero: a crash mark has to
7665        // outlive every commit the session makes (issue #308).
7666        new_sb.consistency_flags = self.held_status_flags;
7667        let sb_bytes = new_sb.serialize();
7668        self.publish_attempted = true;
7669        self.write_at(self.sb_sig_off, &sb_bytes)?;
7670        self.barrier()?;
7671        self.superblock = new_sb;
7672
7673        // Physically shrink the file only after the superblock — now carrying the
7674        // smaller end-of-file — is durable, for the reason the flat tail gives:
7675        // a crash between the two leaves trailing bytes the next open ignores,
7676        // where the reverse order would advertise an end-of-file past the file's
7677        // actual length.
7678        if final_eof < self.image.len() {
7679            self.image.truncate(final_eof)?;
7680            self.barrier()?;
7681        }
7682
7683        // The repoint is durable. Only now are this commit's vacated regions
7684        // genuinely free, so adopt the lists built above and drop the padding tails
7685        // they already account for. The blocks just written become the ones the next
7686        // commit supersedes.
7687        if let Some(pg) = self.paged.as_mut() {
7688            pg.meta = post.meta;
7689            pg.raw = post.raw;
7690            pg.dead = post.dead;
7691            // This list is otherwise constant for the session, but a released
7692            // trailing run may have cut into it, and a section naming bytes the
7693            // file no longer has would be written back to the managers by the
7694            // next commit.
7695            pg.unclassified = post.unclassified;
7696            pg.meta_pad.clear();
7697            pg.raw_pad.clear();
7698            if !reused {
7699                // The tail page is fresh metadata: the managers sit in it, so a
7700                // following append of metadata may keep packing that page, and the
7701                // rest of it is free metadata this session can spend. The managers
7702                // just written cannot say so — they would have to describe space
7703                // whose size their own length decides — so the next commit records
7704                // it, from here. A reused tail leaves end-of-file where the data put
7705                // it, so the outgoing page type stands and there is no padding.
7706                pg.last = Some(PageType::Meta);
7707                pg.meta
7708                    .free(ext_addr + blocks_len, final_eof - (ext_addr + blocks_len));
7709            }
7710        }
7711        self.persist = Some(PersistState {
7712            strategy,
7713            threshold,
7714            page_size,
7715            old_blocks: new_old_blocks,
7716        });
7717        // The managers describe the file at this length, so nothing is owed until
7718        // it grows past them again.
7719        self.fsm_len = self.image.len();
7720        Ok(())
7721    }
7722
7723    /// Pad a paged file to a page boundary if its tail page is partially filled,
7724    /// recording the padding as free space of the tail page's type. A no-op on a
7725    /// non-paged file or an already-aligned one.
7726    fn pad_to_page(&mut self) -> Result<(), Error> {
7727        let len = self.image.len();
7728        let pad = match &self.paged {
7729            Some(pg) if len % pg.page_size != 0 => {
7730                Some((pg.last, pg.page_size - len % pg.page_size))
7731            }
7732            _ => None,
7733        };
7734        if let Some((last, pad_len)) = pad {
7735            let pad_at = len;
7736            self.append(&vec![0u8; pad_len.to_usize()?])?;
7737            if let Some(pg) = self.paged.as_mut() {
7738                match last {
7739                    // A partially-filled tail page at commit time is a raw page:
7740                    // the last thing the apply loop writes for a dataset is its
7741                    // header, but a commit that only wrote raw data ends on one.
7742                    Some(PageType::Meta) => pg.meta_pad.push((pad_at, pad_len)),
7743                    Some(PageType::Raw) => pg.raw_pad.push((pad_at, pad_len)),
7744                    // No typed append this commit, so the tail page is one a
7745                    // previous session left non-aligned — a crash, since a clean
7746                    // close pads. Its type is unknown, and recording the padding
7747                    // under a guess would advertise it for reuse of that type and
7748                    // mix the page, so leave it untracked (see `PagedEdit::begin`,
7749                    // which makes the same call for the same reason). Reuse made
7750                    // this reachable: before it, every commit appended at least the
7751                    // root group header, so `last` was always known here.
7752                    None => {}
7753                }
7754            }
7755        }
7756        Ok(())
7757    }
7758
7759    /// The free lists a paged commit is about to persist: the session's durable
7760    /// lists plus the page-padding tails this commit's appends left behind, the
7761    /// regions it vacated, and the superseded extension and manager blocks (all
7762    /// metadata, dead once the superblock is repointed).
7763    ///
7764    /// Every page the result leaves wholly empty is promoted to a free page before
7765    /// it is returned ([`PagedEdit::promote_whole_free_pages`]), so the managers
7766    /// this commit writes already describe it. Deferring the promotion to the
7767    /// repoint would leave the session holding free space its own on-disk record
7768    /// does not name.
7769    ///
7770    /// Returned as temporaries rather than folded into the session, exactly as the
7771    /// flat path builds `post`: every region gathered here is still *live* until
7772    /// that repoint, so the session's own lists must not learn about it until the
7773    /// repoint succeeds. Everything in between can fail (the extension rewrite,
7774    /// each write, each barrier), and a session that survived a failed commit
7775    /// while believing live extents were free would hand them out on the next
7776    /// commit — silently destroying the objects still occupying them.
7777    ///
7778    /// Called once to *size* the tail and again for each round the tail's
7779    /// placement takes to settle, since what remains depends on the space the tail
7780    /// has drawn for itself ([`tail_layout`](Self::tail_layout)). Each call clones
7781    /// both lists; they hold one region per hole, not per byte, so the copies are
7782    /// small.
7783    fn paged_post_free(
7784        &self,
7785        to_free: &[(u64, u64, FreeClass)],
7786        old_blocks: &[(u64, u64)],
7787    ) -> PagedPostFree {
7788        let pg = self
7789            .paged
7790            .as_ref()
7791            .expect("commit_persisting_paged is only called on a paged file");
7792        let (mut meta, mut raw, mut dead) = (pg.meta.clone(), pg.raw.clone(), pg.dead.clone());
7793        // This engine never frees into that list, and nothing it places comes out
7794        // of it.
7795        let unclassified = pg.unclassified.clone();
7796        let mut free = |a: u64, l: u64, class: FreeClass| {
7797            PagedEdit::route_free(&mut meta, &mut raw, &mut dead, a, l, class);
7798        };
7799        for &(a, l) in &pg.meta_pad {
7800            free(a, l, PageType::Meta.into());
7801        }
7802        for &(a, l) in &pg.raw_pad {
7803            free(a, l, PageType::Raw.into());
7804        }
7805        for &(a, l, class) in to_free {
7806            free(a, l, class);
7807        }
7808        // The superseded extension and manager blocks, which are metadata wherever
7809        // they sat: the tail is placed as metadata, and a page it had to open for
7810        // itself was opened as metadata too.
7811        for &(a, l) in old_blocks {
7812            free(a, l, PageType::Meta.into());
7813        }
7814        PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, pg.page_size);
7815        PagedPostFree {
7816            meta,
7817            raw,
7818            dead,
7819            unclassified,
7820        }
7821    }
7822
7823    /// Reserve free metadata space for a paged commit's tail and plan the manager
7824    /// blocks for the address it got, or `None` when nothing reusable fits — the
7825    /// caller then appends instead.
7826    ///
7827    /// The reservation and the plan are mutually dependent: drawing `len` bytes out
7828    /// of the free lists changes the sections those very blocks record, and a
7829    /// different section set is a different length. This proposes a length, plans
7830    /// against the lists as they stand once that length is taken, and accepts only
7831    /// an exact agreement. Anything else hands the reservation back and proposes
7832    /// the length the plan just came out at, which is the natural next candidate.
7833    ///
7834    /// Both kinds of disagreement have to be rejected, not just the obvious one. A
7835    /// plan **longer** than its reservation would write past it into whatever lives
7836    /// after. A plan **shorter** would leave bytes inside the reservation that no
7837    /// manager records and the next commit does not free — the leak this whole
7838    /// change exists to remove, reintroduced a few bytes at a time.
7839    ///
7840    /// Iteration is capped rather than trusted to converge: the length is a
7841    /// step function of the section set, so a proposal can in principle oscillate
7842    /// between two values that each imply the other. Appending is always available
7843    /// and always correct, so giving up costs a page rather than a guarantee.
7844    fn tail_layout(
7845        &mut self,
7846        to_free: &[(u64, u64, FreeClass)],
7847        old_blocks: &[(u64, u64)],
7848        ext_len: u64,
7849        page_size: u64,
7850        os: u8,
7851    ) -> Option<(PagedPostFree, PagedManagerPlan, u64, u64, u64)> {
7852        /// Enough rounds for the section set to settle after a reservation shrinks
7853        /// it, without letting an oscillating proposal spin.
7854        const ROUNDS: usize = 4;
7855
7856        let eof = self.image.len();
7857
7858        // The first proposal: what the blocks would measure with nothing reserved.
7859        // A manager block's length depends only on the sections it records, never
7860        // on its address, so a start of 0 measures the blocks alone.
7861        let probe = self.paged_post_free(to_free, old_blocks);
7862        let mut proposed = ext_len
7863            + plan_paged_managers(
7864                &self.persisted_sections(&probe.meta),
7865                &self.persisted_sections(&probe.raw),
7866                &self.persisted_sections(&probe.unclassified),
7867                page_size,
7868                StoredAddress::new(0),
7869                os,
7870            )
7871            .end_of_managers
7872            .get();
7873
7874        for _ in 0..ROUNDS {
7875            let pg = self
7876                .paged
7877                .as_mut()
7878                .expect("commit_persisting_paged is only called on a paged file");
7879            let at = pg.alloc_typed(proposed, PageType::Meta)?;
7880            let mut post = self.paged_post_free(to_free, old_blocks);
7881            // Whole free pages at the end of the file are released rather than
7882            // recorded, so the sections these blocks are sized from must already
7883            // leave them out (issue #418). The reservation was taken before this
7884            // list was built, so the run can only begin at or above the tail's own
7885            // end.
7886            let eoa = post.release_trailing(eof, page_size, proposed);
7887            // Class the free space into its managers and place their blocks after
7888            // the extension. Shared with the bounded backend so both lay out
7889            // identically.
7890            let plan = plan_paged_managers(
7891                &self.persisted_sections(&post.meta),
7892                &self.persisted_sections(&post.raw),
7893                &self.persisted_sections(&post.unclassified),
7894                page_size,
7895                self.persisted_address(at + ext_len),
7896                os,
7897            );
7898            // An empty plan writes no blocks at all, leaving the tail the extension
7899            // alone; `end_of_managers` is then its own start.
7900            let blocks_len = plan.end_of_managers.get().max(at + ext_len) - at;
7901            if blocks_len == proposed {
7902                debug_assert!(
7903                    at + blocks_len <= eoa,
7904                    "a reused tail at {at} of {blocks_len} bytes runs past the end-of-allocation \
7905                     {eoa} it was placed under"
7906                );
7907                return Some((post, plan, at, blocks_len, eoa));
7908            }
7909            let pg = self
7910                .paged
7911                .as_mut()
7912                .expect("the paged state outlives this loop");
7913            PagedEdit::route_free(
7914                &mut pg.meta,
7915                &mut pg.raw,
7916                &mut pg.dead,
7917                at,
7918                proposed,
7919                PageType::Meta.into(),
7920            );
7921            proposed = blocks_len;
7922        }
7923        None
7924    }
7925
7926    /// Write one block of a persisting commit's tail — paged or flat — at the
7927    /// address its layout gave it, which is either the current end-of-file (the
7928    /// tail is being appended) or inside `region`, the span reserved from the free
7929    /// lists, when the tail is being reused into space an earlier commit left.
7930    ///
7931    /// The region is checked rather than asserted, for the reason
7932    /// [`place`](Self::place) checks its own reservation: a reused span is followed
7933    /// by live bytes, so a block running past it would silently destroy a
7934    /// neighboring object, and this is the one write in a commit that lands in the
7935    /// middle of a live file. The region's length is derived from the same layout
7936    /// that placed these blocks, so the two cannot disagree today; the comparison
7937    /// is what keeps that true if a layout ever learns a length this sizing does
7938    /// not model.
7939    fn write_tail_block(
7940        &mut self,
7941        region: (u64, u64),
7942        addr: u64,
7943        bytes: &[u8],
7944    ) -> Result<(), Error> {
7945        let (start, len) = region;
7946        if addr < start || addr + bytes.len() as u64 > start + len {
7947            return Err(Error::Format(FormatError::SerializationError(format!(
7948                "a commit's tail reserved [{start}, {}) but placed {} bytes at {addr}",
7949                start + len,
7950                bytes.len()
7951            ))));
7952        }
7953        if addr == self.image.len() {
7954            let written = self.append(bytes)?;
7955            debug_assert_eq!(written, addr, "an appended block must land at end-of-file");
7956            return Ok(());
7957        }
7958        self.write_at(addr, bytes)
7959    }
7960
7961    /// Extend the file with zeros up to `target` (>= the current length), used by
7962    /// the paged tail to pad the final metadata page to its boundary.
7963    fn pad_zeros_to(&mut self, target: u64) -> Result<(), Error> {
7964        let len = self.image.len();
7965        if target > len {
7966            let pad = (target - len).to_usize()?;
7967            self.append(&vec![0u8; pad])?;
7968        }
7969        debug_assert_eq!(self.image.len(), target);
7970        Ok(())
7971    }
7972
7973    /// Rebuilds the superblock-extension object header's message region with its
7974    /// File Space Info message replaced by `info` (every other message preserved
7975    /// verbatim), ready to wrap with [`build_v2_object_header`]. The persisting
7976    /// message is fixed-size, so this never changes the region's length.
7977    ///
7978    /// `ext_addr` is the extension header's absolute file offset, which is where
7979    /// the messages are read from, and not the address the superblock stores.
7980    fn rewrite_extension_region(
7981        &self,
7982        ext_addr: u64,
7983        info: &FileSpaceInfo,
7984    ) -> Result<OhRegion, Error> {
7985        let region =
7986            Self::gather_oh_messages(&self.image(), ext_addr, self.superblock.base_address)?;
7987        rewrite_extension_region_bytes(&region, info)
7988    }
7989
7990    /// Repoint a version 0/1 superblock at the rebuilt (now v2) root group and
7991    /// update its end-of-file field, patching the raw bytes in place — these
7992    /// superblocks carry no checksum. The root symbol-table entry is switched to
7993    /// cache type 0 (its scratch-pad B-tree / local-heap addresses, which
7994    /// describe the old symbol-table group, no longer apply). The
7995    /// object-header-address write is done last so it is the linearization point.
7996    fn repoint_v0v1_root(&mut self, new_root: u64, new_eof: u64) -> Result<(), Error> {
7997        let os = self.superblock.offset_size;
7998        let addr_width = u64::from(os);
7999        // Field layout after the fixed prefix: base / free-space / EOF / driver
8000        // addresses, then the root symbol-table entry (link-name offset, object
8001        // header address, cache type(4), reserved(4), scratch(16)). The prefix is
8002        // 24 bytes for v0 and 28 for v1 (the latter adds indexed-storage-K).
8003        let var_start = if self.superblock.version == 0 { 24 } else { 28 };
8004        let base = self.sb_sig_off + var_start;
8005        let eof_off = base + 2 * addr_width;
8006        let ste = base + 4 * addr_width;
8007        let oh_addr_off = ste + addr_width;
8008        let cache_off = ste + 2 * addr_width;
8009        self.write_at(eof_off, &new_eof.to_le_bytes()[..usize::from(os)])?;
8010        self.write_at(cache_off, &[0u8; 4])?; // cache type = none
8011        self.write_at(cache_off + 8, &[0u8; 16])?; // clear scratch-pad
8012        self.write_at(oh_addr_off, &new_root.to_le_bytes()[..usize::from(os)])?;
8013        Ok(())
8014    }
8015
8016    /// Collect every message of the object header at `addr` into one contiguous
8017    /// region, following continuation blocks across chunks and dropping the
8018    /// `Continuation` messages themselves. Re-emitting the result through
8019    /// [`build_v2_object_header`] collapses a multi-chunk header (as the
8020    /// reference C library often writes) into a single chunk, which is how this
8021    /// editor rebuilds headers. The chunk-0 prefix is validated by
8022    /// [`oh_region_at`]; each continuation block must be a well-formed `OCHK`
8023    /// block within the file.
8024    ///
8025    /// Reads each header chunk out of `src` as one bounded buffer rather than
8026    /// indexing a whole-file image, so this serves a session whose file is not
8027    /// mirrored in memory (issue #198).
8028    fn gather_oh_messages<S: Source + ?Sized>(
8029        src: &S,
8030        addr: u64,
8031        base: BaseAddress,
8032    ) -> Result<OhRegion, Error> {
8033        let chunks = read_oh_chunks(src, addr, base)?;
8034        // Every chunk of one header shares what chunk 0's prefix declared, which
8035        // is what makes concatenating their records into one region well defined,
8036        // and what carries the prefix's optional blocks through to the rebuild.
8037        let mut out = OhRegion::empty(chunks[0].props());
8038        for chunk in &chunks {
8039            let layout = chunk.layout();
8040            let (region, mut p) = chunk.message_region();
8041            while let Some((msg_type, _body, body_end)) = layout.next_message(region, p)? {
8042                if msg_type != MessageType::OBJECT_HEADER_CONTINUATION {
8043                    out.push_bytes(&region[p..body_end]);
8044                }
8045                p = body_end;
8046            }
8047        }
8048        Ok(out)
8049    }
8050
8051    /// Reconstructs a version-1 (symbol-table) group as a fresh v2 compact-link
8052    /// message region: a LinkInfo message, one Link message per existing child,
8053    /// and the group's existing attributes (re-wrapped as v2 messages). The
8054    /// symbol-table message and other non-link/non-attribute messages
8055    /// (modification time, comment, …) are dropped — editing a v0/v1 group
8056    /// converts it to the latest format. Refuses an attribute it cannot
8057    /// reproduce (shared, or larger than a v2 message can hold).
8058    fn reconstruct_v1_group(&self, addr: u64) -> Result<GroupInfo, Error> {
8059        let os = self.superblock.offset_size;
8060        let ls = self.superblock.length_size;
8061        let base = self.superblock.base_address;
8062        let oh = ObjectHeader::parse_from_source(
8063            &self.image(),
8064            AccessMode::ReadWrite,
8065            addr,
8066            os,
8067            ls,
8068            base,
8069        )?;
8070        if oh
8071            .messages
8072            .iter()
8073            .any(|m| m.msg_type == MessageType::DATA_LAYOUT)
8074        {
8075            return Err(Error::EditUnsupported(
8076                "a target path names a dataset, not a group",
8077            ));
8078        }
8079        let entries = resolve_group_entries_from_source(&self.image(), &oh, os, ls, base)?;
8080
8081        let mut region = fresh_group_region();
8082        let mut link_names = Vec::with_capacity(entries.len());
8083        for e in &entries {
8084            // A version 1 group has no link creation order, which the Link Info
8085            // message introduced, so these links get no creation index and the
8086            // Link Info message `fresh_group_region` writes tracks none.
8087            region.push_link(&e.name, e.object_header_address, None);
8088            link_names.push(e.name.clone());
8089        }
8090        for m in &oh.messages {
8091            if m.msg_type == MessageType::ATTRIBUTE {
8092                if m.data.len() > OBJECT_HEADER_MESSAGE_MAX {
8093                    return Err(Error::EditUnsupported(
8094                        "a v0/v1 group attribute is too large to convert in place",
8095                    ));
8096                }
8097                if !m.flags.difference(MessageFlags::SHARED).is_empty() {
8098                    // Every other flag bit says something about the message this
8099                    // rewrite would have to reproduce and does not — that it is
8100                    // constant, that it must not be shared, what a reader should
8101                    // do if it cannot decode it. The shared bit is the one this
8102                    // conversion knows how to carry.
8103                    return Err(Error::EditUnsupported(
8104                        "a v0/v1 group attribute message carries an object-header flag this \
8105                         conversion cannot reproduce",
8106                    ));
8107                }
8108                if m.flags.is_shared() {
8109                    // The body is a reference, not an attribute; the reference is
8110                    // what gets rewrapped, in the encoding a version 2 header
8111                    // uses. Reachable only from a file whose version 1 objects
8112                    // share messages, which the reference C library does not
8113                    // write — it moves an object to a version 2 header before
8114                    // sharing anything on it.
8115                    region.push_shared(
8116                        MessageType::ATTRIBUTE,
8117                        &modernize_shared_reference(&m.data, os, ls)?,
8118                    );
8119                    continue;
8120                }
8121                // Re-wrap the attribute message body (it is self-describing) in a
8122                // v2 message record. The rebuilt header does not track creation
8123                // order — a version 1 header cannot have carried any — so the
8124                // attribute needs no creation index.
8125                region.push(MessageType::ATTRIBUTE, &m.data);
8126            }
8127        }
8128        Ok(GroupInfo { region, link_names })
8129    }
8130
8131    /// Parses and validates a group's object header, returning its message region
8132    /// — the bytes to copy when rewriting the header — and the names of its
8133    /// existing links. A version 2 header is rebuilt from its own message bytes
8134    /// (collapsing continuation chunks, preserving every message); a version 1
8135    /// symbol-table group is converted to v2 via [`reconstruct_v1_group`].
8136    ///
8137    /// [`reconstruct_v1_group`]: Self::reconstruct_v1_group
8138    fn inspect_group(&self, addr: u64) -> Result<GroupInfo, Error> {
8139        let sig = self.image().read_metadata_at(addr, 4);
8140        if sig.as_deref() != Ok(&b"OHDR"[..]) {
8141            return self.reconstruct_v1_group(addr);
8142        }
8143        let mut region =
8144            Self::gather_oh_messages(&self.image(), addr, self.superblock.base_address)?;
8145        let mut p = 0;
8146        let mut has_link_info = false;
8147        let mut link_names = Vec::new();
8148        while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8149            match msg_type {
8150                MessageType::LINK_INFO => {
8151                    has_link_info = true;
8152                    // LinkInfo: version(1) flags(1) [max_creation_index(8) if
8153                    // flags&0x01] fractal_heap_addr(8) … — dense storage has a
8154                    // defined fractal-heap address. Bound the read by this
8155                    // message's own body, not just the region, so a short or
8156                    // malformed LinkInfo can't make us read the next message.
8157                    let mut q = body + 2;
8158                    if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
8159                        q += 8;
8160                    }
8161                    if q + 8 <= body_end {
8162                        let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
8163                        if heap_addr != u64::MAX {
8164                            return Err(Error::EditUnsupported(
8165                                "a target group uses dense (fractal-heap) link storage (not supported in place yet)",
8166                            ));
8167                        }
8168                    }
8169                }
8170                MessageType::LINK => {
8171                    if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
8172                        link_names.push(link.name);
8173                    }
8174                }
8175                MessageType::DATA_LAYOUT => {
8176                    return Err(Error::EditUnsupported(
8177                        "a target path names a dataset, not a group",
8178                    ));
8179                }
8180                _ => {}
8181            }
8182            p = body_end;
8183        }
8184        if !has_link_info {
8185            return Err(Error::EditUnsupported(
8186                "a target group's object header has no link-info message",
8187            ));
8188        }
8189        // Heal headers written by older hdf5-pure releases that omitted the
8190        // Group Info message, so the rewritten group stays writable by the C
8191        // library.
8192        ensure_group_info(&mut region)?;
8193        Ok(GroupInfo { region, link_names })
8194    }
8195
8196    /// The refusals a staged value overwrite (`write_dataset`) makes from the
8197    /// staged builder alone, with no on-disk header involved.
8198    ///
8199    /// [`stage_dataset_write`](Self::stage_dataset_write) applies these as the
8200    /// write is staged, so the call that configured the builder is the one that
8201    /// reports the mistake. A refusal that reads only `fd` belongs here; one that
8202    /// needs the target's header belongs in
8203    /// [`prepare_write`](Self::prepare_write).
8204    fn refuse_unsupported_overwrite(fd: &FlatDataset) -> Result<(), Error> {
8205        // A value overwrite never introduces chunking, filters, or an extensible
8206        // shape: those would change the storage layout, not just the bytes.
8207        if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
8208            return Err(Error::EditUnsupported(
8209                "write_dataset overwrites values only; it cannot make a dataset \
8210                 chunked, filtered, or extensible",
8211            ));
8212        }
8213
8214        // `write_dataset` overwrites element bytes only; it does not touch the
8215        // object header's attribute messages. Attributes staged on the returned
8216        // builder would otherwise be silently dropped (the in-place path rewrites
8217        // only the data block, and the moving path reuses the verbatim on-disk
8218        // header), so refuse rather than degrade — set them in a separate edit.
8219        if !fd.attrs.is_empty() {
8220            return Err(Error::EditUnsupported(
8221                "write_dataset overwrites values only; it cannot set attributes \
8222                 (set them with a separate edit)",
8223            ));
8224        }
8225
8226        // `write_dataset` overwrites element bytes only; it reuses the dataset's
8227        // existing Fill Value message (the in-place path rewrites only the data
8228        // block, and the moving path keeps every header message but the layout
8229        // verbatim). A fill value staged on the returned builder would otherwise
8230        // be silently ignored, so refuse rather than degrade — set the fill value
8231        // when the dataset is first created.
8232        if fd.fill.is_some() {
8233            return Err(Error::EditUnsupported(
8234                "write_dataset overwrites values only; it cannot change the fill \
8235                 value (set it when the dataset is created)",
8236            ));
8237        }
8238
8239        // `with_vlen_strings` stages placeholder element references, resolved by
8240        // placing their global heap collections and patching the addresses in
8241        // (issue #321). That happens where each plan is written, not here: see
8242        // `OverwriteBytes`.
8243
8244        // `reference_targets` holds elements only the add path resolves, in
8245        // `preflight_reference_targets` and the apply loop after it; the
8246        // overwrite path never reads the field. Checked on the field rather than
8247        // on `with_path_references`, since every producer stages elements that
8248        // are equally unresolved. Left unrefused, a staged overwrite writes
8249        // address-zero placeholders over a working reference dataset and
8250        // `commit` reports `Ok` (issue #318). `with_reference_data` supplies
8251        // resolved addresses and overwrites like any other value.
8252        if fd.reference_targets.is_some() {
8253            return Err(Error::EditUnsupported(
8254                "write_dataset cannot overwrite an object-reference dataset's \
8255                 data in place yet",
8256            ));
8257        }
8258
8259        Ok(())
8260    }
8261
8262    /// Preflight a staged value overwrite (`write_dataset`): resolve the dataset
8263    /// at `addr`, validate that the staged `fd` matches it byte-exactly in
8264    /// datatype and shape, and classify how the bytes will be applied. No file
8265    /// bytes are written here — this is part of the all-or-nothing preflight, so a
8266    /// rejected write leaves the commit unapplied.
8267    ///
8268    /// Contiguous, compact, and chunked (including filtered) datasets are all
8269    /// supported; the chunk geometry, filter pipeline, and chunk index come from
8270    /// the on-disk header (a chunk index this engine cannot enumerate — a
8271    /// version-2 B-tree — is refused). A datatype or shape that differs from the
8272    /// on-disk dataset's is likewise refused — this is a value overwrite, not a
8273    /// reshape or retype.
8274    ///
8275    /// Every refusal `fd` can decide on its own has been made by then, where the
8276    /// write is staged.
8277    fn prepare_write<S: Source + ?Sized>(
8278        src: &S,
8279        addr: u64,
8280        fd: &FlatDataset,
8281        base: BaseAddress,
8282        path: &ObjectPathBuf,
8283    ) -> Result<WritePlan, Error> {
8284        // Enforced by construction: `staged.writes` has one producer, and it
8285        // refuses there. Asserted rather than re-refused because a second caller
8286        // that skipped it would not fail here — a chunked builder would fall
8287        // through and be written as a plain contiguous overwrite, with
8288        // `chunk_options` silently dropped.
8289        debug_assert!(
8290            Self::refuse_unsupported_overwrite(fd).is_ok(),
8291            "a staged write reached prepare_write without refuse_unsupported_overwrite"
8292        );
8293
8294        let region = Self::gather_oh_messages(src, addr, base)?;
8295
8296        // Locate the datatype, dataspace, and data-layout messages, and detect a
8297        // filter pipeline (filtered storage is always chunked, never contiguous).
8298        let mut datatype: Option<(usize, usize)> = None;
8299        let mut dataspace: Option<(usize, usize)> = None;
8300        let mut layout: Option<(usize, usize)> = None;
8301        let mut filter: Option<(usize, usize)> = None;
8302        // The dataset's own fill value, which the edge overhang of a partial
8303        // chunk must hold (issue #296). Both message forms, newest wins.
8304        let mut fill_msg: Option<(MessageType, usize, usize)> = None;
8305        let mut has_link = false;
8306        let mut p = 0;
8307        while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8308            match msg_type {
8309                MessageType::DATATYPE => datatype = Some((body, body_end)),
8310                MessageType::DATASPACE => dataspace = Some((body, body_end)),
8311                MessageType::DATA_LAYOUT => layout = Some((body, body_end)),
8312                MessageType::FILTER_PIPELINE => filter = Some((body, body_end)),
8313                // Versioned beats legacy, and within a type the first wins —
8314                // the same rule `Dataset::fill_bytes` and `Located::from_walk`
8315                // apply, so all three agree on a header carrying more than one.
8316                MessageType::FILL_VALUE
8317                    if !matches!(fill_msg, Some((MessageType::FILL_VALUE, ..))) =>
8318                {
8319                    fill_msg = Some((msg_type, body, body_end));
8320                }
8321                MessageType::FILL_VALUE_OLD if fill_msg.is_none() => {
8322                    fill_msg = Some((msg_type, body, body_end));
8323                }
8324                MessageType::LINK | MessageType::LINK_INFO | MessageType::SYMBOL_TABLE => {
8325                    has_link = true;
8326                }
8327                _ => {}
8328            }
8329            p = body_end;
8330        }
8331
8332        if has_link {
8333            return Err(Error::EditUnsupported(
8334                "write_dataset target is a group, not a dataset",
8335            ));
8336        }
8337        let (dt_b, dt_e) =
8338            datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
8339        let (ds_b, ds_e) =
8340            dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
8341        let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
8342
8343        // Compare datatype and shape structurally against the staged data. A
8344        // value overwrite must keep both exactly: the datatype (including its
8345        // class, size, endianness, and any compound/array/enumeration layout) so
8346        // the bytes are interpreted the same, and the *current* dimensions so the
8347        // byte count is unchanged. Parsing both sides and comparing the decoded
8348        // values — rather than the raw message bytes — tolerates the harmless
8349        // encoding differences between this crate's writer and the reference C
8350        // library (e.g. the C library records a maximum-dimensions array equal to
8351        // the current dimensions, which this crate omits) while still refusing any
8352        // real retype or reshape.
8353        let (disk_dt, _) = Datatype::parse(&region[dt_b..dt_e])?;
8354        if disk_dt != fd.dt {
8355            return Err(Error::EditUnsupported(
8356                "write_dataset datatype does not match the on-disk dataset (overwrite, not retype)",
8357            ));
8358        }
8359        let disk_ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE)?;
8360        if disk_ds.space_type != fd.ds.space_type
8361            || disk_ds.rank != fd.ds.rank
8362            || disk_ds.dimensions != fd.ds.dimensions
8363        {
8364            return Err(Error::EditUnsupported(
8365                "write_dataset shape does not match the on-disk dataset (overwrite, not reshape)",
8366            ));
8367        }
8368
8369        // Classify the layout. Version 3/4 compact (class 0), contiguous (class
8370        // 1), and chunked (class 2) are supported; an old-version layout or a
8371        // virtual layout (class 3) is refused.
8372        if le - lb < 2 {
8373            return Err(Error::EditUnsupported("malformed data-layout message"));
8374        }
8375        let version = region[lb];
8376        if version != 3 && version != 4 {
8377            return Err(Error::EditUnsupported(
8378                "an unsupported data-layout version cannot be overwritten in place yet",
8379            ));
8380        }
8381        match region[lb + 1] {
8382            // Compact: the data is inline in the header. Rebuild the header with
8383            // the new inline bytes (relocating it), patching the parent link.
8384            0 => Ok(WritePlan::Moving(MovingWrite::Compact {
8385                region,
8386                bytes: staged_bytes(fd, path),
8387            })),
8388            1 => {
8389                if le - lb < 18 {
8390                    return Err(Error::EditUnsupported("malformed contiguous data layout"));
8391                }
8392                let addr_off = lb + 2;
8393                let data_addr = StoredAddress::new(u64::from_le_bytes(
8394                    region[addr_off..addr_off + 8].try_into().unwrap(),
8395                ));
8396                let data_size = u64::from_le_bytes(region[lb + 10..lb + 18].try_into().unwrap());
8397
8398                // Same length and a defined, in-bounds data block: overwrite the
8399                // bytes straight in place. No header rewrite, no relink. The
8400                // in-place write targets the data block's absolute file offset.
8401                //
8402                // Never for a staged variable-length overwrite, which relocates
8403                // instead. Its resolution *allocates* — a global heap collection,
8404                // drawn from a freed region where one fits — and an in-place write
8405                // is a live mutation of a block the current root already reaches.
8406                // Together those put a reference to a just-allocated span inside
8407                // bytes the live tree names, so a commit failing after the write
8408                // and before its repoint hands the span back to the free list
8409                // (`restore_free`) while the image still points into it. The next
8410                // commit is then free to place something else there, and the
8411                // dataset reads that object's bytes with every checksum intact.
8412                //
8413                // A relocating write has no such window: its new data block is
8414                // reachable from nothing until the superblock repoint, so a
8415                // failed attempt leaves the region genuinely dead and re-offering
8416                // it is sound — which is the invariant `restore_free` states.
8417                if fd.vl_string_staging.is_none()
8418                    && !data_addr.is_undefined(OFFSET_SIZE)
8419                    && data_size == fd.raw.len() as u64
8420                {
8421                    if let Ok(start) = base.absolute(data_addr) {
8422                        if start.checked_add(data_size).is_some_and(|e| e <= src.len()) {
8423                            return Ok(WritePlan::InPlace {
8424                                data_addr: start,
8425                                bytes: staged_bytes(fd, path),
8426                            });
8427                        }
8428                    }
8429                }
8430
8431                // Length differs or the block was undefined/out of bounds: the new
8432                // data goes elsewhere and the old extent (if any) is freed. The
8433                // freed extent is recorded as an absolute file offset, which is the
8434                // frame the session free list uses.
8435                let old_extent = if !data_addr.is_undefined(OFFSET_SIZE) && data_size > 0 {
8436                    Some((base.absolute(data_addr)?, data_size))
8437                } else {
8438                    None
8439                };
8440                Ok(WritePlan::Moving(MovingWrite::Contiguous {
8441                    region,
8442                    addr_off,
8443                    bytes: staged_bytes(fd, path),
8444                    old_extent,
8445                }))
8446            }
8447            // Chunked: overwrite each chunk in place when every new (re-encoded)
8448            // chunk is the same byte length as its slot, else rebuild and relocate
8449            // the whole chunk storage. The chunk geometry, filter pipeline, and
8450            // index type all come from the existing on-disk header (the staged
8451            // builder carries none — chunked/filtered/extensible builders are
8452            // refused at the top of this function as "not a value overwrite").
8453            2 => {
8454                // Chunked overwrite (in-place or relocating). On a userblock file
8455                // every stored chunk-index and chunk address is relative to `base`:
8456                // the in-place path below walks the index on a base-relative view of
8457                // the file and shifts the resulting write offsets back by `base`,
8458                // and the relocating path rebuilds the chunk blob with stored
8459                // addresses (see `write_chunked_relocatable`).
8460                let dl = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE)?;
8461                let DataLayout::Chunked { index, .. } = dl else {
8462                    return Err(Error::EditUnsupported("dataset is not chunked"));
8463                };
8464                if !index.enumerable() {
8465                    return Err(Error::EditUnsupported(
8466                        "a chunked dataset with a version-2 B-tree chunk index cannot be \
8467                         overwritten in place yet",
8468                    ));
8469                }
8470
8471                let ChunkedGeometry {
8472                    spatial,
8473                    element_size,
8474                    raw_size,
8475                    maxshape,
8476                } = chunked_geometry(&fd.dt, &disk_ds, &dl)?;
8477
8478                // Split the new value into full-size chunk buffers in dense
8479                // row-major grid order, then re-encode through the on-disk
8480                // pipeline when the dataset is filtered.
8481                // The overhang past the dataset's edge holds the dataset's own
8482                // fill value, not zeros: an allocated chunk is expected to carry
8483                // it wherever nothing was written, and those slots are what a
8484                // reader returns once the dataset is extended into them (#296).
8485                let padding = fill_msg
8486                    .map_or(crate::fill_value::PaddingFill::Zero, |(mt, b, e)| {
8487                        crate::fill_value::PaddingFill::from_message(mt, &region[b..e])
8488                    });
8489                let pipeline_message: Option<Vec<u8>> =
8490                    filter.map(|(fb, fe)| region[fb..fe].to_vec());
8491
8492                // Refuse a pipeline this engine cannot re-encode before anything
8493                // is split or encoded, so the refusal reaches the preflight on
8494                // both branches below — including the staged one, which does its
8495                // splitting in the apply phase and so has no other chance to make
8496                // it.
8497                if let Some(pm) = &pipeline_message {
8498                    let pipeline = FilterPipeline::parse(pm)?;
8499                    if !pipeline_reencodable(&pipeline) {
8500                        return Err(Error::EditUnsupported(
8501                            "a chunked dataset using a filter this engine cannot re-encode \
8502                             cannot be overwritten in place yet",
8503                        ));
8504                    }
8505                }
8506
8507                // Element bytes still carrying unresolved variable-length
8508                // references cannot be split here: a filtered chunk's compressed
8509                // length depends on the heap addresses patched into it, so the
8510                // split has to follow the patch, which follows a placement the
8511                // preflight may not make. See `ChunkPayload::Deferred`.
8512                if fd.vl_string_staging.is_some() {
8513                    return Ok(WritePlan::Moving(MovingWrite::Chunked {
8514                        region,
8515                        shape: disk_ds.dimensions.clone(),
8516                        chunk_dims: spatial,
8517                        element_size,
8518                        maxshape,
8519                        pipeline_message,
8520                        payload: ChunkPayload::Deferred {
8521                            bytes: staged_bytes(fd, path),
8522                            padding,
8523                            dt: fd.dt.clone(),
8524                        },
8525                        old_addr: addr,
8526                    }));
8527                }
8528
8529                let new_chunk_bytes = split_and_encode_chunks(
8530                    &fd.raw,
8531                    &disk_ds.dimensions,
8532                    &spatial,
8533                    element_size,
8534                    &padding,
8535                    pipeline_message.as_deref(),
8536                    &fd.dt,
8537                )?;
8538
8539                // Fast path: overwrite each chunk straight in its slot when every
8540                // new chunk fits. No header rewrite and no superblock flip — the
8541                // chunk (and index) blocks are reachable from both roots. The index
8542                // is left untouched when chunks keep their size and rebuilt in place
8543                // when they shrink. The index walk runs on a base-relative view of
8544                // the file (so the layout's stored addresses index correctly), and
8545                // the returned write offsets are shifted back to absolute file
8546                // offsets by adding `base` (a no-op on a base-0 file).
8547                if let Some(writes) = try_inplace_chunk_writes(
8548                    &BaseOffsetSource { inner: src, base },
8549                    &dl,
8550                    &disk_ds,
8551                    &spatial,
8552                    raw_size,
8553                    &new_chunk_bytes,
8554                ) {
8555                    let writes = writes
8556                        .into_iter()
8557                        .map(|(off, b)| Ok((base.absolute(StoredAddress::new(off))?, b)))
8558                        .collect::<Result<Vec<_>, FormatError>>()?;
8559                    return Ok(WritePlan::InPlaceChunks { writes });
8560                }
8561
8562                // Otherwise relocate: rebuild a fresh chunk blob + index at
8563                // end-of-file (carrying the re-encoded chunk bytes and the source
8564                // pipeline verbatim), swap the data-layout message in the verbatim
8565                // header, and free the old chunk storage after the commit lands.
8566                Ok(WritePlan::Moving(MovingWrite::Chunked {
8567                    region,
8568                    shape: disk_ds.dimensions.clone(),
8569                    chunk_dims: spatial,
8570                    element_size,
8571                    maxshape,
8572                    pipeline_message,
8573                    payload: ChunkPayload::Encoded(new_chunk_bytes),
8574                    old_addr: addr,
8575                }))
8576            }
8577            _ => Err(Error::EditUnsupported(
8578                "an unsupported data-layout class cannot be overwritten in place yet",
8579            )),
8580        }
8581    }
8582
8583    /// Plan a relocating append to an existing chunked, unlimited,
8584    /// Extensible-Array-indexed dataset at `addr`. Validates the target, splits
8585    /// the appended elements into new (and one rewritten trailing) chunks —
8586    /// compressed through the on-disk pipeline when filtered — and gathers the
8587    /// existing complete chunks by metadata alone. Returns the
8588    /// [`MovingWrite::AppendedChunks`] plan; the commit machinery appends the new
8589    /// chunks and a rebuilt index and repoints the header (see
8590    /// [`write_appended_chunks`](Self::write_appended_chunks)).
8591    ///
8592    /// Reads only; no bytes are written here. `src` is the file image and `base`
8593    /// its userblock base; the dataset's stored (base-relative) structures are read
8594    /// through a `base`-shifted view.
8595    fn prepare_append<S: Source + ?Sized>(
8596        src: &S,
8597        addr: u64,
8598        ab: &AppendBuilder,
8599        base: BaseAddress,
8600    ) -> Result<MovingWrite, Error> {
8601        if ab.dt_conflict {
8602            return Err(Error::AppendUnsupported(
8603                "append mixes element types in one builder; use one element type per \
8604                 append_dataset call",
8605            ));
8606        }
8607
8608        let region = Self::gather_oh_messages(src, addr, base)?;
8609
8610        // Locate the datatype, dataspace, data-layout, and filter-pipeline
8611        // messages, and detect a group (link) header.
8612        let mut datatype: Option<(usize, usize)> = None;
8613        let mut dataspace: Option<(usize, usize)> = None;
8614        let mut layout: Option<(usize, usize)> = None;
8615        let mut filter: Option<(usize, usize)> = None;
8616        // The dataset's own fill value, which the edge overhang of a partial
8617        // chunk must hold (issue #296). Both message forms, newest wins.
8618        let mut fill_msg: Option<(MessageType, usize, usize)> = None;
8619        let mut has_link = false;
8620        let mut p = 0;
8621        while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8622            match msg_type {
8623                MessageType::DATATYPE => datatype = Some((body, body_end)),
8624                MessageType::DATASPACE => dataspace = Some((body, body_end)),
8625                MessageType::DATA_LAYOUT => layout = Some((body, body_end)),
8626                MessageType::FILTER_PIPELINE => filter = Some((body, body_end)),
8627                // Versioned beats legacy, and within a type the first wins —
8628                // the same rule `Dataset::fill_bytes` and `Located::from_walk`
8629                // apply, so all three agree on a header carrying more than one.
8630                MessageType::FILL_VALUE
8631                    if !matches!(fill_msg, Some((MessageType::FILL_VALUE, ..))) =>
8632                {
8633                    fill_msg = Some((msg_type, body, body_end));
8634                }
8635                MessageType::FILL_VALUE_OLD if fill_msg.is_none() => {
8636                    fill_msg = Some((msg_type, body, body_end));
8637                }
8638                MessageType::LINK | MessageType::LINK_INFO | MessageType::SYMBOL_TABLE => {
8639                    has_link = true;
8640                }
8641                _ => {}
8642            }
8643            p = body_end;
8644        }
8645        if has_link {
8646            return Err(Error::AppendUnsupported(
8647                "append target is a group, not a dataset",
8648            ));
8649        }
8650        let (dt_b, dt_e) =
8651            datatype.ok_or(Error::AppendUnsupported("dataset header has no datatype"))?;
8652        let (ds_b, ds_e) =
8653            dataspace.ok_or(Error::AppendUnsupported("dataset header has no dataspace"))?;
8654        let (lb, le) = layout.ok_or(Error::AppendUnsupported(
8655            "dataset header has no data layout",
8656        ))?;
8657
8658        let (disk_dt, _) = Datatype::parse(&region[dt_b..dt_e])?;
8659        let disk_ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE)?;
8660        let dl = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE)?;
8661
8662        // Require a chunked dataset indexed by an Extensible Array, which only a
8663        // version 4 layout message stores.
8664        let DataLayout::Chunked { index, .. } = &dl else {
8665            return Err(Error::AppendUnsupported(
8666                "append requires a chunked dataset",
8667            ));
8668        };
8669        let ChunkIndexLayout::ExtensibleArray { address } = index else {
8670            return Err(Error::AppendUnsupported(
8671                "append requires an Extensible-Array-indexed chunked dataset (a single \
8672                 unlimited dimension under the latest format)",
8673            ));
8674        };
8675
8676        // Require rank 1, unlimited along axis 0.
8677        if disk_ds.space_type != DataspaceType::Simple || disk_ds.dimensions.len() != 1 {
8678            return Err(Error::AppendUnsupported(
8679                "append requires a rank-1 dataset in this release",
8680            ));
8681        }
8682        if !matches!(
8683            disk_ds.max_dimensions.as_deref(),
8684            Some([MaxExtent::Unlimited, ..])
8685        ) {
8686            return Err(Error::AppendUnsupported(
8687                "append requires a dataset that is unlimited along its first dimension",
8688            ));
8689        }
8690
8691        let ChunkedGeometry {
8692            spatial,
8693            element_size,
8694            ..
8695        } = chunked_geometry(&disk_dt, &disk_ds, &dl)?;
8696        let chunk_elems = spatial[0];
8697        if chunk_elems == 0 {
8698            return Err(Error::AppendUnsupported(
8699                "append requires a nonzero chunk length",
8700            ));
8701        }
8702
8703        // Validate the appended bytes against the on-disk element type.
8704        if ab.raw.len() % element_size != 0 {
8705            return Err(Error::AppendUnsupported(
8706                "appended byte length is not a whole number of elements",
8707            ));
8708        }
8709        match &ab.elem_dt {
8710            // A typed append must match the on-disk datatype exactly (class, size,
8711            // and byte order) — this is a value append, not a retype.
8712            Some(expected) if *expected != disk_dt => {
8713                return Err(Error::AppendUnsupported(
8714                    "append datatype does not match the on-disk dataset (wrong element \
8715                     type or byte order)",
8716                ));
8717            }
8718            Some(_) => {}
8719            // A raw append trusts the caller's bytes but still refuses any datatype
8720            // whose flat little-endian bytes cannot be written verbatim: a
8721            // big-endian numeric leaf would silently misencode, and a
8722            // variable-length or reference leaf embeds heap/object addresses a byte
8723            // append cannot reproduce. A typed append is byte-order- and
8724            // class-checked by the datatype-equality arm above.
8725            None => {
8726                if !datatype_is_raw_appendable(&disk_dt) {
8727                    return Err(Error::AppendUnsupported(
8728                        "append_raw onto this dataset's datatype (non-little-endian, \
8729                         variable-length, or reference) could misencode the bytes; use a \
8730                         typed append",
8731                    ));
8732                }
8733            }
8734        }
8735
8736        let new_elems = (ab.raw.len() / element_size) as u64;
8737        let current_dim0 = disk_ds.dimensions[0];
8738        let new_dim0 = current_dim0
8739            .checked_add(new_elems)
8740            .ok_or(Error::AppendUnsupported(
8741                "append would overflow the dataset dimension",
8742            ))?;
8743
8744        // The filter pipeline is preserved verbatim in the rebuilt header; parse it
8745        // to re-encode the new chunks. An engine-unencodable filter is refused.
8746        let pipeline_message: Option<Vec<u8>> = filter.map(|(fb, fe)| region[fb..fe].to_vec());
8747        let has_filters = pipeline_message.is_some();
8748        let pipeline = match &pipeline_message {
8749            Some(pm) => {
8750                let parsed = FilterPipeline::parse(pm)?;
8751                if !pipeline_reencodable(&parsed) {
8752                    return Err(Error::AppendUnsupported(
8753                        "dataset uses a filter this engine cannot re-encode",
8754                    ));
8755                }
8756                Some(parsed)
8757            }
8758            None => None,
8759        };
8760
8761        if base.get() > src.len() {
8762            return Err(Error::AppendUnsupported(
8763                "userblock base address past end-of-file",
8764            ));
8765        }
8766        let view = BaseOffsetSource { inner: src, base };
8767
8768        // The rebuilt index's element format (bare address vs address+size+mask) is
8769        // chosen by `has_filters`; it must agree with the source index's client id,
8770        // or the kept chunks — carried by metadata into the new index — would be
8771        // re-encoded in the wrong element width.
8772        if let Some(idx_addr) = *address {
8773            let hdr = ExtensibleArrayHeader::parse_from_source(
8774                &view,
8775                idx_addr,
8776                OFFSET_SIZE,
8777                LENGTH_SIZE,
8778            )?;
8779            if (hdr.client_id == 1) != has_filters {
8780                return Err(Error::AppendUnsupported(
8781                    "dataset filter metadata is inconsistent (chunk-index client id \
8782                     disagrees with the filter pipeline)",
8783                ));
8784            }
8785        }
8786
8787        // Enumerate the existing chunks (base-relative addresses) and require a
8788        // dense grid: `plan_dense_grid` returns the chunks in index order and
8789        // `None` on any hole, duplicate, or count mismatch against the dimension.
8790        let infos = enumerate_chunks_from_source(&view, &dl, &disk_ds, OFFSET_SIZE, LENGTH_SIZE)?;
8791        let grid = plan_dense_grid(infos, &disk_ds.dimensions, &spatial).ok_or(
8792            Error::AppendUnsupported(
8793                "dataset has a sparse or inconsistent chunk grid; cannot append",
8794            ),
8795        )?;
8796        let grid_order = grid.grid_order;
8797
8798        // Complete chunks are kept by metadata; a trailing partial chunk (when the
8799        // current length is not chunk-aligned) is rewritten.
8800        let n_full = (current_dim0 / chunk_elems).to_usize()?;
8801        let has_partial = current_dim0 % chunk_elems != 0;
8802
8803        // Rewriting that chunk decodes and re-encodes committed values, which a
8804        // *lossy* pipeline does not reproduce: a ZFP block re-quantizes to fit
8805        // its new contents, and float D-scale packs offsets from a per-chunk
8806        // minimum that a smaller appended value moves (measured: 1.45 became 1.5
8807        // at one decimal). Refused here as on the immediate path (#407).
8808        if has_partial
8809            && let Some(pl) = &pipeline
8810            && !pipeline_lossless(pl)
8811        {
8812            return Err(Error::AppendUnsupported(LOSSY_TAIL_REFUSAL));
8813        }
8814
8815        let mut kept_chunks: Vec<WrittenChunk> = Vec::with_capacity(n_full);
8816        for ci in grid_order.iter().take(n_full) {
8817            kept_chunks.push(WrittenChunk {
8818                address: ci.address,
8819                compressed_size: ci.chunk_size.get(),
8820                // Preserve the source mask verbatim: a C/h5py file records a nonzero
8821                // mask for a chunk whose filter was skipped (e.g. deflate on
8822                // incompressible data), and forcing it to 0 would corrupt that chunk.
8823                filter_mask: ci.filter_mask,
8824            });
8825        }
8826
8827        // Build the raw byte region for the tail (from the last chunk boundary to
8828        // the new end): the live prefix of any rewritten partial chunk, then the
8829        // appended bytes.
8830        let mut tail_raw: Vec<u8> = Vec::new();
8831        let mut old_tail_extent: Option<(u64, u64)> = None;
8832        if has_partial {
8833            let partial = &grid_order[n_full];
8834            let len = partial.chunk_size.to_usize()?;
8835            partial
8836                .address
8837                .get()
8838                .checked_add(len as u64)
8839                .filter(|&e| e <= view.len())
8840                .ok_or(Error::AppendUnsupported(
8841                    "trailing chunk extends past end-of-file",
8842                ))?;
8843            let stored = view.read_exact_at(partial.address.get(), len)?;
8844            let full = if let Some(pl) = &pipeline {
8845                let ctx = ChunkContext::from_datatype(&spatial, &disk_dt)?;
8846                decompress_chunk(&stored, pl, ctx, partial.filter_mask).map_err(Error::Format)?
8847            } else {
8848                stored
8849            };
8850            let live_elems = (current_dim0 % chunk_elems).to_usize()?;
8851            let live_bytes = live_elems * element_size.get();
8852            if full.len() < live_bytes {
8853                return Err(Error::AppendUnsupported(
8854                    "trailing chunk decoded shorter than its live element count",
8855                ));
8856            }
8857            tail_raw.extend_from_slice(&full[..live_bytes]);
8858            // The old partial chunk's data block is dead once the new index lands.
8859            old_tail_extent = Some((base.absolute(partial.address)?, partial.chunk_size.get()));
8860        }
8861        tail_raw.extend_from_slice(&ab.raw);
8862
8863        // Split the tail into full chunk buffers and compress each through the
8864        // pipeline when filtered. The final chunk's overhang takes the dataset's
8865        // fill value, which is what a reader returns from those slots once a
8866        // later append or resize reaches them (#296).
8867        let tail_len_elems = new_dim0 - (n_full as u64) * chunk_elems;
8868        let padding = fill_msg.map_or(crate::fill_value::PaddingFill::Zero, |(mt, b, e)| {
8869            crate::fill_value::PaddingFill::from_message(mt, &region[b..e])
8870        });
8871        let split = split_into_chunks(
8872            &tail_raw,
8873            &[tail_len_elems],
8874            &spatial,
8875            element_size,
8876            padding.pattern(element_size),
8877        )
8878        .map_err(Error::Format)?;
8879        let new_chunk_bytes: Vec<Vec<u8>> = if let Some(pl) = &pipeline {
8880            let ctx = ChunkContext::from_datatype(&spatial, &disk_dt)?;
8881            let mut out = Vec::with_capacity(split.len());
8882            // One encoder across the appended tail; see `FilterScratch`.
8883            let mut scratch = FilterScratch::new();
8884            for buf in &split {
8885                out.push(compress_chunk_with(&mut scratch, buf, pl, ctx).map_err(Error::Format)?);
8886            }
8887            out
8888        } else {
8889            split
8890        };
8891
8892        // Grow the dataspace along axis 0, preserving the (unlimited) max-dims.
8893        let mut grown = disk_ds.clone();
8894        grown.dimensions[0] = new_dim0;
8895        let new_dataspace_body = grown.serialize(LENGTH_SIZE);
8896
8897        let chunk_dims_u32: Vec<u32> = spatial
8898            .iter()
8899            .map(|&dim| dim.narrow::<u32>())
8900            .collect::<Result<_, _>>()?;
8901
8902        Ok(MovingWrite::AppendedChunks {
8903            region,
8904            new_dataspace_body,
8905            chunk_dims_u32,
8906            element_size,
8907            has_filters,
8908            kept_chunks,
8909            new_chunk_bytes,
8910            old_addr: addr,
8911            old_tail_extent,
8912        })
8913    }
8914
8915    /// Parse the object header at `addr` into a copyable model, validating that
8916    /// every message can be reproduced faithfully (verbatim message bytes, with
8917    /// only the contiguous data address and child link targets repointed).
8918    /// Dense (fractal-heap) attribute storage is read out of the source heap into
8919    /// a parsed attribute set carried on the model (`dense_attrs`) and re-emitted
8920    /// into a fresh heap on write, within the bounds that heap declares (see
8921    /// `file_writer::dense_attrs_check`); an attribute too large to hold as a
8922    /// managed object is re-emitted as a *huge* object. Rejects multi-chunk
8923    /// headers, dense or soft/external links, old-version data layouts, external
8924    /// data storage ([`reject_external_storage`]), and headers that are neither a
8925    /// dataset nor a group. (A *chunked* layout is modelled, not rejected —
8926    /// [`ObjModel::DatasetChunked`].)
8927    fn read_object<S: Source + ?Sized>(
8928        src: &S,
8929        access_mode: AccessMode,
8930        addr: u64,
8931        base: BaseAddress,
8932    ) -> Result<ObjModel, Error> {
8933        let region = Self::gather_oh_messages(src, addr, base)?;
8934
8935        // Ahead of the layout classification below, where external storage and
8936        // never-allocated storage would otherwise share one undefined address
8937        // and one answer — and ahead of the dense-attribute read, so a header
8938        // this cannot copy says why rather than failing as something else.
8939        reject_external_storage(&region)?;
8940
8941        // First pass: detect whether attributes are stored densely (a defined
8942        // fractal-heap address in the Attribute Info message). A dense object is
8943        // copied by reading its attributes out of the source heap and rebuilding
8944        // a fresh heap on write, so its Attribute Info message and any inline
8945        // Attribute messages are dropped from the verbatim region — the rebuilt
8946        // region carries neither, and `dense_attrs` carries the parsed set.
8947        let mut dense = false;
8948        let mut p = 0;
8949        while let Some((msg_type, body, body_end)) = region.next_message(p)? {
8950            if msg_type == MessageType::ATTRIBUTE_INFO {
8951                // An Attribute Info message does not by itself mean dense
8952                // storage: the reference C library and h5py emit one (with an
8953                // *undefined* fractal-heap address) even for compact, inline
8954                // attributes in the latest format, to carry attribute
8955                // creation-order metadata. Only a *defined* heap address is real
8956                // dense (fractal-heap) storage. A message that cannot be parsed
8957                // is refused conservatively.
8958                let ai = crate::attribute_info::AttributeInfoMessage::parse(
8959                    &region[body..body_end],
8960                    OFFSET_SIZE,
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, access_mode, 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>, StoredAddress)> = 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::ATTRIBUTE_INFO => {
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::LINK_INFO => {
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::DATA_LAYOUT => {
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 = StoredAddress::new(u64::from_le_bytes(
9084                        kept[lbody + 2..lbody + 10].try_into().unwrap(),
9085                    ));
9086                    let data_size =
9087                        u64::from_le_bytes(kept[lbody + 10..lbody + 18].try_into().unwrap());
9088                    Ok(ObjModel::DatasetContiguous {
9089                        region: kept,
9090                        addr_off: lbody + 2,
9091                        data_addr,
9092                        data_size,
9093                        dense_attrs,
9094                    })
9095                }
9096                // Chunked: the verbatim header carries the data-layout and filter-
9097                // pipeline messages; `read_copy_subtree` (which holds the source
9098                // buffer) enumerates and captures the chunk bytes and rebuilds the
9099                // index on write.
9100                2 => Ok(ObjModel::DatasetChunked {
9101                    region: kept,
9102                    dense_attrs,
9103                }),
9104                _ => Err(Error::EditUnsupported(
9105                    "an unsupported data-layout class cannot be copied in place yet",
9106                )),
9107            }
9108        } else if has_link_info {
9109            // A copied group must carry a Group Info message so the copy stays
9110            // writable by the C library, even when the source omitted it.
9111            ensure_group_info(&mut kept)?;
9112            Ok(ObjModel::Group {
9113                non_link_region: kept,
9114                children,
9115                dense_attrs,
9116            })
9117        } else {
9118            Err(Error::EditUnsupported(
9119                "an object is neither a contiguous/compact dataset nor a group",
9120            ))
9121        }
9122    }
9123
9124    /// Read the object at `addr` in the source buffer `d` — and, for a group, its
9125    /// whole subtree — into an owned [`CopyTree`], the read half of an object copy.
9126    /// No bytes are written; this both validates that the subtree is copyable and
9127    /// captures the bytes the write half ([`write_copy_subtree`](Self::write_copy_subtree))
9128    /// later appends, so the source buffer need not outlive the read.
9129    ///
9130    /// `src` is the image the source object lives in: this session's own file image
9131    /// for an in-file [`copy`](Self::copy), or another file's image for a cross-file
9132    /// [`copy_from`](Self::copy_from). `base` is that image's userblock base (the
9133    /// session's own base for an in-file copy, always 0 for a cross-file copy, whose
9134    /// source is gated to base 0): the stored, base-relative addresses read out of
9135    /// the source headers are shifted by it to index `src`. When `cross_file` is set,
9136    /// every copied object header is additionally screened by
9137    /// [`reject_foreign_addresses`] — verbatim bytes that embed a *source-file*
9138    /// absolute address (variable-length or reference data, a committed datatype)
9139    /// would dangle in another file and are refused, whereas an in-file copy keeps
9140    /// them valid by sharing the source file's heaps and objects.
9141    fn read_copy_subtree<S: Source + ?Sized>(
9142        src: &S,
9143        access_mode: AccessMode,
9144        addr: u64,
9145        depth: u32,
9146        cross_file: bool,
9147        base: BaseAddress,
9148    ) -> Result<CopyTree, Error> {
9149        if depth >= MAX_COPY_DEPTH {
9150            return Err(Error::EditUnsupported(
9151                "copy source nests too deeply (possible hard-link cycle)",
9152            ));
9153        }
9154        // `base` is the userblock base of the image `src`: this session's own base
9155        // for an in-file copy, and always 0 for a cross-file copy (the source is
9156        // gated to base 0 in `copy_from`). `addr` is an absolute offset into `src`;
9157        // the stored (base-relative) addresses `read_object` returns for contiguous
9158        // data, chunk storage, and child links are converted to absolute offsets by
9159        // adding `base` before `src` is read or a child is descended into.
9160        match Self::read_object(src, access_mode, addr, base)? {
9161            ObjModel::DatasetVerbatim {
9162                region,
9163                dense_attrs,
9164            } => {
9165                if cross_file {
9166                    reject_foreign_addresses(&region)?;
9167                    reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9168                }
9169                Ok(CopyTree::DatasetVerbatim {
9170                    region,
9171                    dense_attrs,
9172                })
9173            }
9174            ObjModel::DatasetContiguous {
9175                region,
9176                addr_off,
9177                data_addr,
9178                data_size,
9179                dense_attrs,
9180            } => {
9181                if cross_file {
9182                    reject_foreign_addresses(&region)?;
9183                    reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9184                }
9185                // Storage the source never allocated is copied as storage, not as
9186                // the values reading it answers with. The reference library does
9187                // not allocate a contiguous dataset's data until something is
9188                // written to it, leaving the layout message's address undefined,
9189                // and reading one answers the fill value for every element (#292)
9190                // — so a copy that materialized what it read would turn a
9191                // schema-only dataset into a fully written one of the size its
9192                // shape declares. `repack` carries the same shape through as
9193                // unallocated (#293); this is the copy path's half of it (#336).
9194                //
9195                // Only reached once external storage is out of the way: it uses
9196                // this same undefined address for a dataset that *does* hold
9197                // data, and `read_object` refuses it by name above.
9198                let data = if data_addr.is_undefined(OFFSET_SIZE) {
9199                    None
9200                } else {
9201                    // The data block is read at its absolute offset into `src`.
9202                    let start = base.absolute(data_addr)?;
9203                    start
9204                        .checked_add(data_size)
9205                        .filter(|&e| e <= src.len())
9206                        .ok_or(Error::EditUnsupported("dataset data is out of bounds"))?;
9207                    let len = data_size.to_usize()?;
9208                    Some(src.read_exact_at(start, len)?)
9209                };
9210                Ok(CopyTree::DatasetContiguous {
9211                    region,
9212                    addr_off,
9213                    data,
9214                    dense_attrs,
9215                })
9216            }
9217            ObjModel::DatasetChunked {
9218                region,
9219                dense_attrs,
9220            } => {
9221                // Screen the verbatim header on the cross-file path. This refuses a
9222                // variable-length or reference datatype (whose chunk payload embeds
9223                // source-file global-heap / object addresses that would dangle in
9224                // another file) and any shared message — exactly the forms repack
9225                // also refuses for a cross-file verbatim chunk copy. An in-file copy
9226                // keeps them valid by sharing the source file's heaps.
9227                if cross_file {
9228                    reject_foreign_addresses(&region)?;
9229                    reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9230                }
9231                let ChunkedHeaderParts {
9232                    dt,
9233                    ds,
9234                    layout,
9235                    pipeline_message,
9236                } = parse_chunked_header(&region)?;
9237                let DataLayout::Chunked { index, .. } = layout else {
9238                    return Err(Error::EditUnsupported("dataset is not chunked"));
9239                };
9240                if !index.enumerable() {
9241                    return Err(Error::EditUnsupported(
9242                        "a chunked dataset with a version-2 B-tree chunk index cannot be \
9243                         copied in place yet",
9244                    ));
9245                }
9246                let ChunkedGeometry {
9247                    spatial: chunk_dims,
9248                    element_size,
9249                    raw_size: _,
9250                    maxshape,
9251                } = chunked_geometry(&dt, &ds, &layout)?;
9252
9253                // The layout's chunk-index address and every chunk address it leads
9254                // to are stored base-relative, so enumerate and read on a
9255                // base-relative view of the source image (the identity on a base-0
9256                // file). The returned addresses are then offsets into `dview`.
9257                let dview = BaseOffsetSource { inner: src, base };
9258
9259                // Enumerate the source chunks and map them onto a dense grid; a
9260                // sparse (holed/unallocated) dataset cannot be reproduced by the
9261                // verbatim layout path, which needs every grid slot filled.
9262                let infos =
9263                    enumerate_chunks_from_source(&dview, &layout, &ds, OFFSET_SIZE, LENGTH_SIZE)?;
9264                let grid = plan_dense_grid(infos, &ds.dimensions, &chunk_dims).ok_or(
9265                    Error::EditUnsupported(
9266                        "a chunked dataset with unallocated (sparse) chunks cannot be copied in place yet",
9267                    ),
9268                )?;
9269                if grid.grid_order.is_empty() {
9270                    return Err(Error::EditUnsupported(
9271                        "an empty chunked dataset cannot be copied in place yet",
9272                    ));
9273                }
9274
9275                // Capture each chunk's already-compressed bytes (no decode) into an
9276                // owned buffer, in dense row-major grid order, so the copy can be
9277                // written after the source buffer is gone (cross-file copy reads at
9278                // staging time). Sizes and masks are carried verbatim.
9279                let mut meta = Vec::with_capacity(grid.grid_order.len());
9280                let mut chunk_bytes = Vec::with_capacity(grid.grid_order.len());
9281                for ci in &grid.grid_order {
9282                    let len = ci.chunk_size.to_usize()?;
9283                    ci.address
9284                        .get()
9285                        .checked_add(len as u64)
9286                        .filter(|&e| e <= dview.len())
9287                        .ok_or(Error::EditUnsupported("chunk data is out of bounds"))?;
9288                    chunk_bytes.push(dview.read_exact_at(ci.address.get(), len)?);
9289                    meta.push(ChunkMeta {
9290                        compressed_size: ci.chunk_size.get(),
9291                        filter_mask: ci.filter_mask,
9292                    });
9293                }
9294
9295                Ok(CopyTree::DatasetChunked {
9296                    region,
9297                    shape: ds.dimensions.clone(),
9298                    chunk_dims,
9299                    element_size,
9300                    maxshape,
9301                    pipeline_message,
9302                    meta,
9303                    chunk_bytes,
9304                    dense_attrs,
9305                })
9306            }
9307            ObjModel::Group {
9308                non_link_region,
9309                children,
9310                dense_attrs,
9311            } => {
9312                if cross_file {
9313                    reject_foreign_addresses(&non_link_region)?;
9314                    reject_foreign_dense_attrs(&dense_attrs.attrs)?;
9315                }
9316                let mut kids = Vec::with_capacity(children.len());
9317                for (name, creation_order, child) in children {
9318                    // `read_copy_subtree` reads `src` at absolute offsets.
9319                    let child = base.absolute(child)?;
9320                    kids.push((
9321                        name,
9322                        creation_order,
9323                        Self::read_copy_subtree(
9324                            src,
9325                            access_mode,
9326                            child,
9327                            depth + 1,
9328                            cross_file,
9329                            base,
9330                        )?,
9331                    ));
9332                }
9333                Ok(CopyTree::Group {
9334                    non_link_region,
9335                    children: kids,
9336                    dense_attrs,
9337                })
9338            }
9339        }
9340    }
9341
9342    /// Append the fresh copies described by `node` (data blobs and headers) into
9343    /// this session at end-of-file or into reusable freed regions, returning the
9344    /// new object-header address of the copied root. The write half of an object
9345    /// copy; children are written before their parent group so each parent links
9346    /// its children's new addresses, and a contiguous dataset's data-address field
9347    /// is repointed at the freshly-written copy. Every address the copy writes into
9348    /// a header (a contiguous data block, a child link) is stored relative to the
9349    /// userblock base (`- base`, a no-op on a base-0 file); the chunked storage and
9350    /// dense attribute heaps are laid out base-relative by their own builders.
9351    fn write_copy_subtree(&mut self, node: &CopyTree) -> Result<u64, Error> {
9352        let base = self.superblock.base_address;
9353        match node {
9354            CopyTree::DatasetVerbatim {
9355                region,
9356                dense_attrs,
9357            } => {
9358                let mut region = region.clone();
9359                self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9360                let oh = build_v2_object_header(&region)?;
9361                self.alloc_or_append_typed(&oh, PageType::Meta)
9362            }
9363            CopyTree::DatasetContiguous {
9364                region,
9365                addr_off,
9366                data,
9367                dense_attrs,
9368            } => {
9369                let mut region = region.clone();
9370                // Storage the source never allocated is copied as storage: there
9371                // is no block to place, and the region already carries the
9372                // undefined address the source stored, so the copy declares the
9373                // same empty storage (issue #336).
9374                if let Some(data) = data {
9375                    let new_data_addr = self.alloc_or_append_typed(data, PageType::Raw)?;
9376                    // The placement is an absolute offset; the data-layout
9377                    // address field stores it relative to the userblock base.
9378                    let relative = base.relative(new_data_addr)?.get();
9379                    region.bytes_mut()[*addr_off..*addr_off + 8]
9380                        .copy_from_slice(&relative.to_le_bytes());
9381                }
9382                // The dense heap is placed independently of the data — it is
9383                // built for whatever address it gets (see `append_dense_attrs`),
9384                // so no ordering between the two is owed.
9385                self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9386                let oh = build_v2_object_header(&region)?;
9387                self.alloc_or_append_typed(&oh, PageType::Meta)
9388            }
9389            CopyTree::DatasetChunked {
9390                region,
9391                shape,
9392                chunk_dims,
9393                element_size,
9394                maxshape,
9395                pipeline_message,
9396                meta,
9397                chunk_bytes,
9398                dense_attrs,
9399            } => self.write_chunked_relocatable(
9400                region,
9401                shape,
9402                chunk_dims,
9403                *element_size,
9404                maxshape.as_deref(),
9405                pipeline_message.as_deref(),
9406                meta,
9407                chunk_bytes,
9408                dense_attrs.clone(),
9409            ),
9410            CopyTree::Group {
9411                non_link_region,
9412                children,
9413                dense_attrs,
9414            } => {
9415                let mut region = non_link_region.clone();
9416                for (name, creation_order, child) in children {
9417                    let new_child = self.write_copy_subtree(child)?;
9418                    // The link target is stored relative to the userblock base.
9419                    // A copied link keeps the creation index the source recorded
9420                    // for it, so a copy of a group that tracks link creation
9421                    // order carries the same order as its source — the Link Info
9422                    // message naming that order is copied verbatim beside it.
9423                    region.push_link(name, base.relative(new_child)?, *creation_order);
9424                }
9425                // The dense heap is built for whatever address it is placed at
9426                // (see `append_dense_attrs`), so it needs no ordering against the
9427                // children's headers and data.
9428                self.append_dense_attrs(&mut region, dense_attrs.clone())?;
9429                let oh = build_v2_object_header(&region)?;
9430                self.alloc_or_append_typed(&oh, PageType::Meta)
9431            }
9432        }
9433    }
9434
9435    /// Write a chunked dataset's storage and return its new object-header address
9436    /// — the shared write half of a chunked copy ([`CopyTree::DatasetChunked`])
9437    /// and a relocating chunked overwrite ([`MovingWrite::Chunked`]).
9438    ///
9439    /// A fresh chunk-data blob and index are laid out relocatably via
9440    /// [`plan_chunked_data_verbatim`] / [`emit_chunked_data_verbatim`], pulling
9441    /// each chunk's already-compressed bytes from `chunk_bytes` (in dense
9442    /// row-major grid order) and carrying `meta`'s sizes and filter masks and the
9443    /// source `pipeline_message` verbatim — no recompression, no filter-parameter
9444    /// reconstruction. Like [`build_chunked_dataset`](Self::build_chunked_dataset)
9445    /// the blob is sized from its plan before it is placed, so it can go into a
9446    /// freed region that fits it as readily as at end-of-file. The verbatim header
9447    /// `region`'s data-layout message is then swapped for the one the planner
9448    /// produced (every other message preserved), any dense attribute heap is
9449    /// placed, and the header is written into reusable freed space or at
9450    /// end-of-file.
9451    #[expect(
9452        clippy::too_many_arguments,
9453        reason = "the chunked rebuild needs the full geometry, \
9454        pipeline, and chunk payloads; bundling them into a struct would only move the list"
9455    )]
9456    fn write_chunked_relocatable(
9457        &mut self,
9458        region: &OhRegion,
9459        shape: &[u64],
9460        chunk_dims: &[u64],
9461        element_size: NonZeroUsize,
9462        maxshape: Option<&[MaxExtent]>,
9463        pipeline_message: Option<&[u8]>,
9464        meta: &[ChunkMeta],
9465        chunk_bytes: &[Vec<u8>],
9466        dense_attrs: DenseAttrSet,
9467    ) -> Result<u64, Error> {
9468        // Plan once at a provisional base purely to size the data region: the plan
9469        // walks chunk *sizes* and sizes the index from its layout, so it touches
9470        // no bytes at all, and every address it embeds sits in a fixed-width
9471        // field, so its total length is the same wherever the blob lands. That is
9472        // what lets the address be chosen — a freed region or end-of-file — before
9473        // the bytes exist. This plan is discarded; the one built at the real base
9474        // below is what the emit works from.
9475        let sizing = plan_chunked_data_verbatim(
9476            meta,
9477            shape,
9478            chunk_dims,
9479            element_size,
9480            pipeline_message,
9481            StoredAddress::new(0),
9482            maxshape,
9483        )?;
9484        let (_addr, layout_message) =
9485            self.place_relocatable(sizing.plan.total_len, PageType::Raw, |stored_base| {
9486                // Re-plan at the address the blob really occupies, so its embedded
9487                // addresses resolve to their real file offsets once the reader adds
9488                // the userblock base back (see `build_chunked_dataset`).
9489                let layout = plan_chunked_data_verbatim(
9490                    meta,
9491                    shape,
9492                    chunk_dims,
9493                    element_size,
9494                    pipeline_message,
9495                    stored_base,
9496                    maxshape,
9497                )?;
9498                let mut buf =
9499                    Vec::with_capacity(usize::try_from(layout.plan.total_len).unwrap_or(0));
9500                emit_chunked_data_verbatim(
9501                    &mut buf,
9502                    &layout.plan,
9503                    &SliceChunkProvider {
9504                        chunks: chunk_bytes,
9505                    },
9506                )?;
9507                Ok((buf, layout.layout_message))
9508            })?;
9509        // Swap the data-layout message for the rebuilt one; keep every other header
9510        // message (datatype, dataspace, fill value, filter pipeline, attributes)
9511        // verbatim.
9512        let mut new_region = replace_layout_message(region, &layout_message)?;
9513        self.append_dense_attrs(&mut new_region, dense_attrs)?;
9514        let oh = build_v2_object_header(&new_region)?;
9515        self.alloc_or_append_typed(&oh, PageType::Meta)
9516    }
9517
9518    /// When `attrs` is non-empty, build a fresh dense (fractal-heap) attribute
9519    /// blob for it, place it, and splice the matching Attribute Info message onto
9520    /// `region`. A no-op for an empty set.
9521    ///
9522    /// The blob produced by [`DenseAttrPlan::build`] is fully
9523    /// relocatable: every address it embeds is `base + fixed offset`, and its
9524    /// length is the same for every base, so it can go into a freed metadata
9525    /// region as readily as at end-of-file — the base it is built for is whichever
9526    /// address it gets. The reservation comes from the plan the blob is then
9527    /// built from, so sizing it costs no bytes. The freshly built heap is always
9528    /// same-file, so it never aliases the source heap even for an in-file copy.
9529    /// The caller has already validated [`dense_attrs_check`].
9530    ///
9531    /// [`DenseAttrPlan::build`]: crate::file_writer::DenseAttrPlan::build
9532    /// [`dense_attrs_check`]: crate::file_writer::dense_attrs_check
9533    fn append_dense_attrs(
9534        &mut self,
9535        region: &mut OhRegion,
9536        set: DenseAttrSet,
9537    ) -> Result<(), Error> {
9538        if set.is_empty() {
9539            return Ok(());
9540        }
9541        let attr_info_message = self.place_dense_attrs(&set.attrs, set.creation)?;
9542        region.push(MessageType::ATTRIBUTE_INFO, &attr_info_message);
9543        Ok(())
9544    }
9545
9546    /// Place dense attribute storage for a staged dataset whose set needs it,
9547    /// returning the Attribute Info message naming the heap — or `None` when the
9548    /// set belongs in the object header, where the header builders write it
9549    /// inline. Which it is was decided and validated when the dataset was staged
9550    /// ([`FlatDataset::attrs_are_dense`]).
9551    fn place_dense_attrs_if_needed(&mut self, fd: &FlatDataset) -> Result<Option<Vec<u8>>, Error> {
9552        if !fd.attrs_are_dense {
9553            return Ok(None);
9554        }
9555        self.place_dense_attrs(&fd.attrs, DenseAttrCreationOrder::Untracked)
9556            .map(Some)
9557    }
9558
9559    /// Build a fresh dense attribute blob for `attrs`, place it, and return the
9560    /// Attribute Info message naming it — the header's whole share of dense
9561    /// storage. See [`append_dense_attrs`](Self::append_dense_attrs) for why the
9562    /// blob may be placed anywhere.
9563    fn place_dense_attrs(
9564        &mut self,
9565        attrs: &[crate::attribute::AttributeMessage],
9566        creation: DenseAttrCreationOrder,
9567    ) -> Result<Vec<u8>, Error> {
9568        let plan = crate::file_writer::dense_attrs_plan(attrs, creation);
9569        let (_addr, attr_info_message) =
9570            self.place_relocatable(plan.blob_len(), PageType::Meta, |stored_base| {
9571                let blob = plan.build(stored_base);
9572                Ok((blob.blob, blob.attr_info_message))
9573            })?;
9574        Ok(attr_info_message)
9575    }
9576
9577    /// Resolve what an attribute edit left for this phase ([`plan_attr_ops`]) and
9578    /// append what names it to the object's message `region`.
9579    ///
9580    /// Both arms place a variable-length attribute's global heap collection and
9581    /// patch the placeholder references in its message; where they differ is what
9582    /// carries the result. A compact attribute becomes an inline Attribute
9583    /// message; a dense set is built into a fresh heap the header then names.
9584    ///
9585    /// For the dense arm the patching has to come first: the heap stores each
9586    /// attribute's *message bytes*, references and all, so a heap built before
9587    /// them would hold the placeholders — the same ordering the whole-file writer
9588    /// keeps for the same reason.
9589    ///
9590    /// A no-op for an edit that resolved entirely in the preflight, which is what
9591    /// makes it safe to call on every rebuilt header.
9592    fn place_edited_attrs(
9593        &mut self,
9594        region: &mut OhRegion,
9595        attrs: EditedAttrs,
9596    ) -> Result<(), Error> {
9597        match attrs {
9598            EditedAttrs::Compact(pending) => {
9599                for pending in pending {
9600                    let PendingVlAttr {
9601                        mut msg,
9602                        collections,
9603                        creation_index,
9604                    } = pending;
9605                    let addrs = self.place_vl_collections(&collections)?;
9606                    patch_vl_refs(&mut msg.raw_data, &addrs);
9607                    *region = put_attr_message(
9608                        region,
9609                        &msg.name,
9610                        &msg.serialize(LENGTH_SIZE),
9611                        creation_index,
9612                    )?;
9613                }
9614                Ok(())
9615            }
9616            EditedAttrs::Dense(mut dense) => {
9617                for (idx, collections) in std::mem::take(&mut dense.vl) {
9618                    let addrs = self.place_vl_collections(&collections)?;
9619                    patch_vl_refs(&mut dense.set.attrs[idx].raw_data, &addrs);
9620                }
9621                self.append_dense_attrs(region, dense.set)
9622            }
9623        }
9624    }
9625
9626    /// Apply a relocating value overwrite (`write_dataset` resize / compact
9627    /// rewrite): write the new data and a rewritten object header at end-of-file
9628    /// (or into reusable freed space) and return the new header address. The
9629    /// caller patches the parent group's link to this address. The old data
9630    /// extent (for a resized contiguous dataset) is freed separately, after the
9631    /// commit's superblock repoint, so it is never reused mid-commit.
9632    fn write_moving(&mut self, mw: &MovingWrite) -> Result<u64, Error> {
9633        let base = self.superblock.base_address;
9634        match mw {
9635            MovingWrite::Contiguous {
9636                region,
9637                addr_off,
9638                bytes,
9639                ..
9640            } => {
9641                let raw = self.resolve_overwrite_bytes(bytes)?;
9642                let new_data_addr = self.alloc_or_append_typed(&raw, PageType::Raw)?;
9643                let mut region = region.clone();
9644                // The placement is an absolute file offset; the contiguous
9645                // data-layout field stores it relative to the userblock base (`-
9646                // base`, a no-op on a base-0 file).
9647                let relative = base.relative(new_data_addr)?.get();
9648                region.bytes_mut()[*addr_off..*addr_off + 8]
9649                    .copy_from_slice(&relative.to_le_bytes());
9650                // The data size field follows the 8-byte address in the contiguous
9651                // layout body; keep it in sync with the new length.
9652                let size_off = *addr_off + 8;
9653                let raw_len = raw.len() as u64;
9654                region.bytes_mut()[size_off..size_off + 8].copy_from_slice(&raw_len.to_le_bytes());
9655                let oh = build_v2_object_header(&region)?;
9656                self.alloc_or_append_typed(&oh, PageType::Meta)
9657            }
9658            MovingWrite::Compact { region, bytes } => {
9659                let raw = self.resolve_overwrite_bytes(bytes)?;
9660                let region = rebuild_compact_layout_region(region, &raw)?;
9661                let oh = build_v2_object_header(&region)?;
9662                self.alloc_or_append_typed(&oh, PageType::Meta)
9663            }
9664            MovingWrite::Chunked {
9665                region,
9666                shape,
9667                chunk_dims,
9668                element_size,
9669                maxshape,
9670                pipeline_message,
9671                payload,
9672                ..
9673            } => {
9674                let deferred;
9675                let chunk_bytes = match payload {
9676                    ChunkPayload::Encoded(chunk_bytes) => chunk_bytes,
9677                    ChunkPayload::Deferred { bytes, padding, dt } => {
9678                        // Resolve before splitting: the heap collections have to
9679                        // be placed, and their addresses patched into the element
9680                        // references, before those references are cut into chunks
9681                        // and — on a filtered dataset — compressed over.
9682                        let raw = self.resolve_overwrite_bytes(bytes)?;
9683                        deferred = split_and_encode_chunks(
9684                            &raw,
9685                            shape,
9686                            chunk_dims,
9687                            *element_size,
9688                            padding,
9689                            pipeline_message.as_deref(),
9690                            dt,
9691                        )?;
9692                        &deferred
9693                    }
9694                };
9695                let meta: Vec<ChunkMeta> = chunk_bytes
9696                    .iter()
9697                    .map(|c| ChunkMeta {
9698                        compressed_size: c.len() as u64,
9699                        filter_mask: 0,
9700                    })
9701                    .collect();
9702                self.write_chunked_relocatable(
9703                    region,
9704                    shape,
9705                    chunk_dims,
9706                    *element_size,
9707                    maxshape.as_deref(),
9708                    pipeline_message.as_deref(),
9709                    &meta,
9710                    chunk_bytes,
9711                    DenseAttrSet::default(),
9712                )
9713            }
9714            MovingWrite::AppendedChunks {
9715                region,
9716                new_dataspace_body,
9717                chunk_dims_u32,
9718                element_size,
9719                has_filters,
9720                kept_chunks,
9721                new_chunk_bytes,
9722                ..
9723            } => self.write_appended_chunks(
9724                region,
9725                new_dataspace_body,
9726                chunk_dims_u32,
9727                *element_size,
9728                *has_filters,
9729                kept_chunks,
9730                new_chunk_bytes,
9731            ),
9732            MovingWrite::AttrEdit { region, attrs } => {
9733                // `region` already carries what the commit preflight could resolve;
9734                // `place_edited_attrs` places the rest, exactly as the
9735                // group-attribute apply loop does. Then build and place the
9736                // relocated dataset header: the data-layout message is untouched,
9737                // so the dataset's chunk data and index stay in place and only the
9738                // header moves.
9739                let mut region = region.clone();
9740                self.place_edited_attrs(&mut region, attrs.clone())?;
9741                let oh = build_v2_object_header(&region)?;
9742                self.alloc_or_append_typed(&oh, PageType::Meta)
9743            }
9744        }
9745    }
9746
9747    /// Apply a relocating append ([`MovingWrite::AppendedChunks`]): place the new
9748    /// (and any rewritten trailing) chunk bytes, rebuild a fresh
9749    /// Extensible Array over the kept plus appended chunks, grow the dataspace and
9750    /// repoint the data layout in the verbatim header `region`, and write the
9751    /// relocated header. Returns the new header address; the caller patches the
9752    /// parent link. The kept chunk data is untouched (referenced by both the old
9753    /// and new index during the commit); the old index/header/trailing chunk are
9754    /// freed only after the superblock repoint.
9755    ///
9756    /// The new chunks and the new index go down together where one freed region
9757    /// holds them and a paged file is what makes that worth asking, and one at a
9758    /// time otherwise; the body says why.
9759    #[expect(
9760        clippy::too_many_arguments,
9761        reason = "the append rebuild needs the header region, grown dataspace, chunk \
9762        geometry, and both chunk sets; bundling them into a struct would only move the list"
9763    )]
9764    fn write_appended_chunks(
9765        &mut self,
9766        region: &OhRegion,
9767        new_dataspace_body: &[u8],
9768        chunk_dims_u32: &[u32],
9769        element_size: NonZeroUsize,
9770        has_filters: bool,
9771        kept_chunks: &[WrittenChunk],
9772        new_chunk_bytes: &[Vec<u8>],
9773    ) -> Result<u64, Error> {
9774        let base = self.superblock.base_address;
9775        // Where the appended chunks and the rebuilt index go, and whether they go
9776        // together.
9777        //
9778        // Contiguity is worth something on a *paged* file. The index goes in a raw
9779        // page, not a metadata one, because that is where this crate puts every
9780        // chunk index — and the reclaim side can only tell a raw index from the
9781        // metadata one the reference library writes by the chunk data it abuts
9782        // ([`index_is_provably_raw`](Self::index_is_provably_raw)). An index that
9783        // abuts none of its chunks cannot be placed by the commit that supersedes
9784        // it, so its bytes are held as dead until the page around them empties.
9785        // Laying the new chunks and the new index down as one blob — the way the
9786        // from-scratch writer and `write_chunked_relocatable` lay out chunked
9787        // storage — keeps that proof available (issue #388).
9788        //
9789        // It is worth nothing on a file that is not paged, which has no page types
9790        // to keep apart, and it must never be bought with reuse: one blob needs one
9791        // freed region big enough for the lot, where the chunks placed one at a
9792        // time fill several smaller ones. So the blob form is taken only when a
9793        // single region already holds it, and otherwise each chunk is placed on its
9794        // own and the index after them. That fallback keeps the proof only where
9795        // the last chunk placed ends up immediately below the index — which is what
9796        // happens when both append at end-of-file, and not what happens when the
9797        // chunks find holes to sit in. An index left abutting nothing is recorded
9798        // as dead rather than free, which costs space and never correctness.
9799        //
9800        // Sizing runs at a provisional base, exactly as `write_chunked_relocatable`
9801        // sizes its blob: an Extensible Array's length follows the slot layout and
9802        // every address it embeds sits in a fixed-width field, so the total is the
9803        // same wherever the array lands, and `place` rejects any disagreement.
9804        //
9805        // The element width comes from the chunk geometry rather than from the
9806        // chunk list, so a rebuild declares the same width the original index did —
9807        // including when the dataset it grows was created empty.
9808        let chunk_bytes =
9809            full_chunk_bytes(chunk_dims_u32.iter().map(|&d| u64::from(d)), element_size);
9810        // Rank 1 and unlimited along axis 0 (`prepare_append` refuses anything
9811        // else), so every chunk's index slot is its position in the grid and the
9812        // array is dense from zero.
9813        //
9814        // This engine applies every filter to a new chunk (no per-chunk skipping),
9815        // so an appended chunk's mask is always 0. Kept chunks carry their own
9816        // (possibly nonzero) mask.
9817        let chunk_total: u64 = new_chunk_bytes.iter().map(|cb| cb.len() as u64).sum();
9818        let placed_chunks = |blob_stored: StoredAddress| -> Vec<WrittenChunk> {
9819            let mut combined: Vec<WrittenChunk> = kept_chunks.to_vec();
9820            let mut next = blob_stored;
9821            for cb in new_chunk_bytes {
9822                combined.push(WrittenChunk {
9823                    address: next,
9824                    compressed_size: cb.len() as u64,
9825                    filter_mask: 0,
9826                });
9827                next = next.offset(cb.len() as u64);
9828            }
9829            combined
9830        };
9831        let sizing = placed_chunks(StoredAddress::new(0));
9832        let ea_len = extensible_array_len(
9833            &crate::chunked_write::IndexSlots::dense(&sizing),
9834            chunk_bytes,
9835            OFFSET_SIZE,
9836            LENGTH_SIZE,
9837            has_filters,
9838        );
9839        let ea = |slots: &crate::chunked_write::IndexSlots<'_>,
9840                  at: StoredAddress|
9841         -> Result<Vec<u8>, Error> {
9842            build_extensible_array_at(
9843                slots,
9844                chunk_bytes,
9845                OFFSET_SIZE,
9846                LENGTH_SIZE,
9847                has_filters,
9848                at,
9849            )
9850            .map_err(Error::Format)
9851        };
9852        // Reserved by hand rather than through `reserve`, which would fall back to
9853        // end-of-file: this asks only whether a freed region holds the whole blob,
9854        // and takes the per-chunk path when none does.
9855        let blob = match self.paged {
9856            Some(_) => self.alloc_free(chunk_total + ea_len, PageType::Raw),
9857            None => None,
9858        };
9859        let ea_stored = match blob {
9860            Some(addr) => {
9861                // The region is out of the free lists from here, so every way the
9862                // build can fail has to put it back: a `?` straight out of this arm
9863                // would leave it neither free nor written for the rest of the
9864                // session. Collected into a `Result` and handed back on the way out.
9865                let placed = (|| -> Result<StoredAddress, Error> {
9866                    let blob_stored = base.relative(addr)?;
9867                    let combined = placed_chunks(blob_stored);
9868                    let mut buf =
9869                        Vec::with_capacity(usize::try_from(chunk_total + ea_len).unwrap_or(0));
9870                    for cb in new_chunk_bytes {
9871                        buf.extend_from_slice(cb);
9872                    }
9873                    buf.extend_from_slice(&ea(
9874                        &crate::chunked_write::IndexSlots::dense(&combined),
9875                        blob_stored.offset(chunk_total),
9876                    )?);
9877                    self.place(
9878                        Placement::Reused {
9879                            addr,
9880                            len: chunk_total + ea_len,
9881                        },
9882                        &buf,
9883                    )?;
9884                    Ok(blob_stored.offset(chunk_total))
9885                })();
9886                match placed {
9887                    Ok(ea_stored) => ea_stored,
9888                    Err(e) => {
9889                        self.release_raw_alloc(addr, chunk_total + ea_len);
9890                        return Err(e);
9891                    }
9892                }
9893            }
9894            None => {
9895                // A chunk embeds no addresses of its own, so it can go anywhere and
9896                // the index below simply records where it went.
9897                let mut combined: Vec<WrittenChunk> = kept_chunks.to_vec();
9898                for cb in new_chunk_bytes {
9899                    let abs = self.alloc_or_append_typed(cb, PageType::Raw)?;
9900                    combined.push(WrittenChunk {
9901                        address: base.relative(abs)?,
9902                        compressed_size: cb.len() as u64,
9903                        filter_mask: 0,
9904                    });
9905                }
9906                let (ea_addr, ()) = self.place_relocatable(ea_len, PageType::Raw, |at| {
9907                    Ok((
9908                        ea(&crate::chunked_write::IndexSlots::dense(&combined), at)?,
9909                        (),
9910                    ))
9911                })?;
9912                base.relative(ea_addr)?
9913            }
9914        };
9915
9916        // Swap the dataspace (grown) and data-layout (repointed at the new index)
9917        // messages; every other header message is preserved verbatim.
9918        #[expect(
9919            clippy::cast_possible_truncation,
9920            reason = "element size is a datatype byte width that fits u32"
9921        )]
9922        let layout_body = serialize_v4_extensible_array(
9923            chunk_dims_u32,
9924            ea_stored,
9925            OFFSET_SIZE,
9926            element_size.get() as u32,
9927        );
9928        let region = replace_dataspace_message(region, new_dataspace_body)?;
9929        let region = replace_layout_message(&region, &layout_body)?;
9930        let oh = build_v2_object_header(&region)?;
9931        self.alloc_or_append_typed(&oh, PageType::Meta)
9932    }
9933
9934    /// Append `bytes` at end-of-file, returning the absolute address they were
9935    /// written at.
9936    fn append(&mut self, bytes: &[u8]) -> Result<u64, Error> {
9937        self.image.append(bytes)
9938    }
9939
9940    /// Overwrites bytes in place at `offset`. The caller guarantees the range
9941    /// already exists.
9942    fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<(), Error> {
9943        self.image.write_at(offset, bytes)
9944    }
9945
9946    /// Ensure the next allocation begins in a page holding page type `ty`, on a
9947    /// paged file. A no-op on the common non-paged file.
9948    ///
9949    /// A paged file never mixes metadata and raw data within one page, so when the
9950    /// tail page holds the *other* type and is only partially filled it is first
9951    /// padded to a page boundary, the padding being recorded as free space of the
9952    /// outgoing type.
9953    ///
9954    /// Call this **before** reading the image's end-of-file ([`Source::len`]) to compute an
9955    /// address that will be embedded in the bytes being built: several callers
9956    /// (the chunk blob, the extensible-array index, the dense-attribute blob)
9957    /// build content whose interior addresses assume it lands at the current
9958    /// end-of-file, and padding inserted after that read would shift the landing
9959    /// address out from under them.
9960    fn begin_page(&mut self, ty: PageType) -> Result<(), Error> {
9961        // Destructure so the page state and the image are borrowed as the
9962        // separate fields they are.
9963        let Self { image, paged, .. } = self;
9964        match paged.as_mut() {
9965            Some(pg) => pg.begin(image.as_mut(), ty),
9966            None => Ok(()),
9967        }
9968    }
9969
9970    /// Place `bytes` as page type `ty`, reusing a free region where one fits.
9971    /// Equivalent to [`reserve`](Self::reserve) followed by [`place`](Self::place),
9972    /// for the callers whose bytes are already built.
9973    fn alloc_or_append_typed(&mut self, bytes: &[u8], ty: PageType) -> Result<u64, Error> {
9974        let at = self.reserve(bytes.len() as u64, ty)?;
9975        self.place(at, bytes)
9976    }
9977
9978    /// Choose where `len` bytes of page type `ty` will go: a reusable free region
9979    /// left by a prior commit, or the current end-of-file. The returned
9980    /// [`Placement`] must be handed to [`place`](Self::place) with exactly `len`
9981    /// bytes.
9982    ///
9983    /// Splitting the choice from the write is what lets a *relocatable* blob — one
9984    /// whose interior addresses are all `base + fixed offset`, like a chunked
9985    /// dataset's data region or a dense attribute heap — be built for a freed
9986    /// region rather than only for end-of-file: its size is known from its plan
9987    /// before its bytes exist, so the address can be settled first
9988    /// ([`place_relocatable`](Self::place_relocatable) is that sequence).
9989    ///
9990    /// Reuse only ever draws from free space vacated by *earlier* commits in this
9991    /// session (or seeded from the on-disk managers at open) — never space the
9992    /// current commit is about to free, which stays in its `to_free` list until
9993    /// after the superblock repoint. The bytes it overwrites are therefore already
9994    /// unreachable from the on-disk root, so a mid-commit crash cannot corrupt the
9995    /// live tree: the superblock still points at the prior, intact root.
9996    ///
9997    /// A paged file reuses within the matching page type, or out of a page holding
9998    /// nothing at all, which belongs to no type ([`PagedEdit::alloc_typed`]); every
9999    /// page stays homogeneous either way. It never opens a page for a reused region
10000    /// — the tail page is untouched by a write into the middle of the file.
10001    fn reserve(&mut self, len: u64, ty: PageType) -> Result<Placement, Error> {
10002        if let Some(addr) = self.alloc_free(len, ty) {
10003            return Ok(Placement::Reused { addr, len });
10004        }
10005        self.begin_page(ty)?;
10006        Ok(Placement::Appended {
10007            addr: self.image.len(),
10008            len,
10009        })
10010    }
10011
10012    /// Draw `len` bytes from the free space of page type `ty`, or `None` when no
10013    /// single region is large enough.
10014    ///
10015    /// A non-paged file keeps one list for the whole file. A paged file keeps one
10016    /// per page type and must be served from the list matching `ty`: handing a
10017    /// metadata hole to raw data (or the reverse) would mix the two within a page,
10018    /// which is the single invariant the paged strategy exists to hold. A page
10019    /// holding nothing at all is the exception, and
10020    /// [`alloc_typed`](PagedEdit::alloc_typed) is where it is spent.
10021    fn alloc_free(&mut self, len: u64, ty: PageType) -> Option<u64> {
10022        let Some(pg) = self.paged.as_mut() else {
10023            return self.free.alloc(len);
10024        };
10025        pg.alloc_typed(len, ty)
10026    }
10027
10028    /// Hand `[addr, addr + len)`, drawn from
10029    /// [`alloc_free`](Self::alloc_free) with [`PageType::Raw`], back to the list
10030    /// it came from — for a placement refused *after* the draw, which would
10031    /// otherwise hold the region for the rest of the session without writing
10032    /// anything into it.
10033    ///
10034    /// A paged file takes it back into the raw list whichever list served it: a
10035    /// whole page [`PagedEdit::alloc_typed`] claimed from the metadata side for
10036    /// raw data is raw from the moment it is claimed, and that call already
10037    /// returns its alignment tail there. A page that ends up wholly free is
10038    /// promoted out of either list by
10039    /// [`promote_whole_free_pages`](PagedEdit::promote_whole_free_pages) as usual.
10040    fn release_raw_alloc(&mut self, addr: u64, len: u64) {
10041        match self.paged.as_mut() {
10042            Some(pg) => pg.raw.free(addr, len),
10043            None => self.free.free(addr, len),
10044        }
10045    }
10046
10047    /// Write `bytes` at the address [`reserve`](Self::reserve) handed out,
10048    /// returning that address.
10049    ///
10050    /// The length is checked rather than asserted: a reused region is sized to the
10051    /// reservation and is followed by live bytes, so writing more than was reserved
10052    /// would silently destroy a neighboring object. That makes it the one internal
10053    /// miscount worth paying a comparison to catch in every build.
10054    fn place(&mut self, at: Placement, bytes: &[u8]) -> Result<u64, Error> {
10055        if bytes.len() as u64 != at.len() {
10056            return Err(Error::Format(FormatError::SerializationError(format!(
10057                "a placement reserved {} bytes but built {}",
10058                at.len(),
10059                bytes.len()
10060            ))));
10061        }
10062        match at {
10063            Placement::Reused { addr, .. } => {
10064                self.write_at(addr, bytes)?;
10065                Ok(addr)
10066            }
10067            Placement::Appended { addr, .. } => {
10068                let written = self.append(bytes)?;
10069                debug_assert_eq!(
10070                    written, addr,
10071                    "an appended placement must land at end-of-file"
10072                );
10073                Ok(written)
10074            }
10075        }
10076    }
10077
10078    /// Place a *relocatable* blob of `len` bytes as page type `ty`: pick its
10079    /// address first, then build it for that address with `build`, then write it
10080    /// there. Returns the address and whatever else `build` produced (typically the
10081    /// object-header message naming the blob, which embeds the same address).
10082    ///
10083    /// `build` receives the address the blob will occupy. Every address the blob
10084    /// embeds is written in that same form, which a reader resolves against the
10085    /// file's base address.
10086    ///
10087    /// `len` must be the length `build` will produce; [`place`](Self::place)
10088    /// rejects a mismatch. For every blob placed this way the length is a function
10089    /// of the content alone — the addresses sit in fixed-width fields — so every
10090    /// caller derives it, and none builds the blob to measure it:
10091    /// [`DenseAttrPlan::blob_len`](crate::file_writer::DenseAttrPlan::blob_len)
10092    /// for a dense attribute heap, [`extensible_array_len`] for an appended
10093    /// chunk index, and [`chunked_data_len`] / [`plan_chunked_data_verbatim`]
10094    /// for a whole chunked data region, both of which size their index through
10095    /// [`chunk_index_len`](crate::chunked_write::chunk_index_len).
10096    fn place_relocatable<T>(
10097        &mut self,
10098        len: u64,
10099        ty: PageType,
10100        build: impl FnOnce(StoredAddress) -> Result<(Vec<u8>, T), Error>,
10101    ) -> Result<(u64, T), Error> {
10102        let at = self.reserve(len, ty)?;
10103        let (bytes, extra) = build(self.superblock.base_address.relative(at.address())?)?;
10104        let addr = self.place(at, &bytes)?;
10105        Ok((addr, extra))
10106    }
10107
10108    /// Place one variable-length dataset's or attribute's already-built,
10109    /// self-contained global heap collections (from
10110    /// [`build_global_heap_collections`] or a
10111    /// [`VlStringStaging::collections`]) and return, in the same order, the
10112    /// base-relative addresses its variable-length references should be patched
10113    /// to. A `GCOL` blob embeds no addresses of its own, so it can be appended
10114    /// (or dropped into reused free space) at any point in the apply loop,
10115    /// unlike a group or dataset header, which must be built last so it can name
10116    /// its children's real addresses. Each collection is placed independently,
10117    /// so they need not land contiguously.
10118    fn place_vl_collections(&mut self, collections: &[Vec<u8>]) -> Result<Vec<u64>, Error> {
10119        collections
10120            .iter()
10121            .map(|collection| {
10122                let addr = self.alloc_or_append_typed(collection, PageType::Meta)?;
10123                Ok(self.superblock.base_address.relative(addr)?.get())
10124            })
10125            .collect()
10126    }
10127
10128    /// Resolve a staged value overwrite's element bytes: place any global heap
10129    /// collections its variable-length references name, patch their addresses
10130    /// in, and return the bytes ready to write (issue #321). An overwrite that
10131    /// stages nothing — every one but a `with_vlen_strings` one — is handed
10132    /// back untouched.
10133    ///
10134    /// This allocates, so every caller is in the apply phase — the preflight
10135    /// that chose the plan only reads.
10136    ///
10137    /// It also carries the reclaim. The collections a *previous* overwrite of
10138    /// this same path placed are handed to the commit's free list, and the ones
10139    /// placed here are recorded in their stead, so rotating a dataset's strings
10140    /// reaches a steady state instead of leaking a generation per commit. Only
10141    /// collections this session placed are ever reclaimed, and only while
10142    /// nothing has been able to name them twice — see
10143    /// [`vl_overwrite_heaps`](Self::vl_overwrite_heaps) for why that provenance
10144    /// is the whole proof, and `repack` for the collections it cannot make.
10145    fn resolve_overwrite_bytes<'b>(
10146        &mut self,
10147        bytes: &'b OverwriteBytes,
10148    ) -> Result<Cow<'b, [u8]>, Error> {
10149        let Some(vlen) = &bytes.vlen else {
10150            // Nothing to resolve, and nothing to copy: every overwrite but a
10151            // variable-length one takes this path, and the in-place loop writes
10152            // straight through the borrow. Cloning here would charge each of
10153            // them a second copy of its whole payload, which the bounded
10154            // backing in particular is built not to pay.
10155            return Ok(Cow::Borrowed(&bytes.raw));
10156        };
10157        let staging = &vlen.staging;
10158        // Read rather than removed: the record is replaced (not consumed) when
10159        // the new collections land just below.
10160        let superseded = self
10161            .vl_overwrite_heaps
10162            .get(&vlen.path)
10163            .cloned()
10164            .unwrap_or_default();
10165        let mut raw = bytes.raw.clone();
10166        let base = self.superblock.base_address;
10167        // A staging with no collections patches nothing, which is the right
10168        // answer for it: an element with no heap object keeps the zero address
10169        // that reads back as null. It falls out of the general path rather than
10170        // being a case of its own — `patch_vl_refs_masked` has one offset per
10171        // object, so no offsets is no iterations — and it still records (an
10172        // empty record) and still supersedes.
10173        let addrs = self.place_vl_collections(&staging.collections)?;
10174        patch_vl_refs_masked(&mut raw, &staging.patch_offsets, &addrs);
10175        // `place_vl_collections` answers base-relative, since that is what an
10176        // element reference stores; the free list is absolute.
10177        let placed = addrs
10178            .iter()
10179            .zip(&staging.collections)
10180            .map(|(&a, c)| Ok((base.absolute(StoredAddress::new(a))?, c.len() as u64)))
10181            .collect::<Result<Vec<_>, FormatError>>()?;
10182        self.vl_overwrite_heaps.insert(vlen.path.clone(), placed);
10183        self.superseded_heaps.extend(superseded);
10184        Ok(Cow::Owned(raw))
10185    }
10186
10187    /// Forget every heap collection this session recorded as exclusively one
10188    /// dataset's, because `staged` contains an edit that could name one of them
10189    /// a second time (issue #321).
10190    ///
10191    /// The provenance in [`vl_overwrite_heaps`](Self::vl_overwrite_heaps) says a
10192    /// collection was named once *when it was placed*. Two staged edits can
10193    /// falsify that afterwards, and both do it by re-emitting element bytes
10194    /// somebody else's references live in:
10195    ///
10196    /// - a **copy**, in-file or cross-file, which duplicates a dataset's element
10197    ///   references verbatim. This is not hypothetical: an in-file copy of a
10198    ///   variable-length dataset is exactly how two datasets come to name one
10199    ///   collection.
10200    /// - a **raw-bytes write** over a datatype that reaches a heap address
10201    ///   ([`datatype_holds_file_address`]). `with_raw_data` hands the engine
10202    ///   element bytes it does not interpret, so a caller that read them from
10203    ///   one dataset and wrote them to another has aliased whatever they named —
10204    ///   the same unscreened door issue #317 recorded for object references.
10205    ///
10206    /// Wholesale rather than per-path: what a copy's source references is not
10207    /// known without decoding it, and a proof that has to be argued per edit is
10208    /// the kind that goes stale. Reclaim is an optimization, and giving all of
10209    /// it up on a rare edit costs a leak, where keeping one entry too long costs
10210    /// a freed collection somebody still reads.
10211    fn invalidate_heap_provenance(&mut self, staged: &StagedEdits) {
10212        if self.vl_overwrite_heaps.is_empty() {
10213            return;
10214        }
10215        let aliasing_edit = !staged.copies.is_empty()
10216            || !staged.cross_copies.is_empty()
10217            // A dataset staged with element bytes of its own (`with_raw_data`)
10218            // rather than staging its strings here, whether it is being created
10219            // or overwritten.
10220            || staged
10221                .writes
10222                .iter()
10223                .chain(&staged.datasets)
10224                .any(|(_, fd)| {
10225                    fd.vl_string_staging.is_none()
10226                        && datatype_holds_file_address(&fd.dt)
10227                });
10228        if aliasing_edit {
10229            self.vl_overwrite_heaps.clear();
10230            return;
10231        }
10232        // A deleted dataset's record would otherwise outlive it and be applied
10233        // to whatever later takes its path.
10234        for path in &staged.deletes {
10235            self.vl_overwrite_heaps
10236                .retain(|recorded, _| !recorded.overlaps(path));
10237        }
10238    }
10239
10240    /// Resolves one object-reference element's target to the address stored on
10241    /// disk. [`ObjectRefTarget::Raw`] is written back verbatim: a null or an
10242    /// undefined reference points at nothing, and the whole-file writer treats
10243    /// it the same way. [`ObjectRefTarget::Path`] resolves, in order:
10244    ///
10245    /// 1. Against `path_addr` — every group and dataset this commit has
10246    ///    already placed (a sibling dataset placed earlier in the same
10247    ///    group's batch — see the apply loop's non-reference-first ordering —
10248    ///    or a descendant subtree fully processed earlier in the deepest-first
10249    ///    walk).
10250    /// 2. Against the pre-commit on-disk file
10251    ///    ([`resolve_path_any`](crate::group_v2::resolve_path_any)), but only
10252    ///    when the path is untouched by this commit, so its pre-commit
10253    ///    address is guaranteed to still be valid post-commit. "Touched"
10254    ///    means: a dirty group (`nodes`, new or merely rewritten because an
10255    ///    addition lives under it — its own address changes either way); a
10256    ///    path this commit adds, or that lies under a subtree this commit
10257    ///    copies in (`add_targets`, checked by prefix so a copy's interior is
10258    ///    covered even though only its root is enumerated there); or a
10259    ///    `write_dataset` target (`write_targets`) — conservatively refused
10260    ///    even for a same-length overwrite that does not actually relocate,
10261    ///    since resolving that distinction here is not worth the complexity.
10262    /// 3. If the path resolves nowhere at all (neither this commit nor the
10263    ///    pre-commit file has ever heard of it), as an undefined reference
10264    ///    (`HADDR_UNDEF`) — mirroring [`ObjectRefTarget::Path`]'s existing
10265    ///    whole-file-writer resolution convention for the same builder type.
10266    ///
10267    /// A path that step 1 misses but step 2 identifies as commit-touched is
10268    /// refused with a clear [`Error::EditUnsupported`] rather than resolved to
10269    /// a stale or wrong address — the one case this engine cannot resolve
10270    /// without the whole-file writer's two-pass dummy/real-address scheme.
10271    ///
10272    /// A path this same commit *deletes* (`delete_targets`) is refused for a
10273    /// different reason, and so carries its own message: the address step 2
10274    /// would resolve is not stale but doomed, and the next commit to reuse the
10275    /// span leaves a reference that dereferenced cleanly reading whatever
10276    /// landed there. The test is by **prefix**, because a deletion takes the
10277    /// whole subtree with it (`collect_free_spans` walks it): a reference to a
10278    /// child of a deleted group dangles exactly as a reference to the group
10279    /// itself does (issue #314).
10280    ///
10281    /// It is a conservative test in the same sense `write_targets` is. A child
10282    /// whose object survives the delete through another hard link keeps its
10283    /// address — `collect_free_spans` reclaims nothing when the incoming count
10284    /// is not 1 — and is refused here anyway, so the message states what the
10285    /// commit does rather than what the allocator will conclude.
10286    ///
10287    /// The delete test runs *after* the other three so that a replacement this
10288    /// commit has merely not placed yet — a sibling group later in the same
10289    /// depth band — is reported as an ordering problem rather than as a
10290    /// deletion. That ordering is a better default, not a partition: because
10291    /// `add_targets` claims a replaced path's whole subtree by prefix, a child
10292    /// the replacement does *not* recreate is genuinely doomed and still
10293    /// reports "still writing". Distinguishing it would mean enumerating what
10294    /// a replacement actually rebuilds, which neither list holds. A path the
10295    /// commit puts back is resolved by step 1 before either test is reached.
10296    fn resolve_reference_target(
10297        target: &ObjectRefTarget,
10298        path_addr: &BTreeMap<ObjectPathBuf, u64>,
10299        nodes: &BTreeMap<ObjectPathBuf, Node>,
10300        add_targets: &[ObjectPathBuf],
10301        write_targets: &[ObjectPathBuf],
10302        delete_targets: &[ObjectPathBuf],
10303        invalidated: &InvalidatedAddresses,
10304        src: &(impl Source + ?Sized),
10305        superblock: &Superblock,
10306    ) -> Result<StoredAddress, Error> {
10307        let path = match target {
10308            // An address carries no name to test, so the delete check the path
10309            // arm makes by prefix is made here on the address itself.
10310            //
10311            // It fires on nothing today: every `Raw` this crate stages is a
10312            // sentinel, because `repack`'s faithful re-emit resolves a real
10313            // target to a `Path` and leaves only the null and undefined
10314            // references raw. It is here because the variant carries an
10315            // arbitrary address, and a producer that staged a real one would
10316            // otherwise write it past the screen its `Path` twin gets — which
10317            // is the shape of this bug in the first place (issue #317).
10318            ObjectRefTarget::Raw(addr) => {
10319                if let Some(refusal) = invalidated.refusal(*addr) {
10320                    return Err(Error::EditUnsupported(refusal));
10321                }
10322                return Ok(*addr);
10323            }
10324            ObjectRefTarget::Path(path) => path,
10325        };
10326        let base = superblock.base_address;
10327        if let Some(&addr) = path_addr.get(path) {
10328            return Ok(base.relative(addr)?);
10329        }
10330        if nodes.contains_key(path)
10331            || add_targets.iter().any(|t| path.starts_with(t))
10332            || write_targets.contains(path)
10333        {
10334            return Err(Error::EditUnsupported(
10335                "an object-reference dataset targets a path this commit is still writing; \
10336                 use separate commits",
10337            ));
10338        }
10339        // After the three above; see this function's doc for why, and for what
10340        // that ordering does and does not buy.
10341        if delete_targets.iter().any(|d| path.starts_with(d)) {
10342            return Err(Error::EditUnsupported(
10343                "an object-reference dataset targets an object this commit deletes, or one \
10344                 under it; the reference would be left pointing at storage the delete can \
10345                 reclaim",
10346            ));
10347        }
10348        Ok(
10349            match crate::group_v2::resolve_path_any_from_source(
10350                src,
10351                AccessMode::ReadWrite,
10352                superblock,
10353                &path.as_path(),
10354            ) {
10355                Ok(addr) => base.relative(addr)?,
10356                Err(_) => StoredAddress::new(UNDEF),
10357            },
10358        )
10359    }
10360
10361    /// Prove, before any byte of this commit is written, that every
10362    /// object-reference target across every staged dataset will resolve
10363    /// successfully — either against a pre-existing untouched object or
10364    /// against something this same commit places. [`resolve_reference_target`]
10365    /// classifies a target purely from *whether* an [`ObjectPathBuf`] has been placed
10366    /// yet (`path_addr.get`), never from the address *value*, so replaying the
10367    /// apply loop's placement order here with a placeholder address standing in
10368    /// for "already placed" reproduces the exact same verdict the apply loop's
10369    /// own calls will reach later, without writing anything.
10370    ///
10371    /// The placeholder is the file's **base address**, not zero. Any value
10372    /// reproduces the verdict, but it is also handed to the same `addr - base`
10373    /// that converts a real address into its stored form: a zero placeholder
10374    /// underflows that on every file with a userblock, panicking in a debug
10375    /// build on an otherwise legal edit. At the base address it converts to a
10376    /// stored `0`, which is exactly the "placed, real address not known yet"
10377    /// this stands for. If
10378    /// this preflight pass returns `Ok`, none of the apply loop's own
10379    /// `resolve_reference_target` calls can fail, so a reference-resolution
10380    /// error can no longer leave earlier-processed groups' real writes
10381    /// orphaned in the file (the failure surfaces here instead, before the
10382    /// apply loop's first `place`/`write_at`).
10383    ///
10384    /// [`resolve_reference_target`]: Self::resolve_reference_target
10385    fn preflight_reference_targets(
10386        keys: &[ObjectPathBuf],
10387        flat: &BTreeMap<&ObjectPathBuf, Vec<&FlatDataset>>,
10388        nodes: &BTreeMap<ObjectPathBuf, Node>,
10389        add_targets: &[ObjectPathBuf],
10390        write_targets: &[ObjectPathBuf],
10391        delete_targets: &[ObjectPathBuf],
10392        invalidated: &InvalidatedAddresses,
10393        src: &(impl Source + ?Sized),
10394        superblock: &Superblock,
10395    ) -> Result<(), Error> {
10396        let mut by_depth = keys.to_vec();
10397        // Stable on purpose, for the same reason as the apply loop's copy: this
10398        // simulation is only faithful if it walks the groups in that same order.
10399        by_depth.sort_by_key(|k| std::cmp::Reverse(k.len()));
10400        let mut sim_addr: BTreeMap<ObjectPathBuf, u64> = BTreeMap::new();
10401        for key in &by_depth {
10402            if let Some(datasets) = flat.get(key) {
10403                // Mirrors the apply loop's `group_datasets.sort_by_key(|fd|
10404                // fd.reference_targets.is_some())`: non-reference datasets are
10405                // placed (and so become resolvable) before any reference
10406                // dataset in the same group.
10407                let mut ordered: Vec<&FlatDataset> = datasets.to_vec();
10408                // Stable on purpose, like the loop it mirrors.
10409                ordered.sort_by_key(|fd| fd.reference_targets.is_some());
10410                for fd in ordered {
10411                    if let Some(patches) = &fd.reference_targets {
10412                        for patch in patches {
10413                            Self::resolve_reference_target(
10414                                &patch.target,
10415                                &sim_addr,
10416                                nodes,
10417                                add_targets,
10418                                write_targets,
10419                                delete_targets,
10420                                invalidated,
10421                                src,
10422                                superblock,
10423                            )?;
10424                        }
10425                    }
10426                    sim_addr.insert(key.join(&fd.name), superblock.base_address.get());
10427                }
10428            }
10429            sim_addr.insert(key.clone(), superblock.base_address.get());
10430        }
10431        Ok(())
10432    }
10433
10434    /// Lay out a chunked / filtered / extensible dataset and return its object
10435    /// header bytes (which the caller links into the parent group).
10436    ///
10437    /// The chunk data and index (fixed-array / extensible-array, with any filter
10438    /// pipeline applied) are produced as one relocatable blob, whose internal
10439    /// layout — and therefore total size — is independent of the base address it
10440    /// is given. That is what lets the dataset be *sized before it is placed*
10441    /// ([`chunked_data_len`]): the blob goes into a freed region big enough to
10442    /// hold it, or at end-of-file when there is none, and is then assembled for
10443    /// whichever address it got, so every absolute address it embeds (chunk
10444    /// addresses, index-structure addresses, the addresses in the data-layout
10445    /// message) lands exactly where the bytes are written. The header is then
10446    /// built with [`build_chunked_dataset_oh`] — the same function the whole-file
10447    /// writer uses — so the header is byte-identical to one written fresh.
10448    ///
10449    /// The dataset's single pass of the filter pipeline happens in
10450    /// [`compress_chunks`], before either decision; sizing and assembly work from
10451    /// the compressed set, so choosing an address never costs a recompression
10452    /// (issue #261).
10453    fn build_chunked_dataset(&mut self, fd: &FlatDataset) -> Result<Vec<u8>, Error> {
10454        let chunk_dims = fd.chunk_options.resolve_chunk_dims(&fd.ds.dimensions);
10455        let ctx = ChunkContext::from_datatype(&chunk_dims, &fd.dt)?;
10456        // The overhang of a partial edge chunk holds the staged fill value
10457        // (issue #296).
10458        let elem = ctx.element_size.narrow::<NonZeroUsize>()?;
10459        let fill = crate::fill_value::FillPattern::new(fd.fill.as_deref(), elem);
10460        let set = compress_chunks(
10461            &fd.raw,
10462            &fd.ds.dimensions,
10463            ctx,
10464            &fd.chunk_options,
10465            fd.maxshape.as_deref(),
10466            fill,
10467            // `flatten_dataset` refuses an unallocated dataset outright, so
10468            // every dataset this engine builds allocates its storage.
10469            StorageAllocation::Allocated,
10470        )?;
10471        // Chunk data and the index beside it are raw (see `chunked_storage_spans`,
10472        // which reclaims both as raw).
10473        let (_addr, (layout_message, pipeline_message)) =
10474            self.place_relocatable(chunked_data_len(&set)?, PageType::Raw, |stored_base| {
10475                let result = assemble_chunked_at(&set, stored_base)?;
10476                Ok((
10477                    result.data_bytes,
10478                    (result.layout_message, result.pipeline_message),
10479                ))
10480            })?;
10481        // As in the contiguous branch: a set too large for the header goes to a
10482        // fractal heap, which the header then names instead of carrying it.
10483        let attr_info = self.place_dense_attrs_if_needed(fd)?;
10484        Ok(build_chunked_dataset_oh(
10485            &fd.dt,
10486            &DatatypeLocation::Inline,
10487            &fd.ds,
10488            &layout_message,
10489            pipeline_message.as_deref(),
10490            &fd.attrs,
10491            attr_info.as_deref(),
10492            fd.fill.as_deref(),
10493        )?)
10494    }
10495
10496    /// Returns the on-disk byte spans `(addr, len)` of every chunk of the
10497    /// version 2 object header at `addr`: chunk 0 (signature, prefix, messages,
10498    /// checksum) plus each continuation (`OCHK`) block.
10499    ///
10500    /// Used to reclaim a header's storage when its object is deleted. An error
10501    /// (propagated from [`oh_region_at`] or a malformed continuation) means the
10502    /// header is not a plain v2 header this engine can fully account for, and
10503    /// the caller leaves it as dead bytes of unknown extent.
10504    fn oh_chunk_spans(&self, addr: u64) -> Result<Vec<(u64, u64)>, Error> {
10505        Ok(
10506            read_oh_chunks(&self.image(), addr, self.superblock.base_address)?
10507                .into_iter()
10508                .map(|chunk| chunk.span)
10509                .collect(),
10510        )
10511    }
10512
10513    /// Count, for every object-header address reachable from the root, how many
10514    /// hard links in the *pre-commit* file point to it. The result drives the
10515    /// last-hard-link reclaim guard in [`collect_free_spans`](Self::collect_free_spans):
10516    /// an object is freed only when its count is 1.
10517    ///
10518    /// Walks the whole link graph from the root, following hard links through
10519    /// groups of any on-disk format (v0/v1 symbol-table, v2 compact, v2 dense)
10520    /// via [`resolve_group_entries_from_source`], tallying each hard-link edge. Datasets and
10521    /// other leaves contribute no edges. Returns `None` — so the caller reclaims
10522    /// nothing for the deletions, a safe leak — if the graph cannot be walked in
10523    /// full: an unparseable header, a group whose links cannot be enumerated, or
10524    /// more than [`MAX_LINK_GRAPH_NODES`] objects. Cycles are handled by visiting
10525    /// each object once. Base-aware: stored child addresses are shifted by the
10526    /// userblock base, so the returned keys are absolute file offsets.
10527    fn count_incoming_hard_links(&self) -> Option<HashMap<u64, u32>> {
10528        let os = self.superblock.offset_size;
10529        let ls = self.superblock.length_size;
10530        let base = self.superblock.base_address;
10531        let mut counts: HashMap<u64, u32> = HashMap::new();
10532        let mut visited: HashSet<u64> = HashSet::new();
10533        let mut stack: Vec<u64> = vec![self.superblock.root_group_address];
10534        let mut budget = MAX_LINK_GRAPH_NODES;
10535        while let Some(addr) = stack.pop() {
10536            if !visited.insert(addr) {
10537                continue; // already expanded (also breaks hard-link cycles)
10538            }
10539            if budget == 0 {
10540                return None; // graph larger than we will walk; leak conservatively
10541            }
10542            budget -= 1;
10543            let header = ObjectHeader::parse_from_source(
10544                &self.image(),
10545                AccessMode::ReadWrite,
10546                addr,
10547                os,
10548                ls,
10549                base,
10550            )
10551            .ok()?;
10552            // Datasets and other leaves are not groups and own no links.
10553            let is_group = header.messages.iter().any(|m| {
10554                matches!(
10555                    m.msg_type,
10556                    MessageType::SYMBOL_TABLE | MessageType::LINK | MessageType::LINK_INFO
10557                )
10558            });
10559            if !is_group {
10560                continue;
10561            }
10562            // A group we cannot enumerate fully would undercount incoming links
10563            // and risk over-reclaim; bail to the safe-leak fallback instead.
10564            let entries =
10565                resolve_group_entries_from_source(&self.image(), &header, os, ls, base).ok()?;
10566            for e in entries {
10567                let child = base.absolute(e.object_header_address).ok()?;
10568                *counts.entry(child).or_insert(0) += 1;
10569                stack.push(child);
10570            }
10571        }
10572        Some(counts)
10573    }
10574
10575    /// Collects, as far as it can account for them, the span and free class of
10576    /// every on-disk block the object at `addr` owns, and for a group its whole
10577    /// subtree, into `out` for reclamation after a delete.
10578    ///
10579    /// Contiguous datasets (header + data block), chunked datasets (header +
10580    /// chunk index + chunk data, via [`chunked_storage_spans`](Self::chunked_storage_spans)),
10581    /// and whole group subtrees are reclaimed. Deliberately conservative: any
10582    /// object whose layout it cannot fully account for — a non-v2 header, an
10583    /// unsupported or only-partially-enumerable chunk index, a group holding a
10584    /// soft/external link, dense attribute storage — contributes nothing and is
10585    /// not descended into, so `out` never names a region that might still be in
10586    /// use. Bounded by [`MAX_COPY_DEPTH`] against a hard-link cycle.
10587    /// Variable-length data in global-heap collections is never reclaimed here (a
10588    /// collection can be shared between objects), so it is simply left behind.
10589    ///
10590    /// `incoming` is the file-wide hard-link count per object-header address
10591    /// (from [`count_incoming_hard_links`](Self::count_incoming_hard_links)). An
10592    /// object is reclaimed — and, for a group, descended into — only when its
10593    /// count is exactly 1, i.e. the link being removed is its last: an object
10594    /// still reachable through another hard link is live and is left untouched
10595    /// (so is everything below a surviving group), which is what keeps deleting
10596    /// one of several hard links from corrupting the survivor.
10597    fn collect_free_spans(
10598        &self,
10599        addr: u64,
10600        depth: u32,
10601        incoming: &HashMap<u64, u32>,
10602        out: &mut Vec<(u64, u64, FreeClass)>,
10603    ) {
10604        // `addr` is an absolute file offset (the caller resolves it from the live
10605        // file, and the group recursion below re-absolutizes each child). `incoming`
10606        // is keyed by absolute offset, and `oh_chunk_spans`/`chunked_storage_spans`
10607        // both take an absolute address and return absolute spans, so the whole
10608        // walk works in absolute file offsets. The one shift this method must apply
10609        // itself is on the stored addresses `read_object` returns for a contiguous
10610        // data block and a group's child links: each is converted to an absolute
10611        // offset (a no-op on a base-0 file) before it is bounds-checked, recorded,
10612        // or descended into.
10613        let base = self.superblock.base_address;
10614        let file_len = self.image().len();
10615        if depth >= MAX_COPY_DEPTH {
10616            return;
10617        }
10618        // Reclaim only when this delete removes the object's last hard link. A
10619        // count other than 1 (it has surviving links, or the graph walk could
10620        // not account for it) means the object — and a group's whole subtree —
10621        // stays live and must not be freed.
10622        if incoming.get(&addr) != Some(&1) {
10623            return;
10624        }
10625        // The header's own chunks. If they cannot be mapped, account for nothing.
10626        let spans = match self.oh_chunk_spans(addr) {
10627            Ok(s) => s,
10628            Err(_) => return,
10629        };
10630        match Self::read_object(
10631            &self.image(),
10632            AccessMode::ReadWrite,
10633            addr,
10634            self.superblock.base_address,
10635        ) {
10636            Ok(ObjModel::DatasetVerbatim { .. }) => out.extend(meta_spans(spans)),
10637            Ok(ObjModel::DatasetContiguous {
10638                data_addr,
10639                data_size,
10640                ..
10641            }) => {
10642                out.extend(meta_spans(spans));
10643                // A defined, in-bounds contiguous data block is owned outright;
10644                // an empty dataset stores the undefined address and owns none. Its
10645                // absolute file offset is what this bounds-checks and records.
10646                if !data_addr.is_undefined(OFFSET_SIZE) && data_size > 0 {
10647                    if let Ok(abs) = base.absolute(data_addr) {
10648                        if abs.checked_add(data_size).is_some_and(|e| e <= file_len) {
10649                            // A contiguous data block is raw data.
10650                            out.push((abs, data_size, FreeClass::Page(PageType::Raw)));
10651                        }
10652                    }
10653                }
10654            }
10655            Ok(ObjModel::Group { children, .. }) => {
10656                out.extend(meta_spans(spans));
10657                // The recursion works in absolute offsets, matching `incoming`'s
10658                // keys and `oh_chunk_spans`.
10659                for (_, _, child) in children {
10660                    if let Ok(c) = base.absolute(child) {
10661                        self.collect_free_spans(c, depth + 1, incoming, out);
10662                    }
10663                }
10664            }
10665            // A chunked dataset: reclaim its chunk index and chunk data blocks
10666            // alongside its header. `chunked_storage_spans` returns `None` for
10667            // anything it cannot account for exhaustively (an index type with no
10668            // walker, an undefined index address, or spans that fail the
10669            // bounds/overlap check), leaving the whole dataset as dead bytes
10670            // rather than freeing a region that might still be in use.
10671            Ok(ObjModel::DatasetChunked { .. }) => {
10672                if let Some(storage) = self.chunked_storage_spans(addr) {
10673                    out.extend(meta_spans(spans));
10674                    // Already page-typed: chunk data raw, index structure metadata.
10675                    out.extend(storage);
10676                }
10677            }
10678            // A truly unsupported object (one `read_object` cannot model): leave
10679            // its bytes in place rather than guess its extent.
10680            //
10681            // An externally stored dataset lands here as of #336, where before it
10682            // was modelled as the contiguous dataset it structurally is and had
10683            // its header chunks reclaimed. Deleting one therefore leaves those
10684            // chunks behind: measured on the fixture in
10685            // `crates/crosscheck/tests/main/external_storage.rs`, a commit that deletes it
10686            // reports 147 B reusable where it reported 431 B. That is a leak and
10687            // not a hazard — `oh_chunk_spans` never covered the local heap the
10688            // External Data Files message names either, so neither the old
10689            // behaviour nor this one accounted for the whole object — and it is
10690            // the price of stating the refusal once, in the one function both the
10691            // copy planner and this walk read objects through.
10692            Err(_) => {}
10693        }
10694    }
10695
10696    /// Returns every on-disk block a *chunked* dataset at `addr` owns: its
10697    /// chunk index structure (B-tree v1 nodes, or fixed- / extensible-array
10698    /// header, index, super, and data blocks) plus every allocated chunk data
10699    /// block.
10700    ///
10701    /// The object-header chunks are freed by the caller
10702    /// ([`collect_free_spans`](Self::collect_free_spans)). This returns only the
10703    /// storage the data-layout message points at.
10704    ///
10705    /// Returns `None` — contribute nothing, leave the object as dead bytes —
10706    /// whenever the dataset cannot be enumerated *exhaustively* and safely: a
10707    /// header that does not parse or is not a chunked dataset, a chunk index
10708    /// with no walker (a version 2 B-tree, index type 5), an undefined index
10709    /// address (an empty, never-written dataset), or any resulting span that
10710    /// falls outside the file image or overlaps another. This upholds the
10711    /// editor's invariant that reclaimed space is never a region still in use:
10712    /// under-reclaiming only wastes space, while over-reclaiming would corrupt.
10713    ///
10714    /// Chunk data addresses and sizes come from the same index walkers the
10715    /// reader uses, so they match the bytes the writer laid down exactly. The
10716    /// per-layout enumeration lives in
10717    /// [`chunked_read::collect_chunked_storage_spans`](crate::chunked_read::collect_chunked_storage_spans);
10718    /// this method only locates the layout and dataspace messages and validates
10719    /// the result. Variable-length data in global-heap collections is still
10720    /// never reclaimed (a collection can be shared between objects); see the
10721    /// [module docs](self).
10722    fn chunked_storage_spans(&self, addr: u64) -> Option<Vec<(u64, u64, FreeClass)>> {
10723        // Locate the data-layout and dataspace messages in the object header.
10724        let region =
10725            Self::gather_oh_messages(&self.image(), addr, self.superblock.base_address).ok()?;
10726        let mut layout_msg: Option<(usize, usize)> = None;
10727        let mut dataspace_msg: Option<(usize, usize)> = None;
10728        let mut p = 0;
10729        loop {
10730            match region.next_message(p) {
10731                Ok(Some((msg_type, body, body_end))) => {
10732                    match msg_type {
10733                        MessageType::DATA_LAYOUT => layout_msg = Some((body, body_end)),
10734                        MessageType::DATASPACE => dataspace_msg = Some((body, body_end)),
10735                        _ => {}
10736                    }
10737                    p = body_end;
10738                }
10739                Ok(None) => break,
10740                Err(_) => return None,
10741            }
10742        }
10743        let (lb, le) = layout_msg?;
10744        let (db, de) = dataspace_msg?;
10745
10746        let layout = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
10747        if !matches!(layout, DataLayout::Chunked { .. }) {
10748            return None;
10749        }
10750        let dataspace = Dataspace::parse(&region[db..de], LENGTH_SIZE).ok()?;
10751
10752        // Delegate the per-index-type enumeration to the chunked reader (the
10753        // single owner of chunk-storage layout knowledge), then validate: every
10754        // span must lie inside the current file image and be pairwise disjoint,
10755        // or the free list would later hand out live bytes (and a debug build
10756        // would panic on the double-free). On any error or violation, leave the
10757        // whole dataset unreclaimed rather than free a region still in use.
10758        //
10759        // The layout's stored addresses are relative to the userblock base, so the
10760        // enumeration runs on a base-relative view of the file and each returned
10761        // span address is shifted back to an absolute file offset by adding `base`
10762        // (a no-op on a base-0 file). The free list and the bounds check below both
10763        // work in absolute file offsets.
10764        let base = self.superblock.base_address;
10765        let split = crate::chunked_read::collect_chunked_storage_spans(
10766            &BaseOffsetSource {
10767                inner: &self.image(),
10768                base,
10769            },
10770            &layout,
10771            &dataspace,
10772            OFFSET_SIZE,
10773            LENGTH_SIZE,
10774        )
10775        .ok()?;
10776        // Chunk data is raw under every writer, so its spans are raw outright. The
10777        // index is only raw where this crate placed it — see
10778        // [`index_is_provably_raw`](Self::index_is_provably_raw) — and is recorded
10779        // as dead rather than free otherwise.
10780        let mut data: Vec<(u64, u64)> = Vec::with_capacity(split.data.len());
10781        for (addr, len) in split.data {
10782            data.push((base.absolute(StoredAddress::new(addr)).ok()?, len));
10783        }
10784        let mut index: Vec<(u64, u64)> = Vec::with_capacity(split.index.len());
10785        for (addr, len) in split.index {
10786            index.push((base.absolute(StoredAddress::new(addr)).ok()?, len));
10787        }
10788        // Validate both halves together — they must be disjoint from each other as
10789        // well as internally — before either is trusted, including by the proof.
10790        let mut plain: Vec<(u64, u64)> = data.iter().chain(index.iter()).copied().collect();
10791        if !spans_disjoint_in_bounds(&mut plain, self.image.len()) {
10792            return None;
10793        }
10794        let index_class = if self.index_is_provably_raw(&data, &index) {
10795            FreeClass::Page(PageType::Raw)
10796        } else {
10797            FreeClass::Dead
10798        };
10799        let mut spans: Vec<(u64, u64, FreeClass)> = data
10800            .iter()
10801            .map(|&(a, l)| (a, l, FreeClass::Page(PageType::Raw)))
10802            .collect();
10803        spans.extend(index.into_iter().map(|(a, l)| (a, l, index_class)));
10804        Some(spans)
10805    }
10806
10807    /// Whether a chunked dataset's `index` provably occupies raw pages, given the
10808    /// `data` spans it accompanies. Both are absolute file offsets.
10809    ///
10810    /// A chunk index is *metadata* by the format's taxonomy, and the reference C
10811    /// library allocates one as such — out of metadata pages, nowhere near the
10812    /// chunk data. This crate instead lays a dataset's index down in the same run
10813    /// as its chunk data, inside raw pages, so that a reader following the layout
10814    /// message walks one contiguous blob. Both are valid; what matters on a paged
10815    /// file is that freeing an index records it under the page type it actually
10816    /// sits in, since that decides what a later allocation may overwrite there.
10817    ///
10818    /// Nothing in the file says which writer produced it, so the index is placed
10819    /// from the layout itself, by two tests it must pass together: some chunk-data
10820    /// span must **abut** it — ending exactly where the index begins, or beginning
10821    /// exactly where it ends — and no byte of it may lie in **page 0**.
10822    ///
10823    /// The abutment is this crate's single blob, and is not a layout the reference
10824    /// library sets out to produce. A chunk index is metadata to that library,
10825    /// allocated out of metadata pages — and those are allocated from the bottom
10826    /// of the file, while large raw data goes above, so its index lands far below
10827    /// the chunk data it indexes rather than against it. Measured across seven
10828    /// files it wrote (Extensible and Fixed Array indexes, page sizes
10829    /// 512/4096/8192, 8 to 16 chunks): the index began in page 0 every time, with
10830    /// the chunk data starting at page 9 or higher, so neither join address was
10831    /// within reach of it.
10832    ///
10833    /// The page-0 screen closes the one way that measured layout can nonetheless
10834    /// satisfy the abutment. An index block that ends exactly on a page boundary
10835    /// with a raw page after it — a Fixed Array data block that exactly fills its
10836    /// page, say — is abutted by the first chunk in that raw page, and the whole
10837    /// index run would then be filed as raw, its header included, *even though
10838    /// that header sits in page 0 beside the superblock*. A later raw allocation
10839    /// would land inside a metadata page, which is the page-mixing defect of
10840    /// issue #261. Page 0 of a paged file always begins with the superblock, so it
10841    /// is a metadata page by construction and an index with a byte in it cannot be
10842    /// raw whatever abuts it. The screen costs this crate nothing: it allocates
10843    /// chunk data and the indexes beside it out of raw pages, and page 0 — never
10844    /// wholly free, since the superblock lives there — is never one of them.
10845    ///
10846    /// The tighter rule considered instead was to require the join address to fall
10847    /// *strictly inside* a page, which proves rawness outright: the byte before
10848    /// the join and the byte after it are then in one page, and a page holding
10849    /// chunk data is raw. It is sound, and a paged file the reference library
10850    /// wrote cannot satisfy it at all, since such a file never puts raw and
10851    /// metadata bytes in one page and so can only ever join them on a boundary.
10852    /// It was rejected because it also refuses layouts *this* crate produces:
10853    /// whenever a blob's chunk bytes total a page multiple from a page-aligned
10854    /// start, its index begins on a boundary too. Measured over 100
10855    /// `append_staged` commits at a 4096-byte page with 2048-byte chunks, that
10856    /// stranded about 500 bytes per commit — 62 KB against the 10 KB the abutment
10857    /// rule left — because the two writers produce the same geometry there and
10858    /// nothing in the file distinguishes them.
10859    ///
10860    /// Both sides have to be admitted, not just the index that follows its data.
10861    /// A staged append keeps the existing chunks where they are and places the new
10862    /// ones past the index it is superseding, so from the second append onward the
10863    /// dataset has chunk data on both sides of that index and the highest data
10864    /// address is no longer the one beside it. Asking only whether the index
10865    /// begins where the *last* chunk ends refused every such index, and a
10866    /// repeatedly appended dataset dropped one per commit (issue #388).
10867    ///
10868    /// An index the test does not place is [dead](FreeClass::Dead) to the caller
10869    /// rather than free: its bytes are no longer in use, but advertising space
10870    /// inside a possible metadata page for raw reuse would mix the page
10871    /// (issue #261), so they are reusable only once the whole page around them is
10872    /// empty.
10873    ///
10874    /// A non-paged file has no page types to keep apart, so everything is
10875    /// reclaimable there; the tag is ignored by its commit tail entirely.
10876    fn index_is_provably_raw(&self, data: &[(u64, u64)], index: &[(u64, u64)]) -> bool {
10877        match &self.paged {
10878            None => true,
10879            Some(paged) => {
10880                index_abuts_chunk_data(data, index)
10881                    && !index_touches_page_zero(index, paged.page_size)
10882            }
10883        }
10884    }
10885
10886    /// Returns every on-disk byte span of a chunked dataset's *index structure
10887    /// only* (not its chunk data), for reclaiming the old index after a
10888    /// relocating append ([`MovingWrite::AppendedChunks`]) that keeps the chunk
10889    /// data in place.
10890    ///
10891    /// This is the index-only counterpart of
10892    /// [`chunked_storage_spans`](Self::chunked_storage_spans), delegating to
10893    /// [`chunk_index_spans_from_source`], which enumerates the index structure's
10894    /// own blocks (B-tree v1 nodes, or fixed- / extensible-array header, index,
10895    /// super, and data blocks) and never a chunk-data address, so the shared
10896    /// kept chunk data is never freed. Base-aware and validated
10897    /// disjoint/in-bounds. Returns `None` (leave unreclaimed) on any error or
10898    /// violation.
10899    fn chunked_index_spans(&self, addr: u64) -> Option<Vec<(u64, u64)>> {
10900        let region =
10901            Self::gather_oh_messages(&self.image(), addr, self.superblock.base_address).ok()?;
10902        let mut layout_msg: Option<(usize, usize)> = None;
10903        let mut p = 0;
10904        loop {
10905            match region.next_message(p) {
10906                Ok(Some((msg_type, body, body_end))) => {
10907                    if msg_type == MessageType::DATA_LAYOUT {
10908                        layout_msg = Some((body, body_end));
10909                    }
10910                    p = body_end;
10911                }
10912                Ok(None) => break,
10913                Err(_) => return None,
10914            }
10915        }
10916        let (lb, le) = layout_msg?;
10917        let layout = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
10918        if !matches!(layout, DataLayout::Chunked { .. }) {
10919            return None;
10920        }
10921        let base = self.superblock.base_address;
10922        let mut spans = chunk_index_spans_from_source(
10923            &BaseOffsetSource {
10924                inner: &self.image(),
10925                base,
10926            },
10927            &layout,
10928            OFFSET_SIZE,
10929            LENGTH_SIZE,
10930        )
10931        .ok()?;
10932        for (a, _) in &mut spans {
10933            *a = base.absolute(StoredAddress::new(*a)).ok()?;
10934        }
10935        if !spans_disjoint_in_bounds(&mut spans, self.image.len()) {
10936            return None;
10937        }
10938        Some(spans)
10939    }
10940}
10941
10942/// A dirty group in the edit plan: its base object-header message region and the
10943/// additions targeting it.
10944#[derive(Default)]
10945struct Node {
10946    is_new: bool,
10947    /// Names of links to remove from this group (from `delete`).
10948    deletes: Vec<LinkNameBuf>,
10949    /// Copies to add to this group: (new link name, the source subtree read out
10950    /// for writing). Built at staging time from either this file (an in-file
10951    /// [`copy`](crate::File::copy)) or another open file (a cross-file
10952    /// [`copy_from`](crate::File::copy_from)).
10953    copies: Vec<(LinkNameBuf, CopyTree)>,
10954    /// New link names this commit adds to this group by cross-file copy. The
10955    /// subtrees themselves stay in the staged set — which the preflight may
10956    /// still refuse, and must not empty (issue #316) — and join `copies` at the
10957    /// point of no return.
10958    cross_copies: Vec<LinkNameBuf>,
10959    /// Value overwrites whose dataset header relocates (a resize or compact
10960    /// rewrite by `write_dataset`, a staged append, an attribute edit), as (child
10961    /// link name, the pre-commit object-header address, the relocation plan). On
10962    /// apply, the new data and header are written and this group's existing link
10963    /// to the moved header is patched to its new address — exactly like an
10964    /// existing child group's link. The old address is carried rather than
10965    /// re-resolved: the screen above and the reclaim below both need it, and one
10966    /// derivation cannot disagree with itself.
10967    writes: Vec<(LinkNameBuf, u64, MovingWrite)>,
10968    base_region: OhRegion,
10969    existing_links: Vec<String>,
10970    /// What this group's attribute edits left for the apply loop, staged by
10971    /// [`plan_attr_ops`] and resolved by [`WriteEngine::place_edited_attrs`]
10972    /// right before this node's header is built. Default (an empty compact set)
10973    /// for a group with no attribute edits.
10974    attrs: EditedAttrs,
10975}
10976
10977/// A staged compact attribute edit for a group or dataset (shared by
10978/// [`Group::set_attr`](crate::Group::set_attr)/`remove_group_attr` and
10979/// [`Dataset::set_attr`](crate::Dataset::set_attr)/`remove_dataset_attr`).
10980enum AttrOp {
10981    Set { name: String, value: AttrValue },
10982    Remove { name: String },
10983}
10984
10985/// A source object parsed for copying. Headers are reproduced from their
10986/// verbatim message bytes; only the contiguous data address and child link
10987/// targets are repointed to the freshly-written copies.
10988enum ObjModel {
10989    /// A compact dataset (data inline in the header): copy the region verbatim.
10990    /// `dense_attrs` is empty unless the source stored its attributes densely, in
10991    /// which case the Attribute Info message and inline Attribute messages have
10992    /// been stripped from `region` and the parsed set is carried here to be
10993    /// re-emitted into a fresh fractal heap on write.
10994    DatasetVerbatim {
10995        region: OhRegion,
10996        dense_attrs: DenseAttrSet,
10997    },
10998    /// A contiguous dataset: copy the region, repointing the data address at
10999    /// `addr_off` (region-relative) to a fresh copy of `[data_addr, +data_size)`.
11000    /// See [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
11001    DatasetContiguous {
11002        region: OhRegion,
11003        addr_off: usize,
11004        data_addr: StoredAddress,
11005        data_size: u64,
11006        dense_attrs: DenseAttrSet,
11007    },
11008    /// A chunked (and possibly filtered) dataset: the verbatim header `region`
11009    /// (datatype, dataspace, fill value, data layout, and filter pipeline kept as
11010    /// written). The chunk data is not captured here — [`read_copy_subtree`](WriteEngine::read_copy_subtree)
11011    /// enumerates and reads the chunks (it holds the source buffer), repointing the
11012    /// rebuilt index on write. See [`DatasetVerbatim`](ObjModel::DatasetVerbatim)
11013    /// for `dense_attrs`.
11014    DatasetChunked {
11015        region: OhRegion,
11016        dense_attrs: DenseAttrSet,
11017    },
11018    /// A group: every non-link message verbatim, plus its hard-link children to
11019    /// copy and re-link as `(name, creation index, target address)`. See
11020    /// [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
11021    Group {
11022        non_link_region: OhRegion,
11023        children: Vec<(String, Option<u64>, StoredAddress)>,
11024        dense_attrs: DenseAttrSet,
11025    },
11026}
11027
11028/// An object subtree fully read out of a source buffer and owning every byte it
11029/// will write, the read result of [`WriteEngine::read_copy_subtree`] and the
11030/// input to [`WriteEngine::write_copy_subtree`]. Unlike [`ObjModel`] (a single
11031/// object still referencing source addresses) it is recursive and self-contained:
11032/// a contiguous dataset owns its data bytes, and a group owns its children, so it
11033/// can be written into the destination without the source buffer still in hand —
11034/// which is what lets a cross-file copy read the source at staging time and apply
11035/// it at commit time.
11036enum CopyTree {
11037    /// A compact dataset: the header region is written verbatim (data is inline).
11038    /// `dense_attrs`, when non-empty, is re-emitted into a freshly built fractal
11039    /// heap appended just before the header, whose Attribute Info message is
11040    /// spliced into the region on write.
11041    DatasetVerbatim {
11042        region: OhRegion,
11043        dense_attrs: DenseAttrSet,
11044    },
11045    /// A contiguous dataset: `data` is written first and its new address patched
11046    /// into the header `region` at `addr_off` before the header is written. See
11047    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11048    ///
11049    /// `data` is `None` when the source allocated no storage at all: nothing is
11050    /// written and `addr_off` keeps the undefined address the source stored, so
11051    /// the copy declares the same absent storage rather than a block at a real
11052    /// address. That is a statement about storage, not about length (issue
11053    /// #336).
11054    DatasetContiguous {
11055        region: OhRegion,
11056        addr_off: usize,
11057        data: Option<Vec<u8>>,
11058        dense_attrs: DenseAttrSet,
11059    },
11060    /// A chunked (and possibly filtered) dataset. The header `region` is written
11061    /// verbatim except its data-layout message, which is swapped for one naming the
11062    /// freshly rebuilt index; `chunk_bytes` (each chunk's already-compressed bytes,
11063    /// in dense row-major grid order, with sizes/masks in `meta`) and the source
11064    /// `pipeline_message` are carried unchanged, so the copy preserves the filter
11065    /// pipeline and chunk payloads byte-for-byte. The on-disk index *type* is
11066    /// reselected from `maxshape`/chunk count (single / fixed-array / extensible-
11067    /// array), so a B-tree-v1 or implicit source is reproduced with a v4 index. See
11068    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11069    DatasetChunked {
11070        region: OhRegion,
11071        /// The dataset's current shape. Held because the index's element
11072        /// numbering is taken over the *maximum* chunk grid, which needs both
11073        /// extents (see [`crate::chunk_grid`]).
11074        shape: Vec<u64>,
11075        chunk_dims: Vec<u64>,
11076        element_size: NonZeroUsize,
11077        maxshape: Option<Vec<MaxExtent>>,
11078        pipeline_message: Option<Vec<u8>>,
11079        meta: Vec<ChunkMeta>,
11080        chunk_bytes: Vec<Vec<u8>>,
11081        dense_attrs: DenseAttrSet,
11082    },
11083    /// A group: every non-link message verbatim, plus the `(name, creation
11084    /// index, subtree)` children to write first and re-link by name. See
11085    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
11086    Group {
11087        non_link_region: OhRegion,
11088        children: Vec<(String, Option<u64>, CopyTree)>,
11089        dense_attrs: DenseAttrSet,
11090    },
11091}
11092
11093/// The addresses this commit invalidates, against which a reference it writes is
11094/// screened (issue #317).
11095///
11096/// An object reference *is* an object-header address, so a commit can falsify one
11097/// two ways. It can **remove** the object: a deletion frees its header and
11098/// storage, and a group's whole subtree with it
11099/// ([`WriteEngine::collect_free_spans`] is the walk that enumerates it), so the
11100/// address survives the commit only until the next allocation reuses the span and
11101/// then reads as whatever landed there. Or it can **move** it: a dirty group and
11102/// a relocating dataset write are both rebuilt at a fresh address, and the old
11103/// header is freed once the superblock is repointed.
11104///
11105/// Both are screened here because on the *path* side neither can come out as a
11106/// pre-commit address: [`WriteEngine::resolve_reference_target`] refuses a deleted
11107/// path by name, and a dirty group or a write target it either refuses as "still
11108/// writing" or — once this commit has placed the target — resolves to the address
11109/// it lands on. An address that skipped either check would be the one form that
11110/// writes the value the commit is vacating, which is the shape of the defect this
11111/// exists to close.
11112///
11113/// The removal spans are the very ones the commit hands to the free-space
11114/// manager, taken from one walk rather than two, so the screen and the reclaimer
11115/// cannot come to disagree about what this commit removes.
11116struct InvalidatedAddresses {
11117    /// Absolute `(offset, length)` file spans this commit's deletions reclaim, as
11118    /// [`collect_free_spans`](WriteEngine::collect_free_spans) reports them.
11119    removed: Vec<(u64, u64)>,
11120    /// Absolute pre-commit object-header addresses this commit rewrites
11121    /// elsewhere: every existing group it dirties, and every dataset write that
11122    /// relocates (a resizing or compact overwrite, a staged append, an attribute
11123    /// edit). An in-place overwrite does not relocate and is not here. The path
11124    /// side refuses it anyway, deliberately conservatively — `write_targets`
11125    /// records that a dataset is written, not which plan it got — while this list
11126    /// is filled from the plans themselves.
11127    moved: Vec<u64>,
11128    /// The superblock base address. A stored object reference is *base-relative*
11129    /// and both lists above are absolute, so the comparison needs it; it is zero
11130    /// for every file without a userblock.
11131    base: BaseAddress,
11132}
11133
11134impl InvalidatedAddresses {
11135    /// Whether this commit invalidates anything at all, so a caller with nothing
11136    /// cheap to check can skip its own work. Worth testing on the copy screen,
11137    /// whose `moved` is empty by construction; the supplied screen's never is,
11138    /// since every commit that builds one rebuilds its root group.
11139    fn is_empty(&self) -> bool {
11140        self.removed.is_empty() && self.moved.is_empty()
11141    }
11142
11143    /// The refusal `stored` earns — one object-reference element exactly as it is
11144    /// written to disk — or `None` when it still names what it named.
11145    ///
11146    /// The null (`0`) and undefined ([`UNDEF`]) references name no object at all,
11147    /// so neither is screened: the same two values
11148    /// [`crate::repack()`] copies through verbatim without
11149    /// resolving, and the two [`crate::reader`] refuses to dereference.
11150    fn refusal(&self, stored: StoredAddress) -> Option<&'static str> {
11151        if stored.get() == 0 || stored.get() == UNDEF {
11152            return None;
11153        }
11154        // An address that cannot even be shifted into the file is not one of
11155        // ours; leave it to whatever reads it.
11156        let abs = self.base.absolute(stored).ok()?;
11157        if self
11158            .removed
11159            .iter()
11160            .any(|&(off, len)| abs >= off && abs - off < len)
11161        {
11162            return Some(REFERENCE_INTO_RECLAIMED_SPACE);
11163        }
11164        if self.moved.contains(&abs) {
11165            return Some(REFERENCE_TO_A_MOVED_OBJECT);
11166        }
11167        None
11168    }
11169}
11170
11171/// The validated, chunk-collapsed message region and existing link names of a
11172/// group header.
11173struct GroupInfo {
11174    region: OhRegion,
11175    link_names: Vec<String>,
11176}
11177
11178/// Element bytes staged for a value overwrite, together with the
11179/// variable-length staging (if any) whose global heap collections must be
11180/// placed — and whose addresses patched into the element references in `raw` —
11181/// before those bytes reach the file.
11182///
11183/// The two halves travel together because resolving them is an **apply-phase**
11184/// step while planning is a preflight one: placing a collection allocates, and
11185/// [`commit`](WriteEngine::commit)'s preflight only reads. So a plan carries
11186/// the bytes unresolved and every consumer resolves them where it writes, with
11187/// [`resolve_overwrite_bytes`](WriteEngine::resolve_overwrite_bytes) (issue
11188/// #321).
11189///
11190/// Patching never changes `raw`'s length — an element reference is fixed-width
11191/// whatever string it names — which is what lets the plan be *chosen* from the
11192/// unresolved bytes. The one place that is not enough is a filtered chunked
11193/// dataset, whose compressed chunk sizes do depend on the addresses: see
11194/// [`ChunkPayload::Deferred`].
11195struct OverwriteBytes {
11196    raw: Vec<u8>,
11197    /// `None` for every overwrite that stages no variable-length data, which is
11198    /// all of them but a `with_vlen_strings` one.
11199    vlen: Option<VlenOverwrite>,
11200}
11201
11202/// The variable-length half of an [`OverwriteBytes`]: the staged collections to
11203/// place, and the path they are recorded under once placed.
11204struct VlenOverwrite {
11205    staging: VlStringStaging,
11206    /// The overwritten dataset's path, the key its collections are recorded
11207    /// under in [`vl_overwrite_heaps`](WriteEngine::vl_overwrite_heaps) — and
11208    /// the key the ones a previous overwrite left are read back from, to be
11209    /// freed once this commit lands.
11210    path: ObjectPathBuf,
11211}
11212
11213impl OverwriteBytes {
11214    /// Bytes that need no resolving, and so name nothing to record or free.
11215    fn ready(raw: Vec<u8>) -> Self {
11216        Self { raw, vlen: None }
11217    }
11218}
11219
11220/// How a staged value overwrite (`write_dataset`) will be applied, decided by
11221/// [`WriteEngine::prepare_write`] during the all-or-nothing preflight.
11222// `allow` rather than `expect`, which is otherwise this module's habit: the lint
11223// fires on a 64-bit pointer width and not on i686, where the `Vec` and `usize`
11224// fields inside `MovingWrite` shrink and the variant gap falls back under
11225// clippy's threshold. An `expect` is therefore unfulfilled on exactly the two
11226// 32-bit CI jobs, both of which deny `unfulfilled_lint_expectations`.
11227#[allow(
11228    clippy::large_enum_variant,
11229    reason = "boxing the large variant would cost more than it saves: this enum is returned by \
11230              `prepare_write` and destructured at its one call site, which moves the `MovingWrite` \
11231              straight into a `Vec<MovingWrite>` that stores it unboxed either way -- so a box \
11232              here adds an allocation and a free without removing a single copy"
11233)]
11234enum WritePlan {
11235    /// A contiguous dataset whose new data is the same length as its existing,
11236    /// defined data block: overwrite the bytes straight in place at `data_addr`.
11237    /// No object header is rewritten.
11238    ///
11239    /// The superblock root is not flipped either — *unless* the bytes still
11240    /// carry variable-length staging, which appends a heap collection and so
11241    /// moves end-of-file, a figure only the superblock records. `commit`'s
11242    /// fast path excludes exactly that case (issue #321).
11243    InPlace {
11244        data_addr: u64,
11245        bytes: OverwriteBytes,
11246    },
11247    /// A chunked dataset overwritten chunk-by-chunk in place: each `(addr, bytes)`
11248    /// pair is written straight over an existing chunk slot. Used when every new
11249    /// (re-encoded) chunk is the same byte length as the slot it replaces — an
11250    /// unfiltered chunked overwrite (chunk sizes are fixed by the unchanged shape)
11251    /// or a filtered one whose re-encoded chunks happen to match. Like
11252    /// [`InPlace`](WritePlan::InPlace) it touches no header and no chunk index, so
11253    /// the superblock root is not flipped.
11254    InPlaceChunks { writes: Vec<(u64, Vec<u8>)> },
11255    /// The dataset's header relocates: a contiguous resize, a compact rewrite, or
11256    /// a chunked rebuild. The parent group is rebuilt and its link patched. See
11257    /// [`MovingWrite`].
11258    Moving(MovingWrite),
11259}
11260
11261/// A value overwrite that relocates the dataset's object header — a contiguous
11262/// dataset whose data length changed (or had no data block) or a compact dataset
11263/// whose inline bytes are replaced. On apply the new data and a rewritten header
11264/// are written at end-of-file (or into reusable freed space), and the parent
11265/// group's link is repointed at the new header address.
11266enum MovingWrite {
11267    /// A contiguous dataset: write `raw` elsewhere, patch the data-layout address
11268    /// at `addr_off` in the verbatim header `region`, rewrite the header, and free
11269    /// `old_extent` (the prior data block, if any) after the commit lands.
11270    Contiguous {
11271        region: OhRegion,
11272        addr_off: usize,
11273        bytes: OverwriteBytes,
11274        old_extent: Option<(u64, u64)>,
11275    },
11276    /// A compact dataset: rebuild the header `region` with the bytes inline.
11277    Compact {
11278        region: OhRegion,
11279        bytes: OverwriteBytes,
11280    },
11281    /// A chunked dataset whose new (re-encoded) chunks do not all fit their
11282    /// existing slots, so its whole storage is rebuilt and relocated. A fresh
11283    /// chunk-data blob and index are placed — in a freed region that fits, else at
11284    /// end-of-file (via the verbatim
11285    /// layout path, carrying the chunk bytes and the source filter `pipeline_message`
11286    /// unchanged — no recompression and no filter-parameter reconstruction), the
11287    /// data-layout message in the verbatim header `region` is swapped for the new
11288    /// one (every other header message — datatype, dataspace, fill value, filter
11289    /// pipeline, and attributes, including a dense attribute heap referenced by an
11290    /// untouched Attribute Info message — is preserved verbatim), and the old
11291    /// chunk storage at `old_addr` is freed after the commit lands.
11292    Chunked {
11293        region: OhRegion,
11294        /// See [`CopyTree::DatasetChunked::shape`].
11295        shape: Vec<u64>,
11296        chunk_dims: Vec<u64>,
11297        element_size: NonZeroUsize,
11298        maxshape: Option<Vec<MaxExtent>>,
11299        pipeline_message: Option<Vec<u8>>,
11300        payload: ChunkPayload,
11301        old_addr: u64,
11302    },
11303    /// A relocating **append** to a chunked, unlimited, Extensible-Array-indexed
11304    /// dataset (`append_dataset`). The dataset's existing chunk *data* stays in
11305    /// place; only the newly-appended chunks and any rewritten trailing partial
11306    /// chunk (`new_chunk_bytes`, already compressed through the on-disk pipeline)
11307    /// are placed (reusing freed raw space where it fits), a fresh Extensible
11308    /// Array is rebuilt over
11309    /// `kept_chunks ++ new_chunk_bytes`, the verbatim header `region`'s dataspace
11310    /// message is grown (`new_dataspace_body`) and its data-layout message
11311    /// repointed at the new index (every other message — datatype, filter
11312    /// pipeline, fill value, attributes — preserved verbatim), and the header is
11313    /// relocated. After the commit lands, only the old index structure at
11314    /// `old_addr`, the old header, and the relocated old trailing chunk
11315    /// (`old_tail_extent`) are freed — never the kept chunk data, which both the
11316    /// old and new index share during the commit.
11317    AppendedChunks {
11318        region: OhRegion,
11319        /// The grown dataspace message body (v2-serialized), current axis-0
11320        /// dimension increased, maximum dimensions (unlimited) preserved.
11321        new_dataspace_body: Vec<u8>,
11322        /// Rank-only spatial chunk dimensions, for the rebuilt v4 layout message.
11323        chunk_dims_u32: Vec<u32>,
11324        element_size: NonZeroUsize,
11325        /// Full (uncompressed) chunk byte size = product(spatial) * element_size.
11326        has_filters: bool,
11327        /// Existing complete chunks, in index order, carried by metadata alone —
11328        /// their base-relative addresses, on-disk stored sizes, and filter masks
11329        /// preserved exactly (a nonzero mask from a C/h5py-skipped filter is kept).
11330        kept_chunks: Vec<WrittenChunk>,
11331        /// The appended chunks in index order: the recompressed trailing partial
11332        /// chunk first (when present), then the remaining new full chunks.
11333        new_chunk_bytes: Vec<Vec<u8>>,
11334        /// The dataset header address, for old-index and old-header reclaim.
11335        old_addr: u64,
11336        /// The absolute `(addr, len)` of the old trailing partial chunk's data
11337        /// block when it was rewritten, freed after the commit lands. `None` when
11338        /// the append was chunk-aligned (no partial chunk to rewrite).
11339        old_tail_extent: Option<(u64, u64)>,
11340    },
11341    /// A dataset-attribute edit (`set_dataset_attr` / `remove_dataset_attr`).
11342    /// The verbatim header `region` already carries whatever the preflight could
11343    /// resolve; `attrs` is what [`WriteEngine::write_moving`] still has to place —
11344    /// a variable-length attribute's heap collection, or a whole dense set to
11345    /// rebuild. The rewritten header is relocated and the parent link repointed,
11346    /// exactly like the other relocating writes — but the data-layout message is
11347    /// preserved verbatim, so the dataset's chunk data and index stay in place;
11348    /// only the old header is freed.
11349    AttrEdit {
11350        region: OhRegion,
11351        attrs: EditedAttrs,
11352    },
11353}
11354
11355/// The chunk data a relocating chunked overwrite ([`MovingWrite::Chunked`])
11356/// will place, either already encoded or still to be.
11357enum ChunkPayload {
11358    /// Split and encoded by the preflight, which had to do that anyway to find
11359    /// out whether the chunks still fit their slots.
11360    Encoded(Vec<Vec<u8>>),
11361    /// Element bytes that still carry unresolved variable-length references
11362    /// ([`OverwriteBytes::vlen`]), so the split has to wait for the apply
11363    /// phase: a filtered chunk's compressed length depends on the heap
11364    /// addresses patched into it, which is why the preflight cannot split
11365    /// first and patch after (issue #321).
11366    ///
11367    /// Such an overwrite therefore always relocates, which is what keeps the
11368    /// single-hard-link refusal (the one every [`MovingWrite`] answers to) in
11369    /// the preflight, where it belongs: whether it could have stayed in its
11370    /// slots is not knowable until those lengths exist.
11371    Deferred {
11372        bytes: OverwriteBytes,
11373        /// What the edge overhang of a partial chunk must hold (issue #296).
11374        padding: crate::fill_value::PaddingFill,
11375        /// The dataset's datatype, for the filter pipeline's [`ChunkContext`].
11376        dt: crate::datatype::Datatype,
11377    },
11378}
11379
11380/// A staged dataset reduced to the pieces the writer needs.
11381struct FlatDataset {
11382    name: LinkNameBuf,
11383    dt: crate::datatype::Datatype,
11384    ds: Dataspace,
11385    raw: Vec<u8>,
11386    attrs: Vec<crate::attribute::AttributeMessage>,
11387    /// Chunked/filtered storage options. When [`ChunkOptions::is_chunked`] is
11388    /// false and `maxshape` is `None`, the dataset is written as contiguous,
11389    /// unfiltered storage; otherwise its chunk data and index are built by
11390    /// [`WriteEngine::build_chunked_dataset`].
11391    chunk_options: ChunkOptions,
11392    /// Maximum dimensions for an extensible dataset, mirrored into
11393    /// `ds.max_dimensions`. `None` for a fixed-shape dataset. A maxshape with an
11394    /// unlimited dimension selects the extensible-array chunk index, and a
11395    /// finite maxshape the fixed-array or single-chunk one.
11396    maxshape: Option<Vec<MaxExtent>>,
11397    /// Variable-length attributes still carrying a placeholder heap address:
11398    /// (index into `attrs`, that attribute's global heap collections).
11399    /// Resolved in the apply loop right before this dataset's header is built.
11400    vl_attrs: Vec<(usize, Vec<Vec<u8>>)>,
11401    /// Whether `attrs` goes in a fractal heap rather than the object header,
11402    /// decided by `file_writer::needs_dense_attrs` where the set was validated
11403    /// against what that heap can represent.
11404    ///
11405    /// Carried rather than asked again in the apply loop, so the set that was
11406    /// checked and the set that is placed cannot be decided differently — and
11407    /// because the question costs a serialization of every attribute to answer,
11408    /// which is most expensive for exactly the oversized attribute it selects
11409    /// for. Patching a variable-length attribute's references is
11410    /// length-preserving, so nothing between the two phases can change the
11411    /// answer.
11412    attrs_are_dense: bool,
11413    /// A staged variable-length-string dataset's element references (still
11414    /// carrying placeholder heap addresses in `raw`) and global heap
11415    /// collections. Resolved in the apply loop right before `raw` is appended.
11416    vl_string_staging: Option<VlStringStaging>,
11417    /// An object-reference dataset's per-element targets, still unresolved.
11418    /// Resolved (see [`WriteEngine::resolve_reference_target`]) and patched
11419    /// into `raw` in the apply loop, once every object this commit places has
11420    /// a known address. `None` for an ordinary dataset.
11421    reference_targets: Option<Vec<ObjectRefPatch>>,
11422    /// A user-defined fill value, encoded in the dataset's datatype, or `None`
11423    /// for the library default. Validated against the datatype element size in
11424    /// [`flatten_dataset`].
11425    fill: Option<Vec<u8>>,
11426    /// The provenance attributes' own inputs, kept so they can be rebuilt when
11427    /// `raw` changes before the commit writes it. `None` for a dataset staged
11428    /// without [`DatasetBuilder::with_provenance`](crate::DatasetBuilder::with_provenance).
11429    #[cfg(feature = "provenance")]
11430    provenance: Option<StagedProvenance>,
11431}
11432
11433/// A staged dataset's provenance metadata, and where in
11434/// [`FlatDataset::attrs`] the attributes it produced sit.
11435///
11436/// The hash is over the dataset's raw bytes, so it is only correct as of the
11437/// bytes it was taken from; [`FlatDataset::rebuild_provenance`] is what keeps
11438/// it that way when [`WriteEngine::extend_staged_dataset`] grows them.
11439#[cfg(feature = "provenance")]
11440struct StagedProvenance {
11441    /// What [`crate::provenance::Provenance::build_attrs`] was, and is, called
11442    /// with. Held rather than re-derived from the attributes it produced, which
11443    /// a caller may also have set by hand.
11444    inputs: crate::provenance::Provenance,
11445    /// The index in `attrs` where its attributes begin. They are appended last
11446    /// and are the only ones this may replace, so everything below it — the
11447    /// caller's own attributes, and the `vl_attrs` indices into them — is left
11448    /// exactly as it was.
11449    attrs_start: usize,
11450}
11451
11452#[cfg(feature = "provenance")]
11453impl FlatDataset {
11454    /// Recompute this dataset's provenance attributes over the bytes it now
11455    /// holds. A no-op for a dataset staged without provenance.
11456    ///
11457    /// The rebuilt set is the same attributes in the same order and of the same
11458    /// serialized length — the digest is 64 hex characters whatever it hashes —
11459    /// so `attrs_are_dense`, decided once in [`flatten_dataset`] over the
11460    /// original set, still describes this one.
11461    fn rebuild_provenance(&mut self) {
11462        let Some(prov) = &self.provenance else {
11463            return;
11464        };
11465        self.attrs.truncate(prov.attrs_start);
11466        let rebuilt = prov.inputs.build_attrs(&self.raw);
11467        self.attrs.extend(rebuilt);
11468    }
11469}
11470
11471/// A borrow adapter that drives the shared Extensible-Array append engine
11472/// ([`crate::chunk_index_inplace`]) against the engine's *own* image and
11473/// superblock, so a session runs an immediate O(1) in-place append without
11474/// constructing a second writable handle (which would take a second exclusive
11475/// lock and keep a divergent view of the file). It borrows only those two
11476/// fields, leaving [`WriteEngine::located`] independently borrowable.
11477///
11478/// [`Store`] is the append engine's view of a file — an image *plus* the
11479/// superblock, which the image itself knows nothing about. Pairing them here is
11480/// all this adapter does; every primitive delegates, so the image's own
11481/// write-ordering discipline is what applies.
11482///
11483/// It carries the session's paged-file state too, so `alloc_raw` keeps a paged
11484/// file's pages homogeneous through exactly the rule the staged commit uses
11485/// ([`PagedEdit::begin`]). Before issue #198 there were two copies of that state —
11486/// one per engine — and the whole-file editor's copy was reachable only from the
11487/// commit path, so it had to refuse an in-place append to a paged file outright.
11488struct EditStore<'a> {
11489    image: &'a mut dyn FileImage,
11490    superblock: &'a mut Superblock,
11491    sb_sig_off: u64,
11492    /// The session's paged state when the file is paged, `None` otherwise. A
11493    /// borrow rather than a copy: padding recorded here has to reach the manager
11494    /// rewrite at the next commit or at close.
11495    paged: Option<&'a mut PagedEdit>,
11496    /// The list an immediate append may draw from, and `None` when it may draw
11497    /// from none — [`WriteEngine::immediate_reuse_allowed`] states which. It is
11498    /// the session's own free list on a file that forgets its holes at close, and
11499    /// [`WriteEngine::reserved`] — space already taken out of the on-disk
11500    /// managers — on one that persists them.
11501    free: Option<&'a mut FreeList>,
11502    /// The session's `fsync` cadence, carried by value: the append engine's own
11503    /// ordered barriers ([`apply_ea_append`]) are durability points like the
11504    /// commit's, and answer to the same policy.
11505    sync_policy: SyncPolicy,
11506}
11507
11508impl EditStore<'_> {
11509    /// Append `bytes` into a raw page, padding the tail page first when a paged
11510    /// file's tail holds metadata. A plain append on the common non-paged file.
11511    ///
11512    /// Raw is the only page type this adapter allocates: see
11513    /// [`Store::alloc_raw`] for why an extensible-array index block belongs in a
11514    /// raw page here.
11515    fn append_into_raw_page(&mut self, bytes: &[u8]) -> Result<u64, Error> {
11516        if let Some(pg) = self.paged.as_deref_mut() {
11517            pg.begin(self.image, PageType::Raw)?;
11518        }
11519        self.image.append(bytes)
11520    }
11521}
11522
11523impl crate::source::Source for EditStore<'_> {
11524    fn len(&self) -> u64 {
11525        self.image.len()
11526    }
11527    fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), crate::error::FormatError> {
11528        self.image.read_at(offset, buf)
11529    }
11530    fn read_metadata_at(
11531        &self,
11532        offset: u64,
11533        len: usize,
11534    ) -> Result<Vec<u8>, crate::error::FormatError> {
11535        self.image.read_metadata_at(offset, len)
11536    }
11537
11538    // Forwarded because this is a `Source` wrapper over the image and the
11539    // trait requires it, not because anything asks yet: `File`'s own accessor
11540    // reaches the image directly. A wrapper that answered `None` for a live
11541    // cache is the failure that rule exists to prevent.
11542    fn metadata_cache_stats(&self) -> Option<crate::source::MetadataCacheStats> {
11543        self.image.metadata_cache_stats()
11544    }
11545
11546    fn reset_metadata_cache_stats(&self) {
11547        self.image.reset_metadata_cache_stats();
11548    }
11549}
11550
11551impl Store for EditStore<'_> {
11552    fn offset_size(&self) -> u8 {
11553        self.superblock.offset_size
11554    }
11555    fn length_size(&self) -> u8 {
11556        self.superblock.length_size
11557    }
11558    fn alloc_raw(&mut self, bytes: &[u8]) -> Result<u64, Error> {
11559        if let Some(free) = self.free.as_deref_mut() {
11560            if let Some(addr) = free.alloc(bytes.len() as u64) {
11561                self.image.write_at(addr, bytes)?;
11562                return Ok(addr);
11563            }
11564        }
11565        self.append_into_raw_page(bytes)
11566    }
11567    fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<(), Error> {
11568        self.image.write_at(offset, bytes)
11569    }
11570    fn patch_superblock_eof(&mut self) -> Result<(), Error> {
11571        // Advance only the recorded end-of-file and re-serialize the superblock in
11572        // place. Unlike `WriteEngine::commit`, this deliberately does NOT clear the
11573        // consistency flags and does NOT repoint the root group: base_address is 0
11574        // for every in-place-append-eligible file, so the normalized-absolute root
11575        // address serializes back to the same stored value.
11576        let eof = self.image.len();
11577        self.superblock.eof_address = eof;
11578        let bytes = self.superblock.serialize();
11579        self.write_at(self.sb_sig_off, &bytes)
11580    }
11581    fn sync(&mut self) -> Result<(), Error> {
11582        barrier_data(self.image, self.sync_policy)
11583    }
11584}
11585
11586/// Re-tags a rejection from the shared append engine (`AppendUnsupported`) as the fast-path
11587/// [`Error::AppendInPlaceUnsupported`], which a caller catches to fall back to the staged
11588/// [`stage_dataset_append`](WriteEngine::stage_dataset_append). That path covers the filtered
11589/// partial-trailing-chunk case and index-geometry limits. A platform-width limit applies on both
11590/// paths: the engine reports it as `AppendUnsupported`, and the staged path as
11591/// [`FormatError::ValueTooLargeForPlatform`]. I/O and format errors pass through unchanged.
11592pub(crate) fn as_inplace_error(e: Error) -> Error {
11593    match e {
11594        Error::AppendUnsupported(m) => Error::AppendInPlaceUnsupported(m),
11595        other => other,
11596    }
11597}
11598
11599/// Validate a gathered append's bytes against a located dataset: the byte
11600/// length must be a whole number of elements, and the element datatype must
11601/// match the on-disk datatype (or, for a raw append, be raw-appendable).
11602/// Returns the appended element count (`0` = nothing to do). Shared by
11603/// [`WriteEngine::append_inplace_gathered`]'s path and the bounded backend's immediate
11604/// append so the acceptance rules stay identical.
11605pub(crate) fn validate_gathered_append(st: &LocatedState, b: &AppendBuilder) -> Result<u64, Error> {
11606    let raw = b.raw();
11607    if raw.len() % st.element_size != 0 {
11608        return Err(Error::AppendInPlaceUnsupported(
11609            "appended byte length is not a whole number of elements",
11610        ));
11611    }
11612    match b.elem_dt() {
11613        Some(expected) if *expected != st.datatype => {
11614            return Err(Error::AppendInPlaceUnsupported(
11615                "append datatype does not match the on-disk dataset (wrong element \
11616                 type or byte order)",
11617            ));
11618        }
11619        Some(_) => {}
11620        None => {
11621            if !datatype_is_raw_appendable(&st.datatype) {
11622                return Err(Error::AppendInPlaceUnsupported(
11623                    "append_raw onto this dataset's datatype (non-little-endian, \
11624                     variable-length, or reference) could misencode the bytes; use a \
11625                     typed append",
11626                ));
11627            }
11628        }
11629    }
11630    Ok((raw.len() / st.element_size) as u64)
11631}
11632
11633/// Locate the dataset at `oh_addr` in `file` and build its [`LocatedState`],
11634/// validating in-place append eligibility (rank-1 / unlimited / Extensible-Array
11635/// indexed, a nonzero chunk length, and a re-encodable filter pipeline). A
11636/// dataset this path cannot append to takes an
11637/// [`Error::AppendInPlaceUnsupported`]. A datatype or filter pipeline message
11638/// that does not read or parse returns the [`Error::Format`] the read or the
11639/// parse produced.
11640pub(crate) fn locate_dataset_state<F: Store>(
11641    file: &F,
11642    oh_addr: u64,
11643) -> Result<LocatedState, Error> {
11644    let result = Located::locate_at(file, oh_addr, Error::AppendInPlaceUnsupported)?;
11645    if result.located.chunk_elems == 0 {
11646        return Err(Error::AppendInPlaceUnsupported(
11647            "in-place append requires a nonzero chunk length",
11648        ));
11649    }
11650    let (dt_off, dt_size) = result.spans.datatype;
11651    let dt_bytes = file.read_metadata_at(dt_off, dt_size)?;
11652    let (datatype, _) = Datatype::parse(&dt_bytes)?;
11653    let pipeline = match result.spans.filter {
11654        Some((fb, fsize)) => {
11655            let fp_bytes = file.read_metadata_at(fb, fsize)?;
11656            let parsed = FilterPipeline::parse(&fp_bytes)?;
11657            if !pipeline_reencodable(&parsed) {
11658                return Err(Error::AppendInPlaceUnsupported(
11659                    "dataset uses a filter this engine cannot re-encode",
11660                ));
11661            }
11662            Some(parsed)
11663        }
11664        None => None,
11665    };
11666    let element_size = result.located.elem_bytes;
11667    let spatial = vec![result.located.chunk_elems];
11668    // A fill message that cannot be read leaves `PaddingFill::Unknown`, which
11669    // fails only where a chunk actually needs padding — an append that lands on
11670    // a chunk boundary asks nothing of it.
11671    let fill = match result.spans.fill {
11672        Some((msg_type, off, size)) => match file.read_metadata_at(off, size) {
11673            Ok(body) => crate::fill_value::PaddingFill::from_message(msg_type, &body),
11674            Err(_) => crate::fill_value::PaddingFill::Unknown,
11675        },
11676        None => crate::fill_value::PaddingFill::Zero,
11677    };
11678    Ok(LocatedState {
11679        loc: result.located,
11680        datatype,
11681        spatial,
11682        element_size,
11683        pipeline,
11684        fill,
11685    })
11686}
11687
11688/// Group `(parent group, item)` pairs by their parent, preserving the input
11689/// order within each group.
11690///
11691/// The one rule by which a commit's staged datasets become per-group batches:
11692/// the preflight groups borrowed ones to prove its guards, and the apply loop
11693/// groups the owned ones it places, so the order
11694/// [`preflight_reference_targets`](WriteEngine::preflight_reference_targets)
11695/// replays is the order the apply loop uses by construction.
11696fn group_by_parent<K: Ord, T>(items: impl IntoIterator<Item = (K, T)>) -> BTreeMap<K, Vec<T>> {
11697    let mut out: BTreeMap<K, Vec<T>> = BTreeMap::new();
11698    for (parent, item) in items {
11699        out.entry(parent).or_default().push(item);
11700    }
11701    out
11702}
11703
11704/// Ensure a node exists for every ancestor prefix of `path` (so each is rebuilt
11705/// and can re-wire its child link). Does not set `is_new`.
11706fn ensure_ancestors(nodes: &mut BTreeMap<ObjectPathBuf, Node>, path: &ObjectPathBuf) {
11707    for len in 0..=path.len() {
11708        nodes.entry(path.prefix(len)).or_default();
11709    }
11710}
11711
11712/// Validate that every reclaim span `(addr, len)` is non-empty, ends at or
11713/// before `eof`, and that no two overlap; sorts `spans` by address as a side
11714/// effect. Returns `false` on any violation so the caller can decline to
11715/// reclaim the object rather than feed the free list an out-of-bounds or
11716/// overlapping (double-free) region. Touching spans are allowed — the free list
11717/// coalesces them.
11718fn spans_disjoint_in_bounds(spans: &mut [(u64, u64)], eof: u64) -> bool {
11719    for &(addr, len) in spans.iter() {
11720        match addr.checked_add(len) {
11721            Some(end) if len > 0 && end <= eof => {}
11722            _ => return false,
11723        }
11724    }
11725    spans.sort_unstable_by_key(|&(addr, _)| addr);
11726    spans.windows(2).all(|w| w[0].0 + w[0].1 <= w[1].0)
11727}
11728
11729/// Sanitize the accumulated free spans for a whole commit so the free list never
11730/// sees an out-of-bounds or overlapping (double-free) region: drop empty or
11731/// past-`eof` spans, sort by address, then drop any span overlapping one already
11732/// kept. Dropping only leaks (the bytes stay allocated); it never frees a live
11733/// region. With the last-hard-link guard in force nothing should be dropped for
11734/// a well-formed file — this is a backstop, not the primary defense.
11735fn retain_disjoint_in_bounds(spans: &mut Vec<(u64, u64, FreeClass)>, eof: u64) {
11736    spans.retain(|&(addr, len, _)| len > 0 && addr.checked_add(len).is_some_and(|e| e <= eof));
11737    spans.sort_unstable_by_key(|&(addr, _, _)| addr);
11738    let mut kept_end = 0u64;
11739    spans.retain(|&(addr, len, _)| {
11740        if addr >= kept_end {
11741            kept_end = addr + len;
11742            true
11743        } else {
11744            false // overlaps a span already kept; leak it rather than double-free
11745        }
11746    });
11747}
11748
11749/// Whether some `data` span abuts the run of `index` blocks — ends exactly where
11750/// the index begins, or begins exactly where it ends. The geometric half of
11751/// [`WriteEngine::index_is_provably_raw`], where the reasoning lives; split out so
11752/// the rule can be stated against spans alone.
11753///
11754/// An empty index is vacuously placed: there is nothing to file.
11755fn index_abuts_chunk_data(data: &[(u64, u64)], index: &[(u64, u64)]) -> bool {
11756    if index.is_empty() {
11757        return true;
11758    }
11759    // The index as a whole: this crate writes its blocks as one run beside the
11760    // chunk data, so a data span abutting either end places all of them.
11761    let (Some(index_start), Some(index_end)) = (
11762        index.iter().map(|&(a, _)| a).min(),
11763        index.iter().filter_map(|&(a, l)| a.checked_add(l)).max(),
11764    ) else {
11765        return false;
11766    };
11767    // A zero-length span touches an address without occupying a byte beside it,
11768    // so it places nothing.
11769    data.iter().any(|&(addr, len)| {
11770        len > 0 && (addr.checked_add(len) == Some(index_start) || addr == index_end)
11771    })
11772}
11773
11774/// Whether any byte of the `index` run lies in page 0 of a paged file, whose
11775/// first bytes are the superblock and which is therefore a metadata page. The
11776/// screening half of [`WriteEngine::index_is_provably_raw`], where the reasoning
11777/// lives.
11778///
11779/// A page size of zero is not a paged file's; it cannot be reasoned about, so it
11780/// screens everything out.
11781fn index_touches_page_zero(index: &[(u64, u64)], page_size: u64) -> bool {
11782    if page_size == 0 {
11783        return !index.is_empty();
11784    }
11785    index.iter().any(|&(addr, len)| len > 0 && addr < page_size)
11786}
11787
11788/// Tag object-header chunk spans as file metadata. Every span
11789/// [`oh_chunk_spans`](WriteEngine::oh_chunk_spans) returns is part of an object
11790/// header, so the page type is the same for all of them.
11791fn meta_spans(spans: Vec<(u64, u64)>) -> impl Iterator<Item = (u64, u64, FreeClass)> {
11792    spans
11793        .into_iter()
11794        .map(|(a, l)| (a, l, FreeClass::Page(PageType::Meta)))
11795}
11796
11797/// Validate a staged dataset and reduce it to a [`FlatDataset`]. `name` is the link name the
11798/// dataset has in its parent group, taken off the staged path by the caller. Contiguous,
11799/// unfiltered datasets are emitted as such; chunked, filtered, or extensible
11800/// datasets carry their [`ChunkOptions`] and maxshape through to the commit,
11801/// where [`WriteEngine::build_chunked_dataset`] lays out their chunk data and
11802/// index. An empty (zero-element) shape is allowed under either storage,
11803/// mirroring the whole-file writer: a contiguous one takes the `HADDR_UNDEF`
11804/// data address (see the apply loop) and a chunked one an index over zero
11805/// chunks, which is what an extensible dataset is created as before the first
11806/// append fills it. The geometry validation below still requires explicit chunk
11807/// dimensions for it — auto-chunking has no shape to derive them from. A
11808/// `provenance` dataset has its SHA-256/creator/timestamp/source attributes
11809/// computed here from `raw`, exactly as the whole-file writer does. A
11810/// variable-length attribute's global heap collection is built here (it is
11811/// fully self-contained — no address of its own) but placed and patched later,
11812/// in the apply loop, once its final address is known; likewise a
11813/// variable-length-string dataset's staged references and collection
11814/// (`db.vl_string_staging`) are carried through unresolved. An object-reference
11815/// dataset's per-element targets (`db.reference_targets`) are likewise carried
11816/// through unresolved — resolving a path target requires knowing every other
11817/// object this commit places, which is only known well into the apply loop
11818/// (see [`WriteEngine::resolve_reference_target`]). Rejects any remaining
11819/// feature this engine cannot reproduce faithfully: a
11820/// chunked/extensible variable-length-string or object-reference dataset, or a
11821/// filter pipeline the build cannot construct.
11822fn flatten_dataset(db: DatasetBuilder, name: LinkNameBuf) -> Result<FlatDataset, Error> {
11823    let dt = db
11824        .datatype
11825        .ok_or(Error::EditUnsupported("dataset has no datatype/data"))?;
11826    let shape = db
11827        .shape
11828        .ok_or(Error::EditUnsupported("dataset has no shape"))?;
11829    let is_empty = shape.contains(&0);
11830    let chunked = db.chunk_options.is_chunked() || db.maxshape.is_some();
11831    // Storage this engine allocates for every dataset it places: it appends into
11832    // an existing layout, and the undefined data address the whole-file writer
11833    // gives an unallocated dataset (issue #293) has no equivalent here. Refused
11834    // by name rather than left to the "dataset has no data" arm below, which is
11835    // the same symptom from the opposite cause, and refused rather than ignored,
11836    // since ignoring it would write out the grid of fill values the caller asked
11837    // not to have.
11838    if db.allocation == StorageAllocation::Unallocated {
11839        return Err(Error::EditUnsupported(
11840            "a dataset with unallocated storage cannot be added to an existing file in place",
11841        ));
11842    }
11843    // Variable-length string element references live in the global heap, whose
11844    // address is only known once the apply loop places the collection. For
11845    // chunked/filtered/resizable storage the references sit inside chunks
11846    // written before that address exists, so patching them in is impossible.
11847    //
11848    // The whole-file writer lifted the same restriction by placing such a
11849    // dataset's collections ahead of everything else (issue #109); this engine
11850    // appends into an existing layout, where there is no "ahead" to place them
11851    // in, so the equivalent fix is a separate piece of work.
11852    if db.vl_string_staging.is_some() && chunked {
11853        return Err(Error::EditUnsupported(
11854            "chunked or extensible variable-length-string datasets cannot be added in place yet",
11855        ));
11856    }
11857    // Object-reference elements are resolved (see `resolve_reference_target`)
11858    // and patched into `raw` right before it is appended; for chunked storage
11859    // that patch would need to reach inside already-built chunk data, which
11860    // this engine does not support (mirrors the variable-length-string
11861    // refusal above — untested and unneeded combination for v1).
11862    if db.reference_targets.is_some() && chunked {
11863        return Err(Error::EditUnsupported(
11864            "chunked or extensible object-reference datasets cannot be added in place yet",
11865        ));
11866    }
11867    let raw = if is_empty {
11868        db.data.unwrap_or_default()
11869    } else {
11870        db.data
11871            .ok_or(Error::EditUnsupported("dataset has no data"))?
11872    };
11873
11874    // Refused for the same reason the whole-file writer refuses it: nothing
11875    // occupies zero bytes per element, the writers divide by the element size,
11876    // and a caller-built `Datatype` never passes through `Datatype::parse`.
11877    // Taking it as a `NonZeroUsize` hands the proof to the staging below rather
11878    // than leaving each step to re-derive it.
11879    let elem_size = dt.element_size_usize()?;
11880
11881    let elem = elem_size.get() as u64;
11882    // Multiply with checked arithmetic: an absurd shape whose element count
11883    // (or byte size) overflows `u64` is refused rather than panicking in a
11884    // debug build or silently wrapping in release (which could let a wrapped
11885    // product spuriously match `raw.len()`). For a zero-element shape this
11886    // expected length is always 0 (a `0` dimension makes every checked
11887    // multiplication `Some(0)` regardless of the other dimensions), so this
11888    // also catches data mistakenly supplied for a shape that holds nothing.
11889    let expected = shape
11890        .iter()
11891        .try_fold(1u64, |acc, &d| acc.checked_mul(d))
11892        .and_then(|n| n.checked_mul(elem));
11893    match expected {
11894        Some(expected) if raw.len() as u64 == expected => {}
11895        Some(_) => {
11896            return Err(Error::EditUnsupported(
11897                "dataset data length does not match its shape",
11898            ));
11899        }
11900        None => {
11901            return Err(Error::EditUnsupported(
11902                "dataset shape is too large to address on this platform",
11903            ));
11904        }
11905    }
11906
11907    if chunked {
11908        // Refuse malformed chunk geometry up front (the same validation the
11909        // whole-file writer applies), so a bad request — chunk dimensions of the
11910        // wrong rank, a zero chunk dimension, an inconsistent maximum shape, or
11911        // chunking a scalar — never reaches and panics the chunk splitter, nor
11912        // yields a dataset the reader cannot decode.
11913        Extent::new(&shape, db.maxshape.as_deref())
11914            .and_then(|extent| db.chunk_options.validate_geometry(extent))
11915            .map_err(Error::EditUnsupported)?;
11916        // A filter this build cannot apply is refused up front rather than
11917        // failing mid-apply when a chunk is compressed.
11918        db.chunk_options
11919            .refuse_unavailable_filters()
11920            .map_err(Error::EditUnsupported)?;
11921        // Validate the requested filter pipeline now — before any file bytes are
11922        // written — so an unsupported filter, an incompatible datatype, or a
11923        // fill value the filter cannot record is refused up front; the chunk
11924        // data itself is laid out in the commit's apply phase. A filter the
11925        // build compiled out is not among them: `build_pipeline` emits its
11926        // descriptor regardless, which is why the check above exists. Chunked/filtered
11927        // storage flows through the very builder the normal writer uses
11928        // ([`compress_chunks`] + [`assemble_chunked_at`] + [`build_chunked_dataset_oh`]),
11929        // so the
11930        // resulting object header is byte-identical to a freshly written one.
11931        //
11932        // The fill value goes in for the same reason the apply phase passes it:
11933        // scale-offset records it in the filter's parameters, so a validation
11934        // that left it out would be checking a pipeline the commit does not
11935        // build — and would pass a fill value the encoder later refuses.
11936        let chunk_dims = db.chunk_options.resolve_chunk_dims(&shape);
11937        let ctx = ChunkContext::from_datatype(&chunk_dims, &dt)?;
11938        db.chunk_options.build_pipeline(
11939            &ctx,
11940            crate::fill_value::FillPattern::new(
11941                db.fill.as_deref(),
11942                ctx.element_size.narrow::<NonZeroUsize>()?,
11943            ),
11944        )?;
11945    }
11946
11947    // The link message body (whose length is independent of the address) must
11948    // fit the object-header message's u16 size field; a pathologically long
11949    // name would otherwise overflow it into silent corruption. Measured with a
11950    // creation index present — the widest form, written into a group that tracks
11951    // link creation order — since the parent group is not known here.
11952    let mut sized = make_link(name.as_str(), StoredAddress::new(0));
11953    sized.creation_order = Some(0);
11954    if sized.serialize(OFFSET_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
11955        return Err(Error::EditUnsupported(
11956            "dataset name is too long to encode as a link message",
11957        ));
11958    }
11959
11960    let ds = Dataspace {
11961        space_type: if shape.is_empty() {
11962            DataspaceType::Scalar
11963        } else {
11964            DataspaceType::Simple
11965        },
11966        #[expect(
11967            clippy::cast_possible_truncation,
11968            reason = "dataspace rank fits the 1-byte dimensionality field (HDF5 caps rank at 32)"
11969        )]
11970        rank: shape.len() as u8,
11971        dimensions: shape,
11972        // A chunked, extensible dataset records its maximum dimensions. A
11973        // fixed-shape dataset has none.
11974        max_dimensions: db.maxshape.clone(),
11975    };
11976    let mut attrs: Vec<crate::attribute::AttributeMessage> = Vec::with_capacity(db.attrs.len());
11977    for (n, v) in &db.attrs {
11978        attrs.push(v.to_message(n));
11979    }
11980    // The message above already carries a placeholder (heap address 0) for each
11981    // element of a variable-length string attribute; stage its self-contained
11982    // global heap collections here (no address of their own to resolve yet) and
11983    // record which `attrs` slot they patch once the apply loop places them.
11984    let mut vl_attrs: Vec<(usize, Vec<Vec<u8>>)> = Vec::new();
11985    for (i, (_, v)) in db.attrs.iter().enumerate() {
11986        if let Some(strings) = v.var_len_strings() {
11987            vl_attrs.push((i, build_global_heap_collections(strings)));
11988        }
11989    }
11990    // Appended last, and recorded as such: `raw` can still grow before the
11991    // commit writes it (`WriteEngine::extend_staged_dataset`), and the digest
11992    // has to follow it.
11993    #[cfg(feature = "provenance")]
11994    let provenance = db.provenance.as_ref().map(|prov| {
11995        let inputs = crate::provenance::Provenance {
11996            creator: prov.creator.clone(),
11997            timestamp: prov.timestamp.clone(),
11998            source: prov.source.clone(),
11999        };
12000        let attrs_start = attrs.len();
12001        attrs.extend(inputs.build_attrs(&raw));
12002        StagedProvenance {
12003            inputs,
12004            attrs_start,
12005        }
12006    });
12007    // More attributes than an object header keeps compactly, or one whose message
12008    // overflows its 2-byte message-size field, sends the set to a fractal heap —
12009    // the same disjunction, and the same heap, the whole-file writer uses. What
12010    // that heap cannot represent is refused here, in the preflight, so a staged
12011    // dataset that cannot be written is refused before the commit places a byte.
12012    let attrs_are_dense = crate::file_writer::needs_dense_attrs(&attrs);
12013    if attrs_are_dense {
12014        crate::file_writer::dense_attrs_check(&attrs).map_err(Error::Format)?;
12015    }
12016
12017    // A committed datatype is an object of its own, which an in-place edit has no
12018    // way to place: it appends into an existing file rather than laying one out,
12019    // so there is nothing to resolve the named path against. Writing the type
12020    // inline instead would produce a dataset that reads correctly but no longer
12021    // shares the named type, so refuse by name. The whole-file writer places
12022    // them; [`crate::repack`] is the route from an edited file to one.
12023    if db.datatype_location.is_committed()
12024        || attrs.iter().any(|a| a.datatype_location.is_committed())
12025    {
12026        return Err(Error::EditUnsupported(
12027            "a dataset or attribute naming a committed (shared) datatype cannot be added in \
12028             place; write the file with FileBuilder instead",
12029        ));
12030    }
12031
12032    // A user-defined fill value is one element wide, so its byte length must
12033    // equal the datatype's element size (mirrors the whole-file writer's check).
12034    if let Some(fill) = &db.fill {
12035        let expected = elem.to_usize()?;
12036        if fill.len() != expected {
12037            return Err(Error::Format(FormatError::FillValueSizeMismatch {
12038                expected,
12039                actual: fill.len(),
12040            }));
12041        }
12042    }
12043
12044    Ok(FlatDataset {
12045        name,
12046        dt,
12047        ds,
12048        raw,
12049        attrs,
12050        chunk_options: db.chunk_options,
12051        maxshape: db.maxshape,
12052        vl_attrs,
12053        attrs_are_dense,
12054        vl_string_staging: db.vl_string_staging,
12055        reference_targets: db.reference_targets,
12056        fill: db.fill,
12057        #[cfg(feature = "provenance")]
12058        provenance,
12059    })
12060}
12061
12062/// A minimal Group Info message body (type 0x000A): version 0 with neither the
12063/// link-phase-change nor the estimated-entry fields stored. With both absent the
12064/// HDF5 C library fills `max_compact`/`min_dense` from its own defaults (8 and
12065/// 6). See [`ensure_group_info`] for why every group needs this message.
12066const GROUP_INFO_BODY: [u8; 2] = [0, 0];
12067
12068/// Whether every filter in `pipeline` is one this crate can *apply* (re-encode a
12069/// chunk through) — not merely decode. A pipeline with any other filter cannot be
12070/// re-encoded for an in-place overwrite, so the caller refuses with a typed error
12071/// rather than letting `compress_chunk` surface a raw `UnsupportedFilter`.
12072pub(crate) fn pipeline_reencodable(pipeline: &FilterPipeline) -> bool {
12073    pipeline.filters.iter().all(|f| match f.filter_id {
12074        FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_SCALEOFFSET | FILTER_LZF => {
12075            true
12076        }
12077        #[cfg(feature = "zfp")]
12078        crate::filter_pipeline::FILTER_ZFP => true,
12079        _ => false,
12080    })
12081}
12082
12083/// Whether re-encoding a chunk through `pipeline` reproduces the values it was
12084/// decoded from — the condition for rewriting a chunk that is **already
12085/// committed**, as growing a partial trailing chunk does.
12086///
12087/// Stricter than [`pipeline_reencodable`], and for a different question. That
12088/// one asks whether this crate can *apply* the filters at all, which is what a
12089/// brand-new chunk needs. This one asks whether decode-then-encode is the
12090/// identity, which is what a chunk somebody has already read needs. Deflate,
12091/// shuffle, fletcher32 and LZF are lossless by construction; scale-offset only
12092/// in its integer mode; ZFP fixed-rate quantizes every block to a bit budget, so
12093/// re-encoding it against a *different* set of neighbours in the same block
12094/// re-quantizes the values that were already there.
12095///
12096/// This is the line [`repack`](crate::repack())'s `check_pipeline` already draws
12097/// for its two re-encoding paths, stated once more here because the append
12098/// engine reaches it by a different route.
12099pub(crate) fn pipeline_lossless(pipeline: &FilterPipeline) -> bool {
12100    pipeline.filters.iter().all(|f| match f.filter_id {
12101        FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_LZF => true,
12102        // The integer mode subtracts a per-chunk minimum and packs the residuals
12103        // whole; float D-scale rounds to a decimal count and is documented lossy.
12104        // Anything else (float E-scale) this crate neither writes nor decodes.
12105        FILTER_SCALEOFFSET => matches!(
12106            crate::scaleoffset::scale_offset_mode(&f.client_data),
12107            Some((crate::scaleoffset::ScaleOffset::Integer(_), _))
12108        ),
12109        // Unknown ids included: a filter whose semantics are unknown is not one
12110        // to assume round-trips.
12111        _ => false,
12112    })
12113}
12114
12115/// The refusal both append paths raise for a lossy pipeline sitting on a partial
12116/// trailing chunk. Named here beside the predicate it goes with, and used from
12117/// [`chunk_index_inplace`](crate::chunk_index_inplace) as well as the staged
12118/// rebuild above.
12119pub(crate) const LOSSY_TAIL_REFUSAL: &str = "this dataset's filter pipeline is lossy (ZFP, or float D-scale scale-offset), and its \
12120     length is not a whole multiple of the chunk length: growing that trailing chunk would \
12121     decode and re-encode values that are already committed, changing them. Append whole \
12122     chunks from a chunk-aligned length instead";
12123
12124/// Rebuild a header message `region`, replacing the single Data Layout message's
12125/// record with one carrying `new_layout_body` and leaving every other message
12126/// (datatype, dataspace, fill value, filter pipeline, attributes, attribute info)
12127/// byte-for-byte. The replacement may differ in length from the original — a
12128/// chunked rebuild can change the index type and thus the layout message size — so
12129/// the record is rebuilt via [`OhRecordLayout::record`]. The chunked overwrite
12130/// and copy paths use this to relocate a dataset's chunk storage while preserving
12131/// the rest of its header exactly.
12132fn replace_layout_message(region: &OhRegion, new_layout_body: &[u8]) -> Result<OhRegion, Error> {
12133    let mut out = Vec::with_capacity(region.len());
12134    let mut p = 0;
12135    let mut replaced = false;
12136    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12137        if msg_type == MessageType::DATA_LAYOUT && !replaced {
12138            out.extend_from_slice(
12139                &region
12140                    .layout()
12141                    .record(MessageType::DATA_LAYOUT, new_layout_body),
12142            );
12143            replaced = true;
12144        } else {
12145            out.extend_from_slice(&region[p..body_end]);
12146        }
12147        p = body_end;
12148    }
12149    if !replaced {
12150        return Err(Error::EditUnsupported(
12151            "chunked dataset header has no data-layout message to relocate",
12152        ));
12153    }
12154    Ok(region.with_bytes(out))
12155}
12156
12157/// Rebuild a header message `region`, replacing the single Dataspace message's
12158/// record with one carrying `new_dataspace_body` (the grown current dimensions,
12159/// v2-serialized, maximum dimensions preserved) and leaving every other message
12160/// byte-for-byte. Used by the append path to grow a dataset's axis-0 dimension.
12161/// The replacement may differ in length from the original (a v1 on-disk
12162/// dataspace is normalized to v2 in the rebuilt header), so the record is rebuilt
12163/// via [`OhRecordLayout::record`].
12164fn replace_dataspace_message(
12165    region: &OhRegion,
12166    new_dataspace_body: &[u8],
12167) -> Result<OhRegion, Error> {
12168    let mut out = Vec::with_capacity(region.len());
12169    let mut p = 0;
12170    let mut replaced = false;
12171    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12172        if msg_type == MessageType::DATASPACE && !replaced {
12173            out.extend_from_slice(
12174                &region
12175                    .layout()
12176                    .record(MessageType::DATASPACE, new_dataspace_body),
12177            );
12178            replaced = true;
12179        } else {
12180            out.extend_from_slice(&region[p..body_end]);
12181        }
12182        p = body_end;
12183    }
12184    if !replaced {
12185        return Err(Error::AppendUnsupported(
12186            "dataset header has no dataspace message to grow",
12187        ));
12188    }
12189    Ok(region.with_bytes(out))
12190}
12191
12192/// Whether a datatype's raw on-disk bytes can be appended verbatim from a caller
12193/// via [`AppendBuilder::append_raw`]. True only when every scalar leaf is safe to
12194/// write as flat little-endian bytes:
12195///
12196/// - numeric leaves (fixed-point, floating-point, time, bit field) must be
12197///   little-endian, or the caller's little-endian bytes would silently misencode
12198///   into a big-endian (or VAX) field;
12199/// - string and opaque leaves are byte arrays with no numeric byte order, so they
12200///   are order-agnostic and safe;
12201/// - aggregates (enumeration, array, compound) are appendable iff every leaf is;
12202/// - variable-length and reference leaves embed global-heap or object addresses
12203///   that a flat byte append cannot reproduce, so they are never raw-appendable.
12204///
12205/// A typed `append_*` bypasses this: it checks full datatype equality instead, so
12206/// it already refuses every non-little-endian and non-scalar dataset.
12207pub(crate) fn datatype_is_raw_appendable(dt: &Datatype) -> bool {
12208    match dt {
12209        Datatype::FixedPoint { byte_order, .. }
12210        | Datatype::FloatingPoint { byte_order, .. }
12211        | Datatype::Time { byte_order, .. }
12212        | Datatype::BitField { byte_order, .. } => *byte_order == DatatypeByteOrder::LittleEndian,
12213        Datatype::String { .. } | Datatype::Opaque { .. } => true,
12214        Datatype::Enumeration { base_type, .. } | Datatype::Array { base_type, .. } => {
12215            datatype_is_raw_appendable(base_type)
12216        }
12217        Datatype::Compound { members, .. } => members
12218            .iter()
12219            .all(|m| datatype_is_raw_appendable(&m.datatype)),
12220        Datatype::VariableLength { .. } | Datatype::Reference { .. } => false,
12221    }
12222}
12223
12224/// The datatype, dataspace, parsed chunked data layout, and verbatim filter-
12225/// pipeline message bytes (if any) of a chunked dataset header, parsed by
12226/// [`parse_chunked_header`].
12227struct ChunkedHeaderParts {
12228    dt: crate::datatype::Datatype,
12229    ds: Dataspace,
12230    layout: DataLayout,
12231    pipeline_message: Option<Vec<u8>>,
12232}
12233
12234/// Parse the datatype, dataspace, chunked data layout, and verbatim filter-
12235/// pipeline message bytes (if any) from a chunked dataset header `region`. Used by
12236/// the chunked copy path to derive chunk geometry and the on-disk filter pipeline.
12237/// Errors if any required message is missing or the layout is not chunked.
12238fn parse_chunked_header(region: &OhRegion) -> Result<ChunkedHeaderParts, Error> {
12239    let mut datatype: Option<(usize, usize)> = None;
12240    let mut dataspace: Option<(usize, usize)> = None;
12241    let mut layout: Option<(usize, usize)> = None;
12242    let mut pipeline: Option<(usize, usize)> = None;
12243    let mut p = 0;
12244    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12245        match msg_type {
12246            MessageType::DATATYPE => datatype = Some((body, body_end)),
12247            MessageType::DATASPACE => dataspace = Some((body, body_end)),
12248            MessageType::DATA_LAYOUT => layout = Some((body, body_end)),
12249            MessageType::FILTER_PIPELINE => pipeline = Some((body, body_end)),
12250            _ => {}
12251        }
12252        p = body_end;
12253    }
12254    let (dt_b, dt_e) = datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
12255    let (ds_b, ds_e) =
12256        dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
12257    let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
12258    let (dt, _) = Datatype::parse(&region[dt_b..dt_e])?;
12259    let ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE)?;
12260    let dl = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE)?;
12261    if !matches!(dl, DataLayout::Chunked { .. }) {
12262        return Err(Error::EditUnsupported("dataset is not chunked"));
12263    }
12264    let pipeline_message = pipeline.map(|(b, e)| region[b..e].to_vec());
12265    Ok(ChunkedHeaderParts {
12266        dt,
12267        ds,
12268        layout: dl,
12269        pipeline_message,
12270    })
12271}
12272
12273/// The chunk geometry a verbatim chunked rebuild needs, derived by
12274/// [`chunked_geometry`] from a chunked dataset's datatype, dataspace, and parsed
12275/// [`DataLayout::Chunked`].
12276struct ChunkedGeometry {
12277    /// Rank-only spatial chunk dimensions.
12278    spatial: Vec<u64>,
12279    /// Element size in bytes, proven non-zero: the chunk splitter divides by
12280    /// it, and so does the append path's element-count arithmetic.
12281    element_size: NonZeroUsize,
12282    /// Full (uncompressed) chunk byte size, `product(spatial) * element_size`.
12283    /// This is what the chunk index's element width is derived from, so it has
12284    /// to be the geometry's product and not any written chunk's size.
12285    raw_size: u64,
12286    /// The on-disk maximum dimensions, when they differ from the current shape.
12287    /// An unlimited dimension selects the extensible-array index and a finite
12288    /// one the fixed-array index. `None` keeps the fixed-array or single-chunk
12289    /// index.
12290    maxshape: Option<Vec<MaxExtent>>,
12291}
12292
12293/// Derive the [`ChunkedGeometry`] for a chunked dataset from its datatype,
12294/// dataspace, and parsed [`DataLayout::Chunked`].
12295fn chunked_geometry(
12296    dt: &crate::datatype::Datatype,
12297    ds: &Dataspace,
12298    layout: &DataLayout,
12299) -> Result<ChunkedGeometry, Error> {
12300    let DataLayout::Chunked {
12301        chunk_dimensions, ..
12302    } = layout
12303    else {
12304        return Err(Error::EditUnsupported("dataset is not chunked"));
12305    };
12306    let rank = ds.dimensions.len();
12307    if chunk_dimensions.len() <= rank {
12308        return Err(Error::EditUnsupported(
12309            "chunked layout has malformed dimensions",
12310        ));
12311    }
12312    let spatial: Vec<u64> = chunk_dimensions[..rank].to_vec();
12313    let element_size = dt.element_size_usize()?;
12314    let raw_size = spatial
12315        .iter()
12316        .copied()
12317        .product::<u64>()
12318        .saturating_mul(element_size.get() as u64);
12319    let maxshape = ds.extensible_max_dimensions().map(<[MaxExtent]>::to_vec);
12320    Ok(ChunkedGeometry {
12321        spatial,
12322        element_size,
12323        raw_size,
12324        maxshape,
12325    })
12326}
12327
12328/// The element bytes a staged value overwrite will write, paired with the
12329/// variable-length staging that has still to be resolved into them and the
12330/// `path` they belong to.
12331///
12332/// Cloned rather than moved because the staged set must survive a refused
12333/// commit whole (issue #316), and this runs in the preflight that may refuse.
12334fn staged_bytes(fd: &FlatDataset, path: &ObjectPathBuf) -> OverwriteBytes {
12335    OverwriteBytes {
12336        raw: fd.raw.clone(),
12337        vlen: fd.vl_string_staging.clone().map(|staging| VlenOverwrite {
12338            staging,
12339            path: path.clone(),
12340        }),
12341    }
12342}
12343
12344/// Split `raw` into full-size chunk buffers in dense row-major grid order and
12345/// re-encode each through `pipeline_message`, the dataset's on-disk filter
12346/// pipeline.
12347///
12348/// The overhang past the dataset's edge holds the dataset's own fill value, not
12349/// zeros: an allocated chunk is expected to carry it wherever nothing was
12350/// written, and those slots are what a reader returns once the dataset is
12351/// extended into them (issue #296).
12352///
12353/// The caller has already refused a pipeline
12354/// [`pipeline_reencodable`] rejects, so the only errors here are a malformed
12355/// pipeline message (unreachable for the same reason) and the encoder's own.
12356fn split_and_encode_chunks(
12357    raw: &[u8],
12358    shape: &[u64],
12359    chunk_dims: &[u64],
12360    element_size: NonZeroUsize,
12361    padding: &crate::fill_value::PaddingFill,
12362    pipeline_message: Option<&[u8]>,
12363    dt: &crate::datatype::Datatype,
12364) -> Result<Vec<Vec<u8>>, Error> {
12365    let split = split_into_chunks(
12366        raw,
12367        shape,
12368        chunk_dims,
12369        element_size,
12370        padding.pattern(element_size),
12371    )
12372    .map_err(Error::Format)?;
12373    let Some(pm) = pipeline_message else {
12374        return Ok(split);
12375    };
12376    let pipeline = FilterPipeline::parse(pm)?;
12377    let ctx = ChunkContext::from_datatype(chunk_dims, dt)?;
12378    let mut encoded = Vec::with_capacity(split.len());
12379    // One encoder across the rewrite; see `FilterScratch`.
12380    let mut scratch = FilterScratch::new();
12381    for buf in &split {
12382        encoded.push(compress_chunk_with(&mut scratch, buf, &pipeline, ctx)?);
12383    }
12384    Ok(encoded)
12385}
12386
12387/// Tries to overwrite a chunked dataset's chunks in place. When the dataset's
12388/// on-disk chunks form a dense grid aligned with `new_bytes` (dense row-major
12389/// order), every slot is unmasked (`filter_mask == 0`), and every new chunk
12390/// **fits** the slot it replaces (`new_len <= slot`), returns the in-place
12391/// `(address, bytes)` writes:
12392///
12393/// - When every new chunk is **exactly** its slot's size, only the chunk data is
12394///   written; the index is untouched (so any enumerable index type works, and a
12395///   crash can tear at most a chunk's value bytes, not the structure).
12396/// - When some new chunks are **smaller** (fit with slack), the chunk index
12397///   records each chunk's stored size, so the index is rebuilt in place to record
12398///   the new sizes (see [`try_rebuild_index_in_place`]). This is supported only
12399///   for a v4 fixed-array or extensible-array index occupying a single contiguous
12400///   on-disk region; any other case returns `None` to relocate.
12401///
12402/// Returns `None` — so the caller relocates the dataset instead — when the index
12403/// cannot be enumerated, the grid is sparse, a slot is masked, a new chunk does
12404/// not fit, the index cannot be rebuilt in place, or any write would be out of
12405/// bounds or overlap another.
12406fn try_inplace_chunk_writes<S: Source + ?Sized>(
12407    src: &S,
12408    layout: &DataLayout,
12409    ds: &Dataspace,
12410    spatial: &[u64],
12411    raw_size: u64,
12412    new_bytes: &[Vec<u8>],
12413) -> Option<Vec<(u64, Vec<u8>)>> {
12414    let infos = enumerate_chunks_from_source(src, layout, ds, OFFSET_SIZE, LENGTH_SIZE).ok()?;
12415    let grid = plan_dense_grid(infos, &ds.dimensions, spatial)?;
12416    if grid.grid_order.len() != new_bytes.len() {
12417        return None;
12418    }
12419    let mut writes = Vec::with_capacity(new_bytes.len() + 1);
12420    let mut spans: Vec<(u64, u64)> = Vec::with_capacity(new_bytes.len() + 1);
12421    let mut any_shrunk = false;
12422    for (ci, bytes) in grid.grid_order.iter().zip(new_bytes.iter()) {
12423        // A nonzero filter mask means the source left some filter unapplied for
12424        // this chunk; re-encoding always applies every filter (mask 0), so an
12425        // in-place overwrite would desync the index-recorded mask. Relocate.
12426        if ci.filter_mask != 0 {
12427            return None;
12428        }
12429        let new_len = bytes.len() as u64;
12430        let slot = ci.chunk_size.get();
12431        // A chunk that no longer fits its slot must relocate.
12432        if new_len > slot {
12433            return None;
12434        }
12435        if new_len < slot {
12436            any_shrunk = true;
12437        }
12438        ci.address
12439            .get()
12440            .checked_add(new_len)
12441            .filter(|&e| e <= src.len())?;
12442        writes.push((ci.address.get(), bytes.clone()));
12443        spans.push((ci.address.get(), new_len));
12444    }
12445
12446    // A shrinking overwrite changes the index-recorded chunk sizes, so the index
12447    // must be rebuilt in place to match; an equal-size one leaves it untouched.
12448    if any_shrunk {
12449        let (index_addr, index_bytes) = try_rebuild_index_in_place(
12450            src,
12451            layout,
12452            ds,
12453            spatial,
12454            raw_size,
12455            &grid.grid_order,
12456            new_bytes,
12457        )?;
12458        spans.push((index_addr, index_bytes.len() as u64));
12459        writes.push((index_addr, index_bytes));
12460    }
12461
12462    // Refuse to perform overlapping in-place writes (a malformed source index, or
12463    // an index region that overlaps a chunk slot); relocate instead so two writes
12464    // never clobber each other.
12465    if !spans_disjoint_in_bounds(&mut spans, src.len()) {
12466        return None;
12467    }
12468    Some(writes)
12469}
12470
12471/// Rebuilds a chunked dataset's index **in place** so it records the new
12472/// (smaller) per-chunk stored sizes after a fits-with-slack overwrite, returning
12473/// the `(address, bytes)` write that replaces it. The chunks keep their existing
12474/// addresses (only their stored bytes shrank), so the rebuilt index points at the
12475/// same slots with the new sizes.
12476///
12477/// Supported only for a v4 **fixed-array** or **extensible-array** index whose
12478/// on-disk structure is a single contiguous region starting at the index address
12479/// — the layout this crate's own writer produces. The element width derives from
12480/// the unchanged raw chunk size, so the rebuilt structure is byte-for-byte the
12481/// same length as the original; this is required to match exactly, which rejects a
12482/// scattered or differently-laid-out (e.g. C-written) index, leaving the caller
12483/// to relocate. Single-chunk (size in the layout message) and B-tree-v1 (no
12484/// writer) indexes are not rebuilt here.
12485///
12486/// Like any in-place value overwrite (the HDF5 `H5Dwrite` model) this is not
12487/// atomic: a crash mid-write can tear the index and leave the dataset needing a
12488/// rewrite. It is used only on the in-place path, whose linearization point is the
12489/// synced data write.
12490#[allow(clippy::too_many_arguments)]
12491fn try_rebuild_index_in_place<S: Source + ?Sized>(
12492    src: &S,
12493    layout: &DataLayout,
12494    ds: &Dataspace,
12495    spatial: &[u64],
12496    raw_size: u64,
12497    grid_order: &[crate::chunked_read::ChunkInfo],
12498    new_bytes: &[Vec<u8>],
12499) -> Option<(u64, Vec<u8>)> {
12500    let DataLayout::Chunked { index, .. } = layout else {
12501        return None;
12502    };
12503    let index_addr = index.address()?;
12504    let written: Vec<crate::chunked_write::WrittenChunk> = grid_order
12505        .iter()
12506        .zip(new_bytes)
12507        .map(|(ci, b)| crate::chunked_write::WrittenChunk {
12508            address: ci.address,
12509            compressed_size: b.len() as u64,
12510            filter_mask: 0,
12511        })
12512        .collect();
12513    // `grid_order` is dense row-major over the *current* shape; where each of
12514    // those chunks sits in the index is the maximum grid's business, and the
12515    // rebuild has to reproduce the numbering the original index was written
12516    // with. Same rule, same function as the writer.
12517    let (_, slot_of_chunk, index_slots) = crate::chunked_write::plan_index_slots(
12518        &ds.dimensions,
12519        spatial,
12520        ds.max_dimensions.as_deref(),
12521        raw_size,
12522        true,
12523        // Rebuilding the index of a dataset whose chunks are in hand.
12524        crate::chunked_write::StorageAllocation::Allocated,
12525    )
12526    .ok()?;
12527    let slots =
12528        crate::chunked_write::IndexSlots::new(&written, &slot_of_chunk, index_slots).ok()?;
12529    let new_index = match index {
12530        // `raw_size` is the whole-chunk byte size, which is what the element
12531        // width derives from — the same value the original index was built with,
12532        // so the rebuilt structure matches its length. (An index written by a
12533        // version that derived the width from the written chunks instead can
12534        // disagree; the length check below then rejects it and the caller
12535        // relocates, which is the safe direction.)
12536        ChunkIndexLayout::FixedArray { .. } => crate::chunked_write::build_fixed_array_at(
12537            &slots,
12538            raw_size,
12539            OFFSET_SIZE,
12540            LENGTH_SIZE,
12541            true,
12542            index_addr,
12543        ),
12544        ChunkIndexLayout::ExtensibleArray { .. } => {
12545            crate::chunked_write::build_extensible_array_at(
12546                &slots,
12547                raw_size,
12548                OFFSET_SIZE,
12549                LENGTH_SIZE,
12550                true,
12551                index_addr,
12552            )
12553            .ok()?
12554        }
12555        // A single chunk's stored size sits in the layout message, so rebuilding
12556        // it would rewrite the header, an implicit index has no structure to
12557        // rebuild, and neither B-tree has a writer. All four relocate instead.
12558        ChunkIndexLayout::SingleChunk { .. }
12559        | ChunkIndexLayout::Implicit { .. }
12560        | ChunkIndexLayout::BTreeV1 { .. }
12561        | ChunkIndexLayout::BTreeV2 { .. } => return None,
12562    };
12563
12564    // The on-disk index must be a single contiguous region starting at the index
12565    // address, and the rebuilt structure must be exactly the same length (true for
12566    // an index this crate wrote). A scattered or different on-disk layout fails
12567    // the check and the caller relocates.
12568    let mut spans =
12569        crate::chunked_read::chunk_index_spans_from_source(src, layout, OFFSET_SIZE, LENGTH_SIZE)
12570            .ok()?;
12571    if spans.is_empty() {
12572        return None;
12573    }
12574    spans.sort_unstable_by_key(|&(a, _)| a);
12575    if spans[0].0 != index_addr.get() {
12576        return None;
12577    }
12578    let mut end = index_addr.get();
12579    for &(a, l) in &spans {
12580        if a != end {
12581            return None; // a gap means the index is not contiguous
12582        }
12583        end = a.checked_add(l)?;
12584    }
12585    if new_index.len() as u64 != end - index_addr.get() {
12586        return None;
12587    }
12588    index_addr
12589        .get()
12590        .checked_add(new_index.len() as u64)
12591        .filter(|&e| e <= src.len())?;
12592    Some((index_addr.get(), new_index))
12593}
12594
12595/// A [`ChunkProvider`] over chunk bytes already held in memory, in dense
12596/// row-major grid order. Used by the editor's chunked copy and relocating
12597/// overwrite, which own each chunk's bytes (a [`CopyTree`] or [`MovingWrite`]
12598/// captured them) rather than streaming from a source file like repack.
12599struct SliceChunkProvider<'a> {
12600    chunks: &'a [Vec<u8>],
12601}
12602
12603impl ChunkProvider for SliceChunkProvider<'_> {
12604    fn chunk_bytes(&self, index: usize, out: &mut Vec<u8>) -> Result<(), FormatError> {
12605        let chunk = self.chunks.get(index).ok_or_else(|| {
12606            FormatError::ChunkedReadError("chunk index out of range for in-memory provider".into())
12607        })?;
12608        out.extend_from_slice(chunk);
12609        Ok(())
12610    }
12611}
12612
12613/// The chunk-0 message region of a fresh, empty compact-link group: a LinkInfo
12614/// message advertising no dense storage, followed by a GroupInfo message.
12615/// Mirrors `build_group_oh`.
12616fn fresh_group_region() -> OhRegion {
12617    let mut li = Vec::with_capacity(18);
12618    li.push(0); // version
12619    li.push(0); // flags
12620    li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap addr = UNDEF
12621    li.extend_from_slice(&u64::MAX.to_le_bytes()); // btree name index addr = UNDEF
12622    let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
12623    region.push(MessageType::LINK_INFO, &li);
12624    region.push(MessageType::GROUP_INFO, &GROUP_INFO_BODY);
12625    region
12626}
12627
12628/// Ensure a group's chunk-0 message `region` carries a Group Info message,
12629/// appending a minimal one when absent.
12630///
12631/// The HDF5 C library refuses to insert a link into a group whose object header
12632/// has a Link Info message but no Group Info message: on the new-format path
12633/// `H5G_obj_insert` reads the Group Info message unconditionally and fails with
12634/// "message type not found". Such a group round-trips for *reading* but cannot
12635/// be *modified* by the C library. Earlier hdf5-pure releases wrote groups that
12636/// way, so heal any such header whenever we rewrite one in place.
12637fn ensure_group_info(region: &mut OhRegion) -> Result<(), Error> {
12638    let mut p = 0;
12639    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12640        if msg_type == MessageType::GROUP_INFO {
12641            return Ok(());
12642        }
12643        p = body_end;
12644    }
12645    region.push(MessageType::GROUP_INFO, &GROUP_INFO_BODY);
12646    Ok(())
12647}
12648
12649/// Ensure a chunk-0 message `region` that carries inline Attribute messages also
12650/// carries an Attribute Info message, appending the compact-storage one when
12651/// absent.
12652///
12653/// On a version 2 object header the reference C library never counts attribute
12654/// messages: `H5O__attr_count_real` reads the count out of the Attribute Info
12655/// message, and reports zero when there is none, even though `H5Aiterate` and
12656/// `H5Aopen_by_name` still find every attribute. Tools that size their work by
12657/// that count then skip the attributes silently — `h5repack` copies such an
12658/// object with none of them. Releases through 0.33.0 wrote every compact
12659/// attribute set that way, so heal any such header whenever we rewrite one, the
12660/// same reason [`ensure_group_info`] exists.
12661///
12662/// A region already carrying an Attribute Info message is left alone, whichever
12663/// storage it names: a defined heap address means dense storage, whose count the
12664/// C library takes from the heap's B-tree instead.
12665fn ensure_attribute_info(region: &mut OhRegion) -> Result<(), Error> {
12666    let mut has_attrs = false;
12667    let mut p = 0;
12668    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
12669        match msg_type {
12670            MessageType::ATTRIBUTE_INFO => return Ok(()),
12671            MessageType::ATTRIBUTE => has_attrs = true,
12672            _ => {}
12673        }
12674        p = body_end;
12675    }
12676    if has_attrs {
12677        let body = compact_attribute_info_body(region)?;
12678        region.push(MessageType::ATTRIBUTE_INFO, &body);
12679    }
12680    Ok(())
12681}
12682
12683/// Encode a complete object-header Link message (4-byte record header + body)
12684/// for a hard link `name -> addr`. The caller must have validated that the body
12685/// fits the u16 size field (see [`flatten_dataset`]); group names are short.
12686///
12687/// `creation_order` is the link's creation index, which a group tracking **link**
12688/// creation order carries on every link and no other group carries at all
12689/// ([`LinkCreationOrder`] is what hands one out). It is a flagged field of the
12690/// Link message *body*, quite separate from the object header's own per-message
12691/// creation index: that one records an *attribute*'s creation order, and the
12692/// reference C library writes it as the zero [`OhRecordLayout::record`] passes
12693/// for every other message type.
12694fn encode_link_message(
12695    layout: OhRecordLayout,
12696    name: &str,
12697    addr: StoredAddress,
12698    creation_order: Option<u64>,
12699) -> Vec<u8> {
12700    let mut link = make_link(name, addr);
12701    link.creation_order = creation_order;
12702    let body = link.serialize(OFFSET_SIZE);
12703    layout.record(MessageType::LINK, &body)
12704}
12705
12706/// Patch an existing hard Link message in a chunk-0 message `region`, retargeting
12707/// the link named `name` to `new_addr` (used to repoint a parent at a relocated
12708/// child group). The target address is the trailing `OFFSET_SIZE` bytes of the
12709/// link body for a hard link.
12710fn patch_link_target(
12711    region: &mut OhRegion,
12712    name: &str,
12713    new_addr: StoredAddress,
12714) -> Result<(), Error> {
12715    let mut p = 0;
12716    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12717        if msg_type == MessageType::LINK {
12718            if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
12719                if link.name == name {
12720                    return match link.link_target {
12721                        LinkTarget::Hard { .. } => {
12722                            let ofs = body_end - OFFSET_SIZE as usize;
12723                            region.bytes_mut()[ofs..body_end]
12724                                .copy_from_slice(&new_addr.get().to_le_bytes());
12725                            Ok(())
12726                        }
12727                        _ => Err(Error::EditUnsupported(
12728                            "a group on the edited path is reached by a soft/external link",
12729                        )),
12730                    };
12731                }
12732            }
12733        }
12734        p = body_end;
12735    }
12736    Err(Error::EditUnsupported(
12737        "expected child link not found in parent group",
12738    ))
12739}
12740
12741/// Bytes a compact Data Layout message carries ahead of its inline data:
12742/// version(1) + class(1) + the 2-byte inline size.
12743const COMPACT_LAYOUT_PREAMBLE: usize = 4;
12744
12745/// Copy a chunk-0 message `region`, replacing the single (compact) Data Layout
12746/// message's inline data with `raw` and preserving every other message verbatim.
12747/// Used by `write_dataset` to overwrite a compact dataset's values. The message
12748/// header (type and flags) and version byte are kept; only the inline data — and
12749/// the message size and 2-byte inline-size fields — change. `raw` must fit both
12750/// the compact layout's own 2-byte size field (HDF5's 64 KiB compact-storage
12751/// limit) and, once the 4-byte layout preamble is added, the object header's
12752/// 2-byte message-size field — the tighter of the two, which an overwrite of an
12753/// existing compact dataset always satisfies.
12754fn rebuild_compact_layout_region(region: &OhRegion, raw: &[u8]) -> Result<OhRegion, Error> {
12755    // The bound is on the *message body* the layout becomes — version, class,
12756    // and the 2-byte inline size ahead of the data — not on `raw` alone, or the
12757    // last four lengths below the limit would truncate the size field written
12758    // for them.
12759    if raw.len() > OBJECT_HEADER_MESSAGE_MAX - COMPACT_LAYOUT_PREAMBLE {
12760        return Err(Error::EditUnsupported(
12761            "compact dataset data is too large to overwrite in place",
12762        ));
12763    }
12764    let mut out = Vec::with_capacity(region.len() + raw.len());
12765    let mut p = 0;
12766    let mut replaced = false;
12767    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12768        if msg_type == MessageType::DATA_LAYOUT {
12769            if body_end - body < 2 || region[body + 1] != 0 {
12770                return Err(Error::EditUnsupported(
12771                    "compact-layout overwrite found a non-compact data layout",
12772                ));
12773            }
12774            // New compact layout body: version (kept), class=0, 2-byte inline
12775            // size, then the data.
12776            let mut layout = Vec::with_capacity(COMPACT_LAYOUT_PREAMBLE + raw.len());
12777            layout.push(region[body]); // version (3 or 4)
12778            layout.push(0); // class = compact
12779            #[expect(
12780                clippy::cast_possible_truncation,
12781                reason = "raw.len() bounded below the u16 inline-size field above"
12782            )]
12783            layout.extend_from_slice(&(raw.len() as u16).to_le_bytes());
12784            layout.extend_from_slice(raw);
12785            // Message record: type byte, 2-byte size (LE), then the rest of
12786            // the prefix — flags, and a creation index where the header has one
12787            // — kept verbatim.
12788            out.push(region[p]);
12789            #[expect(
12790                clippy::cast_possible_truncation,
12791                reason = "the guard above bounds COMPACT_LAYOUT_PREAMBLE + raw.len(), this \
12792                          body's exact length, to the 2-byte message-size field"
12793            )]
12794            out.extend_from_slice(&(layout.len() as u16).to_le_bytes());
12795            out.extend_from_slice(&region[p + 3..p + region.layout().prefix_len()]);
12796            out.extend_from_slice(&layout);
12797            replaced = true;
12798        } else {
12799            out.extend_from_slice(&region[p..body_end]);
12800        }
12801        p = body_end;
12802    }
12803    if p < region.len() {
12804        out.extend_from_slice(&region[p..]);
12805    }
12806    if !replaced {
12807        return Err(Error::EditUnsupported(
12808            "compact dataset header has no data-layout message",
12809        ));
12810    }
12811    Ok(region.with_bytes(out))
12812}
12813
12814/// The link creation indexes one commit hands out to the links it adds to one
12815/// group, and the running maximum its Link Info message ends up recording.
12816///
12817/// Link creation order is a separate mechanism from the attribute creation order
12818/// the object header's own flags describe: a group that tracks it — h5py's
12819/// `track_order=True`, `H5Pset_link_creation_order`, and every group netCDF-4
12820/// writes — carries a creation index on every Link message and, in its Link Info
12821/// message, the maximum it has ever assigned. The reference C library hands out
12822/// that maximum and increments it (`H5G_obj_insert`), so the recorded value is
12823/// the *next* index rather than the highest in use, and deleting a link never
12824/// lowers it. This is the link-side counterpart of [`next_attr_creation_index`],
12825/// and it works the same way.
12826///
12827/// A group that does not track the order has no maximum recorded, hands out no
12828/// index, and records nothing back — which is every group this crate writes
12829/// itself.
12830struct LinkCreationOrder {
12831    /// The next index to assign, on a group that tracks link creation order.
12832    next: Option<u64>,
12833    /// Whether any index has been handed out. Nothing is recorded back
12834    /// otherwise, so a commit that adds no link to the group leaves its Link
12835    /// Info message byte-identical.
12836    assigned: bool,
12837}
12838
12839impl LinkCreationOrder {
12840    /// Read a group's counter out of its object-header message `region`.
12841    fn for_region(region: &OhRegion) -> Result<Self, Error> {
12842        let next = find_link_info(region)?.and_then(|(_, _, info)| info.max_creation_order);
12843        Ok(Self {
12844            next,
12845            assigned: false,
12846        })
12847    }
12848
12849    /// The creation index for one link being added, or `None` on a group that
12850    /// does not track the order. Consecutive calls return consecutive indexes,
12851    /// so links added in one commit are ordered by the order they are placed in.
12852    fn take(&mut self) -> Result<Option<u64>, Error> {
12853        let Some(index) = self.next else {
12854            return Ok(None);
12855        };
12856        self.next = Some(index.checked_add(1).ok_or(Error::EditUnsupported(
12857            "a group has assigned every link creation index its link-info message can record",
12858        ))?);
12859        self.assigned = true;
12860        Ok(Some(index))
12861    }
12862
12863    /// Write the running maximum back into the group's Link Info message, once
12864    /// every link this commit adds to that group has taken an index.
12865    ///
12866    /// The maximum is patched in place rather than re-encoded: it is present
12867    /// (that is what [`Self::for_region`] read) and it is the eight bytes
12868    /// following the message's version and flags whatever the file's offset
12869    /// size, so the record's length — and every offset into the region — is
12870    /// unchanged.
12871    fn record(&self, region: &mut OhRegion) -> Result<(), Error> {
12872        if !self.assigned {
12873            return Ok(());
12874        }
12875        let Some(next) = self.next else {
12876            return Ok(());
12877        };
12878        let (_, body, _) = find_link_info(region)?.ok_or(Error::EditUnsupported(
12879            "a group's link-info message went missing while its links were being added",
12880        ))?;
12881        let max = body.start + 2..body.start + 10;
12882        if max.end > body.end {
12883            return Err(Error::EditUnsupported(
12884                "a group's link-info message is too short to record its link creation order",
12885            ));
12886        }
12887        region.bytes_mut()[max].copy_from_slice(&next.to_le_bytes());
12888        Ok(())
12889    }
12890}
12891
12892/// The Link Info message in `region`, as `(record start, body range, parsed
12893/// message)`. An unparseable message is reported as absent, the same reading
12894/// [`find_attribute_info`] gives an unparseable Attribute Info message — safe
12895/// here because a group whose link storage this editor cannot account for is
12896/// refused by [`inspect_group`](WriteEngine::inspect_group) before it reaches
12897/// this.
12898fn find_link_info(
12899    region: &OhRegion,
12900) -> Result<Option<(usize, core::ops::Range<usize>, LinkInfoMessage)>, Error> {
12901    let mut p = 0;
12902    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12903        if msg_type == MessageType::LINK_INFO
12904            && let Ok(info) = LinkInfoMessage::parse(&region[body..body_end], OFFSET_SIZE)
12905        {
12906            return Ok(Some((p, body..body_end, info)));
12907        }
12908        p = body_end;
12909    }
12910    Ok(None)
12911}
12912
12913/// The number of links a group keeps in its object header before they move into
12914/// a fractal heap: the "maximum compact value" its Group Info message (type
12915/// 0x000A) declares, or the reference C library's default of 8 where that
12916/// message stores no link-phase-change values.
12917///
12918/// Body: version(1), flags(1), then the maximum compact (2) and minimum dense
12919/// (2) values if bit 0 of the flags is set, then the estimated entry count (2)
12920/// and name length (2) if bit 1 is. An absent or truncated message reads as the
12921/// default, which is the value the C library itself would use for it.
12922///
12923/// Not to be confused with [`AttrPhaseChange`], the *attribute* thresholds
12924/// (`H5Pset_attr_phase_change`) that live in the object header's own prefix:
12925/// this is the *link* phase change, it lives in a message, and it is what
12926/// [`reject_dense_link_creation_order`] measures an addition against.
12927fn max_compact_links(region: &OhRegion) -> Result<u16, Error> {
12928    /// `H5G_CRT_GINFO_MAX_COMPACT`, the C library's default.
12929    const DEFAULT_MAX_COMPACT: u16 = 8;
12930    let mut p = 0;
12931    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12932        if msg_type == MessageType::GROUP_INFO {
12933            let stores_phase_change = body_end - body >= 2 && region[body + 1] & 0x01 != 0;
12934            if stores_phase_change && body + 4 <= body_end {
12935                return Ok(u16::from_le_bytes([region[body + 2], region[body + 3]]));
12936            }
12937            return Ok(DEFAULT_MAX_COMPACT);
12938        }
12939        p = body_end;
12940    }
12941    Ok(DEFAULT_MAX_COMPACT)
12942}
12943
12944/// Refuse to add a link to a group that tracks **link** creation order when the
12945/// addition would move that group's links into *dense* (fractal-heap) storage.
12946///
12947/// `links_after_commit` is how many links the group would hold once this commit's
12948/// deletions and additions are applied. Past the threshold its Group Info message
12949/// declares, the reference C library moves a tracked group's links into a fractal
12950/// heap indexed by *two* B-trees: one on name, and a type 6 one on creation
12951/// order. This crate emits neither, so the point where the group would stop
12952/// being compact is the point where an addition stops being reproducible — that
12953/// work belongs with dense link storage as a whole (issue #102).
12954///
12955/// Below the threshold an addition is written rather than refused
12956/// ([`LinkCreationOrder`] numbers it), as are the two things that write no
12957/// creation order at all: **removing** a link, which leaves a gap in the order
12958/// exactly as dropping an Attribute message does and copies the running maximum
12959/// through untouched (the C library does not lower it on a deletion either), and
12960/// **retargeting** one ([`patch_link_target`], how a relocated child is rewired),
12961/// which rewrites an address inside a Link message and leaves every other field
12962/// of it, creation index included, where it was.
12963///
12964/// A group whose links are *already* dense never reaches here at all:
12965/// [`inspect_group`](WriteEngine::inspect_group) refuses every dense-link group,
12966/// tracked or not, as it reads the header.
12967fn reject_dense_link_creation_order(
12968    region: &OhRegion,
12969    links_after_commit: usize,
12970) -> Result<(), Error> {
12971    let tracked =
12972        find_link_info(region)?.is_some_and(|(_, _, info)| info.max_creation_order.is_some());
12973    if tracked && links_after_commit > usize::from(max_compact_links(region)?) {
12974        return Err(Error::EditUnsupported(
12975            "a group on the edited path tracks link creation order, and the links this commit \
12976             adds to it would take it past the compact storage its group-info message allows; \
12977             dense (fractal-heap) link storage cannot be written in place yet",
12978        ));
12979    }
12980    Ok(())
12981}
12982
12983/// Copy a chunk-0 message `region`, dropping the single Link message named
12984/// `name` and preserving every other message verbatim (used by `delete`). Errors
12985/// if no such link is present.
12986fn remove_link_from_region(region: &OhRegion, name: &str) -> Result<OhRegion, Error> {
12987    let mut out = Vec::with_capacity(region.len());
12988    let mut p = 0;
12989    let mut removed = false;
12990    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
12991        let mut skip = false;
12992        if msg_type == MessageType::LINK {
12993            if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
12994                if link.name == name {
12995                    skip = true;
12996                    removed = true;
12997                }
12998            }
12999        }
13000        if !skip {
13001            out.extend_from_slice(&region[p..body_end]);
13002        }
13003        p = body_end;
13004    }
13005    if p < region.len() {
13006        out.extend_from_slice(&region[p..]);
13007    }
13008    if !removed {
13009        return Err(Error::EditUnsupported(
13010            "link to delete not found in its parent group",
13011        ));
13012    }
13013    Ok(region.with_bytes(out))
13014}
13015
13016/// Apply attribute edits to an object's header `region` *compactly*, preserving
13017/// every non-attribute message verbatim. A fixed-size `Set`/`Remove` is resolved
13018/// into `region` directly; a variable-length `Set` (one whose value reports
13019/// [`AttrValue::var_len_strings`]) is
13020/// instead collected into the returned `pending_vl_attrs` — its placeholder
13021/// heap address is only patched, and the message appended to the object's
13022/// header, by the apply loop once its global heap collection's real address
13023/// is known (see [`WriteEngine::place_vl_collections`]). A later op for the
13024/// same name (another `Set`, fixed-size or not, or a `Remove`) replaces or
13025/// cancels an earlier still-pending variable-length entry, keeping the net
13026/// effect the same regardless of op order within one commit.
13027///
13028/// Only [`plan_attr_ops`] calls this, and only for an object whose attributes
13029/// are stored compactly: appending an inline Attribute message to an object
13030/// whose set lives in a fractal heap would leave it carrying two storage forms,
13031/// so a dense object is routed to a heap rebuild there instead.
13032fn apply_compact_attr_ops(
13033    region: &OhRegion,
13034    ops: &[&AttrOp],
13035) -> Result<(OhRegion, PendingVlAttrs), Error> {
13036    let mut out = region.clone();
13037    let mut pending_vl: PendingVlAttrs = Vec::new();
13038    for op in ops {
13039        match op {
13040            AttrOp::Set { name, value } => {
13041                pending_vl.retain(|a| &a.msg.name != name);
13042                if let Some(strings) = value.var_len_strings() {
13043                    // The message this replaces is dropped now and re-appended
13044                    // in the apply phase, so the creation index it carried has
13045                    // to be read before it goes: an overwrite keeps the index
13046                    // the attribute already had.
13047                    let creation_index = attr_creation_index(&out, name)?;
13048                    // Nothing yet to remove from `region` if this name has
13049                    // never been set as a fixed-size attribute.
13050                    out = remove_attr_from_region(&out, name, false)?;
13051                    let msg = build_attr_message(name, value);
13052                    if msg.serialize(LENGTH_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
13053                        return Err(Error::EditUnsupported(
13054                            "attribute is too large to encode in place",
13055                        ));
13056                    }
13057                    pending_vl.push(PendingVlAttr {
13058                        msg,
13059                        collections: build_global_heap_collections(strings),
13060                        creation_index,
13061                    });
13062                } else {
13063                    out = set_attr_in_region(&out, name, value)?;
13064                }
13065            }
13066            AttrOp::Remove { name } => {
13067                let before = pending_vl.len();
13068                pending_vl.retain(|a| &a.msg.name != name);
13069                if pending_vl.len() == before {
13070                    out = remove_attr_from_region(&out, name, true)?;
13071                }
13072            }
13073        }
13074    }
13075    Ok((out, pending_vl))
13076}
13077
13078/// The attribute storage an edit resolves to, and the header region that carries
13079/// it. Produced by [`plan_attr_ops`] in the commit preflight, so the apply phase
13080/// only places bytes.
13081struct AttrEdits {
13082    /// The object's chunk-0 message region with the edit applied. Compact: it
13083    /// already carries every fixed-size attribute. Dense: it carries no attribute
13084    /// message at all, and the Attribute Info message naming the heap is appended
13085    /// once that heap is placed.
13086    region: OhRegion,
13087    /// What the apply phase still has to place, and where the result is stored.
13088    attrs: EditedAttrs,
13089}
13090
13091/// What an attribute edit leaves for the apply phase, in the storage the result
13092/// belongs in. The two are alternatives rather than a pair of fields, because
13093/// every object has exactly one attribute storage: a compact result's
13094/// variable-length attributes are appended to the header as messages, a dense
13095/// one's are patched into the set the heap is then built over.
13096#[derive(Clone)]
13097enum EditedAttrs {
13098    /// Variable-length attributes still to be placed and appended as messages.
13099    /// Empty for an edit that resolved entirely in the preflight, and for one
13100    /// that left the object with no attributes at all.
13101    Compact(PendingVlAttrs),
13102    /// A set to rebuild into a fresh fractal heap.
13103    Dense(DenseAttrEdit),
13104}
13105
13106impl Default for EditedAttrs {
13107    /// An object with nothing left to place: what a group carries before an
13108    /// attribute edit reaches it, and what one without attribute edits keeps.
13109    fn default() -> Self {
13110        Self::Compact(Vec::new())
13111    }
13112}
13113
13114/// A dense (fractal-heap) attribute set and what its object records about
13115/// attribute creation order.
13116///
13117/// The two travel together because a dense set is never copied as bytes: the
13118/// heap is rebuilt from the parsed attributes wherever it lands, so the creation
13119/// indexes the old heap's indexes carried have to be carried alongside or they
13120/// are lost. An object that does not track the order leaves this
13121/// [`DenseAttrCreationOrder::Untracked`], which is every set this crate's
13122/// whole-file writer produces.
13123#[derive(Clone, Default)]
13124struct DenseAttrSet {
13125    attrs: Vec<crate::attribute::AttributeMessage>,
13126    creation: DenseAttrCreationOrder,
13127}
13128
13129impl DenseAttrSet {
13130    fn is_empty(&self) -> bool {
13131        self.attrs.is_empty()
13132    }
13133}
13134
13135/// A dense (fractal-heap) attribute set an edit rebuilds, staged by
13136/// [`plan_attr_ops`] and placed by the apply phase.
13137#[derive(Clone)]
13138struct DenseAttrEdit {
13139    /// Every attribute the object will carry: the ones it already had, in the
13140    /// order it had them, with this edit's applied over them by name — and what
13141    /// the object records about their creation order.
13142    set: DenseAttrSet,
13143    /// `(index into `attrs`, its global heap collections)` for each
13144    /// variable-length attribute this edit *sets*. The heap is built over the
13145    /// attribute message bytes, so each of these has to be patched with its
13146    /// collection's real address before the build rather than after it, which is
13147    /// why they are carried to the apply phase instead of resolved here.
13148    ///
13149    /// A variable-length attribute the object already had is not in this list and
13150    /// needs no patch: it was read back carrying the addresses it is stored with,
13151    /// and they name collections in this same file.
13152    vl: Vec<(usize, Vec<Vec<u8>>)>,
13153}
13154
13155/// Apply attribute edits to an object's header `region`, choosing the storage
13156/// the result belongs in (issue #102).
13157///
13158/// `addr` is the object's pre-commit object-header address, or `None` for a
13159/// group this commit creates, which has no stored attributes to read.
13160///
13161/// Three outcomes, in the order they are tried:
13162///
13163/// - **Compact.** The region is rewritten message by message, so every attribute
13164///   the edit does not name keeps its exact bytes. An ordinary `set_attr` on an
13165///   ordinary object takes this route.
13166/// - **Dense.** The object already stores its attributes in a fractal heap, or
13167///   this edit takes it past what an object header holds compactly — more than
13168///   [`MAX_COMPACT_ATTRS`] attributes, or one whose message overflows the
13169///   header's 2-byte message-size field. The object's whole attribute set is read
13170///   back, the edits are applied to it, and the apply phase builds a fresh heap
13171///   for the result. Those are the two halves of the disjunction
13172///   `file_writer::needs_dense_attrs` selects on, so an object an edit sends to a
13173///   heap is one the whole-file writer would have written to a heap — measured
13174///   here over the ops as staged, which is why setting an oversized attribute and
13175///   removing it again in one commit still sends the object to a heap.
13176/// - **Neither.** The edit removes the object's last attribute, so the rebuilt
13177///   header carries no attribute message and no Attribute Info message — what
13178///   this crate and the reference C library both write for an object with none.
13179///
13180/// A dense rebuild is a re-encoding rather than a byte copy: the attributes are
13181/// read into messages and re-serialized, as [`WriteEngine::read_object`] already
13182/// does for a copied object's heap. Name, datatype, dataspace and value survive
13183/// it; a version 1 or 2 message becomes the version 3 one dense storage holds,
13184/// and attribute creation order — which this crate indexes for neither storage
13185/// form — does not.
13186///
13187/// An object that is dense stays dense, even where the edit takes its set back
13188/// under the threshold. The reference C library keeps one dense down to its
13189/// `min_dense` (6 by default) as well, and re-encoding a set that is only losing
13190/// members buys nothing.
13191fn plan_attr_ops<S: Source + ?Sized>(
13192    src: &S,
13193    base: BaseAddress,
13194    addr: Option<u64>,
13195    region: &OhRegion,
13196    ops: &[&AttrOp],
13197) -> Result<AttrEdits, Error> {
13198    let dense_now = region_uses_dense_attrs(region)?;
13199    if !dense_now {
13200        // An attribute past the header's message-size field has no compact form
13201        // at all, so the compact pass would refuse it rather than report a count
13202        // this could act on. Ask before running it, not after.
13203        let oversized = ops.iter().any(|op| match op {
13204            AttrOp::Set { name, value } => {
13205                build_attr_message(name, value).serialize(LENGTH_SIZE).len()
13206                    > OBJECT_HEADER_MESSAGE_MAX
13207            }
13208            AttrOp::Remove { .. } => false,
13209        });
13210        if !oversized {
13211            let (out, pending_vl) = apply_compact_attr_ops(region, ops)?;
13212            // An edit that only *removes* stays compact whatever the count is.
13213            // A region already holding more attributes than this writer would
13214            // emit came from another writer — the C library's `max_compact` is a
13215            // property list setting — and taking one away is no reason to
13216            // re-encode the ones that remain.
13217            let only_removes = !ops.iter().any(|op| matches!(op, AttrOp::Set { .. }));
13218            if only_removes || compact_attr_count(&out)? + pending_vl.len() <= MAX_COMPACT_ATTRS {
13219                return Ok(AttrEdits {
13220                    region: out,
13221                    attrs: EditedAttrs::Compact(pending_vl),
13222                });
13223            }
13224        }
13225    }
13226
13227    // The compact pass refuses a shared attribute message as it walks the
13228    // messages it copies; this path never walks them, so it asks here — before
13229    // the read below resolves one into a message indistinguishable from a private
13230    // attribute.
13231    if region_has_shared_attr(region)? {
13232        return Err(Error::EditUnsupported(SHARED_ATTRIBUTE_MESSAGE));
13233    }
13234
13235    // A heap is rebuilt from the object's whole attribute set — half of which may
13236    // live in a heap the header only names — so read that set back before
13237    // applying the edits to it. A group this commit creates has none.
13238    let existing = match addr {
13239        Some(addr) => read_object_attrs(src, AccessMode::ReadWrite, addr, base)?,
13240        None => Vec::new(),
13241    };
13242    // Each attribute travels with the global heap collections it still needs
13243    // placed (`Some` only for a variable-length attribute this edit sets) and
13244    // with the creation index its object records for it, so no removal can shift
13245    // one away from the bytes — or the index — it belongs to.
13246    let existing = dense_attr_set(region, existing)?;
13247    let tracked = region.layout().tracks_creation_order();
13248    // The next index the object would hand out: what a *new* attribute takes.
13249    // `dense_attr_set` has already raised it past every index in use.
13250    let mut next_index = match &existing.creation {
13251        DenseAttrCreationOrder::Tracked { max, .. } => *max,
13252        DenseAttrCreationOrder::Untracked => 0,
13253    };
13254    let mut set: Vec<DenseAttrSlot> = existing
13255        .attrs
13256        .into_iter()
13257        .enumerate()
13258        .map(|(i, msg)| DenseAttrSlot {
13259            msg,
13260            collections: None,
13261            creation_index: existing.creation.index_at(i),
13262        })
13263        .collect();
13264    for op in ops {
13265        match op {
13266            AttrOp::Set { name, value } => {
13267                let msg = build_attr_message(name, value);
13268                let collections = value.var_len_strings().map(build_global_heap_collections);
13269                match set.iter_mut().find(|slot| &slot.msg.name == name) {
13270                    // Setting an attribute the object already has replaces it
13271                    // where it stands, so a repeated `set_attr` does not reorder
13272                    // the set — and it keeps the creation index it had, so an
13273                    // iteration by creation order does not reorder it either.
13274                    Some(slot) => {
13275                        slot.msg = msg;
13276                        slot.collections = collections;
13277                    }
13278                    None => {
13279                        let creation_index = tracked.then_some(next_index);
13280                        if tracked {
13281                            next_index = bump_creation_index(next_index)?;
13282                        }
13283                        set.push(DenseAttrSlot {
13284                            msg,
13285                            collections,
13286                            creation_index,
13287                        });
13288                    }
13289                }
13290            }
13291            AttrOp::Remove { name } => {
13292                let before = set.len();
13293                // A deletion leaves a gap in the creation order rather than
13294                // renumbering what is left, and does not lower the maximum:
13295                // the reference C library hands out indexes from a counter that
13296                // only ever rises.
13297                set.retain(|slot| &slot.msg.name != name);
13298                if set.len() == before {
13299                    return Err(Error::EditUnsupported("attribute to remove was not found"));
13300                }
13301            }
13302        }
13303    }
13304
13305    // Every attribute message is rebuilt from the set above, so the region keeps
13306    // none of them, nor the Attribute Info message naming the storage they were
13307    // in.
13308    let region = strip_attr_messages(region)?;
13309    // No attributes left: the header carries neither an Attribute message nor an
13310    // Attribute Info one. `append_dense_attrs` would decline to build a heap for
13311    // an empty set anyway; saying `None` here is what makes that a property of
13312    // this decision rather than of the emitter that carries it out.
13313    if set.is_empty() {
13314        return Ok(AttrEdits {
13315            region,
13316            attrs: EditedAttrs::default(),
13317        });
13318    }
13319    let mut attrs = Vec::with_capacity(set.len());
13320    let mut indices = Vec::with_capacity(set.len());
13321    let mut vl = Vec::new();
13322    for (i, slot) in set.into_iter().enumerate() {
13323        if let Some(collections) = slot.collections {
13324            vl.push((i, collections));
13325        }
13326        // Every slot of a tracked object carries an index: one read from its
13327        // storage, or one this edit just handed out. `indices` is discarded for
13328        // an untracked object, where none of them do.
13329        debug_assert!(
13330            !tracked || slot.creation_index.is_some(),
13331            "a tracked object's attribute must carry a creation index"
13332        );
13333        indices.push(slot.creation_index.unwrap_or_default());
13334        attrs.push(slot.msg);
13335    }
13336    let creation = if tracked {
13337        DenseAttrCreationOrder::Tracked {
13338            indices,
13339            max: next_index,
13340            indexed: region.layout().indexes_creation_order(),
13341        }
13342    } else {
13343        DenseAttrCreationOrder::Untracked
13344    };
13345    // Moving a set into a heap takes it out of reach of the reference repointing
13346    // a commit does as its last act: `reference_patch::scan_object` reads an
13347    // object whose attributes are dense as unproven and collects no edit from it.
13348    // An object *already* dense is not this refusal's business — its attributes
13349    // stood outside that walk before this edit and still do — but converting one
13350    // would strand a reference the header was keeping reachable, and permanently,
13351    // since an object that goes dense stays dense (issue #324).
13352    if !dense_now {
13353        for attr in &attrs {
13354            if crate::reference_patch::attribute_references_are_repointable(&attr.datatype) {
13355                return Err(Error::EditUnsupported(
13356                    REFERENCE_ATTRIBUTE_WOULD_LEAVE_THE_HEADER,
13357                ));
13358            }
13359        }
13360    }
13361    // What the heap itself cannot represent is the last refusal, and it belongs
13362    // in the preflight: past here the commit is placing bytes.
13363    crate::file_writer::dense_attrs_check(&attrs).map_err(Error::Format)?;
13364    Ok(AttrEdits {
13365        region,
13366        attrs: EditedAttrs::Dense(DenseAttrEdit {
13367            set: DenseAttrSet { attrs, creation },
13368            vl,
13369        }),
13370    })
13371}
13372
13373/// One attribute of the set a dense rebuild is assembled from.
13374struct DenseAttrSlot {
13375    msg: crate::attribute::AttributeMessage,
13376    /// Global heap collections still to be placed, for a variable-length
13377    /// attribute *this edit* sets.
13378    collections: Option<Vec<Vec<u8>>>,
13379    /// The creation index the object records for it, where it tracks the order.
13380    creation_index: Option<u16>,
13381}
13382
13383/// Whether an object's chunk-0 message `region` stores its attributes densely —
13384/// an Attribute Info message naming a fractal heap. See
13385/// [`attribute_info_is_dense`] for why the message's mere presence is not that.
13386fn region_uses_dense_attrs(region: &OhRegion) -> Result<bool, Error> {
13387    let mut p = 0;
13388    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13389        if msg_type == MessageType::ATTRIBUTE_INFO
13390            && attribute_info_is_dense(&region[body..body_end])
13391        {
13392            return Ok(true);
13393        }
13394        p = body_end;
13395    }
13396    Ok(false)
13397}
13398
13399/// Whether any Attribute message in `region` carries a message flag — which for
13400/// an attribute means the body is a shared (SOHM) record rather than the
13401/// attribute itself. Reads the same byte [`parse_compact_attr_name`] does, so the
13402/// two attribute paths refuse exactly the same messages.
13403fn region_has_shared_attr(region: &OhRegion) -> Result<bool, Error> {
13404    let mut p = 0;
13405    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13406        if msg_type == MessageType::ATTRIBUTE && region[p + 3] != 0 {
13407            return Ok(true);
13408        }
13409        p = body_end;
13410    }
13411    Ok(false)
13412}
13413
13414/// Copy a chunk-0 message `region`, dropping every Attribute message and the
13415/// Attribute Info message that names their storage. What the object carries in
13416/// their place is [`plan_attr_ops`]'s decision, appended by the apply phase.
13417fn strip_attr_messages(region: &OhRegion) -> Result<OhRegion, Error> {
13418    let mut out = Vec::with_capacity(region.len());
13419    let mut p = 0;
13420    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13421        if !matches!(
13422            msg_type,
13423            MessageType::ATTRIBUTE | MessageType::ATTRIBUTE_INFO
13424        ) {
13425            out.extend_from_slice(&region[p..body_end]);
13426        }
13427        p = body_end;
13428    }
13429    if p < region.len() {
13430        out.extend_from_slice(&region[p..]);
13431    }
13432    Ok(region.with_bytes(out))
13433}
13434
13435/// Every attribute an object carries, compact or dense, parsed into messages.
13436///
13437/// This read is what makes a dense edit possible at all: the set an edit works
13438/// from is the object's whole attribute set, and for a dense object none of it
13439/// is in the header — `extract_attributes_full_from_source` walks the fractal
13440/// heap the Attribute Info message names. It reads both storage forms, so no
13441/// caller has to ask which one an object uses.
13442///
13443/// Shared with [`WriteEngine::read_object`], which needs the same set for the
13444/// same reason when it copies a dense object. That caller checks
13445/// [`crate::file_writer::dense_attrs_check`] on what it reads; the attribute
13446/// editor checks it after applying its edits, on the set it will actually write.
13447fn read_object_attrs<S: Source + ?Sized>(
13448    src: &S,
13449    access_mode: AccessMode,
13450    addr: u64,
13451    base: BaseAddress,
13452) -> Result<Vec<crate::attribute::StoredAttribute>, Error> {
13453    let header =
13454        ObjectHeader::parse_from_source(src, access_mode, addr, OFFSET_SIZE, LENGTH_SIZE, base)?;
13455    if base.get() > src.len() {
13456        return Err(Error::EditUnsupported(
13457            "this file's userblock is larger than the file itself",
13458        ));
13459    }
13460    // Heap addresses in the Attribute Info message are stored relative to the
13461    // base address, so the walk gets the file framed past its userblock — the
13462    // same view the reader uses. `base` is 0 for a plain file, where this is
13463    // `src` itself.
13464    let framed = BaseOffsetSource { inner: src, base };
13465    crate::attribute::extract_stored_attributes_from_source(
13466        &framed,
13467        access_mode,
13468        &header,
13469        OFFSET_SIZE,
13470        LENGTH_SIZE,
13471        // No shared-message table: this engine refuses an object carrying a
13472        // shared attribute message before it gets here ([`SHARED_ATTRIBUTE_MESSAGE`]),
13473        // and resolving one silently is exactly what that refusal prevents — the
13474        // resolved copy is indistinguishable from a private attribute, and would
13475        // be re-emitted as one, leaving the file's reference count naming an
13476        // attribute that no longer exists.
13477        None,
13478    )
13479    .map_err(Error::Format)
13480}
13481
13482/// Assemble a [`DenseAttrSet`] from an object's stored attributes and what its
13483/// header `region` says about attribute creation order.
13484///
13485/// An object that does not track the order gets
13486/// [`DenseAttrCreationOrder::Untracked`], which is the whole story for every
13487/// file this crate writes itself. One that does keeps each attribute's stored
13488/// index, and the maximum its Attribute Info message records — the next index it
13489/// would hand out, which a deletion does not lower, so it cannot be recomputed
13490/// from the attributes that remain. An attribute whose storage recorded no index
13491/// (a header that claims to track the order and then does not) is given the next
13492/// unused one rather than silently colliding with a real index.
13493fn dense_attr_set(
13494    region: &OhRegion,
13495    stored: Vec<crate::attribute::StoredAttribute>,
13496) -> Result<DenseAttrSet, Error> {
13497    let layout = region.layout();
13498    if !layout.tracks_creation_order() {
13499        return Ok(DenseAttrSet {
13500            attrs: stored.into_iter().map(|a| a.message).collect(),
13501            creation: DenseAttrCreationOrder::Untracked,
13502        });
13503    }
13504    let mut next = next_attr_creation_index(region)?;
13505    let mut attrs = Vec::with_capacity(stored.len());
13506    let mut indices = Vec::with_capacity(stored.len());
13507    for attr in stored {
13508        let index = match attr.creation_index {
13509            Some(index) => index,
13510            None => {
13511                let index = next;
13512                next = bump_creation_index(next)?;
13513                index
13514            }
13515        };
13516        next = next.max(bump_creation_index(index)?);
13517        attrs.push(attr.message);
13518        indices.push(index);
13519    }
13520    Ok(DenseAttrSet {
13521        attrs,
13522        creation: DenseAttrCreationOrder::Tracked {
13523            indices,
13524            max: next,
13525            indexed: layout.indexes_creation_order(),
13526        },
13527    })
13528}
13529
13530/// One past `index`, refusing the object that has exhausted the 2-byte creation
13531/// index its Attribute Info message records.
13532fn bump_creation_index(index: u16) -> Result<u16, Error> {
13533    index.checked_add(1).ok_or(Error::EditUnsupported(
13534        "an object has assigned every attribute creation index its header can record",
13535    ))
13536}
13537
13538/// Whether an Attribute Info (0x0015) message body denotes *dense* (fractal-heap)
13539/// attribute storage — a *defined* heap address. The reference C library and h5py
13540/// emit an Attribute Info message with an *undefined* heap address even for
13541/// compact, inline attributes in the latest format (to carry creation-order
13542/// metadata), so its mere presence is not dense storage; only a defined heap
13543/// address is. An unparseable message is treated as dense (refused conservatively).
13544/// Mirrors the copy path's dense detection so the compact-attribute editors accept
13545/// the undefined-address message that nearly every real-world object carries.
13546pub(crate) fn attribute_info_is_dense(body: &[u8]) -> bool {
13547    match crate::attribute_info::AttributeInfoMessage::parse(body, OFFSET_SIZE) {
13548        Ok(ai) => ai.fractal_heap_address.is_some(),
13549        Err(_) => true,
13550    }
13551}
13552
13553/// Copy a message region, dropping all Attribute messages named `name` and then
13554/// appending a fresh compact Attribute message for `value`.
13555fn set_attr_in_region(region: &OhRegion, name: &str, value: &AttrValue) -> Result<OhRegion, Error> {
13556    let body = encode_attr_body(name, value)?;
13557    let keep = attr_creation_index(region, name)?;
13558    put_attr_message(region, name, &body, keep)
13559}
13560
13561/// The creation index the Attribute message named `name` carries, or `None`
13562/// where the object has no such attribute — or does not track the order at all.
13563fn attr_creation_index(region: &OhRegion, name: &str) -> Result<Option<u16>, Error> {
13564    if !region.layout().tracks_creation_order() {
13565        return Ok(None);
13566    }
13567    let mut p = 0;
13568    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13569        if msg_type == MessageType::ATTRIBUTE
13570            && parse_compact_attr_name(region, p, body, body_end)? == name
13571        {
13572            return Ok(region.creation_index(p));
13573        }
13574        p = body_end;
13575    }
13576    Ok(None)
13577}
13578
13579/// The highest creation index any Attribute record in `region` carries.
13580fn highest_attr_creation_index(region: &OhRegion) -> Result<Option<u16>, Error> {
13581    let mut highest = None;
13582    let mut p = 0;
13583    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13584        if msg_type == MessageType::ATTRIBUTE {
13585            highest = highest.max(region.creation_index(p));
13586        }
13587        p = body_end;
13588    }
13589    Ok(highest)
13590}
13591
13592/// The next unused attribute creation index for an object whose messages are
13593/// `region`.
13594///
13595/// The reference C library hands out `ainfo.max_crt_idx` and increments it
13596/// (`H5O__attr_create`), so the recorded maximum is the *next* index rather
13597/// than the highest in use, and deleting an attribute never lowers it. Read it
13598/// from the Attribute Info message where there is one, and otherwise derive one
13599/// past the highest index the records carry — the best evidence a header with no
13600/// such message leaves.
13601fn next_attr_creation_index(region: &OhRegion) -> Result<u16, Error> {
13602    let recorded = find_attribute_info(region)?
13603        .and_then(|(_, _, info)| info.max_creation_index)
13604        .unwrap_or(0);
13605    let derived = highest_attr_creation_index(region)?.map_or(0, |i| u32::from(i) + 1);
13606    let next = u32::from(recorded).max(derived);
13607    next.narrow_or_else(|| {
13608        Error::EditUnsupported(
13609            "an object has assigned every attribute creation index its header can record",
13610        )
13611    })
13612}
13613
13614/// The Attribute Info message in `region`, as `(record start, body range, parsed
13615/// message)`. An unparseable message is reported as absent, which leaves it
13616/// untouched: `attribute_info_is_dense` reads the same message and refuses the
13617/// object rather than letting an edit rewrite one it did not understand.
13618fn find_attribute_info(
13619    region: &OhRegion,
13620) -> Result<Option<(usize, core::ops::Range<usize>, AttributeInfoMessage)>, Error> {
13621    let mut p = 0;
13622    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13623        if msg_type == MessageType::ATTRIBUTE_INFO
13624            && let Ok(info) = AttributeInfoMessage::parse(&region[body..body_end], OFFSET_SIZE)
13625        {
13626            return Ok(Some((p, body..body_end, info)));
13627        }
13628        p = body_end;
13629    }
13630    Ok(None)
13631}
13632
13633/// The Attribute Info message body an object storing its attributes compactly
13634/// needs, in `region`'s layout.
13635///
13636/// A header that tracks attribute creation order records the maximum index ever
13637/// assigned here, and nowhere else, so a header carrying no such message leaves
13638/// only the records to derive it from: one past the highest index in use. That
13639/// under-counts an object whose most recently created attributes have since been
13640/// deleted, which is why an edit *patches* an existing message
13641/// ([`put_attr_message`]) rather than rebuilding one.
13642fn compact_attribute_info_body(region: &OhRegion) -> Result<Vec<u8>, Error> {
13643    let layout = region.layout();
13644    let info = AttributeInfoMessage {
13645        max_creation_index: layout
13646            .tracks_creation_order()
13647            .then(|| next_attr_creation_index(region))
13648            .transpose()?,
13649        indexes_creation_order: layout.indexes_creation_order(),
13650        fractal_heap_address: None,
13651        btree_name_index_address: None,
13652        btree_creation_order_address: None,
13653    };
13654    Ok(info.serialize(OFFSET_SIZE))
13655}
13656
13657/// Copy a message region, dropping every Attribute message named `name` and
13658/// appending one carrying `body`, then keep the object's Attribute Info message
13659/// in step with the creation index the new record takes.
13660///
13661/// `keep` is the index an *overwrite* preserves: the reference C library modifies
13662/// an attribute in place, so writing over one does not move it in an iteration
13663/// by creation order. `None` asks for the object's next unused index, which then
13664/// becomes the maximum the Attribute Info message records. Both are inert on a
13665/// header that does not track the order, where no record has the field.
13666fn put_attr_message(
13667    region: &OhRegion,
13668    name: &str,
13669    body: &[u8],
13670    keep: Option<u16>,
13671) -> Result<OhRegion, Error> {
13672    let layout = region.layout();
13673    let (creation_index, new_max) = if layout.tracks_creation_order() {
13674        match keep {
13675            Some(index) => (index, None),
13676            None => {
13677                let index = next_attr_creation_index(region)?;
13678                let next = index.checked_add(1).ok_or(Error::EditUnsupported(
13679                    "an object has assigned every attribute creation index its header can record",
13680                ))?;
13681                (index, Some(next))
13682            }
13683        }
13684    } else {
13685        (0, None)
13686    };
13687    let new_msg = layout.record_with_creation_index(MessageType::ATTRIBUTE, body, creation_index);
13688
13689    let mut out = Vec::with_capacity(region.len() + new_msg.len());
13690    let mut p = 0;
13691    while let Some((msg_type, msg_body, body_end)) = region.next_message(p)? {
13692        match msg_type {
13693            MessageType::ATTRIBUTE
13694                if parse_compact_attr_name(region, p, msg_body, body_end)? == name =>
13695            {
13696                p = body_end;
13697                continue;
13698            }
13699            // Bumping the recorded maximum re-encodes the message, since a
13700            // header that was not recording one at all needs the field added.
13701            MessageType::ATTRIBUTE_INFO if new_max.is_some() => {
13702                if let Ok(mut info) =
13703                    AttributeInfoMessage::parse(&region[msg_body..body_end], OFFSET_SIZE)
13704                {
13705                    info.max_creation_index = new_max;
13706                    info.indexes_creation_order |= layout.indexes_creation_order();
13707                    let encoded = info.serialize(OFFSET_SIZE);
13708                    let len: u16 = encoded.len().narrow_or_else(|| {
13709                        Error::EditUnsupported("an Attribute Info message is too large to record")
13710                    })?;
13711                    // Only the body and its length change: the record keeps its
13712                    // own flags — the reference C library marks this message
13713                    // "don't share" — and its creation index.
13714                    out.push(region[p]);
13715                    out.extend_from_slice(&len.to_le_bytes());
13716                    out.extend_from_slice(&region[p + 3..msg_body]);
13717                    out.extend_from_slice(&encoded);
13718                    p = body_end;
13719                    continue;
13720                }
13721                out.extend_from_slice(&region[p..body_end]);
13722            }
13723            _ => out.extend_from_slice(&region[p..body_end]),
13724        }
13725        p = body_end;
13726    }
13727    out.extend_from_slice(&new_msg);
13728    if p < region.len() {
13729        out.extend_from_slice(&region[p..]);
13730    }
13731    let mut out = region.with_bytes(out);
13732    // An object that had no Attribute Info message to bump gets the one
13733    // `build_v2_object_header` would add anyway, now, so the maximum this edit
13734    // just assigned is recorded rather than re-derived from the records.
13735    if new_max.is_some() && find_attribute_info(&out)?.is_none() {
13736        let body = compact_attribute_info_body(&out)?;
13737        out.push(MessageType::ATTRIBUTE_INFO, &body);
13738    }
13739    Ok(out)
13740}
13741
13742/// Copy a message region, dropping all Attribute messages named `name`. When
13743/// `required` is true, an absent `name` is an [`Error::EditUnsupported`] (a
13744/// `Remove` of a nonexistent attribute); when false, it is not an error (a
13745/// `Set` of a fresh variable-length attribute may have no fixed-size message
13746/// to remove from the region yet).
13747fn remove_attr_from_region(
13748    region: &OhRegion,
13749    name: &str,
13750    required: bool,
13751) -> Result<OhRegion, Error> {
13752    let mut out = Vec::with_capacity(region.len());
13753    let mut p = 0;
13754    let mut removed = false;
13755    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
13756        let mut skip = false;
13757        if msg_type == MessageType::ATTRIBUTE {
13758            let attr_name = parse_compact_attr_name(region, p, body, body_end)?;
13759            if attr_name == name {
13760                skip = true;
13761                removed = true;
13762            }
13763        }
13764        if !skip {
13765            out.extend_from_slice(&region[p..body_end]);
13766        }
13767        p = body_end;
13768    }
13769    if p < region.len() {
13770        out.extend_from_slice(&region[p..]);
13771    }
13772    if !removed && required {
13773        return Err(Error::EditUnsupported("attribute to remove was not found"));
13774    }
13775    Ok(region.with_bytes(out))
13776}
13777
13778/// How many attributes an object's message `region` carries inline. Only a
13779/// compact region is ever counted this way: a dense object carries no inline
13780/// Attribute message at all, and would count zero — which is why
13781/// [`plan_attr_ops`] decides an object's storage before it reaches here.
13782fn compact_attr_count(region: &OhRegion) -> Result<usize, Error> {
13783    let mut count = 0usize;
13784    let mut p = 0;
13785    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13786        if msg_type == MessageType::ATTRIBUTE {
13787            count += 1;
13788        }
13789        p = body_end;
13790    }
13791    Ok(count)
13792}
13793
13794fn parse_compact_attr_name(
13795    region: &[u8],
13796    msg_start: usize,
13797    body: usize,
13798    body_end: usize,
13799) -> Result<String, Error> {
13800    if region[msg_start + 3] != 0 {
13801        return Err(Error::EditUnsupported(SHARED_ATTRIBUTE_MESSAGE));
13802    }
13803    // Only the name is wanted here, and it is the one field that never depends on
13804    // the datatype or dataspace — either of which may be a reference to a
13805    // committed message this walk has no file context to follow. Reading the name
13806    // alone lets an edit pass over such an attribute instead of refusing the
13807    // whole object because one of its neighbours is committed.
13808    crate::attribute::message_name(&region[body..body_end]).map_err(Error::Format)
13809}
13810
13811fn encode_attr_body(name: &str, value: &AttrValue) -> Result<Vec<u8>, Error> {
13812    // `apply_compact_attr_ops`'s `Set` branch — this function's only caller —
13813    // handles a value that needs the global heap itself (staging it into
13814    // `pending_vl` instead of calling `set_attr_in_region`/here), so this value
13815    // is always inline by construction, not by a check made at this call site.
13816    debug_assert!(
13817        value.var_len_strings().is_none(),
13818        "a variable-length attribute must be intercepted by apply_compact_attr_ops before reaching encode_attr_message"
13819    );
13820    let body = build_attr_message(name, value).serialize(LENGTH_SIZE);
13821    if body.len() > OBJECT_HEADER_MESSAGE_MAX {
13822        return Err(Error::EditUnsupported(
13823            "group attribute is too large to encode in place",
13824        ));
13825    }
13826    Ok(body)
13827}
13828
13829/// Parse the version-2 object-header message record at `p` within a chunk-0
13830/// message region, returning `(message type, body start, body end)`; the next
13831/// record begins at `body end`. Returns `Ok(None)` once fewer than 4 bytes
13832/// remain (a clean end of the region), and `Err` if a record's declared body
13833/// runs past the region. Centralizes the bounds check shared by every walker.
13834/// Rebuild a superblock-extension object header's message region (as collapsed by
13835/// [`WriteEngine::gather_oh_messages`]) with
13836/// its File Space Info message replaced by `info`, preserving every other message
13837/// verbatim. The persisting message is fixed-size, so the region length is stable.
13838/// Shared by the whole-file mirror commit and the bounded finalize so both write
13839/// the same extension bytes.
13840pub(crate) fn rewrite_extension_region_bytes(
13841    region: &OhRegion,
13842    info: &FileSpaceInfo,
13843) -> Result<OhRegion, Error> {
13844    let new_body = info.serialize();
13845    // The message body is the fixed-size File Space Info record (≤ 125 bytes),
13846    // so it always fits the u16 size field.
13847    let new_len: u16 = new_body
13848        .len()
13849        .narrow_or_else(|| Error::EditUnsupported("File Space Info message too large"))?;
13850    let mut out = Vec::with_capacity(region.len());
13851    let mut p = 0;
13852    let mut replaced = false;
13853    while let Some((msg_type, _body, body_end)) = region.next_message(p)? {
13854        if msg_type == MessageType::FILE_SPACE_INFO {
13855            out.push(region[p]); // message type byte
13856            out.extend_from_slice(&new_len.to_le_bytes());
13857            // Preserve the message flags (0x14) and, where the header carries
13858            // one, the record's creation index.
13859            out.extend_from_slice(&region[p + 3..p + region.layout().prefix_len()]);
13860            out.extend_from_slice(&new_body);
13861            replaced = true;
13862        } else {
13863            out.extend_from_slice(&region[p..body_end]);
13864        }
13865        p = body_end;
13866    }
13867    if !replaced {
13868        // Persistence is armed only when the extension already carries a File
13869        // Space Info message, so this is unreachable; refuse rather than
13870        // silently restructure an extension we did not understand.
13871        return Err(Error::EditUnsupported(
13872            "a persisting file's superblock extension has no File Space Info message",
13873        ));
13874    }
13875    Ok(region.with_bytes(out))
13876}
13877
13878/// Parse and validate a version 2 object header's prefix, returning the absolute
13879/// `[start, end)` byte range of its chunk-0 message region.
13880///
13881/// `prefix` holds the bytes at `[addr, addr + prefix.len())` — up to
13882/// [`OH_PREFIX_MAX`], fewer when the header sits near the end of the image — and
13883/// `file_len` is the length of the image the header lives in, which bounds the
13884/// region. Rejects headers that are not OHDR v2. Everything the prefix declares
13885/// ([`OhHeaderProps`]) is returned with the region: the record layout, because a
13886/// header that tracks attribute creation order carries 6-byte message records
13887/// and every walk of this region has to step by that width, and the two optional
13888/// blocks, because nothing below the prefix records them and a rebuild has to
13889/// put them back.
13890fn oh_region_at(
13891    prefix: &[u8],
13892    addr: u64,
13893    file_len: u64,
13894) -> Result<(u64, u64, OhHeaderProps), Error> {
13895    if prefix.len() < 6 || &prefix[..4] != b"OHDR" || prefix[4] != 2 {
13896        return Err(Error::EditUnsupported(
13897            "an object does not use a version 2 object header",
13898        ));
13899    }
13900    let flags = prefix[5];
13901    let layout = OhRecordLayout::from_header_flags(flags);
13902    let mut pos = 6usize;
13903    let mut take = |len: usize| -> Result<usize, Error> {
13904        let at = pos;
13905        pos += len;
13906        if prefix.len() < pos {
13907            return Err(Error::EditUnsupported("truncated object header"));
13908        }
13909        Ok(at)
13910    };
13911    let times = if flags & OH_FLAG_STORE_TIMES != 0 {
13912        let at = take(ObjectTimes::LEN)?;
13913        Some(ObjectTimes::parse(prefix, at))
13914    } else {
13915        None
13916    };
13917    let attr_phase_change = if flags & OH_FLAG_ATTR_PHASE_CHANGE != 0 {
13918        let at = take(AttrPhaseChange::LEN)?;
13919        Some(AttrPhaseChange::parse(prefix, at))
13920    } else {
13921        None
13922    };
13923    let props = OhHeaderProps {
13924        layout,
13925        times,
13926        attr_phase_change,
13927    };
13928    let size_width = usize::from(UintWidth::from_flags(flags).get());
13929    if prefix.len() < pos + size_width {
13930        return Err(Error::EditUnsupported("truncated object header"));
13931    }
13932    let chunk0_size = read_le(&prefix[pos..pos + size_width]) as u64;
13933    pos += size_width;
13934    let region_start = addr
13935        .checked_add(pos as u64)
13936        .ok_or(Error::EditUnsupported("truncated object header"))?;
13937    // The region is followed by a 4-byte checksum, which must also be present.
13938    let region_end = region_start
13939        .checked_add(chunk0_size)
13940        .filter(|e| e.checked_add(4).is_some_and(|end| end <= file_len))
13941        .ok_or(Error::EditUnsupported("truncated object header"))?;
13942    Ok((region_start, region_end, props))
13943}
13944
13945/// One chunk of a version 2 object header, read out of a file image.
13946///
13947/// The buffer stops at the end of the chunk's message region: the trailing
13948/// checksum is never walked, and it has already been confirmed present. `span`
13949/// covers the *whole* on-disk chunk including that checksum, so it can be handed
13950/// to the free list when the header is reclaimed.
13951pub(crate) struct OhChunk {
13952    /// Absolute file address and full on-disk length of the chunk.
13953    pub(crate) span: (u64, u64),
13954    /// The chunk's bytes, from `span.0` through the end of its message region.
13955    buf: Vec<u8>,
13956    /// Offset of the first message within [`buf`](Self::buf).
13957    messages_start: usize,
13958    /// What chunk 0 of this header declared in its prefix, which every chunk of
13959    /// the header shares.
13960    props: OhHeaderProps,
13961}
13962
13963impl OhChunk {
13964    /// The slice to walk messages in, and the offset to start at. The two are
13965    /// returned together because [`OhRecordLayout::next_message`] must not read
13966    /// past the end of the message region into the checksum.
13967    pub(crate) fn message_region(&self) -> (&[u8], usize) {
13968        (&self.buf, self.messages_start)
13969    }
13970
13971    /// The record layout to walk [`message_region`](Self::message_region) in.
13972    pub(crate) fn layout(&self) -> OhRecordLayout {
13973        self.props.layout
13974    }
13975
13976    /// Everything this header's chunk-0 prefix declared.
13977    pub(crate) fn props(&self) -> OhHeaderProps {
13978        self.props
13979    }
13980}
13981
13982/// Read chunk 0 of the version 2 object header at `addr` out of `src`.
13983fn read_oh_chunk0<S: Source + ?Sized>(src: &S, addr: u64) -> Result<OhChunk, Error> {
13984    let file_len = src.len();
13985    let window = file_len
13986        .saturating_sub(addr)
13987        .min(OH_PREFIX_MAX as u64)
13988        .to_usize()?;
13989    let prefix = src.read_metadata_at(addr, window)?;
13990    let (rs, re, props) = oh_region_at(&prefix, addr, file_len)?;
13991    // `re >= rs > addr`, so both differences are non-negative, and `oh_region_at`
13992    // has checked that the 4-byte checksum past `re` is present.
13993    let len = (re - addr).to_usize()?;
13994    Ok(OhChunk {
13995        span: (addr, len as u64 + 4),
13996        buf: src.read_metadata_at(addr, len)?,
13997        messages_start: (rs - addr).to_usize()?,
13998        props,
13999    })
14000}
14001
14002/// Read every chunk of the version 2 object header at `addr`, chunk 0 first,
14003/// following each `Continuation` message to its `OCHK` block.
14004///
14005/// This is the one traversal of a header's chunk chain: [`WriteEngine::gather_oh_messages`]
14006/// collects the messages out of the result and
14007/// [`oh_chunk_spans`](WriteEngine::oh_chunk_spans) collects the extents, so the
14008/// two cannot disagree about what a header occupies.
14009pub(crate) fn read_oh_chunks<S: Source + ?Sized>(
14010    src: &S,
14011    addr: u64,
14012    base: BaseAddress,
14013) -> Result<Vec<OhChunk>, Error> {
14014    let mut chunks = vec![read_oh_chunk0(src, addr)?];
14015    let mut i = 0;
14016    while i < chunks.len() {
14017        if chunks.len() > MAX_OH_CHUNKS {
14018            return Err(Error::EditUnsupported(
14019                "object header has too many continuation chunks",
14020            ));
14021        }
14022        // Collect this chunk's continuations before extending the worklist, so the
14023        // borrow of `chunks[i]` ends first.
14024        let mut found = Vec::new();
14025        let props = chunks[i].props();
14026        let layout = props.layout;
14027        let (region, mut p) = chunks[i].message_region();
14028        while let Some((msg_type, body, body_end)) = layout.next_message(region, p)? {
14029            if msg_type == MessageType::OBJECT_HEADER_CONTINUATION {
14030                found.push(read_oh_continuation(
14031                    src, region, body, body_end, base, props,
14032                )?);
14033            }
14034            p = body_end;
14035        }
14036        i += 1;
14037        chunks.extend(found);
14038    }
14039    Ok(chunks)
14040}
14041
14042/// Read the `OCHK` continuation block a continuation message points at.
14043///
14044/// `region[body..body_end]` is the continuation message's body: the block's
14045/// base-relative address followed by its length.
14046fn read_oh_continuation<S: Source + ?Sized>(
14047    src: &S,
14048    region: &[u8],
14049    body: usize,
14050    body_end: usize,
14051    base: BaseAddress,
14052    props: OhHeaderProps,
14053) -> Result<OhChunk, Error> {
14054    if body_end - body < (OFFSET_SIZE + LENGTH_SIZE) as usize {
14055        return Err(Error::EditUnsupported("malformed continuation message"));
14056    }
14057    let off = u64::from_le_bytes(region[body..body + 8].try_into().unwrap());
14058    let len = u64::from_le_bytes(region[body + 8..body + 16].try_into().unwrap());
14059    // The block address is stored relative to the base address; shift it to an
14060    // absolute file offset before reading.
14061    let off = base.absolute(StoredAddress::new(off))?;
14062    // An OCHK block is signature(4) + messages + checksum(4).
14063    let end = off
14064        .checked_add(len)
14065        .filter(|&e| e <= src.len() && len >= 8)
14066        .ok_or(Error::EditUnsupported("continuation block out of bounds"))?;
14067    let want = (end - off).to_usize()?;
14068    let mut buf = src.read_metadata_at(off, want)?;
14069    if buf[..4] != *b"OCHK" {
14070        return Err(Error::EditUnsupported(
14071            "invalid continuation block signature",
14072        ));
14073    }
14074    // Trim the trailing checksum so the message walk stops at the last message.
14075    buf.truncate(want - 4);
14076    Ok(OhChunk {
14077        span: (off, len),
14078        buf,
14079        messages_start: 4,
14080        props,
14081    })
14082}
14083
14084/// How a version 2 object header's message records are laid out, and what its
14085/// flags say about attribute creation order.
14086///
14087/// Every record opens with a type byte, a 2-byte body size and a flags byte. A
14088/// header that *tracks* attribute creation order — bit 2 of the object header's
14089/// own flags, what `H5Pset_attr_creation_order` and h5py's `track_order=True`
14090/// turn on, and what netCDF-4 sets on every object it writes — follows those
14091/// with a 2-byte creation index, so its records are 6 bytes wide rather than 4.
14092/// A header that also *indexes* that order (bit 3) carries a creation-order
14093/// B-tree beside the name index once its attributes go dense; the reference C
14094/// library reads that bit back out of the header when it builds an Attribute
14095/// Info message, so a rewrite that dropped it would quietly stop indexing.
14096///
14097/// Both bits are properties of the whole header, so chunk 0 and every
14098/// continuation block of one header share a layout. Carrying it beside the bytes
14099/// ([`OhRegion`]) is what keeps the two dozen walkers and the emitters in this
14100/// module from having to agree about it one by one.
14101#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14102pub(crate) struct OhRecordLayout {
14103    /// Records carry a 2-byte creation index (object header flags bit 2).
14104    tracked: bool,
14105    /// Dense attribute storage indexes that order (object header flags bit 3).
14106    indexed: bool,
14107}
14108
14109/// Object header flags bit 2: message creation order is tracked, so every
14110/// message record carries a creation index.
14111const OH_FLAG_CREATION_ORDER_TRACKED: u8 = 0x04;
14112
14113/// Object header flags bit 3: attribute creation order is *indexed* as well as
14114/// tracked, so dense attribute storage carries a creation-order B-tree beside
14115/// its name index.
14116const OH_FLAG_CREATION_ORDER_INDEXED: u8 = 0x08;
14117
14118/// Object header flags bit 4: the header prefix carries the attribute
14119/// phase-change thresholds (`H5O_HDR_ATTR_STORE_PHASE_CHANGE`).
14120const OH_FLAG_ATTR_PHASE_CHANGE: u8 = 0x10;
14121
14122/// Object header flags bit 5: the header prefix carries the four access,
14123/// modification, change and birth timestamps (`H5O_HDR_STORE_TIMES`).
14124const OH_FLAG_STORE_TIMES: u8 = 0x20;
14125
14126/// The four timestamps a version 2 object header stores when
14127/// [`OH_FLAG_STORE_TIMES`] is set, each 4 bytes of seconds since the Unix epoch
14128/// and stored in this order.
14129///
14130/// The reference C library stores them on **every** version 2 header it writes:
14131/// `H5O_CRT_OHDR_FLAGS_DEF` is `H5O_HDR_STORE_TIMES`, so a header from libhdf5,
14132/// h5py or netCDF-4 carries all four, and `H5Oget_info` reads them straight out
14133/// of this block.
14134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14135pub(crate) struct ObjectTimes {
14136    access: u32,
14137    modification: u32,
14138    change: u32,
14139    birth: u32,
14140}
14141
14142impl ObjectTimes {
14143    /// Bytes this block occupies in a header prefix.
14144    const LEN: usize = 16;
14145
14146    /// Parse the block at `at`. The caller must have checked that 16 bytes are
14147    /// available there.
14148    fn parse(prefix: &[u8], at: usize) -> Self {
14149        let field =
14150            |i: usize| u32::from_le_bytes(prefix[at + 4 * i..at + 4 * i + 4].try_into().unwrap());
14151        Self {
14152            access: field(0),
14153            modification: field(1),
14154            change: field(2),
14155            birth: field(3),
14156        }
14157    }
14158
14159    /// The block's on-disk bytes.
14160    fn to_bytes(self) -> [u8; Self::LEN] {
14161        let mut out = [0u8; Self::LEN];
14162        out[0..4].copy_from_slice(&self.access.to_le_bytes());
14163        out[4..8].copy_from_slice(&self.modification.to_le_bytes());
14164        out[8..12].copy_from_slice(&self.change.to_le_bytes());
14165        out[12..16].copy_from_slice(&self.birth.to_le_bytes());
14166        out
14167    }
14168
14169    /// The same times with the modification and change times moved to `now`.
14170    /// The access and birth times are the object's own history and are left
14171    /// where they were.
14172    ///
14173    /// The reference C library's `H5O_touch_oh` reaches the same two-of-four
14174    /// shape by a different pair: on a version 2 header it writes
14175    /// `oh->atime = oh->ctime = now` and carries a source comment saying the
14176    /// modification time still needs code to update. A rewrite is a
14177    /// modification, and it is not an *access*, so this writes the field that
14178    /// says so; both agree on the change time, and neither disturbs the birth
14179    /// time.
14180    fn touched(self, now: u32) -> Self {
14181        Self {
14182            modification: now,
14183            change: now,
14184            ..self
14185        }
14186    }
14187}
14188
14189/// The attribute phase-change thresholds a version 2 object header stores when
14190/// [`OH_FLAG_ATTR_PHASE_CHANGE`] is set: `H5Pset_attr_phase_change`'s maximum
14191/// number of attributes kept compact (in the header) and minimum kept dense (in
14192/// a fractal heap).
14193///
14194/// The reference C library writes this block only when the pair differs from its
14195/// defaults of 8 and 6, so most headers carry no such block at all. Preserved
14196/// verbatim: this editor's own compact/dense decision still uses
14197/// [`MAX_COMPACT_ATTRS`], so a non-default pair survives a rewrite without yet
14198/// steering it.
14199#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14200pub(crate) struct AttrPhaseChange {
14201    max_compact: u16,
14202    min_dense: u16,
14203}
14204
14205impl AttrPhaseChange {
14206    /// Bytes this block occupies in a header prefix.
14207    const LEN: usize = 4;
14208
14209    /// Parse the block at `at`. The caller must have checked that 4 bytes are
14210    /// available there.
14211    fn parse(prefix: &[u8], at: usize) -> Self {
14212        Self {
14213            max_compact: u16::from_le_bytes(prefix[at..at + 2].try_into().unwrap()),
14214            min_dense: u16::from_le_bytes(prefix[at + 2..at + 4].try_into().unwrap()),
14215        }
14216    }
14217
14218    /// The block's on-disk bytes.
14219    fn to_bytes(self) -> [u8; Self::LEN] {
14220        let mut out = [0u8; Self::LEN];
14221        out[0..2].copy_from_slice(&self.max_compact.to_le_bytes());
14222        out[2..4].copy_from_slice(&self.min_dense.to_le_bytes());
14223        out
14224    }
14225}
14226
14227/// Everything chunk 0's prefix declares about a version 2 object header: the
14228/// record layout its messages are written in, and the two optional blocks the
14229/// prefix itself may carry.
14230///
14231/// All of it is a property of the *header*, shared by chunk 0 and every
14232/// continuation block, and none of it can be re-derived from the message bytes —
14233/// so it travels beside them ([`OhRegion`]) from the parse right through to the
14234/// rebuild. A rewrite that dropped the optional blocks would silently zero every
14235/// timestamp `H5Oget_info` reports on a file the C library wrote, and reset the
14236/// phase-change thresholds a caller set with `H5Pset_attr_phase_change`.
14237#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
14238pub(crate) struct OhHeaderProps {
14239    /// How wide a message record prefix is, and whether creation order is
14240    /// tracked and indexed.
14241    layout: OhRecordLayout,
14242    /// The access/modification/change/birth block, where the header stores one.
14243    times: Option<ObjectTimes>,
14244    /// The compact/dense attribute thresholds, where the header stores them.
14245    attr_phase_change: Option<AttrPhaseChange>,
14246}
14247
14248impl OhHeaderProps {
14249    /// 4-byte records, no creation order, and neither optional block: what this
14250    /// crate's whole-file writer emits, and what a header this editor creates
14251    /// from nothing uses.
14252    pub(crate) const PLAIN: Self = Self::with_layout(OhRecordLayout::PLAIN);
14253
14254    /// A header in `layout` carrying neither optional block.
14255    pub(crate) const fn with_layout(layout: OhRecordLayout) -> Self {
14256        Self {
14257            layout,
14258            times: None,
14259            attr_phase_change: None,
14260        }
14261    }
14262
14263    /// The object-header flag bits these properties imply, above the two size
14264    /// bits the emitter chooses from the region's length.
14265    const fn header_flags(self) -> u8 {
14266        let times = if self.times.is_some() {
14267            OH_FLAG_STORE_TIMES
14268        } else {
14269            0
14270        };
14271        let phase = if self.attr_phase_change.is_some() {
14272            OH_FLAG_ATTR_PHASE_CHANGE
14273        } else {
14274            0
14275        };
14276        self.layout.header_flags() | times | phase
14277    }
14278
14279    /// Bytes the optional blocks add to the header prefix.
14280    const fn optional_len(self) -> usize {
14281        let times = if self.times.is_some() {
14282            ObjectTimes::LEN
14283        } else {
14284            0
14285        };
14286        let phase = if self.attr_phase_change.is_some() {
14287            AttrPhaseChange::LEN
14288        } else {
14289            0
14290        };
14291        times + phase
14292    }
14293}
14294
14295impl OhRecordLayout {
14296    /// 4-byte record prefixes and no creation order at all: what this crate's
14297    /// whole-file writer emits, and what a header this editor creates from
14298    /// nothing uses.
14299    pub(crate) const PLAIN: Self = Self {
14300        tracked: false,
14301        indexed: false,
14302    };
14303
14304    /// The layout a version 2 object header's flags byte declares.
14305    pub(crate) const fn from_header_flags(flags: u8) -> Self {
14306        Self {
14307            tracked: flags & OH_FLAG_CREATION_ORDER_TRACKED != 0,
14308            indexed: flags & OH_FLAG_CREATION_ORDER_INDEXED != 0,
14309        }
14310    }
14311
14312    /// Bytes a message record spends before its body.
14313    pub(crate) const fn prefix_len(self) -> usize {
14314        if self.tracked { 6 } else { 4 }
14315    }
14316
14317    /// The object-header flag bits this layout implies.
14318    const fn header_flags(self) -> u8 {
14319        let tracked = if self.tracked {
14320            OH_FLAG_CREATION_ORDER_TRACKED
14321        } else {
14322            0
14323        };
14324        let indexed = if self.indexed {
14325            OH_FLAG_CREATION_ORDER_INDEXED
14326        } else {
14327            0
14328        };
14329        tracked | indexed
14330    }
14331
14332    /// Whether records carry a creation index at all.
14333    pub(crate) const fn tracks_creation_order(self) -> bool {
14334        self.tracked
14335    }
14336
14337    /// Whether dense attribute storage on this object indexes that order, with
14338    /// a creation-order B-tree beside the name index.
14339    pub(crate) const fn indexes_creation_order(self) -> bool {
14340        self.indexed
14341    }
14342
14343    /// Parse the message record at `p` within a chunk's message region,
14344    /// returning `(message type, body start, body end)`; the next record begins
14345    /// at `body end`. Returns `Ok(None)` once fewer bytes remain than a record
14346    /// prefix takes (a clean end of the region, or the gap the reference C
14347    /// library leaves when a chunk's free space is too small to hold a message),
14348    /// and `Err` if a record's declared body runs past the region. Centralizes
14349    /// the bounds check shared by every walker.
14350    pub(crate) fn next_message(
14351        self,
14352        region: &[u8],
14353        p: usize,
14354    ) -> Result<Option<(MessageType, usize, usize)>, Error> {
14355        if p + self.prefix_len() > region.len() {
14356            return Ok(None);
14357        }
14358        let msg_type = MessageType::from_u16(region[p] as u16);
14359        let msg_size = u16::from_le_bytes([region[p + 1], region[p + 2]]) as usize;
14360        let body = p + self.prefix_len();
14361        let body_end = body + msg_size;
14362        if body_end > region.len() {
14363            return Err(Error::EditUnsupported("malformed object header message"));
14364        }
14365        Ok(Some((msg_type, body, body_end)))
14366    }
14367
14368    /// The creation index the record at `msg_start` carries, or `None` where the
14369    /// layout has no such field. The caller must have located `msg_start` with
14370    /// [`next_message`](Self::next_message), which bounds the read.
14371    fn creation_index(self, region: &[u8], msg_start: usize) -> Option<u16> {
14372        self.tracked
14373            .then(|| u16::from_le_bytes([region[msg_start + 4], region[msg_start + 5]]))
14374    }
14375
14376    /// Encode one message record: this layout's prefix, then `body`.
14377    ///
14378    /// `creation_index` is written only where the layout carries one. It is
14379    /// meaningful for an Attribute message, whose creation index *is* the
14380    /// attribute's creation order; the reference C library writes zero on every
14381    /// other message type, which is what [`Self::record`] passes.
14382    fn record_with_creation_index(
14383        self,
14384        msg_type: MessageType,
14385        body: &[u8],
14386        creation_index: u16,
14387    ) -> Vec<u8> {
14388        let mut m = Vec::with_capacity(self.prefix_len() + body.len());
14389        #[expect(
14390            clippy::cast_possible_truncation,
14391            reason = "message type ids are a small enum that fits the 1-byte v2 type field"
14392        )]
14393        m.push(msg_type.to_u16() as u8);
14394        #[expect(
14395            clippy::cast_possible_truncation,
14396            reason = "callers pass bodies that fit the 2-byte message-size field (see doc comment)"
14397        )]
14398        m.extend_from_slice(&(body.len() as u16).to_le_bytes());
14399        m.push(MessageFlags::NONE.get()); // message flags
14400        if self.tracks_creation_order() {
14401            m.extend_from_slice(&creation_index.to_le_bytes());
14402        }
14403        m.extend_from_slice(body);
14404        m
14405    }
14406
14407    /// Encode one message record whose creation index, if the layout has one, is
14408    /// the zero the reference C library writes for every non-attribute message.
14409    fn record(self, msg_type: MessageType, body: &[u8]) -> Vec<u8> {
14410        self.record_with_creation_index(msg_type, body, 0)
14411    }
14412}
14413
14414/// A version 2 object header's message records, in the layout the header
14415/// declares them in.
14416///
14417/// The editor's model of a header is one contiguous run of message records:
14418/// [`WriteEngine::gather_oh_messages`] collapses a multi-chunk header into it,
14419/// the rewriters copy it message by message, and
14420/// [`build_v2_object_header`] wraps it back up. Every one of those has to agree
14421/// with the header's own flags about how wide a record prefix is, so the layout
14422/// travels with the bytes rather than being re-derived — or assumed — at each
14423/// step.
14424#[derive(Clone, Debug, Default)]
14425pub(crate) struct OhRegion {
14426    bytes: Vec<u8>,
14427    props: OhHeaderProps,
14428}
14429
14430impl core::ops::Deref for OhRegion {
14431    type Target = [u8];
14432    fn deref(&self) -> &[u8] {
14433        &self.bytes
14434    }
14435}
14436
14437impl OhRegion {
14438    /// A region of `bytes` belonging to a header with these `props`.
14439    pub(crate) fn new(bytes: Vec<u8>, props: OhHeaderProps) -> Self {
14440        Self { bytes, props }
14441    }
14442
14443    /// An empty region for a header with these `props`, to be filled record by
14444    /// record.
14445    fn empty(props: OhHeaderProps) -> Self {
14446        Self::new(Vec::new(), props)
14447    }
14448
14449    /// The same header's properties over different bytes: what every rewriter
14450    /// that copies a region message by message returns.
14451    fn with_bytes(&self, bytes: Vec<u8>) -> Self {
14452        Self::new(bytes, self.props)
14453    }
14454
14455    pub(crate) fn layout(&self) -> OhRecordLayout {
14456        self.props.layout
14457    }
14458
14459    /// Everything the header's prefix declares, which a rebuild re-emits.
14460    pub(crate) fn props(&self) -> OhHeaderProps {
14461        self.props
14462    }
14463
14464    /// [`OhRecordLayout::next_message`] over this region's own bytes.
14465    pub(crate) fn next_message(
14466        &self,
14467        p: usize,
14468    ) -> Result<Option<(MessageType, usize, usize)>, Error> {
14469        self.props.layout.next_message(&self.bytes, p)
14470    }
14471
14472    /// The creation index of the record at `msg_start`, or `None` for a header
14473    /// that does not track it.
14474    fn creation_index(&self, msg_start: usize) -> Option<u16> {
14475        self.props.layout.creation_index(&self.bytes, msg_start)
14476    }
14477
14478    /// Append one already-encoded record (or run of records) written in this
14479    /// region's layout.
14480    fn push_bytes(&mut self, record: &[u8]) {
14481        self.bytes.extend_from_slice(record);
14482    }
14483
14484    /// The bytes, for a patch that changes a field inside a record without
14485    /// changing any record's length.
14486    fn bytes_mut(&mut self) -> &mut [u8] {
14487        &mut self.bytes
14488    }
14489
14490    /// Append a hard Link message record for `name -> addr`, carrying
14491    /// `creation_order` where the group tracks link creation order and `None`
14492    /// where it does not (see [`LinkCreationOrder`], which is what decides
14493    /// which).
14494    fn push_link(&mut self, name: &str, addr: StoredAddress, creation_order: Option<u64>) {
14495        let record = encode_link_message(self.props.layout, name, addr, creation_order);
14496        self.push_bytes(&record);
14497    }
14498
14499    /// Append a message record for `body`, with the zero creation index the
14500    /// reference C library writes for every non-attribute message.
14501    fn push(&mut self, msg_type: MessageType, body: &[u8]) {
14502        let record = self.props.layout.record(msg_type, body);
14503        self.push_bytes(&record);
14504    }
14505
14506    /// Append a message whose body is a *reference* to the one copy of it stored
14507    /// elsewhere, rather than the message itself.
14508    ///
14509    /// The flag is what tells every reader to follow the body instead of
14510    /// decoding it, so it travels with the bytes: a rewrite that dropped it
14511    /// would leave a reference to be read as content.
14512    fn push_shared(&mut self, msg_type: MessageType, reference: &[u8]) {
14513        let mut record = self.props.layout.record(msg_type, reference);
14514        record[3] = MessageFlags::SHARED.get();
14515        self.push_bytes(&record);
14516    }
14517}
14518
14519/// Refuse to copy a dataset whose element bytes live in files outside this one
14520/// (`H5Pset_external`, the External Data Files header message, type 7), which
14521/// this crate does not follow (issue #336).
14522///
14523/// Such a dataset carries a *contiguous* layout message with an undefined data
14524/// address, the same encoding a never-written dataset uses — and
14525/// [`read_copy_subtree`](WriteEngine::read_copy_subtree) copies that encoding as
14526/// the storage it does not have. The layout alone cannot tell the two apart, so
14527/// naming this message is the only thing standing between a dataset that holds
14528/// data and a copy carrying its schema and none of it. [`crate::repack()`]
14529/// rejects the same shape for the same reason.
14530///
14531/// Applied on both copy paths rather than only the cross-file one: an in-file
14532/// copy would reproduce the header without its data just as readily. It is also
14533/// the only screen that reads this message at all — [`reject_foreign_addresses`]
14534/// inspects shared messages, datatypes, and attributes, not this body, which
14535/// carries a local-heap address that would dangle in another file.
14536fn reject_external_storage(region: &OhRegion) -> Result<(), Error> {
14537    let mut p = 0;
14538    while let Some((msg_type, _, body_end)) = region.next_message(p)? {
14539        if msg_type == MessageType::EXTERNAL_DATA_FILES {
14540            return Err(Error::EditUnsupported(
14541                "a dataset stores its elements in external files (H5Pset_external), \
14542                 which a copy cannot reproduce -- its data lives in files this crate \
14543                 does not read",
14544            ));
14545        }
14546        p = body_end;
14547    }
14548    Ok(())
14549}
14550
14551/// Refuse to copy an object whose header embeds a *source-file* absolute address
14552/// that a verbatim copy into another file cannot translate. An in-file copy keeps
14553/// these valid by sharing the source file's heaps and objects; a cross-file copy
14554/// cannot. Three things qualify:
14555///
14556/// - a **variable-length** datatype, whose element bytes are global-heap
14557///   references (collection address + index) into the source file's heap;
14558/// - a **reference** datatype (object or dataset-region), whose element bytes are
14559///   absolute object addresses in the source file;
14560/// - any **shared message** ([`MessageFlags::SHARED`] set): a committed datatype, but also a shared
14561///   dataspace, fill value, or filter-pipeline message, whose body is a reference into the source
14562///   file's shared-message storage.
14563///
14564/// The scan covers a copied object's whole message region (a dataset's or a
14565/// group's): it refuses any shared message outright, and inspects Datatype
14566/// messages (the element type) and Attribute messages (their own datatype),
14567/// recursing through compound members, array elements, and enumeration bases so a
14568/// nested variable-length or reference occurrence is caught too. It is applied
14569/// only on the cross-file path; the same-file [`copy`](crate::File::copy)
14570/// deliberately keeps these forms (their addresses stay valid in one file).
14571fn reject_foreign_addresses(region: &OhRegion) -> Result<(), Error> {
14572    let mut p = 0;
14573    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
14574        // A *shared* message stores, in place of its real body, a reference into
14575        // the source file's shared-message storage — an object-header address or a
14576        // fractal-heap (SOHM) id — which means nothing in another file. This
14577        // catches committed (shared) datatypes and shared attributes as well as a
14578        // shared dataspace, fill value, or filter-pipeline message, all of which
14579        // HDF5 may place in the shared-message table. Refuse any of them, whatever
14580        // the message type. The flags byte is the 4th of the record header (type,
14581        // size, flags); `next_message` returning `Some` guarantees
14582        // `p + 4 <= region.len()`.
14583        if MessageFlags::new(region[p + 3]).is_shared() {
14584            return Err(Error::EditUnsupported(
14585                "a shared (committed/SOHM) object-header message cannot be copied to another file yet",
14586            ));
14587        }
14588        match msg_type {
14589            MessageType::DATATYPE => {
14590                let (dt, _) = Datatype::parse(&region[body..body_end])?;
14591                if datatype_holds_file_address(&dt) {
14592                    return Err(Error::EditUnsupported(
14593                        "variable-length or reference datasets cannot be copied to another file yet",
14594                    ));
14595                }
14596            }
14597            MessageType::ATTRIBUTE => {
14598                // An attribute's *own* datatype or dataspace field can be a
14599                // reference to a committed message, which the record's shared
14600                // flag above does not report: that flag describes the attribute
14601                // message, not the fields inside it. The reference addresses the
14602                // source file, so it cannot travel any more than a shared record
14603                // can — and the parse below cannot resolve it here in any case.
14604                if crate::attribute::message_shares_a_field(&region[body..body_end]) {
14605                    return Err(Error::EditUnsupported(
14606                        "an attribute with a committed (shared) datatype cannot be copied to another file yet",
14607                    ));
14608                }
14609                let attr = crate::attribute::AttributeMessage::parse(
14610                    &region[body..body_end],
14611                    LENGTH_SIZE,
14612                )?;
14613                if datatype_holds_file_address(&attr.datatype) {
14614                    return Err(Error::EditUnsupported(
14615                        "variable-length or reference attributes cannot be copied to another file yet",
14616                    ));
14617                }
14618            }
14619            _ => {}
14620        }
14621        p = body_end;
14622    }
14623    Ok(())
14624}
14625
14626/// Cross-file screen for a dense (fractal-heap) attribute set. The bytes parsed
14627/// out of the source heap can embed source-file absolute addresses just as inline
14628/// attribute messages can — variable-length (global-heap) or reference attribute
14629/// data — which would dangle in another file. [`reject_foreign_addresses`] screens
14630/// the verbatim object-header region but not heap-resident attribute bytes, so a
14631/// dense attribute set is screened here instead. Same-file copies skip this (their
14632/// addresses stay valid); the fresh heap built on write is same-file by
14633/// construction, so only the source datatypes matter.
14634fn reject_foreign_dense_attrs(attrs: &[crate::attribute::AttributeMessage]) -> Result<(), Error> {
14635    for attr in attrs {
14636        if datatype_holds_file_address(&attr.datatype) {
14637            return Err(Error::EditUnsupported(
14638                "variable-length or reference dense (fractal-heap) attributes cannot be copied to another file yet",
14639            ));
14640        }
14641    }
14642    Ok(())
14643}
14644
14645/// Re-encode a shared-message reference in the modern form, for a message being
14646/// rewrapped from a version 1 object header into a version 2 one.
14647///
14648/// A version 1 header carries the oldest reference encoding: a symbol-table
14649/// entry, with the object-header address buried behind a local-heap address.
14650/// Copying those bytes into a version 2 header would put an encoding there that
14651/// no writer of such a header has produced since the format gained one; parsing
14652/// and re-encoding gives the shape every current reader and writer uses.
14653///
14654/// Which shape that is depends on where the message lives. An object header is
14655/// named by the version 2 form this crate already writes for a committed
14656/// datatype. The shared-message heap has no encoding before version 3, so a heap
14657/// reference gets that one. Both name exactly what the version 1 reference named,
14658/// so the message keeps its single copy and its reference count is unchanged.
14659fn modernize_shared_reference(
14660    body: &[u8],
14661    offset_size: u8,
14662    length_size: u8,
14663) -> Result<Vec<u8>, Error> {
14664    let reference = crate::shared_message::parse_shared_ref(body, offset_size, length_size)?;
14665    Ok(match reference.location {
14666        crate::shared_message::SharedLocation::ObjectHeader(addr) => {
14667            crate::shared_message::encode_committed_ref(addr, offset_size)
14668        }
14669        crate::shared_message::SharedLocation::SohmHeap(id) => {
14670            crate::shared_message::encode_sohm_ref(&id)
14671        }
14672    })
14673}
14674
14675/// Refuse a commit that removes or moves an object header the file's
14676/// shared-message (SOHM) index names.
14677///
14678/// A heap-stored record names no object, so it is not screened here: what a
14679/// commit does to it is a *reference count* that goes stale, which the engine
14680/// refuses at the point it would change one (see [`SHARED_ATTRIBUTE_MESSAGE`]).
14681/// This screens the other record shape, whose address would dangle.
14682fn screen_shared_message_records(
14683    records: &[crate::sohm::SohmRecord],
14684    invalidated: &InvalidatedAddresses,
14685) -> Result<(), Error> {
14686    for record in records {
14687        if let crate::sohm::SohmLocation::ObjectHeader { address, .. } = record.location
14688            && invalidated.refusal(address).is_some()
14689        {
14690            return Err(Error::EditUnsupported(
14691                SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT,
14692            ));
14693        }
14694    }
14695    Ok(())
14696}
14697
14698/// Refuse a staged dataset whose element bytes already hold *resolved* object
14699/// references naming space this commit reclaims (issue #317).
14700///
14701/// This is the address-side half of the rule
14702/// [`WriteEngine::resolve_reference_target`] enforces on the path side. A target
14703/// named as a path is screened there, by name; a target supplied as an address —
14704/// `DatasetBuilder::with_reference_data`, or `with_raw_data` over a datatype that
14705/// holds a reference — never reaches that function at all, and before this screen
14706/// existed it was written straight through to disk.
14707///
14708/// Element bytes still carrying placeholders are unaffected: an unresolved slot
14709/// holds zero, which [`InvalidatedAddresses::refusal`] passes, and the target
14710/// that replaces it is screened by `resolve_reference_target` instead — by name
14711/// for a path, by address for a raw one. So this runs over every staged
14712/// dataset's `raw` without asking which builder filled it: the rule is about the
14713/// bytes, not the door they came through.
14714fn screen_resolved_references(
14715    dt: &Datatype,
14716    raw: &[u8],
14717    invalidated: &InvalidatedAddresses,
14718) -> Result<(), Error> {
14719    if !datatype_holds_object_address(dt) {
14720        return Ok(());
14721    }
14722    // The datatype declares an object reference, so the walker must find one.
14723    // Two ways it does not: slots that do not fit the element size (`None`), and
14724    // a reference `embedded_reference_slots` does not map — it locates the
14725    // 8-byte object reference only, and reports any other width as no slots at
14726    // all. Both mean the addresses cannot be read, and an empty list taken for
14727    // "nothing to check" would wave through exactly the datatype
14728    // `datatype_holds_object_address` had just recognised. Neither shape is
14729    // one this crate builds; refuse rather than write references past a screen
14730    // that could not see them.
14731    let Some(slots) = embedded_reference_slots(dt).filter(|slots| !slots.is_empty()) else {
14732        // Unconditional, unlike the per-address checks below. Those refuse an
14733        // address that lands somewhere this commit vacates; this one cannot read
14734        // the addresses at all, so there is nothing to compare against and no
14735        // amount of screening would help. Every commit that reaches here rebuilds
14736        // at least the root group, so `for_supplied.moved` is never empty and
14737        // gating this would have changed nothing anyway. A commit whose only
14738        // staged edit is a same-length in-place overwrite never reaches here at
14739        // all — it takes the fast path above, which vacates nothing. An in-file
14740        // copy reaches this only beside a deletion, because it is screened
14741        // against `for_copied`, whose `moved` is empty by construction.
14742        //
14743        // The `filter` is belt-and-braces: `embedded_reference_slots` already
14744        // reports an unaddressable reference as `None` rather than as an empty
14745        // list, and reading an empty list as "nothing to check" is exactly the
14746        // hole this screen was found to have.
14747        return Err(Error::EditUnsupported(
14748            "a reference this commit writes sits in a datatype whose addresses this screen \
14749             cannot read — a width other than eight, a dataset-region reference, a \
14750             variable length of them, or a compound holding one beside a reference it can \
14751             read — so it cannot be checked against what this commit vacates; supply an \
14752             8-byte object reference (`with_reference_data`)",
14753        ));
14754    };
14755    // Non-empty slots mean the datatype has room for at least one 8-byte
14756    // address, so the element size is at least 8.
14757    for (_, stored) in stored_object_references(raw, dt.type_size() as usize, &slots) {
14758        if let Some(refusal) = invalidated.refusal(StoredAddress::new(stored)) {
14759            return Err(Error::EditUnsupported(refusal));
14760        }
14761    }
14762    Ok(())
14763}
14764
14765/// Screen an in-file copy's subtree against the space this commit reclaims
14766/// (issue #317).
14767///
14768/// An in-file copy re-emits its source's element bytes verbatim, which is what
14769/// keeps a copied variable-length or reference dataset valid: the addresses
14770/// still name the same file, so nothing has to be rewritten. A deletion in the
14771/// same commit takes that away for object references alone — a global heap
14772/// collection is never reclaimed by a delete, so variable-length elements keep
14773/// pointing at data that is still there.
14774///
14775/// Every element that can be read is screened by address, through the same
14776/// [`screen_resolved_references`] a staged dataset's bytes go through: a
14777/// contiguous dataset's data block, a compact dataset's inline data, and every
14778/// attribute, inline or dense. A committed (shared) datatype is resolved through
14779/// `src` first, so a copy of an object with a named type is screened like any
14780/// other rather than refused for carrying a type this could not read.
14781///
14782/// One form cannot be read at all and so is refused by *datatype*, and only when
14783/// it holds an object reference: a **chunked** dataset, whose addresses sit
14784/// inside chunks this path carries compressed and never decodes — the same
14785/// obstacle that makes [`crate::repack()`] reject a chunked object-reference
14786/// dataset outright.
14787///
14788/// `src` is the session's image framed at its base address, the view a stored
14789/// (base-relative) shared-message address indexes directly.
14790fn screen_copied_references(
14791    tree: &CopyTree,
14792    invalidated: &InvalidatedAddresses,
14793    src: &(impl Source + ?Sized),
14794) -> Result<(), Error> {
14795    if invalidated.is_empty() {
14796        return Ok(());
14797    }
14798    use crate::shared_message::SharedResolver as _;
14799    // No shared-message table: a heap-stored message is refused rather than
14800    // followed here, and this screen treats an unreadable datatype as one it
14801    // cannot clear, which is the conservative answer.
14802    let resolver = crate::shared_message::SourceResolver::new(
14803        src,
14804        AccessMode::ReadWrite,
14805        OFFSET_SIZE,
14806        LENGTH_SIZE,
14807        None,
14808    );
14809    let (region, dense_attrs) = match tree {
14810        CopyTree::DatasetVerbatim {
14811            region,
14812            dense_attrs,
14813        }
14814        | CopyTree::DatasetContiguous {
14815            region,
14816            dense_attrs,
14817            ..
14818        }
14819        | CopyTree::DatasetChunked {
14820            region,
14821            dense_attrs,
14822            ..
14823        }
14824        | CopyTree::Group {
14825            non_link_region: region,
14826            dense_attrs,
14827            ..
14828        } => (region, dense_attrs),
14829    };
14830    for attr in &dense_attrs.attrs {
14831        screen_resolved_references(&attr.datatype, &attr.raw_data, invalidated)?;
14832    }
14833
14834    // One walk of the header: the object's own element datatype, the compact
14835    // data the layout message may carry, and every inline attribute.
14836    let mut element_dt: Option<Datatype> = None;
14837    let mut compact: Option<Vec<u8>> = None;
14838    let mut p = 0;
14839    while let Some((msg_type, body, body_end)) = region.next_message(p)? {
14840        // The flags byte is the 4th of the record header (type, size, flags);
14841        // `next_message` returning `Some` guarantees it is in bounds.
14842        let shared = MessageFlags::new(region[p + 3]).is_shared();
14843        match msg_type {
14844            MessageType::DATATYPE => {
14845                // A committed datatype's message body is a pointer into the
14846                // file's shared-message storage rather than an encoded type, so
14847                // read the type it names before parsing.
14848                let committed;
14849                let encoded = if shared {
14850                    committed = resolver
14851                        .resolve(&region[body..body_end], MessageType::DATATYPE)
14852                        .map_err(|source| Error::CopyScreenUnreadable {
14853                            message: MessageType::DATATYPE,
14854                            source,
14855                        })?;
14856                    &committed[..]
14857                } else {
14858                    &region[body..body_end]
14859                };
14860                let (dt, _) = Datatype::parse(encoded)?;
14861                element_dt = Some(dt);
14862            }
14863            MessageType::DATA_LAYOUT => {
14864                if let Ok(DataLayout::Compact { data }) =
14865                    DataLayout::parse(&region[body..body_end], OFFSET_SIZE, LENGTH_SIZE)
14866                {
14867                    compact = Some(data);
14868                }
14869            }
14870            MessageType::ATTRIBUTE => {
14871                // A *shared record* is the whole attribute message held in the
14872                // file's shared-message table, which is a different indirection
14873                // from the committed datatype `parse_resolving` follows inside
14874                // the fields — and a rare one this path has never modelled. Its
14875                // elements cannot be reached here, so it is refused.
14876                if shared {
14877                    return Err(Error::EditUnsupported(
14878                        "a copy in this commit carries a shared (SOHM) attribute message, whose \
14879                         elements cannot be screened against the same commit's deletions; use \
14880                         separate commits",
14881                    ));
14882                }
14883                // `parse_resolving` rather than `parse`: an attribute's own
14884                // datatype field can name a committed message, which the record's
14885                // shared flag does not report — that flag describes the attribute
14886                // message, not the fields inside it — and `parse` refuses one.
14887                let attr = crate::attribute::AttributeMessage::parse_resolving(
14888                    &region[body..body_end],
14889                    LENGTH_SIZE,
14890                    &resolver,
14891                )
14892                .map_err(|source| Error::CopyScreenUnreadable {
14893                    message: MessageType::ATTRIBUTE,
14894                    source,
14895                })?;
14896                screen_resolved_references(&attr.datatype, &attr.raw_data, invalidated)?;
14897            }
14898            _ => {}
14899        }
14900        p = body_end;
14901    }
14902
14903    match tree {
14904        // Compact: the elements are inline in the data-layout message. A layout
14905        // that did not yield them leaves a reference datatype unscreened, so it
14906        // is refused for the same reason a chunked one is.
14907        CopyTree::DatasetVerbatim { .. } => match (&element_dt, &compact) {
14908            (Some(dt), Some(data)) => screen_resolved_references(dt, data, invalidated)?,
14909            (Some(dt), None) if datatype_holds_object_address(dt) => {
14910                return Err(Error::EditUnsupported(
14911                    "a compact object-reference dataset's elements could not be read to screen \
14912                     them against this commit's deletions; use separate commits",
14913                ));
14914            }
14915            _ => {}
14916        },
14917        // No Datatype message means no declared reference, here and in the
14918        // chunked arm below: such a header is not a dataset any reader can
14919        // interpret, so there is nothing in it to dangle. That is why only the
14920        // compact arm above refuses on a missing piece — there the datatype is
14921        // present and says a reference is in bytes it could not reach.
14922        CopyTree::DatasetContiguous { data, .. } => {
14923            // A dataset whose storage the source never allocated (`None`) stores
14924            // no elements, so it holds no address that could dangle. Skipped
14925            // rather than screened as an empty run of element bytes: when
14926            // `screen_resolved_references` cannot map a datatype's addresses — a
14927            // dataset-region reference, a variable length of references, one
14928            // wider than eight bytes — it refuses *unconditionally*, before it
14929            // reads a byte, so an empty run would refuse this dataset for
14930            // elements it does not have.
14931            if let (Some(dt), Some(data)) = (&element_dt, data) {
14932                screen_resolved_references(dt, data, invalidated)?;
14933            }
14934        }
14935        // Chunked: `chunk_bytes` are carried exactly as the source stored them,
14936        // filters and all, so there is nothing here to decode addresses out of.
14937        CopyTree::DatasetChunked { .. } => {
14938            if element_dt
14939                .as_ref()
14940                .is_some_and(datatype_holds_object_address)
14941            {
14942                return Err(Error::EditUnsupported(
14943                    "a chunked object-reference dataset cannot be copied in a commit that also \
14944                     deletes objects: its addresses live inside chunks this path does not \
14945                     decode; use separate commits",
14946                ));
14947            }
14948        }
14949        CopyTree::Group { children, .. } => {
14950            for (_, _, child) in children {
14951                screen_copied_references(child, invalidated, src)?;
14952            }
14953        }
14954    }
14955    Ok(())
14956}
14957
14958/// Wrap a chunk-0 message region in a fresh single-chunk version 2 object header
14959/// (`OHDR` prefix + region + Jenkins checksum), first normalizing the region's
14960/// attribute storage with [`ensure_attribute_info`]. Mirrors the encoding in
14961/// [`crate::object_header_writer::ObjectHeaderWriter::serialize`].
14962///
14963/// The normalization belongs here rather than at the fifteen call sites because
14964/// carrying an Attribute Info message is a property of a version 2 header holding
14965/// inline attributes, not of any one edit operation — and a site that forgot it
14966/// would reintroduce the zero-count defect silently.
14967///
14968/// A region that cannot be walked is reported, not asserted away. Every region
14969/// reaching here should have been built by this crate or already walked
14970/// message-by-message on the way in, but "should" is a claim about a file this
14971/// session did not write: a header whose message size field overruns the region
14972/// is a malformed *file*, which is the caller's input and so takes the
14973/// [`Error::EditUnsupported`] the region walk reports. A debug assertion here
14974/// would make the two build profiles disagree about whether such a file is
14975/// writable at all: a panic in a test build, and in a release build a header
14976/// silently missing its Attribute Info message, which is the zero-count defect
14977/// this function exists to prevent.
14978pub(crate) fn build_v2_object_header(region: &OhRegion) -> Result<Vec<u8>, Error> {
14979    let mut owned = region.clone();
14980    ensure_attribute_info(&mut owned)?;
14981    Ok(build_v2_object_header_verbatim(&owned))
14982}
14983
14984/// [`build_v2_object_header`] without the attribute-storage normalization, for
14985/// the region that function has already normalized.
14986///
14987/// **The header's optional prefix blocks are re-emitted, and its timestamps are
14988/// stamped.** A region carries whatever chunk 0's prefix declared
14989/// ([`OhHeaderProps`]), so a rewrite of a header from the reference C library,
14990/// h5py or netCDF-4 puts its four timestamps and any attribute phase-change
14991/// thresholds back where it found them, with the flag bits that announce them.
14992///
14993/// Of those, the **modification and change times are moved to now**: this
14994/// function runs once per rebuilt header, and every rebuild is a modification.
14995/// Access and birth times are the object's own history and are copied verbatim
14996/// ([`ObjectTimes::touched`] says how that compares with `H5O_touch_oh`).
14997/// Under `no_std` there is no clock to read, so all four are preserved as they
14998/// were rather than zeroed — a stale modification time being the honest reading
14999/// of "this build cannot tell the time", where a zero would claim the epoch.
15000/// A header this crate creates from nothing stores no times at all, so nothing
15001/// here applies to it.
15002fn build_v2_object_header_verbatim(region: &OhRegion) -> Vec<u8> {
15003    let total = region.len();
15004    let width = UintWidth::smallest_for_len(total);
15005    let props = region.props();
15006    // The creation-order bits and the two optional-block bits are the header's
15007    // own claim about what follows, so they come from the properties the region
15008    // was parsed with.
15009    let flags = width.flag_bits() | props.header_flags();
15010    let mut buf = Vec::with_capacity(8 + props.optional_len() + total + 4);
15011    buf.extend_from_slice(b"OHDR");
15012    buf.push(2); // version
15013    buf.push(flags);
15014    if let Some(times) = props.times {
15015        let stamped = match unix_time_now() {
15016            Some(now) => times.touched(now),
15017            None => times,
15018        };
15019        buf.extend_from_slice(&stamped.to_bytes());
15020    }
15021    if let Some(phase) = props.attr_phase_change {
15022        buf.extend_from_slice(&phase.to_bytes());
15023    }
15024    buf.extend_from_slice(&(total as u64).to_le_bytes()[..usize::from(width.get())]);
15025    buf.extend_from_slice(region);
15026    let checksum = jenkins_lookup3(&buf);
15027    buf.extend_from_slice(&checksum.to_le_bytes());
15028    buf
15029}
15030
15031/// Seconds since the Unix epoch, for the object-header timestamps a rebuild
15032/// stamps, or `None` where this build has no clock.
15033///
15034/// The crate carries no other wall-clock reader: every other date it writes
15035/// comes from its caller. `no_std` builds have no `SystemTime` at all and return
15036/// `None`, and so does a `std` build whose clock is set before 1970 — neither is
15037/// a reason to fail a commit, so both leave the header's stored times alone (see
15038/// [`build_v2_object_header_verbatim`]). The 4-byte field saturates rather than
15039/// wrapping, which matters only past 2106.
15040#[cfg(feature = "std")]
15041fn unix_time_now() -> Option<u32> {
15042    std::time::SystemTime::now()
15043        .duration_since(std::time::UNIX_EPOCH)
15044        .ok()
15045        .map(|d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX))
15046}
15047
15048/// No wall clock outside `std`; see the `std` definition.
15049#[cfg(not(feature = "std"))]
15050fn unix_time_now() -> Option<u32> {
15051    None
15052}
15053
15054/// Read a little-endian unsigned integer of `bytes.len()` (≤ 8) bytes.
15055#[expect(
15056    clippy::cast_possible_truncation,
15057    reason = "callers parse in-file sizes/offsets bounded by the in-memory image; downstream \
15058              slicing is length-checked, so a malformed oversized field errors rather than reads OOB"
15059)]
15060fn read_le(bytes: &[u8]) -> usize {
15061    let mut v = 0u64;
15062    for (i, &b) in bytes.iter().enumerate() {
15063        v |= (b as u64) << (8 * i);
15064    }
15065    v as usize
15066}
15067
15068/// The engine as the target a [`reference_patch::Plan`] is applied to
15069/// (issue #324).
15070///
15071/// Deliberately the whole engine rather than the image alone: an object header
15072/// is republished as one checksummed write, so applying a plan reads as well as
15073/// writes, and both have to go through the same image the rest of the commit
15074/// used — the same write-gathering, the same pending-write overlay, the same
15075/// end-of-file bound.
15076///
15077/// [`reference_patch::Plan`]: crate::reference_patch::Plan
15078impl crate::reference_patch::PatchTarget for WriteEngine {
15079    fn read(&self, at: u64, len: usize) -> Result<Vec<u8>, Error> {
15080        self.image().read_exact_at(at, len).map_err(Error::Format)
15081    }
15082    fn write(&mut self, at: u64, bytes: &[u8]) -> Result<(), Error> {
15083        self.image.write_at(at, bytes)
15084    }
15085}
15086
15087#[cfg(test)]
15088mod tests {
15089    use rstest::rstest;
15090
15091    use super::*;
15092    use crate::{CompoundMember, datatype::layout::FloatingPointLayout, object_path::ObjectPath};
15093
15094    /// The rule that places a chunk index on a paged file: some chunk-data span
15095    /// abuts it. Both sides count, which is what a repeatedly appended dataset
15096    /// needs — its blobs leave chunk data above the index as well as below it, so
15097    /// the highest data address is not the one beside the index (issue #388).
15098    #[test]
15099    fn a_chunk_index_is_placed_by_the_chunk_data_that_abuts_it() {
15100        // Data below, index above: the from-scratch layout.
15101        assert!(index_abuts_chunk_data(&[(100, 60)], &[(160, 40)]));
15102        // Index below, data above: a blob placed in a hole under existing chunks.
15103        assert!(index_abuts_chunk_data(&[(200, 60)], &[(160, 40)]));
15104        // Data on both sides, with the abutting span not the highest one — the
15105        // shape every staged append after the first produces.
15106        assert!(index_abuts_chunk_data(
15107            &[(100, 60), (900, 60)],
15108            &[(160, 40)]
15109        ));
15110        // A multi-block index is placed as one run by the span beside its edge.
15111        assert!(index_abuts_chunk_data(
15112            &[(100, 60)],
15113            &[(180, 20), (160, 20)]
15114        ));
15115
15116        // Neither edge is touched: the reference library's layout, where the index
15117        // sits in a metadata page far below the chunk data.
15118        assert!(!index_abuts_chunk_data(&[(9000, 60)], &[(160, 40)]));
15119        // A gap of one byte is not an abutment.
15120        assert!(!index_abuts_chunk_data(&[(100, 59)], &[(160, 40)]));
15121        // No chunk data at all — an empty resizable dataset's eagerly built index
15122        // — places nothing.
15123        assert!(!index_abuts_chunk_data(&[], &[(160, 40)]));
15124        // Nothing to place.
15125        assert!(index_abuts_chunk_data(&[], &[]));
15126        // A zero-length data span touches the index without occupying a byte
15127        // beside it.
15128        assert!(!index_abuts_chunk_data(&[(160, 0)], &[(160, 40)]));
15129    }
15130
15131    /// The screen that keeps the abutment from admitting a reference-library
15132    /// layout: an index with a byte in page 0 sits beside the superblock, so it is
15133    /// in a metadata page however its far end lines up with chunk data.
15134    #[test]
15135    fn a_chunk_index_in_page_zero_is_never_raw() {
15136        const PAGE: u64 = 512;
15137        // The C-written shape this exists to refuse: the index header sits in
15138        // page 0 behind the superblock, its last block fills page 1 exactly, and
15139        // the first raw page begins where that block ends. The abutment alone
15140        // called the whole run raw.
15141        let c_written = [(48u64, 80u64), (512, 512)];
15142        assert!(index_abuts_chunk_data(&[(1024, 512)], &c_written));
15143        assert!(index_touches_page_zero(&c_written, PAGE));
15144        // An index wholly above page 0 — every one this crate writes, since it
15145        // places chunk data and the index beside it out of raw pages — is not
15146        // screened out.
15147        assert!(!index_touches_page_zero(&[(1024, 200)], PAGE));
15148        // The boundary: the last byte of page 0 is still page 0.
15149        assert!(index_touches_page_zero(&[(511, 200)], PAGE));
15150        assert!(!index_touches_page_zero(&[(512, 200)], PAGE));
15151        // A zero-length span occupies nothing, in page 0 as anywhere else.
15152        assert!(!index_touches_page_zero(&[(0, 0)], PAGE));
15153        // Nothing to screen.
15154        assert!(!index_touches_page_zero(&[], PAGE));
15155        // A page size a paged file never has cannot place anything.
15156        assert!(index_touches_page_zero(&[(1024, 200)], 0));
15157    }
15158
15159    /// A page every byte of which is free or dead belongs to no page type, so it
15160    /// is promoted whole into the raw list — the free-page convention the rest of
15161    /// the paged allocator already follows. The partial edges keep their type,
15162    /// since the pages they sit in may still hold something live.
15163    #[test]
15164    fn a_page_that_is_wholly_free_or_dead_is_promoted_whole() {
15165        const PAGE: u64 = 4096;
15166        let (mut meta, mut raw, mut dead) = (FreeList::new(), FreeList::new(), FreeList::new());
15167        // Page 1 is half free metadata and half dead, so neither list can show it
15168        // empty on its own.
15169        meta.free(PAGE, 2048);
15170        dead.free(PAGE + 2048, 2048);
15171        // Page 2 is wholly dead.
15172        dead.free(2 * PAGE, PAGE);
15173        // Page 3 keeps something live at each end, so only its middle is free —
15174        // and not adjacent to page 2, so the promotion is visible on its own.
15175        raw.free(3 * PAGE + 1024, 1024);
15176        PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, PAGE);
15177
15178        assert_eq!(
15179            raw.sections(),
15180            [(PAGE, 2 * PAGE), (3 * PAGE + 1024, 1024)],
15181            "the two empty pages join the raw list as one run; the partial page stays"
15182        );
15183        assert!(
15184            meta.sections().is_empty(),
15185            "the promoted page left the metadata list"
15186        );
15187        assert!(
15188            dead.sections().is_empty(),
15189            "every dead byte was inside a promoted page"
15190        );
15191    }
15192
15193    /// Dead space that does not complete a page stays dead: it is not free space,
15194    /// and handing it out as either page type would mix the page it sits in.
15195    #[test]
15196    fn dead_space_short_of_a_whole_page_is_not_promoted() {
15197        const PAGE: u64 = 4096;
15198        let (mut meta, mut raw, mut dead) = (FreeList::new(), FreeList::new(), FreeList::new());
15199        dead.free(PAGE, 512);
15200        raw.free(PAGE + 512, 1024);
15201        PagedEdit::promote_whole_free_pages(&mut meta, &mut raw, &mut dead, PAGE);
15202        assert_eq!(dead.sections(), [(PAGE, 512)]);
15203        assert_eq!(raw.sections(), [(PAGE + 512, 1024)]);
15204    }
15205
15206    /// The shared-message flag is read from the message header's fourth byte, and
15207    /// the two attribute paths have to read the same one: the compact pass
15208    /// refuses such a message as it copies, the dense pass before it reads a set
15209    /// that would resolve it into something indistinguishable from a private
15210    /// attribute. A wrong offset here reads a *size* byte and refuses or accepts
15211    /// by accident.
15212    #[test]
15213    fn a_shared_attribute_message_is_told_from_a_private_one_by_its_flags() {
15214        let body = crate::type_builders::build_attr_message("a", &AttrValue::I64(1))
15215            .serialize(LENGTH_SIZE);
15216        let private = message_record(MessageType::ATTRIBUTE, &body);
15217        assert!(!region_has_shared_attr(&plain_region(private.clone())).unwrap());
15218
15219        let mut shared = private.clone();
15220        shared[3] = MessageFlags::SHARED.get();
15221        assert!(region_has_shared_attr(&plain_region(shared)).unwrap());
15222
15223        // The flag is only read on an Attribute message: the same byte set on a
15224        // neighbouring message says nothing about attribute storage.
15225        let mut other = message_record(MessageType::DATASPACE, &body);
15226        other[3] = MessageFlags::SHARED.get();
15227        assert!(!region_has_shared_attr(&plain_region(other)).unwrap());
15228    }
15229
15230    /// An object-reference attribute named `name` pointing at `address`.
15231    ///
15232    /// Built by taking a `u64` attribute — whose value is already the 8 little-
15233    /// endian bytes an object reference is stored as — and relabelling its
15234    /// datatype, because no public API stages one: [`AttrValue`] has no
15235    /// reference variant, so a file carrying such an attribute was written by
15236    /// the reference C library, and the copy path re-emits its bytes verbatim
15237    /// like any other attribute's.
15238    fn reference_attr(name: &str, address: u64) -> crate::attribute::AttributeMessage {
15239        let mut attr = crate::type_builders::build_attr_message(name, &AttrValue::U64(address));
15240        attr.datatype = Datatype::Reference {
15241            size: 8,
15242            ref_type: crate::datatype::ReferenceType::Object,
15243        };
15244        assert_eq!(
15245            attr.raw_data,
15246            address.to_le_bytes(),
15247            "the value is the address"
15248        );
15249        attr
15250    }
15251
15252    /// Wrap a message body in the object-header record a header region holds it
15253    /// in: type, body size, flags, body.
15254    fn message_record(msg_type: MessageType, body: &[u8]) -> Vec<u8> {
15255        OhRecordLayout::PLAIN.record(msg_type, body)
15256    }
15257
15258    /// A region of plain (4-byte-record) messages, the layout every writer in
15259    /// this crate emits.
15260    fn plain_region(bytes: Vec<u8>) -> OhRegion {
15261        OhRegion::new(bytes, OhHeaderProps::PLAIN)
15262    }
15263
15264    /// A header region holding one attribute inline.
15265    fn inline_attr_region(attr: &crate::attribute::AttributeMessage) -> OhRegion {
15266        plain_region(message_record(
15267            MessageType::ATTRIBUTE,
15268            &attr.serialize_v3(LENGTH_SIZE),
15269        ))
15270    }
15271
15272    /// The header region of a one-element *compact* object-reference dataset:
15273    /// its datatype, and a version 3 compact data-layout message carrying the
15274    /// element inline (version, class 0, a 2-byte inline size, then the data).
15275    fn compact_reference_region(address: u64) -> OhRegion {
15276        let mut region = message_record(
15277            MessageType::DATATYPE,
15278            &crate::type_builders::make_object_reference_type().serialize(),
15279        );
15280        let mut layout = vec![3u8, 0];
15281        layout.extend_from_slice(&8u16.to_le_bytes());
15282        layout.extend_from_slice(&address.to_le_bytes());
15283        region.extend_from_slice(&message_record(MessageType::DATA_LAYOUT, &layout));
15284        plain_region(region)
15285    }
15286
15287    /// A *compact* dataset keeps its elements inside the data-layout message
15288    /// rather than in a data block, so the copy screen reads them straight out
15289    /// of the header region (issue #317).
15290    ///
15291    /// Driven from a hand-built region because no writer in this crate emits a
15292    /// compact layout — the files that carry one come from the reference C
15293    /// library, which is also why the userblock suite notes that its
15294    /// compact-layout fixtures have to come from there.
15295    #[test]
15296    fn a_copied_compact_reference_dataset_is_screened() {
15297        let empty = BytesSource::new(Vec::new());
15298        let invalidated = InvalidatedAddresses {
15299            removed: vec![(248, 71)],
15300            moved: Vec::new(),
15301            base: BaseAddress::ZERO,
15302        };
15303        for (address, refused) in [(248u64, true), (318, true), (319, false)] {
15304            let tree = CopyTree::DatasetVerbatim {
15305                region: compact_reference_region(address),
15306                dense_attrs: DenseAttrSet::default(),
15307            };
15308            let got = screen_copied_references(&tree, &invalidated, &empty);
15309            assert_eq!(got.is_err(), refused, "compact element {address}: {got:?}");
15310        }
15311
15312        // A region whose layout message yields no inline data leaves a reference
15313        // datatype unscreened, so it is refused rather than waved through.
15314        // `read_object` builds this variant only from a compact layout, so this
15315        // is a header that did not parse as one — malformed input, not a shape
15316        // the copy path produces.
15317        let no_layout = CopyTree::DatasetVerbatim {
15318            region: plain_region(message_record(
15319                MessageType::DATATYPE,
15320                &crate::type_builders::make_object_reference_type().serialize(),
15321            )),
15322            dense_attrs: DenseAttrSet::default(),
15323        };
15324        let err = screen_copied_references(&no_layout, &invalidated, &empty).unwrap_err();
15325        assert!(
15326            err.to_string().contains("could not be read to screen"),
15327            "got: {err}"
15328        );
15329    }
15330
15331    /// The committed reference points at address 248, and the source is empty,
15332    /// so the four-byte signature read there runs to 252 over 0 bytes.
15333    #[test]
15334    fn a_copy_whose_committed_datatype_cannot_be_read_reports_the_read_failure() {
15335        let empty = BytesSource::new(Vec::new());
15336        let invalidated = InvalidatedAddresses {
15337            removed: vec![(248, 71)],
15338            moved: Vec::new(),
15339            base: BaseAddress::ZERO,
15340        };
15341        let mut bytes = message_record(
15342            MessageType::DATATYPE,
15343            &crate::shared_message::encode_committed_ref(StoredAddress::new(248), OFFSET_SIZE),
15344        );
15345        bytes[3] = MessageFlags::SHARED.get();
15346        let tree = CopyTree::DatasetVerbatim {
15347            region: plain_region(bytes),
15348            dense_attrs: DenseAttrSet::default(),
15349        };
15350
15351        let err = screen_copied_references(&tree, &invalidated, &empty).unwrap_err();
15352        let Error::CopyScreenUnreadable { message, source } = &err else {
15353            panic!("expected CopyScreenUnreadable, got {err:?}");
15354        };
15355        assert_eq!(*message, MessageType::DATATYPE);
15356        assert_eq!(
15357            *source,
15358            FormatError::UnexpectedEof {
15359                expected: 252,
15360                available: 0
15361            }
15362        );
15363    }
15364
15365    /// A datatype that declares an object reference the element bytes have no
15366    /// room for cannot be walked, so its addresses cannot be screened — and a
15367    /// commit that reclaims space refuses it rather than writing references past
15368    /// a screen that could not read them (issue #317).
15369    ///
15370    /// Reachable only from a malformed source header: every datatype this crate
15371    /// builds sizes its element to its members. Driven directly for that reason.
15372    #[test]
15373    fn a_datatype_whose_reference_slots_do_not_fit_is_refused() {
15374        use crate::datatype::{CompoundMember, ReferenceType};
15375        // An 8-byte compound declaring an 8-byte reference at offset 4: the slot
15376        // runs four bytes past the element.
15377        let dt = Datatype::Compound {
15378            size: 8,
15379            members: vec![CompoundMember {
15380                name: "r".to_string(),
15381                byte_offset: 4,
15382                datatype: Datatype::Reference {
15383                    size: 8,
15384                    ref_type: ReferenceType::Object,
15385                },
15386            }],
15387        };
15388        assert!(
15389            embedded_reference_slots(&dt).is_none(),
15390            "the fixture must be one the walker cannot map"
15391        );
15392
15393        let raw = [0u8; 8];
15394        let invalidated = InvalidatedAddresses {
15395            removed: vec![(248, 71)],
15396            moved: Vec::new(),
15397            base: BaseAddress::ZERO,
15398        };
15399        let err = screen_resolved_references(&dt, &raw, &invalidated).unwrap_err();
15400        assert!(
15401            err.to_string().contains("this screen cannot read"),
15402            "got: {err}"
15403        );
15404
15405        // Unconditional: a screen holding nothing still refuses, because the
15406        // refusal is about addresses that cannot be read rather than about what
15407        // the commit is vacating. `moved` is never empty in a real commit, so
15408        // there is no case this could have been made conditional on.
15409        let nothing = InvalidatedAddresses {
15410            removed: Vec::new(),
15411            moved: Vec::new(),
15412            base: BaseAddress::ZERO,
15413        };
15414        assert!(screen_resolved_references(&dt, &raw, &nothing).is_err());
15415    }
15416
15417    /// A variable-length *of object references* is refused rather than skipped:
15418    /// its addresses live in the global heap the elements point at, not in the
15419    /// element bytes this screen reads (issue #317).
15420    ///
15421    /// The reference C library writes such a datatype (`H5T_VLEN` of
15422    /// `H5T_STD_REF_OBJ`) and an in-file copy carries it, so it reaches the
15423    /// screen through `copy` even though nothing here builds one. A
15424    /// variable-length *string* is unaffected — the heap it points at is never
15425    /// reclaimed by a delete, so its elements stay valid.
15426    #[test]
15427    fn a_variable_length_of_object_references_is_refused() {
15428        use crate::datatype::{CharacterSet, ReferenceType};
15429        let of_references = Datatype::VariableLength {
15430            is_string: false,
15431            padding: None,
15432            charset: None,
15433            base_type: Box::new(Datatype::Reference {
15434                size: 8,
15435                ref_type: ReferenceType::Object,
15436            }),
15437        };
15438        let of_strings = crate::type_builders::make_vlen_string_type(CharacterSet::Utf8);
15439        let raw = vec![0u8; 32];
15440        let invalidated = InvalidatedAddresses {
15441            removed: vec![(248, 71)],
15442            moved: Vec::new(),
15443            base: BaseAddress::ZERO,
15444        };
15445
15446        let err = screen_resolved_references(&of_references, &raw, &invalidated).unwrap_err();
15447        assert!(
15448            err.to_string().contains("this screen cannot read"),
15449            "got: {err}"
15450        );
15451        assert!(
15452            screen_resolved_references(&of_strings, &raw, &invalidated).is_ok(),
15453            "a variable-length string points at a heap no delete reclaims"
15454        );
15455    }
15456
15457    /// An object reference wider than the 8 bytes the slot walker maps is
15458    /// refused, not skipped (issue #317).
15459    ///
15460    /// [`datatype_holds_object_address`] answers for an object reference of
15461    /// *any* width, while [`embedded_reference_slots`] locates only the 8-byte
15462    /// one — so the walker reports a width it cannot address as unwalkable
15463    /// rather than as "no slots here", which a screen would read as "nothing to
15464    /// check". `Dataset::dereference` reads such an element, taking the address
15465    /// from its first eight bytes, so the reference is live.
15466    #[test]
15467    fn an_object_reference_wider_than_eight_bytes_is_refused() {
15468        use crate::datatype::ReferenceType;
15469        let dt = Datatype::Reference {
15470            size: 16,
15471            ref_type: ReferenceType::Object,
15472        };
15473        assert!(
15474            embedded_reference_slots(&dt).is_none(),
15475            "the walker must report a width it cannot map, not an empty slot list"
15476        );
15477
15478        let mut raw = vec![0u8; 16];
15479        raw[..8].copy_from_slice(&300u64.to_le_bytes());
15480        let invalidated = InvalidatedAddresses {
15481            removed: vec![(248, 71)],
15482            moved: Vec::new(),
15483            base: BaseAddress::ZERO,
15484        };
15485        let err = screen_resolved_references(&dt, &raw, &invalidated).unwrap_err();
15486        assert!(
15487            err.to_string().contains("this screen cannot read"),
15488            "got: {err}"
15489        );
15490
15491        // Unconditional, for the reason
15492        // `a_datatype_whose_reference_slots_do_not_fit_is_refused` states.
15493        let nothing = InvalidatedAddresses {
15494            removed: Vec::new(),
15495            moved: Vec::new(),
15496            base: BaseAddress::ZERO,
15497        };
15498        assert!(screen_resolved_references(&dt, &raw, &nothing).is_err());
15499    }
15500
15501    /// A reference target supplied as a *raw address* is screened exactly as one
15502    /// supplied as a path (issue #317), and the address it would have resolved
15503    /// to is returned unchanged when it names space this commit keeps.
15504    ///
15505    /// Driven directly because nothing stages a `Raw` target carrying a real
15506    /// address: a builder reachable from a session produces only
15507    /// [`ObjectRefTarget::Path`], and [`crate::repack`]'s faithful re-emit
15508    /// resolves every real target to a `Path`, leaving `Raw` for the null and
15509    /// undefined references alone. The arm exists so that the two ways a target
15510    /// can name an object answer to the same rule rather than to whichever one
15511    /// a caller happened to use, and this is what holds it to that.
15512    /// A file with nothing a reference could live in is walked once and never
15513    /// again, and one that holds a reference is walked every time.
15514    ///
15515    /// The first half is the whole of `proved_free_of_references`, and it is
15516    /// invisible from outside the session: a build that never caches the proof
15517    /// writes byte-identical files and passes every other test here, having
15518    /// silently gone back to walking the file on every commit. The second half
15519    /// is what stops a too-eager proof from being the fix for the first.
15520    #[test]
15521    fn a_file_proved_free_of_references_is_walked_once_and_no_more() {
15522        use crate::reference_patch::{reset_walks, walks};
15523        use tempfile::tempdir;
15524        let dir = tempdir().unwrap();
15525
15526        let commit_three = |path: &std::path::Path| {
15527            let session = crate::File::open_rw(path).unwrap();
15528            for i in 0..3 {
15529                session
15530                    .root()
15531                    .create_dataset(&format!("added{i}"), |b| {
15532                        b.with_i32_data(&[i]);
15533                    })
15534                    .unwrap();
15535                session.commit().unwrap();
15536            }
15537        };
15538
15539        let plain = dir.path().join("plain.h5");
15540        let mut b = crate::writer::FileBuilder::new();
15541        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15542        b.write(&plain).unwrap();
15543        reset_walks();
15544        commit_three(&plain);
15545        assert_eq!(
15546            walks(),
15547            1,
15548            "the first commit's walk proves the file reference-free; the rest \
15549             must take its word for it"
15550        );
15551
15552        let referencing = dir.path().join("referencing.h5");
15553        let mut b = crate::writer::FileBuilder::new();
15554        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15555        b.create_dataset("refs").with_path_references(&["d"]);
15556        b.write(&referencing).unwrap();
15557        reset_walks();
15558        commit_three(&referencing);
15559        assert_eq!(
15560            walks(),
15561            3,
15562            "a file that holds a reference is never proved free of one, so every \
15563             commit walks it"
15564        );
15565    }
15566
15567    #[test]
15568    fn a_raw_reference_target_is_screened_like_a_path_one() {
15569        use tempfile::tempdir;
15570        let dir = tempdir().unwrap();
15571        let path = dir.path().join("raw_target.h5");
15572        let mut b = crate::writer::FileBuilder::new();
15573        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
15574        b.write(&path).unwrap();
15575        let engine = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
15576
15577        let nodes: BTreeMap<ObjectPathBuf, Node> = BTreeMap::new();
15578        let path_addr: BTreeMap<ObjectPathBuf, u64> = BTreeMap::new();
15579        let resolve = |address: u64, removed: Vec<(u64, u64)>, moved: Vec<u64>| {
15580            WriteEngine::resolve_reference_target(
15581                &ObjectRefTarget::Raw(StoredAddress::new(address)),
15582                &path_addr,
15583                &nodes,
15584                &[],
15585                &[],
15586                &[],
15587                &InvalidatedAddresses {
15588                    removed,
15589                    moved,
15590                    base: BaseAddress::ZERO,
15591                },
15592                &engine.image(),
15593                engine.superblock(),
15594            )
15595        };
15596
15597        assert!(
15598            resolve(300, vec![(248, 71)], Vec::new()).is_err(),
15599            "an address inside a reclaimed span is refused"
15600        );
15601        assert!(
15602            resolve(300, Vec::new(), vec![300]).is_err(),
15603            "an address this commit rewrites elsewhere is refused"
15604        );
15605        assert_eq!(
15606            resolve(300, vec![(400, 71)], vec![299, 301]).unwrap(),
15607            StoredAddress::new(300),
15608            "an address outside every reclaimed span and every moved header is carried through"
15609        );
15610        assert_eq!(
15611            resolve(300, Vec::new(), Vec::new()).unwrap(),
15612            StoredAddress::new(300),
15613            "a commit that reclaims nothing screens nothing"
15614        );
15615        // The two sentinels name no object, so they are carried through even
15616        // when they fall inside a reclaimed span.
15617        assert_eq!(
15618            resolve(0, vec![(0, 4096)], vec![0]).unwrap(),
15619            StoredAddress::new(0)
15620        );
15621        assert_eq!(
15622            resolve(UNDEF, vec![(0, u64::MAX)], vec![UNDEF]).unwrap(),
15623            StoredAddress::new(UNDEF)
15624        );
15625    }
15626
15627    #[rstest]
15628    #[case("a/b")]
15629    #[case("/a/b")]
15630    #[case("a/./b")]
15631    #[case("a//b/")]
15632    fn a_reference_target_is_keyed_by_the_object_its_spelling_names(#[case] spelling: &str) {
15633        use tempfile::tempdir;
15634        let dir = tempdir().unwrap();
15635        let path = dir.path().join("path_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<ObjectPathBuf, Node> = BTreeMap::new();
15642        let resolve = |path_addr: &BTreeMap<ObjectPathBuf, u64>, add_targets: &[ObjectPathBuf]| {
15643            WriteEngine::resolve_reference_target(
15644                &ObjectRefTarget::Path(ObjectPathBuf::parse(spelling)),
15645                path_addr,
15646                &nodes,
15647                add_targets,
15648                &[],
15649                &[],
15650                &InvalidatedAddresses {
15651                    removed: Vec::new(),
15652                    moved: Vec::new(),
15653                    base: BaseAddress::ZERO,
15654                },
15655                &engine.image(),
15656                engine.superblock(),
15657            )
15658        };
15659
15660        let placed: BTreeMap<ObjectPathBuf, u64> =
15661            std::iter::once((ObjectPathBuf::parse("a/b"), 4096)).collect();
15662        assert_eq!(resolve(&placed, &[]).unwrap(), StoredAddress::new(4096));
15663
15664        let err = resolve(&BTreeMap::new(), &[ObjectPathBuf::parse("a/b")]).unwrap_err();
15665        let Error::EditUnsupported(reason) = &err else {
15666            panic!("expected EditUnsupported, got {err:?}");
15667        };
15668        assert_eq!(
15669            *reason,
15670            "an object-reference dataset targets a path this commit is still writing; \
15671             use separate commits"
15672        );
15673    }
15674
15675    /// A copied object's *attributes* are screened against the space the commit
15676    /// reclaims, in either storage an object can hold them in — inline in the
15677    /// header region, or in the fractal heap a dense object uses — and an
15678    /// address outside those spans passes in both (issue #317).
15679    #[test]
15680    fn a_copied_reference_attribute_is_screened_in_both_storages() {
15681        let empty = BytesSource::new(Vec::new());
15682        let invalidated = InvalidatedAddresses {
15683            removed: vec![(248, 71)],
15684            moved: Vec::new(),
15685            base: BaseAddress::ZERO,
15686        };
15687        // 248 is the first byte of the reclaimed span, 318 its last, 319 the
15688        // byte after it.
15689        for (address, refused) in [(248u64, true), (318, true), (319, false), (247, false)] {
15690            let attr = reference_attr("target", address);
15691            let dense = CopyTree::DatasetVerbatim {
15692                region: OhRegion::default(),
15693                dense_attrs: DenseAttrSet {
15694                    attrs: vec![attr.clone()],
15695                    creation: DenseAttrCreationOrder::Untracked,
15696                },
15697            };
15698            let inline = CopyTree::DatasetVerbatim {
15699                region: inline_attr_region(&attr),
15700                dense_attrs: DenseAttrSet::default(),
15701            };
15702            for (storage, tree) in [("dense", &dense), ("inline", &inline)] {
15703                let got = screen_copied_references(tree, &invalidated, &empty);
15704                assert_eq!(
15705                    got.is_err(),
15706                    refused,
15707                    "{storage} attribute at {address}: {got:?}"
15708                );
15709            }
15710        }
15711    }
15712
15713    /// Collect the message types present in a chunk-0 region, in order.
15714    fn region_types(region: &OhRegion) -> Vec<MessageType> {
15715        let mut out = Vec::new();
15716        let mut p = 0;
15717        while let Some((mt, _, end)) = region.next_message(p).unwrap() {
15718            out.push(mt);
15719            p = end;
15720        }
15721        out
15722    }
15723
15724    /// Stopping an in-place append (`append_inplace`) at any phase boundary must
15725    /// leave the file readable as a consistent prefix — the old length until the
15726    /// phase-4 dimension commit, the new length after it — even though a
15727    /// partial-tail append repoints the visible trailing element in place. Mirrors
15728    /// `Dataset::append`'s crash-consistency harness, but driven through
15729    /// the in-place edit engine's own mirror (disk-before-mirror ordering) to prove the shared
15730    /// engine is crash-safe under both owners. Three starting layouts: the trailing
15731    /// element inline in the index block (chunk 4, n 6), in a data block
15732    /// (chunk 2, n 9, slot 0), and a *filtered* inline one (issue #393), whose
15733    /// repointed element is the three-field record rather than a bare address and
15734    /// whose relocated chunk has to decode to the old prefix at every phase
15735    /// before the dimension moves.
15736    #[test]
15737    fn append_inplace_crash_consistency_partial_tail_prefix() {
15738        use crate::reader::File as PureFile;
15739        use crate::writer::FileBuilder;
15740        use tempfile::tempdir;
15741
15742        let build = |path: &std::path::Path, n: i32, chunk: u64, deflate: bool| {
15743            let data: Vec<i32> = (0..n).collect();
15744            let mut b = FileBuilder::new();
15745            let d = b
15746                .create_dataset("d")
15747                .with_i32_data(&data)
15748                .with_shape(&[n as u64])
15749                .with_maxshape(&[MaxExtent::Unlimited])
15750                .with_chunks(&[chunk]);
15751            if deflate {
15752                d.with_deflate(6);
15753            }
15754            b.write(path).unwrap();
15755        };
15756
15757        for (n, chunk, add, deflate) in [
15758            (6i32, 4u64, 5i32, false),
15759            (9, 2, 6, false),
15760            (6, 4, 5, true),
15761            (9, 4, 3, true),
15762        ] {
15763            let dir = tempdir().unwrap();
15764            let base = dir.path().join("base.h5");
15765            build(&base, n, chunk, deflate);
15766
15767            for max_phase in 1u8..=4 {
15768                let p = dir
15769                    .path()
15770                    .join(format!("crash_{n}_{chunk}_{deflate}_{max_phase}.h5"));
15771                std::fs::copy(&base, &p).unwrap();
15772                {
15773                    let mut s = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
15774                    s.append_inplace_i32_phased("d", &(n..n + add).collect::<Vec<_>>(), max_phase)
15775                        .unwrap();
15776                    // session dropped here, simulating a crash after `max_phase`
15777                }
15778                let expected_len = if max_phase == 4 { n + add } else { n };
15779                let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
15780                assert_eq!(
15781                    f.dataset("d").unwrap().read_i32().unwrap(),
15782                    (0..expected_len).collect::<Vec<_>>(),
15783                    "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk}, \
15784                     deflate={deflate})"
15785                );
15786            }
15787        }
15788    }
15789
15790    /// Growing a partial trailing chunk decodes and re-encodes elements that are
15791    /// already committed, so a **lossy** pipeline is refused on the immediate
15792    /// in-place path, with the file left exactly as it was.
15793    ///
15794    /// The values are the point rather than the error: measured on this fixture
15795    /// before the refusal existed, the committed pair `[4.5, 5.5]` read back as
15796    /// `[16.0, 0.0]` once two more elements joined their ZFP block. That is
15797    /// quantization following the block's new contents, not a decode that lost
15798    /// its place — a *fresh* chunk of `[99.0, 98.0, 97.0]` at this rate reads
15799    /// back as `[98.0, 102.0, 90.0]` with nothing decoded anywhere, which is what
15800    /// eight bits per value buys and is not this test's business.
15801    #[cfg(feature = "zfp")]
15802    #[test]
15803    fn append_onto_a_lossy_partial_tail_is_refused() {
15804        use crate::reader::File as PureFile;
15805        use crate::writer::FileBuilder;
15806        use tempfile::tempdir;
15807
15808        let dir = tempdir().unwrap();
15809        let committed: Vec<f64> = vec![0.5, 1.5, 2.5, 3.5, 4.5, 5.5];
15810
15811        // `d` is 6 of a chunk of 4: a partial trailing chunk holding 4.5 and 5.5.
15812        let build = |path: &std::path::Path| {
15813            let mut b = FileBuilder::new();
15814            b.create_dataset("d")
15815                .with_f64_data(&committed)
15816                .with_shape(&[6])
15817                .with_maxshape(&[MaxExtent::Unlimited])
15818                .with_chunks(&[4])
15819                .with_zfp(8.0);
15820            b.write(path).unwrap();
15821        };
15822
15823        // The immediate in-place path.
15824        let p = dir.path().join("inplace.h5");
15825        build(&p);
15826        let before = std::fs::read(&p).unwrap();
15827        {
15828            let f = crate::reader::File::open_rw(&p).unwrap();
15829            let err = f
15830                .dataset("d")
15831                .unwrap()
15832                .append(&[99.0f64, 98.0, 97.0])
15833                .expect_err("a lossy pipeline must not have its trailing chunk re-encoded");
15834            assert!(
15835                matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15836                "got: {err:?}"
15837            );
15838        }
15839        assert_eq!(
15840            std::fs::read(&p).unwrap(),
15841            before,
15842            "the refusal wrote bytes"
15843        );
15844        assert_eq!(
15845            PureFile::open(&p)
15846                .unwrap()
15847                .dataset("d")
15848                .unwrap()
15849                .read_f64()
15850                .unwrap(),
15851            committed
15852        );
15853
15854        // A chunk-aligned start needs no rewrite, so it is still accepted and the
15855        // committed values are still exactly what they were.
15856        let p = dir.path().join("aligned.h5");
15857        {
15858            let mut b = FileBuilder::new();
15859            b.create_dataset("d")
15860                .with_f64_data(&committed[..4])
15861                .with_shape(&[4])
15862                .with_maxshape(&[MaxExtent::Unlimited])
15863                .with_chunks(&[4])
15864                .with_zfp(8.0);
15865            b.write(&p).unwrap();
15866        }
15867        {
15868            let f = crate::reader::File::open_rw(&p).unwrap();
15869            f.dataset("d").unwrap().append(&[9.5f64, 8.5, 7.5]).unwrap();
15870        }
15871        let back = PureFile::open(&p)
15872            .unwrap()
15873            .dataset("d")
15874            .unwrap()
15875            .read_f64()
15876            .unwrap();
15877        assert_eq!(back[..4], committed[..4], "an untouched chunk changed");
15878
15879        // The staged path rebuilds the index at commit and rewrote the same chunk
15880        // on the way; it refuses at the call that stages the append, so nothing is
15881        // staged and the file is left alone.
15882        let p = dir.path().join("staged.h5");
15883        build(&p);
15884        let before = std::fs::read(&p).unwrap();
15885        {
15886            let f = crate::reader::File::open_rw(&p).unwrap();
15887            let err = f
15888                .dataset("d")
15889                .unwrap()
15890                .append_staged(|b| {
15891                    b.append_f64(&[99.0, 98.0, 97.0]);
15892                })
15893                .expect_err("a staged append must not re-encode the committed tail either");
15894            assert!(
15895                matches!(&err, Error::AppendUnsupported(m) if m.contains("lossy")),
15896                "got: {err:?}"
15897            );
15898            f.close().unwrap();
15899        }
15900        assert_eq!(
15901            std::fs::read(&p).unwrap(),
15902            before,
15903            "the refusal wrote bytes"
15904        );
15905
15906        // A buffered appender over the unaligned shape is refused when it is
15907        // made, since its very first write would be the rewrite above.
15908        let p = dir.path().join("buffered.h5");
15909        build(&p);
15910        {
15911            let f = crate::reader::File::open_rw(&p).unwrap();
15912            let mut ds = f.dataset("d").unwrap();
15913            let err = ds
15914                .buffered_appender()
15915                .expect_err("its first write would re-encode the committed tail");
15916            assert!(
15917                matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15918                "got: {err:?}"
15919            );
15920        }
15921    }
15922
15923    /// Float D-scale scale-offset is the other lossy mode, and is refused for the
15924    /// same reason — while the **integer** mode, which is lossless, grows its
15925    /// partial trailing chunk with every value intact. Both halves matter: a
15926    /// predicate that refused all of scale-offset would pass the first assertion
15927    /// and take a working case away.
15928    #[test]
15929    fn a_lossy_scale_offset_mode_is_refused_and_a_lossless_one_is_not() {
15930        use crate::reader::File as PureFile;
15931        use crate::scaleoffset::ScaleOffset;
15932        use crate::writer::FileBuilder;
15933        use tempfile::tempdir;
15934
15935        let dir = tempdir().unwrap();
15936
15937        let p = dir.path().join("dscale.h5");
15938        {
15939            let mut b = FileBuilder::new();
15940            b.create_dataset("d")
15941                .with_f64_data(&[0.5, 1.5, 2.5, 3.5, 4.5, 5.5])
15942                .with_shape(&[6])
15943                .with_maxshape(&[MaxExtent::Unlimited])
15944                .with_chunks(&[4])
15945                .with_scale_offset(ScaleOffset::FloatDScale(1));
15946            b.write(&p).unwrap();
15947        }
15948        {
15949            let f = crate::reader::File::open_rw(&p).unwrap();
15950            let err = f
15951                .dataset("d")
15952                .unwrap()
15953                .append(&[99.0f64, 98.0, 97.0])
15954                .expect_err("float D-scale is lossy");
15955            assert!(
15956                matches!(&err, Error::AppendInPlaceUnsupported(m) if m.contains("lossy")),
15957                "got: {err:?}"
15958            );
15959        }
15960
15961        let p = dir.path().join("int.h5");
15962        {
15963            let mut b = FileBuilder::new();
15964            b.create_dataset("d")
15965                .with_i32_data(&(0..6).collect::<Vec<_>>())
15966                .with_shape(&[6])
15967                .with_maxshape(&[MaxExtent::Unlimited])
15968                .with_chunks(&[4])
15969                .with_scale_offset(ScaleOffset::Integer(0));
15970            b.write(&p).unwrap();
15971        }
15972        {
15973            let f = crate::reader::File::open_rw(&p).unwrap();
15974            let mut ds = f.dataset("d").unwrap();
15975            ds.append(&[6i32, 7, 8]).unwrap();
15976            ds.append(&[9i32]).unwrap();
15977        }
15978        assert_eq!(
15979            PureFile::open(&p)
15980                .unwrap()
15981                .dataset("d")
15982                .unwrap()
15983                .read_i32()
15984                .unwrap(),
15985            (0..10).collect::<Vec<_>>()
15986        );
15987    }
15988
15989    /// A *filtered* append whose length is not a whole number of chunks writes a
15990    /// partial last chunk. That chunk's index element is a fresh insert past the
15991    /// old dimension — the same position every whole chunk beside it occupies —
15992    /// so stopping anywhere in the durability sequence must still read back as
15993    /// the old prefix, exactly as an aligned filtered append does. This is the
15994    /// case the in-place refusal used to cover and no longer does; without the
15995    /// phase sweep, "it reads back fine" would only be testing phase 4.
15996    #[test]
15997    fn append_inplace_crash_consistency_filtered_partial_last_chunk() {
15998        use crate::reader::File as PureFile;
15999        use crate::writer::FileBuilder;
16000        use tempfile::tempdir;
16001
16002        let build = |path: &std::path::Path, n: i32, chunk: u64| {
16003            let data: Vec<i32> = (0..n).collect();
16004            let mut b = FileBuilder::new();
16005            b.create_dataset("d")
16006                .with_i32_data(&data)
16007                .with_shape(&[n as u64])
16008                .with_maxshape(&[MaxExtent::Unlimited])
16009                .with_chunks(&[chunk])
16010                .with_shuffle()
16011                .with_deflate(4);
16012            b.write(path).unwrap();
16013        };
16014
16015        // Aligned starts, unaligned lengths: one that stays inside a single new
16016        // chunk, and one that spans several and ends partway through the last.
16017        // (An unaligned *start* is the sibling sweep,
16018        // `append_inplace_crash_consistency_partial_tail_prefix`.)
16019        for (n, chunk, add) in [(8i32, 4u64, 2i32), (8, 4, 9), (0, 4, 3)] {
16020            let dir = tempdir().unwrap();
16021            let base = dir.path().join("base.h5");
16022            build(&base, n, chunk);
16023
16024            for max_phase in 1u8..=4 {
16025                let p = dir
16026                    .path()
16027                    .join(format!("crash_f_{n}_{chunk}_{max_phase}.h5"));
16028                std::fs::copy(&base, &p).unwrap();
16029                {
16030                    let mut s = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
16031                    s.append_inplace_i32_phased("d", &(n..n + add).collect::<Vec<_>>(), max_phase)
16032                        .unwrap();
16033                    // session dropped here, simulating a crash after `max_phase`
16034                }
16035                let expected_len = if max_phase == 4 { n + add } else { n };
16036                let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
16037                assert_eq!(
16038                    f.dataset("d").unwrap().read_i32().unwrap(),
16039                    (0..expected_len).collect::<Vec<_>>(),
16040                    "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk}, \
16041                     add={add})"
16042                );
16043            }
16044        }
16045    }
16046
16047    /// Build a one-element-per-chunk unlimited `d` holding `0..n`, the shape the
16048    /// crash-consistency harnesses below grow.
16049    fn build_unit_chunked(path: &std::path::Path, n: i32) {
16050        use crate::writer::FileBuilder;
16051        let data: Vec<i32> = (0..n).collect();
16052        let mut b = FileBuilder::new();
16053        b.create_dataset("d")
16054            .with_i32_data(&data)
16055            .with_shape(&[n as u64])
16056            .with_maxshape(&[MaxExtent::Unlimited])
16057            .with_chunks(&[1]);
16058        b.write(path).unwrap();
16059    }
16060
16061    /// Stop an append after `max_phase` durability phases and hand back the
16062    /// resulting file. Dropping the engine inside is the simulated crash: no
16063    /// further phases run and no close barrier is written.
16064    fn append_stopped_at(
16065        base: &std::path::Path,
16066        out: &std::path::Path,
16067        values: std::ops::Range<i32>,
16068        max_phase: u8,
16069    ) {
16070        std::fs::copy(base, out).unwrap();
16071        let mut s = WriteEngine::open_with_locking(out, FileLocking::Enabled).unwrap();
16072        s.append_inplace_i32_phased("d", &values.collect::<Vec<_>>(), max_phase)
16073            .unwrap();
16074    }
16075
16076    /// Growing an Extensible-Array index across its inline -> direct-block ->
16077    /// super-block boundaries touches far more index structure than the
16078    /// partial-tail case above. Stopping at any phase boundary must still read
16079    /// back as a consistent prefix.
16080    ///
16081    /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
16082    /// owned path drives the same `apply_ea_append` engine.
16083    #[test]
16084    fn append_inplace_crash_consistency_across_ea_boundaries() {
16085        use crate::reader::File as PureFile;
16086        use tempfile::tempdir;
16087
16088        let dir = tempdir().unwrap();
16089        let base = dir.path().join("base.h5");
16090        let (n, target) = (50i32, 250i32);
16091        build_unit_chunked(&base, n);
16092
16093        for max_phase in 1u8..=4 {
16094            let p = dir.path().join(format!("crash_ea_{max_phase}.h5"));
16095            append_stopped_at(&base, &p, n..target, max_phase);
16096            let expected_len = if max_phase == 4 { target } else { n };
16097            let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
16098            assert_eq!(
16099                f.dataset("d").unwrap().read_i32().unwrap(),
16100                (0..expected_len).collect::<Vec<_>>(),
16101                "inconsistent view after crash at phase {max_phase}"
16102            );
16103        }
16104    }
16105
16106    /// The same guarantee for an append that crosses the paged-data-block
16107    /// boundary (~131,060 chunks), where phase 1 allocates a paged super block,
16108    /// paged data blocks, the per-page checksums, and the page-init bitmap. This
16109    /// is the most intricate in-place growth the engine performs, and truncating
16110    /// it partway is exactly what a power loss does.
16111    ///
16112    /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202).
16113    /// Opening a paged persisting file must seed each free section into the list
16114    /// for the page type its *slot* names, not one derived from its size.
16115    ///
16116    /// The managers a paged file uses mean different things — SUPER (slot 0) is
16117    /// metadata, DRAW (slot 2) is raw — and a freed section's size says nothing
16118    /// about which it came from. Getting this wrong is invisible from the outside:
16119    /// the total free space is unchanged, the reference library still opens the
16120    /// file, and only a later allocation drawn from the wrong list would mix a
16121    /// page. So assert the routing directly.
16122    #[test]
16123    fn paged_open_seeds_each_manager_by_slot() {
16124        use crate::writer::FileBuilder;
16125        use tempfile::tempdir;
16126
16127        let dir = tempdir().unwrap();
16128        let path = dir.path().join("paged_seed.h5");
16129        let mut b = FileBuilder::new();
16130        b.create_dataset("d")
16131            .with_i32_data(&(0..1000).collect::<Vec<i32>>())
16132            .with_shape(&[1000]);
16133        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16134            .with_file_space_page_size(4096);
16135        b.write(&path).unwrap();
16136
16137        // Read the file's recorded free space *before* opening the session: the
16138        // session holds an exclusive OS lock, and on Windows those locks are
16139        // mandatory, so a concurrent `File::open` would fail outright.
16140        let on_disk: u64 = crate::reader::File::open(&path)
16141            .unwrap()
16142            .persisted_free_space()
16143            .iter()
16144            .map(|&(_, l)| l)
16145            .sum();
16146
16147        let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16148        let pg = s.paged.as_ref().expect("a paged file installs paged state");
16149        assert_eq!(pg.page_size, 4096);
16150
16151        // The from-scratch writer leaves a page tail free in both the metadata and
16152        // the raw pages, so both per-type managers are populated. If every slot
16153        // were funnelled into one list, one of these would be empty.
16154        assert!(
16155            !pg.meta.sections().is_empty(),
16156            "SUPER (slot 0) sections seed the metadata list"
16157        );
16158        assert!(
16159            !pg.raw.sections().is_empty(),
16160            "DRAW (slot 2) sections seed the raw list, not the metadata list"
16161        );
16162
16163        // Nothing is double-counted or dropped: the lists partition exactly the
16164        // free space the file records, and no two sections overlap. A file this
16165        // crate wrote has nothing unclassified — it only ever files a whole
16166        // aligned page under the generic-large manager — so summing it in is a
16167        // statement about that too.
16168        let mut all = pg.reusable_sections();
16169        all.extend(pg.unclassified.sections());
16170        assert!(
16171            pg.unclassified.sections().is_empty(),
16172            "our own paged writer files nothing whose page type is unknown"
16173        );
16174        let flat: u64 = all.iter().map(|&(_, l)| l).sum();
16175        assert_eq!(
16176            flat, on_disk,
16177            "the split lists hold exactly the file's free space"
16178        );
16179        all.sort_by_key(|&(a, _)| a);
16180        let mut prev_end = 0u64;
16181        for (addr, len) in all {
16182            assert!(addr >= prev_end, "the per-type lists do not overlap");
16183            prev_end = addr + len;
16184        }
16185    }
16186
16187    /// Deleting a chunked dataset from a paged file the *reference library* wrote
16188    /// must not offer its chunk index for raw reuse.
16189    ///
16190    /// A chunk index is metadata by the format's taxonomy, and the C library
16191    /// allocates one accordingly — out of metadata pages, which on a small file
16192    /// means page 0, alongside the superblock, the root group, and every other
16193    /// object's header. This crate places its own indexes in raw pages beside the
16194    /// chunk data instead, and the reclaim path used to assume that of every file.
16195    /// Freeing a C-written index under that assumption puts its bytes on the raw
16196    /// list, and the next commit writes a dataset's values into the middle of a
16197    /// live metadata page.
16198    ///
16199    /// Both libraries still read such a file — every address in it is still
16200    /// correct — so nothing downstream reports the damage. The assertion has to be
16201    /// about placement itself: no byte this commit writes may land in a page that
16202    /// held live metadata beforehand.
16203    #[test]
16204    // The file is a committed one the reference C library wrote, see
16205    // `crates/crosscheck/tests/main/c_test_data.rs`.
16206    fn a_c_written_chunk_index_is_not_reclaimed_as_raw() {
16207        use tempfile::tempdir;
16208
16209        const PAGE: u64 = 4096;
16210        let dir = tempdir().unwrap();
16211        let path = crate::test_data::copy("c/paged_index.h5", dir.path());
16212
16213        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16214        let page_size = s.paged.as_ref().expect("a paged file").page_size;
16215        assert_eq!(page_size, PAGE);
16216        // Where the C library put the index: every page it touches is a metadata
16217        // page, since the C library allocates an index as metadata.
16218        let victim_addr = crate::group_v2::resolve_path_any(
16219            s.image.as_slice().unwrap(),
16220            AccessMode::ReadWrite,
16221            &s.superblock,
16222            &ObjectPath::parse("victim"),
16223        )
16224        .unwrap();
16225        let index_spans = s
16226            .chunked_index_spans(victim_addr)
16227            .expect("the C library's extensible-array index is enumerable");
16228        assert!(!index_spans.is_empty());
16229        // Page 0 always holds the superblock and the root group header; the index
16230        // pages are what this test is really about.
16231        let mut meta_pages: Vec<u64> = vec![0];
16232        for (addr, len) in &index_spans {
16233            for p in (addr / PAGE)..=((addr + len - 1) / PAGE) {
16234                meta_pages.push(p);
16235            }
16236        }
16237        meta_pages.sort_unstable();
16238        meta_pages.dedup();
16239
16240        // Delete the dataset, then write a small dataset that would fit in the
16241        // index's freed bytes.
16242        s.delete(&ObjectPathBuf::parse("/victim")).unwrap();
16243        s.commit().unwrap();
16244        let mut db = crate::type_builders::DatasetBuilder::new("added");
16245        db.with_f64_data(&[2.5f64; 8]).with_shape(&[8]);
16246        s.stage_created_dataset(&ObjectPathBuf::parse("/added"), db)
16247            .unwrap();
16248        s.commit().unwrap();
16249
16250        // Release the session's exclusive OS lock before reading the file back;
16251        // those locks are mandatory on Windows.
16252        drop(s);
16253
16254        // Every raw byte of the new dataset must be outside those pages.
16255        let f = crate::reader::File::open(&path).unwrap();
16256        let added = f.dataset("added").unwrap();
16257        let crate::Layout::Contiguous {
16258            address: Some(addr),
16259            size,
16260        } = added.layout().unwrap()
16261        else {
16262            panic!("a small f64 dataset is stored contiguously");
16263        };
16264        for p in (addr / PAGE)..=((addr + size - 1) / PAGE) {
16265            assert!(
16266                !meta_pages.contains(&p),
16267                "the new dataset's raw data landed at ({addr}, {size}), in page {p}, \
16268                 which held live metadata before the edit: {meta_pages:?}"
16269            );
16270        }
16271
16272        assert_eq!(added.read_f64().unwrap(), vec![2.5f64; 8]);
16273        assert_eq!(
16274            f.dataset("keep").unwrap().read_i32().unwrap(),
16275            vec![1, 2, 3, 4]
16276        );
16277    }
16278
16279    /// Padding a tail page whose type is unknown must leave the padding
16280    /// untracked, not guess a type for it.
16281    ///
16282    /// A commit pads the file to a page boundary before laying down its manager
16283    /// blocks. When this session has made no typed allocation, the tail page is
16284    /// one a previous session left non-aligned — only a crash does that, since a
16285    /// clean close pads — and nothing says what it holds. Recording the padding
16286    /// under a guess would advertise those bytes for reuse of that type, and half
16287    /// the time they sit in a page of the other one. Under-reporting is the safe
16288    /// direction, and it is the call [`PagedEdit::begin`] already makes for the
16289    /// same situation.
16290    ///
16291    /// Reuse is what made this reachable: before it, every commit appended at
16292    /// least the root group's header, so the tail type was always known by the
16293    /// time the padding ran.
16294    #[test]
16295    fn padding_a_tail_page_of_unknown_type_records_nothing() {
16296        use crate::writer::FileBuilder;
16297        use tempfile::tempdir;
16298
16299        const PAGE: u64 = 4096;
16300        let dir = tempdir().unwrap();
16301
16302        let build = |path: &std::path::Path| {
16303            let mut b = FileBuilder::new();
16304            b.create_dataset("d")
16305                .with_i32_data(&(0..1000).collect::<Vec<i32>>())
16306                .with_shape(&[1000]);
16307            b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16308                .with_file_space_page_size(PAGE);
16309            b.write(path).unwrap();
16310        };
16311
16312        // Unknown tail type: pad, but record nothing.
16313        let unknown = dir.path().join("unknown_tail.h5");
16314        build(&unknown);
16315        let mut s = WriteEngine::open_with_locking(&unknown, FileLocking::Enabled).unwrap();
16316        s.append(&[0u8; 100]).unwrap(); // leaves the image non-page-aligned
16317        assert!(s.paged.as_ref().unwrap().last.is_none());
16318        s.pad_to_page().unwrap();
16319        let pg = s.paged.as_ref().unwrap();
16320        assert_eq!(s.image.len() % PAGE, 0, "the file is padded to a page");
16321        assert!(
16322            pg.meta_pad.is_empty() && pg.raw_pad.is_empty(),
16323            "padding a tail page of unknown type must claim no page type"
16324        );
16325        drop(s);
16326
16327        // The control: a known tail type is recorded, so the test above is about
16328        // the unknown case and not about padding never being tracked at all.
16329        let known = dir.path().join("known_tail.h5");
16330        build(&known);
16331        let mut s = WriteEngine::open_with_locking(&known, FileLocking::Enabled).unwrap();
16332        s.begin_page(PageType::Meta).unwrap();
16333        s.append(&[0u8; 100]).unwrap();
16334        s.pad_to_page().unwrap();
16335        let pg = s.paged.as_ref().unwrap();
16336        assert_eq!(
16337            pg.meta_pad.len(),
16338            1,
16339            "a known metadata tail records its padding as metadata free space"
16340        );
16341        assert!(pg.raw_pad.is_empty());
16342    }
16343
16344    /// The reference C library's generic-large manager holds free space of *both*
16345    /// page types, so a section from it is only reusable when it covers whole
16346    /// aligned pages.
16347    ///
16348    /// `H5F_MEM_PAGE_GENERIC` is aliased to `H5F_MEM_PAGE_LARGE_SUPER` and
16349    /// commented in the C library's own header as *"large-sized generic: meta and
16350    /// raw"*. Under paged aggregation on a contiguous-address driver — the default
16351    /// — `H5MF__alloc_to_fs_type` sends **every** allocation of a page or more
16352    /// there whatever its type, and `H5MF__alloc_pagefs` then records that
16353    /// allocation's page-alignment tail as a free section in the same manager. The
16354    /// tail is smaller than a page and sits in a page whose earlier bytes are the
16355    /// live object, so filing it as raw and handing it to chunk data would put raw
16356    /// bytes inside a metadata page.
16357    ///
16358    /// This measures that the C library really does write such a section — the
16359    /// assertion is worthless if it does not — and then pins that this engine
16360    /// keeps it out of reach: it is recorded, so a rewrite gives it back to the
16361    /// manager it came from, but never offered to an allocation.
16362    #[test]
16363    // The file is a committed one the reference C library wrote, see
16364    // `crates/crosscheck/tests/main/c_test_data.rs`.
16365    fn a_generic_large_section_is_only_reusable_as_whole_pages() {
16366        use tempfile::tempdir;
16367
16368        const PAGE: u64 = 512;
16369        let dir = tempdir().unwrap();
16370        let path = crate::test_data::copy("c/generic_large.h5", dir.path());
16371
16372        // The premise: at least one section in the generic-large manager (slot 6)
16373        // is a sub-page fragment. Read the managers slot by slot, since the
16374        // flattened public view cannot say which manager a section came from.
16375        let opened = crate::reader::File::open(&path).unwrap();
16376        let info = opened.file_space_info().expect("a persisting file").clone();
16377        drop(opened);
16378        assert_eq!(info.page_size, PAGE);
16379        let bytes = std::fs::read(&path).unwrap();
16380        let src = crate::source::BytesSource::new(bytes.as_slice());
16381        let slot6 = info.manager_addrs[6];
16382        assert_ne!(slot6, UNDEF, "the C library populated the large manager");
16383        let (sections, _) = free_space_manager::read_persisted_sections_source(
16384            &src,
16385            &[slot6],
16386            BaseAddress::ZERO,
16387            8,
16388        )
16389        .unwrap();
16390        let fragments: Vec<(u64, u64)> = sections
16391            .iter()
16392            .map(|s| (s.addr.get(), s.size))
16393            .filter(|&(addr, size)| addr % PAGE != 0 || size % PAGE != 0)
16394            .collect();
16395        assert!(
16396            !fragments.is_empty(),
16397            "the premise of this test: the C library files sub-page fragments in \
16398             its generic-large manager, but this file has none ({sections:?})"
16399        );
16400
16401        // The behavior: every such fragment is recorded but unreachable, and only
16402        // whole aligned pages from that manager are placeable.
16403        let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16404        let pg = s.paged.as_ref().expect("a paged file installs paged state");
16405        let unclassified = pg.unclassified.sections();
16406        let reusable = pg.reusable_sections();
16407        for &(addr, size) in &fragments {
16408            assert!(
16409                unclassified.contains(&(addr, size)),
16410                "fragment ({addr}, {size}) must be recorded as unclassified, not lost"
16411            );
16412            assert!(
16413                !reusable
16414                    .iter()
16415                    .any(|&(a, l)| a < addr + size && addr < a + l),
16416                "fragment ({addr}, {size}) must not be offered to any allocation: {reusable:?}"
16417            );
16418        }
16419    }
16420
16421    /// A paged allocation may be served from the other page type's free list only
16422    /// where that space covers whole free pages — never from a hole in a page the
16423    /// other type still occupies.
16424    ///
16425    /// This is the allocation-side half of the rule
16426    /// [`paged_open_seeds_each_manager_by_slot`] pins on the seeding side, and it
16427    /// is just as invisible from the outside: handing a raw allocation a hole in a
16428    /// live metadata page puts chunk bytes inside that page, which every reader —
16429    /// this crate's and the reference library's — resolves correctly, since the
16430    /// file's addresses all still point where they should. Only the paging
16431    /// degrades. So assert the choice directly, in both directions, with a
16432    /// same-type control proving the free region really was big enough to be
16433    /// taken, and the whole-page case proving the exception is reached rather than
16434    /// merely permitted.
16435    #[test]
16436    fn a_paged_allocation_only_crosses_page_types_over_whole_free_pages() {
16437        use crate::writer::FileBuilder;
16438        use tempfile::tempdir;
16439
16440        const PAGE: u64 = 4096;
16441
16442        /// A paged session whose free lists hold exactly one region each, of the
16443        /// page types named. Both regions are interior, so an allocation that
16444        /// takes one is distinguishable from one that appends.
16445        fn session(
16446            path: &std::path::Path,
16447            meta: Option<(u64, u64)>,
16448            raw: Option<(u64, u64)>,
16449        ) -> WriteEngine {
16450            let mut b = FileBuilder::new();
16451            b.create_dataset("d")
16452                .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16453                .with_shape(&[4000]);
16454            b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16455                .with_file_space_page_size(PAGE);
16456            b.write(path).unwrap();
16457            let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
16458            let pg = s.paged.as_mut().expect("a paged file installs paged state");
16459            pg.meta = FreeList::new();
16460            pg.raw = FreeList::new();
16461            if let Some((addr, len)) = meta {
16462                pg.meta.free(addr, len);
16463            }
16464            if let Some((addr, len)) = raw {
16465                pg.raw.free(addr, len);
16466            }
16467            s
16468        }
16469
16470        let dir = tempdir().unwrap();
16471        // Two interior regions well inside the file the builder wrote (16 KiB of
16472        // raw data alone), each a *fragment* of a page whose other bytes are live,
16473        // so either could physically hold the request and only the page-type rule
16474        // decides. Deliberately not page-aligned and not a whole page: that is the
16475        // case the rule forbids outright.
16476        let hole_a = (PAGE + 512, 2048);
16477        let hole_b = (2 * PAGE + 512, 2048);
16478
16479        // Raw request, only a metadata fragment free: appends rather than mixing
16480        // the page.
16481        let mut s = session(&dir.path().join("a.h5"), Some(hole_a), None);
16482        assert!(
16483            matches!(
16484                s.reserve(1024, PageType::Raw).unwrap(),
16485                Placement::Appended { .. }
16486            ),
16487            "a raw allocation must not be served out of a live metadata page"
16488        );
16489        drop(s);
16490
16491        // Metadata request, only a raw fragment free: likewise.
16492        let mut s = session(&dir.path().join("b.h5"), None, Some(hole_b));
16493        assert!(
16494            matches!(
16495                s.reserve(1024, PageType::Meta).unwrap(),
16496                Placement::Appended { .. }
16497            ),
16498            "a metadata allocation must not be served out of a live raw page"
16499        );
16500        drop(s);
16501
16502        // The control: with the matching type free, the same request is reused —
16503        // so the two refusals above are the page-type rule, not a size failure.
16504        let mut s = session(&dir.path().join("c.h5"), Some(hole_a), Some(hole_b));
16505        assert!(
16506            matches!(
16507                s.reserve(1024, PageType::Raw).unwrap(),
16508                Placement::Reused { addr, .. } if addr == hole_b.0
16509            ),
16510            "a raw allocation takes the raw hole"
16511        );
16512        assert!(
16513            matches!(
16514                s.reserve(1024, PageType::Meta).unwrap(),
16515                Placement::Reused { addr, .. } if addr == hole_a.0
16516            ),
16517            "a metadata allocation takes the metadata hole"
16518        );
16519        drop(s);
16520
16521        // The exception, and the whole reason the file stops growing (issue #286):
16522        // a page with *nothing* in it holds no type to contradict, so either kind
16523        // may open it. The page is claimed whole and what the request does not use
16524        // becomes free space of the claiming type.
16525        let mut s = session(&dir.path().join("d.h5"), Some((PAGE, PAGE)), None);
16526        assert!(
16527            matches!(
16528                s.reserve(1024, PageType::Raw).unwrap(),
16529                Placement::Reused { addr, .. } if addr == PAGE
16530            ),
16531            "a raw allocation may open an empty metadata page"
16532        );
16533        let pg = s.paged.as_ref().expect("still paged");
16534        assert_eq!(
16535            pg.raw.sections(),
16536            [(PAGE + 1024, PAGE - 1024)],
16537            "the rest of the claimed page is free space of the claiming type"
16538        );
16539        assert!(
16540            pg.meta.sections().is_empty(),
16541            "the page left the list it was claimed from"
16542        );
16543        drop(s);
16544
16545        // And the claim starts at the empty page, not at the free run's own start.
16546        // A run that spans from mid-page into whole pages beyond it is the common
16547        // shape — the tail of a live page, then pages nothing is left in — and
16548        // taking it from the front would put the request in the live page.
16549        let mut s = session(
16550            &dir.path().join("e.h5"),
16551            None,
16552            Some((PAGE + 512, 3 * PAGE - 512)),
16553        );
16554        assert!(
16555            matches!(
16556                s.reserve(1024, PageType::Meta).unwrap(),
16557                Placement::Reused { addr, .. } if addr == 2 * PAGE
16558            ),
16559            "the claim must begin at the empty page, not at the run's start"
16560        );
16561        // Both edges the claim leaves behind survive it: they are ordinary free
16562        // space of the type that already held them, and dropping either is the same
16563        // silent leak this change exists to remove.
16564        let pg = s.paged.as_ref().expect("still paged");
16565        assert_eq!(
16566            pg.raw.sections(),
16567            [(PAGE + 512, PAGE - 512), (3 * PAGE, PAGE)],
16568            "the fragment below the claimed page and the page above it both stay free"
16569        );
16570        assert_eq!(
16571            pg.meta.sections(),
16572            [(2 * PAGE + 1024, PAGE - 1024)],
16573            "the rest of the claimed page is free space of the claiming type"
16574        );
16575    }
16576
16577    /// Every byte a paged session could still hand out, across both page types.
16578    fn free_total(s: &WriteEngine) -> u64 {
16579        let pg = s.paged.as_ref().expect("a paged session");
16580        pg.meta
16581            .sections()
16582            .into_iter()
16583            .chain(pg.raw.sections())
16584            .map(|(_, len)| len)
16585            .sum()
16586    }
16587
16588    /// A paged commit's tail removes from the free lists exactly the bytes it goes
16589    /// on to write — no more, and nothing at all when it declines to place itself.
16590    ///
16591    /// This is the invariant the whole change rests on, and the one an integration
16592    /// test cannot reach: the tail's length and the hole it lands in determine each
16593    /// other, so which settlement the arithmetic reaches depends on the shape of
16594    /// the free list, and a file the writer builds only ever produces some of them.
16595    /// In particular, best fit prefers the *smallest* region that fits, so a hole
16596    /// whose length is exactly the length the tail proposed is the one it will
16597    /// choose — and consuming a region outright drops a section from the managers,
16598    /// making the plan come out *shorter* than the space just reserved for it. A
16599    /// tail that accepted that would retire the difference from the free list with
16600    /// nothing recording it, which is issue #286 again, a few bytes at a time.
16601    ///
16602    /// Hence a contiguous sweep of hole sizes rather than a chosen one: the length
16603    /// the tail proposes is a property of the file and its free list, not something
16604    /// this test should have to know, and the sweep is certain to cross it.
16605    #[test]
16606    fn a_paged_tail_takes_exactly_the_space_it_fills() {
16607        use crate::writer::FileBuilder;
16608        use tempfile::tempdir;
16609
16610        const PAGE: u64 = 4096;
16611        /// Any plausible extension length exercises the same arithmetic, and the
16612        /// invariant holds for every one of them; the tail's real length is settled
16613        /// by the commit, which is not what is under test here.
16614        const EXT_LEN: u64 = 100;
16615
16616        let dir = tempdir().unwrap();
16617        let path = dir.path().join("tail_exact.h5");
16618        let mut b = FileBuilder::new();
16619        b.create_dataset("d")
16620            .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16621            .with_shape(&[4000]);
16622        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16623            .with_file_space_page_size(PAGE);
16624        b.write(&path).unwrap();
16625        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16626        let os = s.superblock.offset_size;
16627
16628        let mut placed = 0usize;
16629        for hole in 120..420u64 {
16630            {
16631                let pg = s.paged.as_mut().expect("a paged file installs paged state");
16632                pg.meta = FreeList::new();
16633                pg.raw = FreeList::new();
16634                pg.unclassified = FreeList::new();
16635                pg.meta.free(PAGE, hole);
16636            }
16637            let free_before = free_total(&s);
16638            let layout = s.tail_layout(&[], &[], EXT_LEN, PAGE, os);
16639            let free_after = free_total(&s);
16640            match layout {
16641                Some((_, _, at, blocks_len, _)) => {
16642                    placed += 1;
16643                    assert_eq!(
16644                        free_before - free_after,
16645                        blocks_len,
16646                        "hole {hole}: the tail took {} bytes at {at} to write {blocks_len}",
16647                        free_before - free_after
16648                    );
16649                }
16650                None => assert_eq!(
16651                    free_after, free_before,
16652                    "hole {hole}: a tail that declines to place itself must hand back \
16653                     everything it tried"
16654                ),
16655            }
16656        }
16657        assert!(
16658            placed > 0,
16659            "the sweep must reach holes the tail can actually use, or it asserts \
16660             nothing about placement"
16661        );
16662    }
16663
16664    /// The shrink pass never grows the file: asked to rewrite the tail with
16665    /// nothing in the free list to put it in, it writes nothing at all
16666    /// (issue #418).
16667    ///
16668    /// That is the whole safety argument for running it. It exists to make the
16669    /// file smaller, and a tail it cannot place would otherwise go past
16670    /// end-of-file — a rewrite taken for the sake of a shorter file leaving a
16671    /// longer one. The second half of the test is what keeps the first half
16672    /// honest: under `TailPlacement::Anywhere` the same call on the same session
16673    /// does write a tail, so the fixture really did have one to write.
16674    #[test]
16675    fn a_reuse_only_tail_rewrite_that_cannot_place_itself_writes_nothing() {
16676        use crate::writer::FileBuilder;
16677        use tempfile::tempdir;
16678
16679        let dir = tempdir().unwrap();
16680        let path = dir.path().join("reuse_only.h5");
16681        let mut b = FileBuilder::new();
16682        b.create_dataset("d")
16683            .with_i32_data(&(0..400).collect::<Vec<i32>>())
16684            .with_shape(&[400]);
16685        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
16686        b.write(&path).unwrap();
16687        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16688        // No hole anywhere: the only place a tail could go is past end-of-file.
16689        s.free = FreeList::new();
16690        let root = s.superblock.root_group_address;
16691        let len_before = s.image.len();
16692        let ext_before = s.superblock.superblock_extension_address;
16693
16694        s.commit_persisting(root, Vec::new(), TailPlacement::ReuseOnly)
16695            .unwrap();
16696        assert_eq!(
16697            s.image.len(),
16698            len_before,
16699            "a reuse-only rewrite with nothing to reuse must leave the file alone"
16700        );
16701        assert_eq!(
16702            s.superblock.superblock_extension_address, ext_before,
16703            "and must not have published a new extension either"
16704        );
16705
16706        s.commit_persisting(root, Vec::new(), TailPlacement::Anywhere)
16707            .unwrap();
16708        assert!(
16709            s.image.len() > len_before,
16710            "the same rewrite that may append does grow the file, so the case \
16711             above was a tail declined rather than a tail of no size"
16712        );
16713    }
16714
16715    /// A flat commit's tail removes from the free list exactly the bytes its
16716    /// recorded extent covers — no more, and nothing at all when it declines to
16717    /// place itself.
16718    ///
16719    /// The flat tail accepts a plan *shorter* than its reservation where the paged
16720    /// one refuses ([`WriteEngine::flat_tail_layout`] says why), so this is the
16721    /// invariant that keeps those spare bytes from leaking: what the layout hands
16722    /// back as the tail's length is the whole reservation, which the commit records
16723    /// as the extent to free when the next one supersedes it. Returning the plan's
16724    /// length instead would retire the difference from the free list with nothing
16725    /// recording it — issue #286 again, a few bytes at a time.
16726    ///
16727    /// Swept over hole sizes rather than a chosen one, for the reason the paged
16728    /// sweep gives: the length the tail proposes is a property of the file and its
16729    /// free list, and best fit takes a hole of exactly that length outright, which
16730    /// drops a section from the managers and is what makes the plan come out
16731    /// shorter than the reservation in the first place.
16732    #[test]
16733    fn a_flat_tail_takes_exactly_the_extent_it_records() {
16734        use crate::writer::FileBuilder;
16735        use tempfile::tempdir;
16736
16737        /// Any plausible extension length exercises the same arithmetic; the real
16738        /// one is settled by the commit, which is not what is under test here.
16739        const EXT_LEN: u64 = 100;
16740        /// Any address does: the layout only ever asks the free list for a length.
16741        const HOLE_AT: u64 = 512;
16742
16743        let dir = tempdir().unwrap();
16744        let path = dir.path().join("flat_tail_exact.h5");
16745        let mut b = FileBuilder::new();
16746        b.create_dataset("d")
16747            .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16748            .with_shape(&[4000]);
16749        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
16750        b.write(&path).unwrap();
16751        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16752        let os = s.superblock.offset_size;
16753
16754        let free_total =
16755            |s: &WriteEngine| -> u64 { s.free.sections().into_iter().map(|(_, len)| len).sum() };
16756
16757        let (mut placed, mut declined, mut with_slack) = (0usize, 0usize, 0usize);
16758        for hole in 120..420u64 {
16759            s.free = FreeList::new();
16760            s.free.free(HOLE_AT, hole);
16761            let free_before = free_total(&s);
16762            let (post, at, tail_len, _) = s.flat_tail_layout(&[], &[], EXT_LEN, os);
16763            let free_after = free_total(&s);
16764            // The blocks the commit will write into the extent it was handed. A hole
16765            // consumed outright drops a section from the managers, so this comes out
16766            // shorter than the extent for some hole sizes and the difference is what
16767            // the extent has to cover.
16768            let written = EXT_LEN + file_fsm_blocks_len(&s.persisted_sections(&post), os);
16769            match at {
16770                Some(at) => {
16771                    placed += 1;
16772                    assert_eq!(
16773                        free_before - free_after,
16774                        tail_len,
16775                        "hole {hole}: the tail took {} bytes at {at} for an extent of \
16776                         {tail_len}",
16777                        free_before - free_after
16778                    );
16779                    if written < tail_len {
16780                        with_slack += 1;
16781                    }
16782                }
16783                None => {
16784                    declined += 1;
16785                    assert_eq!(
16786                        free_after, free_before,
16787                        "hole {hole}: a tail that declines to place itself must hand \
16788                         back everything it tried"
16789                    );
16790                    assert_eq!(
16791                        tail_len, written,
16792                        "hole {hole}: a tail with nowhere to go is appended, and the \
16793                         length it reports is what it will write there"
16794                    );
16795                }
16796            }
16797        }
16798        assert!(
16799            placed > 0,
16800            "the sweep must reach holes the tail can actually use, or it asserts \
16801             nothing about placement"
16802        );
16803        assert!(
16804            declined > 0,
16805            "the sweep must reach holes too small for the tail, or the rule about \
16806             handing back a failed attempt is never exercised"
16807        );
16808        assert!(
16809            with_slack > 0,
16810            "the sweep must reach a hole the tail does not fill exactly, or the \
16811             rule about spare bytes is never exercised"
16812        );
16813    }
16814
16815    /// A paged commit whose tail finds no free space to sit in opens a page for it
16816    /// — and hands the rest of that page back as free metadata.
16817    ///
16818    /// Every paged file this crate writes has holes in it from the start, so the
16819    /// suite's ordinary fixtures always reuse and this branch is never taken. It is
16820    /// reachable in the wild, by a file whose free space is all spoken for, and the
16821    /// page it opens is the same page a page-per-commit leak used to strand. Emptying
16822    /// the free lists by hand is what puts the file in that state deliberately.
16823    #[test]
16824    fn a_paged_tail_with_nowhere_to_go_opens_a_page_and_frees_the_rest() {
16825        use crate::writer::FileBuilder;
16826        use tempfile::tempdir;
16827
16828        const PAGE: u64 = 4096;
16829        let dir = tempdir().unwrap();
16830        let path = dir.path().join("tail_appends.h5");
16831        let mut b = FileBuilder::new();
16832        b.create_dataset("d")
16833            .with_i32_data(&(0..4000).collect::<Vec<i32>>())
16834            .with_shape(&[4000]);
16835        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16836            .with_file_space_page_size(PAGE);
16837        b.write(&path).unwrap();
16838        let before = std::fs::metadata(&path).unwrap().len();
16839
16840        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16841        {
16842            let pg = s.paged.as_mut().expect("a paged file installs paged state");
16843            pg.meta = FreeList::new();
16844            pg.raw = FreeList::new();
16845            pg.unclassified = FreeList::new();
16846        }
16847        // A commit with nothing staged returns without writing, so give it one
16848        // small metadata object to place. It appends for want of anywhere else,
16849        // and the tail follows it into the same page.
16850        s.create_group(&ObjectPathBuf::parse("g")).unwrap();
16851        s.commit().unwrap();
16852
16853        let after = std::fs::metadata(&path).unwrap().len();
16854        assert!(
16855            after > before && (after - before) % PAGE == 0,
16856            "the commit opened whole pages ({before} -> {after})"
16857        );
16858        // What the tail did not fill of the page it opened is metadata this session
16859        // can still spend. Losing it is how the file used to give up most of a page
16860        // on every commit; the managers just written cannot record it, so the
16861        // session carries it to the next commit.
16862        let pg = s.paged.as_ref().expect("still paged");
16863        let (addr, len) = pg
16864            .meta
16865            .sections()
16866            .into_iter()
16867            .find(|&(addr, len)| addr + len == after)
16868            .expect("the page the tail opened leaves a free remainder at end-of-file");
16869        assert!(
16870            len > 0 && len < PAGE && addr >= before,
16871            "the tail's blocks take the front of the page it opened and the rest is \
16872             free ({len} of {PAGE} at {addr}, file {before} -> {after})"
16873        );
16874    }
16875
16876    /// Deleting a chunked dataset from a paged file must not record its freed
16877    /// chunk index in the *metadata* manager.
16878    ///
16879    /// Every writer in this crate emits a chunk index in the same run as the chunk
16880    /// data it indexes, so the index sits in a raw page. Recording it as metadata
16881    /// would advertise a metadata-sized hole inside a page that still holds another
16882    /// dataset's live chunk data, and the reference library placing metadata there
16883    /// would mix the page — the one thing a paged file forbids. Page homogeneity is
16884    /// preserved on disk either way, so the mis-filing is invisible to a signature
16885    /// scan of the file and to the C library; the manager a section lands in has to
16886    /// be checked directly.
16887    #[test]
16888    fn deleted_chunk_index_is_freed_into_a_raw_manager() {
16889        use crate::writer::FileBuilder;
16890        use tempfile::tempdir;
16891
16892        let dir = tempdir().unwrap();
16893        let path = dir.path().join("paged_chunk_index_free.h5");
16894        let page = 4096u64;
16895        let mut b = FileBuilder::new();
16896        for name in ["drop", "keep"] {
16897            b.create_dataset(name)
16898                .with_i32_data(&(0..200).collect::<Vec<i32>>())
16899                .with_shape(&[200])
16900                .with_chunks(&[50]);
16901        }
16902        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16903            .with_file_space_page_size(page);
16904        b.write(&path).unwrap();
16905
16906        {
16907            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16908            s.delete(&ObjectPathBuf::parse("/drop")).unwrap();
16909            s.commit().unwrap();
16910        }
16911
16912        // Pages still occupied by the surviving dataset's chunk data. Read with the
16913        // session closed: its lock is mandatory on Windows.
16914        let live_raw_pages: Vec<u64> = {
16915            let f = crate::reader::File::open(&path).unwrap();
16916            let ds = f.dataset("keep").unwrap();
16917            let mut pages: Vec<u64> = ds
16918                .chunks()
16919                .unwrap()
16920                .iter()
16921                .filter(|c| c.storage_size > 0)
16922                .flat_map(|c| (c.address / page)..=((c.address + c.storage_size - 1) / page))
16923                .collect();
16924            pages.sort_unstable();
16925            pages.dedup();
16926            pages
16927        };
16928        assert!(!live_raw_pages.is_empty(), "expected live raw pages");
16929
16930        let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16931        let pg = s.paged.as_ref().expect("a paged file installs paged state");
16932        for (addr, len) in pg.meta.sections() {
16933            for p in (addr / page)..=((addr + len - 1) / page) {
16934                assert!(
16935                    !live_raw_pages.contains(&p),
16936                    "metadata free section ({addr}, {len}) sits in page {p}, which still \
16937                     holds live raw chunk data"
16938                );
16939            }
16940        }
16941        // The index really was reclaimed somewhere, so this is not vacuous.
16942        let reclaimed: u64 = pg.reusable_sections().iter().map(|&(_, l)| l).sum();
16943        assert!(reclaimed > 0, "the delete reclaimed nothing");
16944    }
16945
16946    /// A paged commit that fails partway must leave the session's free lists
16947    /// exactly as it found them.
16948    ///
16949    /// Everything the commit gathers to free is still *live* until the superblock
16950    /// repoint: the objects occupying those regions are reachable from the old
16951    /// root, which a failed commit never replaces. A session that recorded them as
16952    /// free anyway would hand them out on the next commit, and the file would lose
16953    /// data with no error anywhere — in a release build, where the free list's
16954    /// double-free `debug_assert` is compiled out, silently.
16955    ///
16956    /// The failure is induced by pointing the superblock extension at a byte range
16957    /// that is not an object header, which fails the extension rewrite immediately
16958    /// after the regions are gathered.
16959    #[test]
16960    fn failed_paged_commit_leaves_the_free_lists_untouched() {
16961        use crate::writer::FileBuilder;
16962        use tempfile::tempdir;
16963
16964        let dir = tempdir().unwrap();
16965        let path = dir.path().join("paged_failed_commit.h5");
16966        let mut b = FileBuilder::new();
16967        b.create_dataset("keep")
16968            .with_i32_data(&(0..200).collect::<Vec<i32>>())
16969            .with_shape(&[200]);
16970        b.create_dataset("drop")
16971            .with_i32_data(&(0..200).collect::<Vec<i32>>())
16972            .with_shape(&[200]);
16973        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
16974            .with_file_space_page_size(4096);
16975        b.write(&path).unwrap();
16976
16977        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
16978        let before = s.space_accounting().reusable_free_space;
16979
16980        // Break the extension so the commit fails *after* it has gathered the
16981        // regions `drop` vacates and *before* the superblock repoint.
16982        let good_ext = s.superblock.superblock_extension_address;
16983        s.superblock.superblock_extension_address = Some(0);
16984        s.delete(&ObjectPathBuf::parse("/drop")).unwrap();
16985        assert!(
16986            s.commit().is_err(),
16987            "a commit with an unreadable extension must fail"
16988        );
16989
16990        assert_eq!(
16991            s.space_accounting().reusable_free_space,
16992            before,
16993            "a failed commit must not record still-live regions as free"
16994        );
16995
16996        // The session stays usable: repair the extension and commit for real. If
16997        // the failed commit had folded its regions in, this second commit would
16998        // double-free them (a debug assertion) and publish `keep`'s live extent.
16999        s.superblock.superblock_extension_address = good_ext;
17000        s.delete(&ObjectPathBuf::parse("/drop")).unwrap();
17001        s.commit()
17002            .expect("the session is usable after a failed commit");
17003
17004        // Release the session's exclusive OS lock before reading the file back.
17005        // Those locks are mandatory on Windows, so a `File::open` overlapping the
17006        // session fails outright there (advisory locks elsewhere would allow it).
17007        drop(s);
17008
17009        let f = crate::reader::File::open(&path).unwrap();
17010        let kept = f.dataset("keep").unwrap().read_i32().unwrap();
17011        assert_eq!(kept, (0..200).collect::<Vec<i32>>(), "keep survives intact");
17012        let freed: u64 = f.persisted_free_space().iter().map(|&(_, l)| l).sum();
17013        let live_end = f.file_size();
17014        assert!(
17015            freed < live_end,
17016            "the recorded free space cannot cover the whole file"
17017        );
17018    }
17019
17020    /// A commit that fails partway must roll back the heap-collection
17021    /// provenance along with the free lists (issue #321).
17022    ///
17023    /// `resolve_overwrite_bytes` places a collection and records it in the
17024    /// *apply* phase. A commit that then fails before its repoint gives that
17025    /// space back to the free list — and used to keep the record, which is the
17026    /// one piece of engine state naming file addresses that the rollback missed.
17027    /// The next overwrite of that path would free the region a *later* commit
17028    /// had since been handed, so the file's live variable-length data would sit
17029    /// in space the allocator considers free, and the next unrelated write would
17030    /// land on it.
17031    ///
17032    /// Induced the same way as
17033    /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17034    /// superblock extension fails the commit after the apply phase has run.
17035    #[test]
17036    fn a_failed_commit_rolls_back_the_heap_collection_provenance() {
17037        use crate::writer::FileBuilder;
17038        use tempfile::tempdir;
17039
17040        let dir = tempdir().unwrap();
17041        let path = dir.path().join("failed_commit_vl_provenance.h5");
17042        let mut b = FileBuilder::new();
17043        b.create_dataset("labels")
17044            .with_vlen_strings(&["seed-one", "seed-two"]);
17045        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17046        b.write(&path).unwrap();
17047
17048        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17049
17050        // One good round, so there is a record to roll back.
17051        s.stage_dataset_write(&ObjectPathBuf::parse("/labels"), {
17052            let mut db = crate::type_builders::DatasetBuilder::new("");
17053            db.with_vlen_strings(&["round-one-aaaa", "round-one-bbbb"]);
17054            db
17055        })
17056        .unwrap();
17057        s.commit().unwrap();
17058        let recorded = s.vl_overwrite_heaps.clone();
17059        assert!(!recorded.is_empty(), "the good round recorded nothing");
17060
17061        // Break the extension so the next commit fails after the apply phase.
17062        let good_ext = s.superblock.superblock_extension_address;
17063        s.superblock.superblock_extension_address = Some(0);
17064        s.stage_dataset_write(&ObjectPathBuf::parse("/labels"), {
17065            let mut db = crate::type_builders::DatasetBuilder::new("");
17066            db.with_vlen_strings(&["round-two-aaaa", "round-two-bbbb"]);
17067            db
17068        })
17069        .unwrap();
17070        assert!(
17071            s.commit().is_err(),
17072            "a commit with an unreadable extension must fail"
17073        );
17074
17075        assert_eq!(
17076            s.vl_overwrite_heaps, recorded,
17077            "a failed commit must not leave a record naming space it gave back"
17078        );
17079
17080        // The session stays usable, and the collections the rolled-back record
17081        // names are still the live ones.
17082        s.superblock.superblock_extension_address = good_ext;
17083        s.stage_dataset_write(&ObjectPathBuf::parse("/labels"), {
17084            let mut db = crate::type_builders::DatasetBuilder::new("");
17085            db.with_vlen_strings(&["round-three-a", "round-three-b"]);
17086            db
17087        })
17088        .unwrap();
17089        s.commit()
17090            .expect("the session is usable after a failed commit");
17091        drop(s);
17092
17093        let f = crate::reader::File::open(&path).unwrap();
17094        assert_eq!(
17095            f.dataset("labels").unwrap().read_string().unwrap(),
17096            vec!["round-three-a".to_string(), "round-three-b".to_string()]
17097        );
17098    }
17099
17100    /// A failed commit must not leave the free list offering a span the image
17101    /// still points into (issue #321).
17102    ///
17103    /// Resolving a staged variable-length overwrite *allocates* — a global heap
17104    /// collection, drawn from a freed region where one fits. If the overwrite
17105    /// were applied in place, that allocation's address would be written into a
17106    /// data block the current root already reaches; a commit failing after the
17107    /// write and before its repoint then hands the span back to the free list,
17108    /// while the image still names it. The next commit places something else
17109    /// there and the dataset reads that object's heap bytes, with every checksum
17110    /// in the file intact.
17111    ///
17112    /// A relocating overwrite has no such window — its new block is reachable
17113    /// from nothing until the repoint — which is why `prepare_write` refuses to
17114    /// plan a staged variable-length overwrite in place at all. This is that
17115    /// rule's test.
17116    ///
17117    /// The freed hole has to exist *first*, or the failed commit's collection is
17118    /// appended past end-of-file where nothing reuses it and the bug hides. The
17119    /// filler datasets each need a metadata span of the size the rolled-back one
17120    /// occupied, which is what draws on it.
17121    #[test]
17122    fn a_failed_commit_leaves_no_reusable_span_the_image_names() {
17123        use crate::writer::FileBuilder;
17124        use tempfile::tempdir;
17125
17126        let dir = tempdir().unwrap();
17127        let path = dir.path().join("failed_commit_live_span.h5");
17128        let mut b = FileBuilder::new();
17129        b.create_dataset("labels")
17130            .with_vlen_strings(&["seed-one", "seed-two"]);
17131        b.create_dataset("big").with_u8_data(&vec![0x5A; 40960]);
17132        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17133        b.write(&path).unwrap();
17134
17135        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17136        // The hole the collections below are placed into by reuse.
17137        s.delete(&ObjectPathBuf::parse("/big")).unwrap();
17138        s.commit().unwrap();
17139
17140        let round_one = ["round-one-aaaa", "round-one-bbbb"];
17141        s.stage_dataset_write(&ObjectPathBuf::parse("/labels"), {
17142            let mut db = crate::type_builders::DatasetBuilder::new("");
17143            db.with_vlen_strings(&round_one);
17144            db
17145        })
17146        .unwrap();
17147        s.commit().unwrap();
17148
17149        // Fail a second overwrite after its apply phase has placed a collection.
17150        let good_ext = s.superblock.superblock_extension_address;
17151        s.superblock.superblock_extension_address = Some(0);
17152        s.stage_dataset_write(&ObjectPathBuf::parse("/labels"), {
17153            let mut db = crate::type_builders::DatasetBuilder::new("");
17154            db.with_vlen_strings(&["round-two-aaaa", "round-two-bbbb"]);
17155            db
17156        })
17157        .unwrap();
17158        assert!(
17159            s.commit().is_err(),
17160            "a commit with an unreadable extension must fail"
17161        );
17162        s.superblock.superblock_extension_address = good_ext;
17163
17164        // Unrelated commits that each want a metadata span of the same size.
17165        // Whatever the failed attempt gave back, these are what would draw on it.
17166        for i in 0..4 {
17167            s.stage_created_dataset(&ObjectPathBuf::parse(&format!("/filler{i}")), {
17168                let mut db = crate::type_builders::DatasetBuilder::new("");
17169                db.with_vlen_strings(&["XXXXXXXXXXXXXXXXXXXX", "YYYYYYYYYYYYYYYYYYYY"]);
17170                db
17171            })
17172            .unwrap();
17173            s.commit().unwrap();
17174        }
17175
17176        // Read only once the session has released its lock on the file: those
17177        // locks are mandatory on Windows, so a `File::open` overlapping the
17178        // session fails outright there where advisory locks elsewhere allow it.
17179        drop(s);
17180
17181        let f = crate::reader::File::open(&path).unwrap();
17182        let got = f.dataset("labels").unwrap().read_string();
17183        assert!(
17184            got.as_ref()
17185                .is_ok_and(|v| v.iter().map(String::as_str).eq(round_one)),
17186            "/labels reads another dataset's heap objects: {got:?}"
17187        );
17188    }
17189
17190    /// A commit that writes into reused free space and then dies must leave the
17191    /// file exactly as it found it.
17192    ///
17193    /// This is the safety argument for reuse, stated as a test: a commit may
17194    /// overwrite a freed region *before* the superblock repoint precisely because
17195    /// nothing reachable from the on-disk root lives there any more, so an attempt
17196    /// that never reaches the repoint is invisible. A chunked dataset is the case
17197    /// worth pinning — it is the largest thing a commit places, so it overwrites
17198    /// the most, and it is the one that used only to append.
17199    ///
17200    /// The failure is induced the same way as
17201    /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17202    /// superblock extension, which the persisting tail hits *after* the apply loop
17203    /// has written every object.
17204    #[test]
17205    fn a_failed_commit_that_reused_free_space_leaves_the_file_intact() {
17206        use crate::writer::FileBuilder;
17207        use tempfile::tempdir;
17208
17209        let dir = tempdir().unwrap();
17210        let path = dir.path().join("reuse_failed_commit.h5");
17211        let victim: Vec<f64> = (0..4096).map(|i| (i % 13) as f64).collect();
17212        let ceiling: Vec<i32> = (0..500).collect();
17213        let mut b = FileBuilder::new();
17214        b.create_dataset("keep").with_i32_data(&[1, 2, 3]);
17215        b.create_dataset("victim")
17216            .with_f64_data(&victim)
17217            .with_shape(&[4096])
17218            .with_chunks(&[512]);
17219        // Above the victim, so the delete leaves an interior hole and the reuse
17220        // has live bytes on both sides of what it overwrites.
17221        b.create_dataset("ceiling")
17222            .with_i32_data(&ceiling)
17223            .with_shape(&[500]);
17224        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
17225        b.write(&path).unwrap();
17226
17227        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17228        s.delete(&ObjectPathBuf::parse("/victim")).unwrap();
17229        s.commit().unwrap();
17230        let free_before = s.space_accounting().reusable_free_space;
17231        let len_before = std::fs::metadata(&path).unwrap().len();
17232        assert!(
17233            free_before.iter().any(|&(_, l)| l > 4096 * 8 / 2),
17234            "the deleted chunked dataset left a hole worth reusing: {free_before:?}"
17235        );
17236
17237        // Break the extension so the commit fails in its persisting tail, after
17238        // the apply loop has written the new dataset into that hole.
17239        s.superblock.superblock_extension_address = Some(0);
17240        let mut db = crate::type_builders::DatasetBuilder::new("fresh");
17241        db.with_f64_data(&vec![7.5f64; 4096])
17242            .with_shape(&[4096])
17243            .with_chunks(&[512]);
17244        s.stage_created_dataset(&ObjectPathBuf::parse("/fresh"), db)
17245            .unwrap();
17246        assert!(
17247            s.commit().is_err(),
17248            "a commit with an unreadable extension must fail"
17249        );
17250        assert_eq!(
17251            s.space_accounting().reusable_free_space,
17252            free_before,
17253            "the failed commit gives back the region it drew from"
17254        );
17255        // Release the session's exclusive OS lock before reading the file back;
17256        // those locks are mandatory on Windows.
17257        drop(s);
17258
17259        // The file still describes the tree the last *successful* commit left: the
17260        // survivors read exactly, the half-written dataset is not linked, and the
17261        // superblock's end-of-file still matches the file.
17262        assert_eq!(std::fs::metadata(&path).unwrap().len(), len_before);
17263        let f = crate::reader::File::open(&path).unwrap();
17264        assert_eq!(f.file_size(), len_before);
17265        assert_eq!(
17266            f.dataset("keep").unwrap().read_i32().unwrap(),
17267            vec![1, 2, 3]
17268        );
17269        assert_eq!(f.dataset("ceiling").unwrap().read_i32().unwrap(), ceiling);
17270        assert!(f.dataset("victim").is_err());
17271        assert!(f.dataset("fresh").is_err());
17272    }
17273
17274    /// A refused commit leaves every dataset reading what it read before —
17275    /// including the one whose values it had already written over (issue #344).
17276    ///
17277    /// Everything else a commit places lands where nothing reaches it until the
17278    /// superblock is repointed, so an attempt that stops short of the repoint is
17279    /// invisible. A same-length value overwrite is the exception: it writes
17280    /// straight over the data block the *current* root already reaches, so there
17281    /// is no repoint to withhold and the write is live the moment it lands. Both
17282    /// halves of one refused batch are asserted here, because the defect was
17283    /// that they disagreed: the overwrite survived and the object creation
17284    /// beside it did not.
17285    ///
17286    /// Closing the session is enough to expose it — no later commit is needed,
17287    /// since the bytes are in the file already and write gathering only delays
17288    /// when they reach the disk.
17289    ///
17290    /// A second staged edit is what keeps this on the full commit path; a batch
17291    /// of nothing but same-length overwrites takes the fast path, which
17292    /// `a_failed_fast_path_commit_puts_back_the_value_it_overwrote` covers. The
17293    /// failure is induced the same way as
17294    /// [`failed_paged_commit_leaves_the_free_lists_untouched`]: an unreadable
17295    /// superblock extension, which fails the commit in its tail, after the apply
17296    /// phase has done the in-place write.
17297    #[test]
17298    fn a_failed_commit_puts_back_the_value_it_overwrote() {
17299        use crate::writer::FileBuilder;
17300        use tempfile::tempdir;
17301
17302        let dir = tempdir().unwrap();
17303        let path = dir.path().join("inplace_partial.h5");
17304        let mut b = FileBuilder::new();
17305        b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
17306        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
17307        b.write(&path).unwrap();
17308
17309        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17310
17311        let good_ext = s.superblock.superblock_extension_address;
17312        s.superblock.superblock_extension_address = Some(0);
17313        s.stage_dataset_write(&ObjectPathBuf::parse("/nums"), {
17314            let mut db = crate::type_builders::DatasetBuilder::new("");
17315            db.with_i32_data(&[9, 9, 9]);
17316            db
17317        })
17318        .unwrap();
17319        s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
17320            let mut db = crate::type_builders::DatasetBuilder::new("");
17321            db.with_i32_data(&[42]);
17322            db
17323        })
17324        .unwrap();
17325        let refused = s.commit();
17326        assert!(
17327            matches!(refused, Err(Error::EditUnsupported(_))),
17328            "the refusal, not a rollback failure: {refused:?}"
17329        );
17330
17331        // The session stays usable, and its next commit is not carrying any of
17332        // the refused batch.
17333        s.superblock.superblock_extension_address = good_ext;
17334        s.stage_created_dataset(&ObjectPathBuf::parse("/later"), {
17335            let mut db = crate::type_builders::DatasetBuilder::new("");
17336            db.with_i32_data(&[7]);
17337            db
17338        })
17339        .unwrap();
17340        s.commit()
17341            .expect("the session is usable after a failed commit");
17342
17343        // Release the session's exclusive OS lock before reading the file back;
17344        // those locks are mandatory on Windows.
17345        drop(s);
17346
17347        let f = crate::reader::File::open(&path).unwrap();
17348        assert_eq!(
17349            f.dataset("nums").unwrap().read_i32().unwrap(),
17350            vec![1, 2, 3],
17351            "the refused batch's value overwrite is in the file"
17352        );
17353        assert!(
17354            f.dataset("extra").is_err(),
17355            "/extra was correctly discarded"
17356        );
17357        assert_eq!(f.dataset("later").unwrap().read_i32().unwrap(), vec![7]);
17358    }
17359
17360    /// The same guarantee on the commit fast path, where a batch of nothing but
17361    /// same-length overwrites skips the header rebuild and the superblock flip
17362    /// entirely (issue #344).
17363    ///
17364    /// That path has no tail to fail in, so the only failure it can suffer is
17365    /// one of its own writes — which is also the one failure the rollback cannot
17366    /// fully repair, since putting the prior bytes back means writing to the
17367    /// address that just refused them. Both outcomes are pinned here: the
17368    /// overwrite that *did* land is put back, the one that could not be is
17369    /// reported as [`Error::CommitPartiallyApplied`] rather than as a plain I/O
17370    /// error, and the values in the file are exactly what those two statements
17371    /// predict.
17372    ///
17373    /// `TornWriteImage` supplies the failure at a byte range rather than at a
17374    /// call ordinal, so the test names the block it means (`victim`'s) instead
17375    /// of depending on how many writes a commit happens to issue.
17376    ///
17377    /// `kept` is staged first on purpose. Staged writes are applied in the order
17378    /// they were staged (`StagedEdits::writes` is a `Vec`), so it is the entry
17379    /// already in the journal when `victim` fails — and restoring it is what
17380    /// shows the rollback carrying on past a failure rather than giving up at
17381    /// the first one. Stage them the other way round and that half goes quiet.
17382    #[test]
17383    fn a_failed_fast_path_commit_puts_back_the_value_it_overwrote() {
17384        use crate::writer::FileBuilder;
17385        use tempfile::tempdir;
17386
17387        let dir = tempdir().unwrap();
17388        let path = dir.path().join("inplace_fast_path_torn.h5");
17389        let mut b = FileBuilder::new();
17390        b.create_dataset("kept").with_i32_data(&[1, 2, 3]);
17391        b.create_dataset("victim").with_i32_data(&[4, 5, 6]);
17392        b.write(&path).unwrap();
17393
17394        // The data block the fake device refuses, named rather than guessed.
17395        let victim_block = {
17396            let f = crate::reader::File::open(&path).unwrap();
17397            match f.dataset("victim").unwrap().layout().unwrap() {
17398                crate::Layout::Contiguous {
17399                    address: Some(a),
17400                    size,
17401                } => a..a + size,
17402                other => panic!("expected a contiguous victim: {other:?}"),
17403            }
17404        };
17405
17406        let mut s = WriteEngine::open_torn_writes(&path, victim_block).unwrap();
17407        for (name, data) in [("/kept", [9, 9, 9]), ("/victim", [8, 8, 8])] {
17408            s.stage_dataset_write(&ObjectPathBuf::parse(name), {
17409                let mut db = crate::type_builders::DatasetBuilder::new("");
17410                db.with_i32_data(&data);
17411                db
17412            })
17413            .unwrap();
17414        }
17415        let refused = s.commit();
17416        assert!(
17417            matches!(refused, Err(Error::CommitPartiallyApplied { .. })),
17418            "a rollback that could not run is not an ordinary refusal: {refused:?}"
17419        );
17420        drop(s);
17421
17422        let f = crate::reader::File::open(&path).unwrap();
17423        assert_eq!(
17424            f.dataset("kept").unwrap().read_i32().unwrap(),
17425            vec![1, 2, 3],
17426            "the overwrite the rollback could reach is put back"
17427        );
17428        assert_eq!(
17429            f.dataset("victim").unwrap().read_i32().unwrap(),
17430            vec![8, 8, 8],
17431            "the overwrite it could not reach is the value the error is about"
17432        );
17433    }
17434
17435    /// Past the superblock repoint the overwrite is part of what the commit
17436    /// *published*, so a failure after that point must leave it standing —
17437    /// the same boundary [`FreeSnapshot`] draws for the free lists (issue #344).
17438    ///
17439    /// The window is real rather than theoretical: `repoint_stored_references`
17440    /// runs after the repoint and writes into live objects. A reference to the
17441    /// root group is what makes it run on any commit at all, since every commit
17442    /// rebuilds the root; the fake device then fails that write, so the commit
17443    /// returns an error having already committed.
17444    ///
17445    /// What the file holds afterwards is the new tree — the value overwrite
17446    /// included — with one stale reference. That is not a state a rollback can
17447    /// improve on: the root the reference would have to be re-pointed at is
17448    /// already the live one.
17449    #[test]
17450    fn a_commit_that_fails_past_its_repoint_keeps_the_value_it_published() {
17451        use crate::writer::FileBuilder;
17452        use tempfile::tempdir;
17453
17454        let dir = tempdir().unwrap();
17455        let path = dir.path().join("inplace_after_repoint.h5");
17456        let mut b = FileBuilder::new();
17457        b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
17458        b.create_dataset("refs").with_path_references(&[""]);
17459        b.write(&path).unwrap();
17460
17461        let refs_block = {
17462            let f = crate::reader::File::open(&path).unwrap();
17463            match f.dataset("refs").unwrap().layout().unwrap() {
17464                crate::Layout::Contiguous {
17465                    address: Some(a),
17466                    size,
17467                } => a..a + size,
17468                other => panic!("expected a contiguous reference dataset: {other:?}"),
17469            }
17470        };
17471
17472        let mut s = WriteEngine::open_torn_writes(&path, refs_block).unwrap();
17473        s.stage_dataset_write(&ObjectPathBuf::parse("/nums"), {
17474            let mut db = crate::type_builders::DatasetBuilder::new("");
17475            db.with_i32_data(&[9, 9, 9]);
17476            db
17477        })
17478        .unwrap();
17479        // A second edit, so the batch takes the full commit path and reaches a
17480        // repoint at all.
17481        s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
17482            let mut db = crate::type_builders::DatasetBuilder::new("");
17483            db.with_i32_data(&[42]);
17484            db
17485        })
17486        .unwrap();
17487        let failed = s.commit();
17488        assert!(
17489            matches!(failed, Err(Error::Io(_))),
17490            "the reference write is what failed, after the commit published: {failed:?}"
17491        );
17492        assert!(
17493            s.publish_attempted,
17494            "the failure has to be past the publish for this test to mean anything"
17495        );
17496        drop(s);
17497
17498        let f = crate::reader::File::open(&path).unwrap();
17499        assert_eq!(
17500            f.dataset("nums").unwrap().read_i32().unwrap(),
17501            vec![9, 9, 9],
17502            "the published overwrite must not be rolled back under it"
17503        );
17504        assert_eq!(f.dataset("extra").unwrap().read_i32().unwrap(), vec![42]);
17505    }
17506
17507    #[test]
17508    fn append_inplace_crash_consistency_paged_prefix() {
17509        use crate::reader::File as PureFile;
17510        use tempfile::tempdir;
17511
17512        let dir = tempdir().unwrap();
17513        let base = dir.path().join("base.h5");
17514        let (start, target) = (131_000i32, 132_000i32);
17515        build_unit_chunked(&base, start);
17516
17517        for max_phase in 1u8..=4 {
17518            let p = dir.path().join(format!("crash_paged_{max_phase}.h5"));
17519            append_stopped_at(&base, &p, start..target, max_phase);
17520            let expected_len = if max_phase == 4 { target } else { start };
17521            let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
17522            assert_eq!(
17523                f.dataset("d").unwrap().read_i32().unwrap(),
17524                (0..expected_len).collect::<Vec<_>>(),
17525                "inconsistent paged view after crash at phase {max_phase}"
17526            );
17527        }
17528    }
17529
17530    /// After an append stopped at any phase, a reader sees the committed prefix
17531    /// and nothing of the rest, for both starting layouts: a partial trailing
17532    /// chunk and the EA-boundary growth above, which exercise different index
17533    /// writes.
17534    ///
17535    /// This reader is the lenient one. It bounds chunk reads by `min(EA count,
17536    /// dimension)`, so it tolerates a phase-3 state where the element count has
17537    /// advanced past the dimension, where the reference C library walks strictly
17538    /// by the dataspace dimension and re-validates block checksums. The C
17539    /// library read these states until it became a dependency of the crosscheck
17540    /// package alone; the phased append that produces them is private, so that
17541    /// coverage is gone, and a stale end-of-file, a half-grown index or a
17542    /// mis-checksummed block that this reader forgives is not caught here.
17543    ///
17544    /// Restores coverage lost with the deprecated `SwmrWriter` and `AppendWriter`
17545    /// (issue #202).
17546    #[test]
17547    fn append_inplace_crash_consistency_leaves_the_committed_prefix() {
17548        use tempfile::tempdir;
17549
17550        // (initial length, chunk length, appended length): a partial trailing
17551        // chunk, a chunk-aligned start, and the EA-boundary crossing.
17552        for (n, chunk, add) in [(6i32, 4u64, 5i32), (8, 2, 6), (50, 1, 200)] {
17553            let dir = tempdir().unwrap();
17554            let base = dir.path().join("base.h5");
17555            {
17556                use crate::writer::FileBuilder;
17557                let mut b = FileBuilder::new();
17558                b.create_dataset("d")
17559                    .with_i32_data(&(0..n).collect::<Vec<i32>>())
17560                    .with_shape(&[n as u64])
17561                    .with_maxshape(&[MaxExtent::Unlimited])
17562                    .with_chunks(&[chunk]);
17563                b.write(&base).unwrap();
17564            }
17565
17566            for max_phase in 1u8..=4 {
17567                let p = dir
17568                    .path()
17569                    .join(format!("crash_c_{n}_{chunk}_{max_phase}.h5"));
17570                append_stopped_at(&base, &p, n..n + add, max_phase);
17571                let expected_len = if max_phase == 4 { n + add } else { n };
17572                let pf = crate::reader::File::from_bytes(std::fs::read(&p).unwrap()).unwrap();
17573                assert_eq!(
17574                    pf.dataset("d").unwrap().read_i32().unwrap(),
17575                    (0..expected_len).collect::<Vec<_>>(),
17576                    "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk})"
17577                );
17578            }
17579        }
17580    }
17581
17582    /// Crash recovery across the phase-3/phase-4 gap.
17583    ///
17584    /// A writer that crashes after publishing the Extensible-Array element count
17585    /// (phase 3) but before publishing the dataspace dimension (phase 4) leaves
17586    /// the on-disk count ahead of the committed dimension. A fresh writer must
17587    /// roll forward from the *committed dimension*, overwriting the uncommitted
17588    /// slots, rather than appending past them and leaving a gap.
17589    ///
17590    /// The crashed and recovering appends deliberately write different values at
17591    /// the overlapping positions, so a regression that seeds the chunk count from
17592    /// the stale EA header surfaces the crashed writer's values rather than
17593    /// merely producing plausible-looking data.
17594    ///
17595    /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
17596    /// surviving `recover_and_reappend_after_clean_phase4` covers only the clean
17597    /// case.
17598    #[test]
17599    fn append_inplace_recover_and_reappend_after_phase3_crash() {
17600        use crate::reader::File as PureFile;
17601        use tempfile::tempdir;
17602
17603        let dir = tempdir().unwrap();
17604        let path = dir.path().join("phase3_recover.h5");
17605        let n = 50i32;
17606        build_unit_chunked(&path, n);
17607
17608        // Writer 1 crashes after phase 3: the element count advances but the
17609        // dimension stays at `n`. Its values are far from the correct
17610        // continuation, so a leak is unmistakable.
17611        {
17612            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17613            s.append_inplace_i32_phased("d", &(1000..1200).collect::<Vec<_>>(), 3)
17614                .unwrap();
17615        }
17616        let committed: Vec<i32> = (0..n).collect();
17617        let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
17618        assert_eq!(
17619            pf.dataset("d").unwrap().read_i32().unwrap(),
17620            committed,
17621            "phase-3 crash exposed uncommitted data to the pure reader"
17622        );
17623
17624        // Writer 2 recovers: roll forward from the committed dimension,
17625        // overwriting the uncommitted slots with the real continuation.
17626        {
17627            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
17628            s.append_inplace_i32_phased("d", &(n..150).collect::<Vec<_>>(), 4)
17629                .unwrap();
17630        }
17631
17632        let expected: Vec<i32> = (0..150).collect();
17633        let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
17634        assert_eq!(
17635            pf.dataset("d").unwrap().read_i32().unwrap(),
17636            expected,
17637            "recovery did not roll forward correctly (pure reader)"
17638        );
17639    }
17640
17641    #[test]
17642    fn raw_appendable_recurses_into_aggregates() {
17643        let f64_with = |byte_order| Datatype::FloatingPoint {
17644            size: 8,
17645            byte_order,
17646            layout: FloatingPointLayout {
17647                bit_offset: 0,
17648                bit_precision: 64,
17649                exponent_location: 52,
17650                exponent_size: 11,
17651                mantissa_location: 0,
17652                mantissa_size: 52,
17653                exponent_bias: 1023,
17654            },
17655        };
17656        let le_f64 = f64_with(DatatypeByteOrder::LittleEndian);
17657        let be_f64 = f64_with(DatatypeByteOrder::BigEndian);
17658
17659        // Little-endian scalar: appendable. Big-endian scalar: not.
17660        assert!(datatype_is_raw_appendable(&le_f64));
17661        assert!(!datatype_is_raw_appendable(&be_f64));
17662
17663        // The confirmed bug: a compound / array whose leaf is big-endian must be
17664        // refused (it was wrongly accepted before recursion was added).
17665        let be_member = Datatype::Compound {
17666            size: 8,
17667            members: vec![CompoundMember {
17668                name: "x".into(),
17669                byte_offset: 0,
17670                datatype: be_f64.clone(),
17671            }],
17672        };
17673        assert!(!datatype_is_raw_appendable(&be_member));
17674        let le_member = Datatype::Compound {
17675            size: 8,
17676            members: vec![CompoundMember {
17677                name: "x".into(),
17678                byte_offset: 0,
17679                datatype: le_f64.clone(),
17680            }],
17681        };
17682        assert!(datatype_is_raw_appendable(&le_member));
17683        assert!(!datatype_is_raw_appendable(&Datatype::Array {
17684            base_type: Box::new(be_f64.clone()),
17685            dimensions: vec![4],
17686        }));
17687
17688        // Variable-length / reference leaves are never raw-appendable, even LE.
17689        assert!(!datatype_is_raw_appendable(&Datatype::VariableLength {
17690            is_string: false,
17691            padding: None,
17692            charset: None,
17693            base_type: Box::new(le_f64.clone()),
17694        }));
17695        assert!(!datatype_is_raw_appendable(&Datatype::Reference {
17696            size: 8,
17697            ref_type: crate::datatype::ReferenceType::Object,
17698        }));
17699    }
17700
17701    #[test]
17702    fn fresh_group_region_pairs_link_info_with_group_info() {
17703        // A new-style group must carry both a Link Info and a Group Info message
17704        // (the C library requires the pair before it will insert a link).
17705        let types = region_types(&fresh_group_region());
17706        assert_eq!(types, vec![MessageType::LINK_INFO, MessageType::GROUP_INFO]);
17707    }
17708
17709    #[test]
17710    fn ensure_group_info_appends_when_missing() {
17711        // A region with a Link Info message but no Group Info message (how older
17712        // hdf5-pure releases wrote groups) gains exactly one Group Info message.
17713        let li_body = {
17714            let mut b = vec![0u8, 0];
17715            b.extend_from_slice(&u64::MAX.to_le_bytes());
17716            b.extend_from_slice(&u64::MAX.to_le_bytes());
17717            b
17718        };
17719        let mut region = plain_region(message_record(MessageType::LINK_INFO, &li_body));
17720        ensure_group_info(&mut region).unwrap();
17721        assert_eq!(
17722            region_types(&region),
17723            vec![MessageType::LINK_INFO, MessageType::GROUP_INFO]
17724        );
17725
17726        // The appended message decodes as a minimal Group Info body.
17727        let mut p = 0;
17728        while let Some((mt, body, end)) = region.next_message(p).unwrap() {
17729            if mt == MessageType::GROUP_INFO {
17730                assert_eq!(&region[body..end], &GROUP_INFO_BODY);
17731            }
17732            p = end;
17733        }
17734    }
17735
17736    /// A group header region that tracks link creation order: a Link Info
17737    /// message recording `max` as the next index it would hand out (with the
17738    /// creation-order *index* declared when `indexed`, as h5py's
17739    /// `track_order=True` does), a Group Info message, and one compact Link
17740    /// message per name in `links`, numbered from zero.
17741    fn link_tracked_region(max: u64, indexed: bool, links: &[&str]) -> OhRegion {
17742        let mut li = vec![0u8, if indexed { 0x03 } else { 0x01 }];
17743        li.extend_from_slice(&max.to_le_bytes());
17744        li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap: compact
17745        li.extend_from_slice(&u64::MAX.to_le_bytes()); // b-tree name index
17746        if indexed {
17747            li.extend_from_slice(&u64::MAX.to_le_bytes()); // b-tree creation order
17748        }
17749        let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
17750        region.push(MessageType::LINK_INFO, &li);
17751        region.push(MessageType::GROUP_INFO, &GROUP_INFO_BODY);
17752        for (i, name) in links.iter().enumerate() {
17753            region.push_link(name, StoredAddress::new(0x100 + i as u64), Some(i as u64));
17754        }
17755        region
17756    }
17757
17758    /// Every Link message in `region`, as `(name, creation index)`.
17759    fn region_links(region: &OhRegion) -> Vec<(String, Option<u64>)> {
17760        let mut out = Vec::new();
17761        let mut p = 0;
17762        while let Some((mt, body, end)) = region.next_message(p).unwrap() {
17763            if mt == MessageType::LINK {
17764                let link = LinkMessage::parse(&region[body..end], OFFSET_SIZE).unwrap();
17765                out.push((link.name, link.creation_order));
17766            }
17767            p = end;
17768        }
17769        out
17770    }
17771
17772    /// The maximum creation index `region`'s Link Info message records.
17773    fn recorded_link_max(region: &OhRegion) -> Option<u64> {
17774        find_link_info(region)
17775            .unwrap()
17776            .and_then(|(_, _, info)| info.max_creation_order)
17777    }
17778
17779    #[test]
17780    fn links_added_to_a_tracked_group_take_consecutive_creation_indexes() {
17781        // A group that has handed out three indexes numbers the next two links
17782        // 3 and 4, in the order they are placed, and its Link Info message ends
17783        // up recording 5 — the next index, not the highest in use.
17784        let mut region = link_tracked_region(3, false, &["a", "b", "c"]);
17785        let mut order = LinkCreationOrder::for_region(&region).unwrap();
17786        region.push_link("d", StoredAddress::new(0x200), order.take().unwrap());
17787        region.push_link("e", StoredAddress::new(0x300), order.take().unwrap());
17788        order.record(&mut region).unwrap();
17789
17790        assert_eq!(
17791            region_links(&region),
17792            vec![
17793                ("a".to_string(), Some(0)),
17794                ("b".to_string(), Some(1)),
17795                ("c".to_string(), Some(2)),
17796                ("d".to_string(), Some(3)),
17797                ("e".to_string(), Some(4)),
17798            ],
17799        );
17800        assert_eq!(recorded_link_max(&region), Some(5));
17801    }
17802
17803    #[test]
17804    fn a_tracked_group_resumes_past_a_gap_a_deletion_left() {
17805        // Two links of three deleted, so the surviving link is index 1 and the
17806        // recorded maximum still says 3: the addition takes 3, not one of the
17807        // indexes the deletions freed.
17808        let region = link_tracked_region(3, false, &["a", "b", "c"]);
17809        let mut region = remove_link_from_region(&region, "a").unwrap();
17810        region = remove_link_from_region(&region, "c").unwrap();
17811        let mut order = LinkCreationOrder::for_region(&region).unwrap();
17812        region.push_link("d", StoredAddress::new(0x200), order.take().unwrap());
17813        order.record(&mut region).unwrap();
17814
17815        assert_eq!(
17816            region_links(&region),
17817            vec![("b".to_string(), Some(1)), ("d".to_string(), Some(3))],
17818        );
17819        assert_eq!(recorded_link_max(&region), Some(4));
17820    }
17821
17822    #[test]
17823    fn a_creation_order_index_shifts_no_field_the_maximum_is_written_into() {
17824        // A group that also declares the creation-order *index* (h5py's
17825        // `track_order=True`) has a longer Link Info body with a third address
17826        // in it. The maximum still precedes every address, so the patch lands on
17827        // it and leaves the three addresses undefined.
17828        let mut region = link_tracked_region(1, true, &["a"]);
17829        let mut order = LinkCreationOrder::for_region(&region).unwrap();
17830        region.push_link("b", StoredAddress::new(0x200), order.take().unwrap());
17831        order.record(&mut region).unwrap();
17832
17833        let (_, _, info) = find_link_info(&region).unwrap().unwrap();
17834        assert_eq!(info.max_creation_order, Some(2));
17835        assert_eq!(info.fractal_heap_address, None);
17836        assert_eq!(info.btree_name_index_address, None);
17837        assert_eq!(info.btree_creation_order_address, None);
17838        assert_eq!(region_links(&region).last().unwrap().1, Some(1));
17839    }
17840
17841    #[test]
17842    fn an_untracked_group_numbers_none_of_its_links() {
17843        // The groups this crate writes itself: no maximum recorded, so no link
17844        // carries a creation index and the Link Info message is left as it was.
17845        let mut region = fresh_group_region();
17846        let before = region.clone();
17847        let mut order = LinkCreationOrder::for_region(&region).unwrap();
17848        assert_eq!(order.take().unwrap(), None);
17849        region.push_link("d", StoredAddress::new(0x200), None);
17850        order.record(&mut region).unwrap();
17851
17852        assert_eq!(region_links(&region), vec![("d".to_string(), None)]);
17853        assert_eq!(recorded_link_max(&region), None);
17854        // Only the appended link is new; every message that was there is
17855        // untouched.
17856        assert_eq!(&region[..before.len()], &*before);
17857    }
17858
17859    #[test]
17860    fn a_tracked_group_that_gains_no_link_keeps_its_maximum_byte_for_byte() {
17861        // A commit that only deletes, or only edits attributes, must not bump
17862        // the counter — the gap it leaves is never handed out again.
17863        let region = link_tracked_region(3, false, &["a", "b", "c"]);
17864        let mut after = remove_link_from_region(&region, "b").unwrap();
17865        let order = LinkCreationOrder::for_region(&after).unwrap();
17866        let before = after.clone();
17867        order.record(&mut after).unwrap();
17868        assert_eq!(*after, *before);
17869        assert_eq!(recorded_link_max(&after), Some(3));
17870    }
17871
17872    #[test]
17873    fn max_compact_links_reads_the_declared_threshold_or_the_library_default() {
17874        // A minimal Group Info message stores no link-phase-change values, so
17875        // the C library's default of 8 applies.
17876        assert_eq!(max_compact_links(&fresh_group_region()).unwrap(), 8);
17877
17878        // One that stores them (flags bit 0) declares its own maximum compact
17879        // value in the two bytes after the flags.
17880        let mut gi = vec![0u8, 0x01];
17881        gi.extend_from_slice(&4u16.to_le_bytes()); // max compact
17882        gi.extend_from_slice(&2u16.to_le_bytes()); // min dense
17883        let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
17884        region.push(MessageType::GROUP_INFO, &gi);
17885        assert_eq!(max_compact_links(&region).unwrap(), 4);
17886    }
17887
17888    #[test]
17889    fn a_tracked_group_is_refused_only_where_the_addition_would_go_dense() {
17890        // Eight links is the default threshold, so a group holding seven takes
17891        // one more and is refused the second.
17892        let region = link_tracked_region(7, false, &["a", "b", "c", "d", "e", "f", "g"]);
17893        reject_dense_link_creation_order(&region, 8).expect("eight links still store compactly");
17894        let err = reject_dense_link_creation_order(&region, 9).unwrap_err();
17895        assert!(
17896            matches!(&err, Error::EditUnsupported(m) if m.contains("link creation order")
17897                && m.contains("dense")),
17898            "got: {err}",
17899        );
17900
17901        // A group that does not track the order is not screened at all: this
17902        // crate writes more than eight compact links routinely.
17903        let mut untracked = fresh_group_region();
17904        untracked.push_link("a", StoredAddress::new(0x100), None);
17905        reject_dense_link_creation_order(&untracked, 99)
17906            .expect("an untracked group has no creation order to lose");
17907    }
17908
17909    #[test]
17910    fn ensure_group_info_is_idempotent() {
17911        // A region that already has a Group Info message is left untouched, so
17912        // re-editing a healed (or C-written) group does not duplicate it.
17913        let mut region = fresh_group_region();
17914        let before = region.clone();
17915        ensure_group_info(&mut region).unwrap();
17916        assert_eq!(*region, *before);
17917    }
17918
17919    // ---- shared (SOHM) messages (issue #417) ----
17920
17921    /// A shared-message index record naming an object header is a stored address
17922    /// like any other, and the one no repoint walk reaches: it lives outside
17923    /// every object a commit rebuilds. A commit that removes or moves that header
17924    /// is refused rather than leaving the index naming bytes that have gone.
17925    #[test]
17926    fn a_commit_that_strands_a_shared_message_record_is_refused() {
17927        let named = crate::sohm::SohmRecord {
17928            hash: 1,
17929            location: crate::sohm::SohmLocation::ObjectHeader {
17930                message_type: MessageType::ATTRIBUTE.to_u16() as u8,
17931                creation_index: 0,
17932                address: StoredAddress::new(0x400),
17933            },
17934        };
17935        let moved = InvalidatedAddresses {
17936            removed: Vec::new(),
17937            moved: vec![0x400],
17938            base: BaseAddress::ZERO,
17939        };
17940        let err = screen_shared_message_records(std::slice::from_ref(&named), &moved).unwrap_err();
17941        assert_eq!(
17942            err.to_string(),
17943            Error::EditUnsupported(SHARED_MESSAGE_INDEX_NAMES_A_MOVED_OBJECT).to_string()
17944        );
17945
17946        let elsewhere = InvalidatedAddresses {
17947            removed: Vec::new(),
17948            moved: vec![0x800],
17949            base: BaseAddress::ZERO,
17950        };
17951        screen_shared_message_records(std::slice::from_ref(&named), &elsewhere).unwrap();
17952    }
17953
17954    /// A heap-stored record names no object header, so no address of it can go
17955    /// stale and the screen passes it. What a commit can spoil about one is its
17956    /// reference count, which the attribute paths refuse to change instead.
17957    #[test]
17958    fn a_heap_stored_shared_message_record_is_not_screened_for_addresses() {
17959        let heap = crate::sohm::SohmRecord {
17960            hash: 1,
17961            location: crate::sohm::SohmLocation::Heap {
17962                reference_count: 2,
17963                // The same eight bytes the address screen would refuse if it read
17964                // them as one.
17965                heap_id: 0x400u64.to_le_bytes(),
17966            },
17967        };
17968        let moved = InvalidatedAddresses {
17969            removed: Vec::new(),
17970            moved: vec![0x400],
17971            base: BaseAddress::ZERO,
17972        };
17973        screen_shared_message_records(&[heap], &moved).unwrap();
17974    }
17975
17976    /// A version 1 reference to a committed object becomes the version 2 form
17977    /// this crate writes everywhere else, naming the same address: the whole
17978    /// point of rewrapping is that the message still resolves to one copy.
17979    #[test]
17980    fn a_version_1_reference_is_rewrapped_as_the_modern_committed_form() {
17981        let mut v1 = vec![1u8, 0];
17982        v1.extend_from_slice(&[0u8; 6]); // reserved
17983        v1.extend_from_slice(&0x1111u64.to_le_bytes()); // local heap address
17984        v1.extend_from_slice(&0x5678u64.to_le_bytes()); // object header address
17985
17986        let modern = modernize_shared_reference(&v1, OFFSET_SIZE, LENGTH_SIZE).unwrap();
17987        assert_eq!(modern[0], 2, "the version this crate writes");
17988        assert_eq!(
17989            crate::shared_message::parse_shared_ref(&modern, OFFSET_SIZE, LENGTH_SIZE)
17990                .unwrap()
17991                .location,
17992            crate::shared_message::SharedLocation::ObjectHeader(StoredAddress::new(0x5678))
17993        );
17994    }
17995
17996    /// A reference into the shared-message heap keeps its heap ID and the only
17997    /// version that encodes one. Re-encoding it as a committed reference would
17998    /// turn eight bytes of heap ID into a file address.
17999    #[test]
18000    fn a_heap_reference_is_rewrapped_as_a_heap_reference() {
18001        let mut v3 = vec![3u8, 1];
18002        v3.extend_from_slice(&[9, 8, 7, 6, 5, 4, 3, 2]);
18003
18004        let modern = modernize_shared_reference(&v3, OFFSET_SIZE, LENGTH_SIZE).unwrap();
18005        assert_eq!(modern, v3);
18006    }
18007
18008    /// The rewrapped record keeps the shared flag. Without it every reader
18009    /// decodes the reference as the attribute it stands for.
18010    #[test]
18011    fn a_rewrapped_shared_message_keeps_its_flag() {
18012        let mut region = OhRegion::empty(OhHeaderProps::PLAIN);
18013        region.push_shared(MessageType::ATTRIBUTE, &[3, 1, 0, 0, 0, 0, 0, 0, 0, 0]);
18014        assert_eq!(region[3], MessageFlags::SHARED.get());
18015        assert!(region_has_shared_attr(&region).unwrap());
18016    }
18017
18018    #[test]
18019    fn reject_foreign_addresses_refuses_any_shared_message() {
18020        // A shared (SOHM) message of *any* type — here a Dataspace — stores a
18021        // source-file reference in place of its body, so a verbatim cross-file
18022        // copy must refuse it, not only shared datatypes/attributes. (A plain,
18023        // non-shared dataspace embeds no foreign address and is accepted.)
18024        let mut shared = message_record(MessageType::DATASPACE, &[0u8; 8]);
18025        shared[3] = MessageFlags::SHARED.get(); // set the message's shared flag
18026        let err = reject_foreign_addresses(&plain_region(shared)).unwrap_err();
18027        assert!(err.to_string().contains("shared"), "got: {err}");
18028
18029        let plain = message_record(MessageType::DATASPACE, &[0u8; 8]);
18030        reject_foreign_addresses(&plain_region(plain)).unwrap();
18031    }
18032
18033    // ---- version 2 object-header record layout (issue #416) ----
18034
18035    /// The layout of a header that tracks *and* indexes attribute creation
18036    /// order: object-header flag bits 2 and 3, and 6-byte message records.
18037    const TRACKED: OhRecordLayout = OhRecordLayout::from_header_flags(0x0C);
18038
18039    /// One compact Attribute message body for `name`.
18040    fn attr_body(name: &str, value: i64) -> Vec<u8> {
18041        crate::type_builders::build_attr_message(name, &AttrValue::I64(value))
18042            .serialize(LENGTH_SIZE)
18043    }
18044
18045    /// [`attr_region_in`] for a header carrying neither optional prefix block.
18046    fn attr_region(layout: OhRecordLayout, attrs: &[(&str, u16)], max: u16) -> OhRegion {
18047        attr_region_in(OhHeaderProps::with_layout(layout), attrs, max)
18048    }
18049
18050    /// A region belonging to a header with `props`, holding one Attribute message
18051    /// per `(name, creation index)`, preceded by the Attribute Info message a
18052    /// tracked object carries.
18053    fn attr_region_in(props: OhHeaderProps, attrs: &[(&str, u16)], max: u16) -> OhRegion {
18054        let layout = props.layout;
18055        let mut region = OhRegion::empty(props);
18056        let info = AttributeInfoMessage {
18057            max_creation_index: layout.tracks_creation_order().then_some(max),
18058            indexes_creation_order: layout.indexes_creation_order(),
18059            fractal_heap_address: None,
18060            btree_name_index_address: None,
18061            btree_creation_order_address: None,
18062        };
18063        let mut record = layout.record(MessageType::ATTRIBUTE_INFO, &info.serialize(OFFSET_SIZE));
18064        // The reference C library sets this flag on the Attribute Info message it writes
18065        // (`H5Oattribute.c`, HDF5 2.2.0), so a rewrite of that message has to keep it.
18066        record[3] = MessageFlags::FORBID_SHARING.get();
18067        region.push_bytes(&record);
18068        for (name, index) in attrs {
18069            let record = layout.record_with_creation_index(
18070                MessageType::ATTRIBUTE,
18071                &attr_body(name, 1),
18072                *index,
18073            );
18074            region.push_bytes(&record);
18075        }
18076        region
18077    }
18078
18079    /// The attribute names a region carries, in the order its records hold them,
18080    /// each with the creation index its record declares.
18081    fn walk_attrs(region: &OhRegion) -> Vec<(String, Option<u16>)> {
18082        let mut out = Vec::new();
18083        let mut p = 0;
18084        while let Some((msg_type, body, body_end)) = region.next_message(p).unwrap() {
18085            if msg_type == MessageType::ATTRIBUTE {
18086                let name = parse_compact_attr_name(region, p, body, body_end).unwrap();
18087                out.push((name, region.creation_index(p)));
18088            }
18089            p = body_end;
18090        }
18091        out
18092    }
18093
18094    /// The message walk steps by the width the header's flags declare: four
18095    /// bytes of record prefix without creation-order tracking, six with it.
18096    #[test]
18097    fn a_record_walk_steps_by_the_width_the_header_declares() {
18098        for layout in [OhRecordLayout::PLAIN, TRACKED] {
18099            let region = attr_region(layout, &[("alpha", 0), ("beta", 7)], 8);
18100            assert_eq!(
18101                walk_attrs(&region),
18102                vec![
18103                    (
18104                        "alpha".to_string(),
18105                        layout.tracks_creation_order().then_some(0)
18106                    ),
18107                    (
18108                        "beta".to_string(),
18109                        layout.tracks_creation_order().then_some(7)
18110                    ),
18111                ],
18112                "layout {layout:?}",
18113            );
18114        }
18115
18116        // Non-vacuity: the two widths are not interchangeable. Reading a tracked
18117        // region four bytes at a time misparses it from the first record on,
18118        // which is the defect this width exists to prevent.
18119        let tracked = attr_region(TRACKED, &[("alpha", 0), ("beta", 7)], 8);
18120        let misread = OhRegion::new(tracked.to_vec(), OhHeaderProps::PLAIN);
18121        let mut p = 0;
18122        let mut agreed = true;
18123        while let Ok(Some((_, _, body_end))) = misread.next_message(p) {
18124            p = body_end;
18125        }
18126        agreed &= p == tracked.len();
18127        assert!(
18128            !agreed,
18129            "a four-byte walk of a six-byte-record region must not come out even"
18130        );
18131    }
18132
18133    /// An emitted record carries the creation index only where the layout has
18134    /// the field, and the flags byte stays where every reader looks for it.
18135    #[test]
18136    fn an_emitted_record_carries_a_creation_index_only_where_the_header_tracks_one() {
18137        let body = [0xABu8; 5];
18138        let plain =
18139            OhRecordLayout::PLAIN.record_with_creation_index(MessageType::ATTRIBUTE, &body, 9);
18140        assert_eq!(plain.len(), 4 + body.len());
18141        assert_eq!(&plain[4..], &body);
18142
18143        let tracked = TRACKED.record_with_creation_index(MessageType::ATTRIBUTE, &body, 0x1234);
18144        assert_eq!(tracked.len(), 6 + body.len());
18145        assert_eq!(
18146            &tracked[..4],
18147            &plain[..4],
18148            "type, size and flags are shared"
18149        );
18150        assert_eq!(&tracked[4..6], &0x1234u16.to_le_bytes());
18151        assert_eq!(&tracked[6..], &body);
18152
18153        // Every non-attribute message the reference C library writes carries a
18154        // zero creation index, which is what the plain emitter passes.
18155        let group_info = TRACKED.record(MessageType::GROUP_INFO, &body);
18156        assert_eq!(&group_info[4..6], &0u16.to_le_bytes());
18157    }
18158
18159    /// A rebuilt header declares the record layout its bytes are written in, so
18160    /// re-reading it walks them the same way.
18161    #[test]
18162    fn a_rebuilt_header_declares_the_record_layout_it_used() {
18163        for layout in [OhRecordLayout::PLAIN, TRACKED] {
18164            let region = attr_region(layout, &[("alpha", 3)], 4);
18165            let header = build_v2_object_header(&region).unwrap();
18166            let (start, end, read_back) =
18167                oh_region_at(&header, 0, header.len() as u64).expect("the header parses");
18168            assert_eq!(
18169                read_back,
18170                OhHeaderProps::with_layout(layout),
18171                "the flags lost the layout"
18172            );
18173            let round_tripped =
18174                OhRegion::new(header[start as usize..end as usize].to_vec(), read_back);
18175            assert_eq!(
18176                walk_attrs(&round_tripped),
18177                walk_attrs(&region),
18178                "layout {layout:?}",
18179            );
18180        }
18181    }
18182
18183    // ---- the optional prefix blocks of a version 2 object header (PR #422) ----
18184
18185    /// Timestamps distinct enough that a field read at the wrong offset — or one
18186    /// copied from a neighbour — shows up as a different number.
18187    const FIXTURE_TIMES: ObjectTimes = ObjectTimes {
18188        access: 0x1111_1111,
18189        modification: 0x2222_2222,
18190        change: 0x3333_3333,
18191        birth: 0x4444_4444,
18192    };
18193
18194    /// A phase-change pair the reference C library would never write by default
18195    /// (its defaults are 8 and 6), so a rebuild that dropped the block and a
18196    /// rebuild that substituted the defaults both fail.
18197    const FIXTURE_PHASE: AttrPhaseChange = AttrPhaseChange {
18198        max_compact: 32,
18199        min_dense: 24,
18200    };
18201
18202    /// Hand-assemble the version 2 object header `props` describes around
18203    /// `region`'s bytes, with a 1-byte chunk-0 size field.
18204    ///
18205    /// Written out here rather than taken from [`build_v2_object_header`] so the
18206    /// parse is checked against bytes the emitter did not produce: an emitter and
18207    /// a parser that agreed on a wrong prefix layout would round-trip perfectly.
18208    fn v2_header_bytes(props: OhHeaderProps, region: &OhRegion) -> Vec<u8> {
18209        let mut buf = Vec::new();
18210        buf.extend_from_slice(b"OHDR");
18211        buf.push(2); // version
18212        buf.push(props.header_flags()); // size flags 0: a 1-byte length field
18213        if let Some(times) = props.times {
18214            buf.extend_from_slice(&times.to_bytes());
18215        }
18216        if let Some(phase) = props.attr_phase_change {
18217            buf.extend_from_slice(&phase.to_bytes());
18218        }
18219        buf.push(u8::try_from(region.len()).expect("the fixture region is under 256 bytes"));
18220        buf.extend_from_slice(region);
18221        let checksum = jenkins_lookup3(&buf);
18222        buf.extend_from_slice(&checksum.to_le_bytes());
18223        buf
18224    }
18225
18226    /// Every combination of the two optional prefix blocks and the record layout:
18227    /// the parse finds the message region, and a rebuild puts both blocks back
18228    /// with the flag bits that announce them.
18229    ///
18230    /// A dropped block is not a lost field alone — the chunk-0 size field sits
18231    /// *after* both, so the message walk is checked too.
18232    #[test]
18233    fn a_headers_optional_prefix_blocks_survive_a_rebuild() {
18234        for layout in [OhRecordLayout::PLAIN, TRACKED] {
18235            for times in [None, Some(FIXTURE_TIMES)] {
18236                for attr_phase_change in [None, Some(FIXTURE_PHASE)] {
18237                    let props = OhHeaderProps {
18238                        layout,
18239                        times,
18240                        attr_phase_change,
18241                    };
18242                    let region = attr_region_in(props, &[("alpha", 3), ("beta", 5)], 6);
18243                    let header = v2_header_bytes(props, &region);
18244
18245                    let (start, end, read_back) = oh_region_at(&header, 0, header.len() as u64)
18246                        .expect("the hand-built header parses");
18247                    assert_eq!(read_back, props, "the prefix lost a block");
18248                    let parsed =
18249                        OhRegion::new(header[start as usize..end as usize].to_vec(), read_back);
18250                    assert_eq!(
18251                        walk_attrs(&parsed),
18252                        walk_attrs(&region),
18253                        "the message region was located wrongly for {props:?}",
18254                    );
18255
18256                    let rebuilt = build_v2_object_header(&parsed).unwrap();
18257                    let (_, _, again) = oh_region_at(&rebuilt, 0, rebuilt.len() as u64)
18258                        .expect("the rebuilt header parses");
18259                    assert_eq!(
18260                        again.attr_phase_change, attr_phase_change,
18261                        "the rebuild lost the attribute phase-change thresholds",
18262                    );
18263                    assert_eq!(
18264                        again.times.is_some(),
18265                        times.is_some(),
18266                        "the rebuild changed whether the header stores times",
18267                    );
18268                    assert_eq!(again.layout, layout, "the rebuild lost the record layout");
18269                }
18270            }
18271        }
18272    }
18273
18274    /// The flag bits the emitter sets are the ones the format assigns: bit 4 for
18275    /// the phase-change block, bit 5 for the timestamps.
18276    #[test]
18277    fn the_optional_blocks_are_announced_by_their_own_flag_bits() {
18278        let with_times = OhHeaderProps {
18279            times: Some(FIXTURE_TIMES),
18280            ..OhHeaderProps::PLAIN
18281        };
18282        let with_phase = OhHeaderProps {
18283            attr_phase_change: Some(FIXTURE_PHASE),
18284            ..OhHeaderProps::PLAIN
18285        };
18286        assert_eq!(OhHeaderProps::PLAIN.header_flags(), 0);
18287        assert_eq!(with_times.header_flags(), 0x20);
18288        assert_eq!(with_phase.header_flags(), 0x10);
18289        assert_eq!(OhHeaderProps::PLAIN.optional_len(), 0);
18290        assert_eq!(with_times.optional_len(), 16);
18291        assert_eq!(with_phase.optional_len(), 4);
18292    }
18293
18294    /// A rebuild of a header that stores times moves the modification and change
18295    /// times to now and leaves the access and birth times alone.
18296    #[cfg(feature = "std")]
18297    #[test]
18298    fn a_rebuild_stamps_the_modification_and_change_times() {
18299        let props = OhHeaderProps {
18300            times: Some(FIXTURE_TIMES),
18301            ..OhHeaderProps::PLAIN
18302        };
18303        let region = attr_region_in(props, &[("alpha", 0)], 1);
18304
18305        let before = unix_time_now().expect("a std test build reads the clock");
18306        let header = build_v2_object_header(&region).unwrap();
18307        let after = unix_time_now().expect("a std test build reads the clock");
18308
18309        let (_, _, read_back) = oh_region_at(&header, 0, header.len() as u64).unwrap();
18310        let times = read_back.times.expect("the header still stores times");
18311        assert_eq!(
18312            (times.access, times.birth),
18313            (FIXTURE_TIMES.access, FIXTURE_TIMES.birth),
18314            "a rewrite is not an access and not a birth",
18315        );
18316        assert!(
18317            (before..=after).contains(&times.modification),
18318            "the modification time {} is outside [{before}, {after}]",
18319            times.modification,
18320        );
18321        assert!(
18322            (before..=after).contains(&times.change),
18323            "the change time {} is outside [{before}, {after}]",
18324            times.change,
18325        );
18326    }
18327
18328    /// A prefix that declares a block it does not carry is a malformed file, not
18329    /// a panic: the parse reads only what the buffer holds.
18330    #[test]
18331    fn a_prefix_truncated_inside_an_optional_block_is_refused() {
18332        let props = OhHeaderProps {
18333            times: Some(FIXTURE_TIMES),
18334            attr_phase_change: Some(FIXTURE_PHASE),
18335            ..OhHeaderProps::PLAIN
18336        };
18337        let region = attr_region_in(props, &[("alpha", 0)], 1);
18338        let header = v2_header_bytes(props, &region);
18339        // Every prefix length short of the chunk-0 size field, which is the last
18340        // thing the parse reads: 6 bytes of signature and flags, then 20 bytes of
18341        // optional blocks.
18342        for cut in 6..6 + 16 + 4 + 1 {
18343            assert!(
18344                oh_region_at(&header[..cut], 0, header.len() as u64).is_err(),
18345                "a {cut}-byte prefix must not parse as a whole header",
18346            );
18347        }
18348    }
18349
18350    /// A fresh attribute takes the object's next unused creation index, and the
18351    /// Attribute Info message records the one after it — the counter the
18352    /// reference C library hands out from.
18353    #[test]
18354    fn a_new_compact_attribute_takes_the_next_creation_index() {
18355        let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1)], 2);
18356        let out = set_attr_in_region(&region, "gamma", &AttrValue::I64(5)).unwrap();
18357        assert_eq!(
18358            walk_attrs(&out),
18359            vec![
18360                ("alpha".to_string(), Some(0)),
18361                ("beta".to_string(), Some(1)),
18362                ("gamma".to_string(), Some(2)),
18363            ],
18364        );
18365        let (record, _, info) = find_attribute_info(&out).unwrap().expect("an info message");
18366        assert_eq!(info.max_creation_index, Some(3));
18367        assert!(
18368            info.indexes_creation_order,
18369            "the index flag survives a rewrite"
18370        );
18371        assert_eq!(
18372            out[record + 3],
18373            MessageFlags::FORBID_SHARING.get(),
18374            "the rewritten record lost the message flags it carried"
18375        );
18376    }
18377
18378    /// Overwriting an attribute keeps the creation index it had, so an
18379    /// iteration by creation order does not reorder it.
18380    #[test]
18381    fn an_overwritten_compact_attribute_keeps_its_creation_index() {
18382        let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1)], 2);
18383        assert_eq!(attr_creation_index(&region, "alpha").unwrap(), Some(0));
18384        let out = set_attr_in_region(&region, "alpha", &AttrValue::I64(9)).unwrap();
18385        // The rewritten message moves to the end of the region — the editor
18386        // rebuilds rather than patches — but its creation index does not move.
18387        assert_eq!(
18388            walk_attrs(&out),
18389            vec![
18390                ("beta".to_string(), Some(1)),
18391                ("alpha".to_string(), Some(0))
18392            ],
18393        );
18394        let (_, _, info) = find_attribute_info(&out).unwrap().expect("an info message");
18395        assert_eq!(
18396            info.max_creation_index,
18397            Some(2),
18398            "an overwrite must not advance the counter"
18399        );
18400    }
18401
18402    /// A deletion leaves a gap and does not lower the recorded maximum, so the
18403    /// next attribute created cannot land in the middle of the order.
18404    #[test]
18405    fn a_deletion_leaves_the_creation_index_counter_where_it_was() {
18406        let region = attr_region(TRACKED, &[("alpha", 0), ("beta", 1), ("gamma", 2)], 3);
18407        let after = remove_attr_from_region(&region, "gamma", true).unwrap();
18408        assert_eq!(
18409            walk_attrs(&after),
18410            vec![
18411                ("alpha".to_string(), Some(0)),
18412                ("beta".to_string(), Some(1))
18413            ],
18414        );
18415        assert_eq!(
18416            next_attr_creation_index(&after).unwrap(),
18417            3,
18418            "the highest index still in use is 1, but the counter is 3"
18419        );
18420
18421        let out = put_attr_message(&after, "delta", &attr_body("delta", 1), None).unwrap();
18422        assert_eq!(attr_creation_index(&out, "delta").unwrap(), Some(3));
18423    }
18424
18425    /// A header that tracks the order but carries no Attribute Info message —
18426    /// which nothing this crate writes, and nothing the C library writes, but
18427    /// which the format permits — gets one recording a counter derived from the
18428    /// records themselves.
18429    #[test]
18430    fn a_tracked_header_without_an_info_message_gains_one() {
18431        let mut region = OhRegion::empty(OhHeaderProps::with_layout(TRACKED));
18432        for (name, index) in [("alpha", 0u16), ("beta", 4)] {
18433            let record = TRACKED.record_with_creation_index(
18434                MessageType::ATTRIBUTE,
18435                &attr_body(name, 1),
18436                index,
18437            );
18438            region.push_bytes(&record);
18439        }
18440        assert!(find_attribute_info(&region).unwrap().is_none());
18441        ensure_attribute_info(&mut region).unwrap();
18442        let (_, _, info) = find_attribute_info(&region)
18443            .unwrap()
18444            .expect("an info message");
18445        assert_eq!(info.max_creation_index, Some(5));
18446        assert!(info.indexes_creation_order);
18447    }
18448
18449    /// Build a compact data-layout message body: version, class=0, 2-byte inline
18450    /// size, then the data.
18451    fn compact_layout_body(version: u8, data: &[u8]) -> Vec<u8> {
18452        let mut b = vec![version, 0];
18453        b.extend_from_slice(&(data.len() as u16).to_le_bytes());
18454        b.extend_from_slice(data);
18455        b
18456    }
18457
18458    #[test]
18459    fn rebuild_compact_layout_replaces_inline_data_only() {
18460        // A region with a Dataspace message, a compact Data Layout, and a trailing
18461        // Attribute message: rewriting the inline data must replace exactly the
18462        // layout's bytes and leave every other message verbatim.
18463        let mut region = message_record(MessageType::DATASPACE, &[0xAB; 8]);
18464        region.extend_from_slice(&message_record(
18465            MessageType::DATA_LAYOUT,
18466            &compact_layout_body(3, &[1, 2, 3, 4]),
18467        ));
18468        region.extend_from_slice(&message_record(MessageType::ATTRIBUTE, &[0xCD; 5]));
18469
18470        let out = rebuild_compact_layout_region(&plain_region(region), &[9, 8, 7, 6]).unwrap();
18471
18472        // Same messages in the same order; only the layout's inline data changed.
18473        assert_eq!(
18474            region_types(&out),
18475            vec![
18476                MessageType::DATASPACE,
18477                MessageType::DATA_LAYOUT,
18478                MessageType::ATTRIBUTE,
18479            ]
18480        );
18481        let mut p = 0;
18482        while let Some((mt, body, end)) = out.next_message(p).unwrap() {
18483            match mt {
18484                MessageType::DATASPACE => assert_eq!(&out[body..end], &[0xAB; 8]),
18485                MessageType::DATA_LAYOUT => {
18486                    assert_eq!(out[body], 3, "version preserved");
18487                    assert_eq!(out[body + 1], 0, "still compact");
18488                    let size = u16::from_le_bytes([out[body + 2], out[body + 3]]) as usize;
18489                    assert_eq!(size, 4);
18490                    assert_eq!(&out[body + 4..body + 4 + size], &[9, 8, 7, 6]);
18491                }
18492                MessageType::ATTRIBUTE => assert_eq!(&out[body..end], &[0xCD; 5]),
18493                other => panic!("unexpected message {other:?}"),
18494            }
18495            p = end;
18496        }
18497    }
18498
18499    #[test]
18500    fn rebuild_compact_layout_refuses_non_compact() {
18501        // A contiguous (class 1) data layout is not compact, so the rebuild refuses
18502        // rather than corrupt it.
18503        let mut region = message_record(MessageType::DATA_LAYOUT, &{
18504            let mut b = vec![3u8, 1]; // version 3, class 1 (contiguous)
18505            b.extend_from_slice(&0u64.to_le_bytes());
18506            b.extend_from_slice(&0u64.to_le_bytes());
18507            b
18508        });
18509        region.extend_from_slice(&message_record(MessageType::DATASPACE, &[0; 8]));
18510        let err = rebuild_compact_layout_region(&plain_region(region), &[1, 2]).unwrap_err();
18511        assert!(err.to_string().contains("non-compact"), "got: {err}");
18512    }
18513
18514    #[test]
18515    fn a_refused_open_never_builds_the_image() {
18516        // The image is what may cost `O(file size)` — the mirror reads the whole
18517        // file — so every refusal has to come first. Asserting on the build
18518        // closure states that directly; measuring memory or elapsed time would
18519        // only correlate with it.
18520        use crate::writer::FileBuilder;
18521
18522        let dir = tempfile::tempdir().unwrap();
18523
18524        // One refusal from each family: the superblock's status flags (issue
18525        // #245), and an unsupported superblock version, which has always been
18526        // refused after the read this reorders.
18527        let flagged = dir.path().join("flagged.h5");
18528        let mut b = FileBuilder::new();
18529        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18530        b.write(&flagged).unwrap();
18531        let ancient = dir.path().join("ancient.h5");
18532        std::fs::copy(&flagged, &ancient).unwrap();
18533        for (path, version, flags) in [(&flagged, 3, SWMR_WRITE_FLAGS), (&ancient, 9, 0)] {
18534            let mut data = std::fs::read(path).unwrap();
18535            let off = signature::find_signature(&data).unwrap();
18536            let mut sb = Superblock::parse(&data, off).unwrap();
18537            sb.version = version;
18538            sb.consistency_flags = flags;
18539            let bytes = sb.serialize();
18540            data[off..off + bytes.len()].copy_from_slice(&bytes);
18541            std::fs::write(path, &data).unwrap();
18542        }
18543
18544        for path in [&flagged, &ancient] {
18545            let built = std::cell::Cell::new(false);
18546            let err = match WriteEngine::open_imaged(path, Some(FileLocking::Enabled), |h, len| {
18547                built.set(true);
18548                Ok(Box::new(HandleImage::new(
18549                    h,
18550                    len,
18551                    MetadataCacheConfig::disabled(),
18552                )))
18553            }) {
18554                Err(e) => e,
18555                Ok(_) => panic!("{} must be refused", path.display()),
18556            };
18557            assert!(
18558                !built.get(),
18559                "{} was refused with {err:?}, but the image was built first",
18560                path.display()
18561            );
18562        }
18563    }
18564
18565    #[test]
18566    fn a_stale_consistency_flag_is_refused_then_cleared_by_a_commit() {
18567        // A v3 file a crashed SWMR writer left flagged is refused by the editor
18568        // (issue #245) rather than edited under a writer the file still records.
18569        // On a v2 file, where the check is gated off to match the C library, the
18570        // editor opens — and the commit clears the stale flag rather than
18571        // re-emitting it, so the file stays properly closed for the C library
18572        // (issue #73).
18573        use crate::writer::FileBuilder;
18574
18575        let dir = tempfile::tempdir().unwrap();
18576        let path = dir.path().join("stale_flag.h5");
18577
18578        let mut b = FileBuilder::new();
18579        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18580        b.write(&path).unwrap();
18581
18582        // Simulate a crashed SWMR writer by stamping the on-disk write+SWMR flag
18583        // (0x05) into the superblock, recomputing its checksum.
18584        {
18585            let mut data = std::fs::read(&path).unwrap();
18586            let off = signature::find_signature(&data).unwrap();
18587            let mut sb = Superblock::parse(&data, off).unwrap();
18588            assert!(
18589                sb.version >= 2,
18590                "FileBuilder should emit a v2/v3 superblock"
18591            );
18592            sb.consistency_flags = 0x05;
18593            let bytes = sb.serialize();
18594            data[off..off + bytes.len()].copy_from_slice(&bytes);
18595            std::fs::write(&path, &data).unwrap();
18596            // Sanity: the stale flag is really set on disk now.
18597            assert_eq!(
18598                Superblock::parse(&data, off).unwrap().consistency_flags,
18599                0x05
18600            );
18601        }
18602
18603        // The editor refuses it while the flag stands.
18604        match WriteEngine::open_with_locking(&path, FileLocking::Enabled) {
18605            Err(Error::FileMarkedInUse(_)) => {}
18606            Err(e) => panic!("expected the flag refusal, got {e:?}"),
18607            Ok(_) => panic!("a flagged file must not be edited in place"),
18608        }
18609
18610        // The flag survives a *version-2* superblock, where the check is gated
18611        // off to match the C library — which is the one state that still carries
18612        // a stale flag into a commit, and so the one that keeps the healing below
18613        // load-bearing. (v2 and v3 superblocks share a byte layout, so restamping
18614        // the version is the whole difference.) A crashed C writer leaves plain
18615        // write access, without the SWMR bit.
18616        {
18617            let mut data = std::fs::read(&path).unwrap();
18618            let off = signature::find_signature(&data).unwrap();
18619            let mut sb = Superblock::parse(&data, off).unwrap();
18620            sb.version = 2;
18621            sb.consistency_flags = crate::file_lock::WRITE_ACCESS;
18622            let bytes = sb.serialize();
18623            data[off..off + bytes.len()].copy_from_slice(&bytes);
18624            std::fs::write(&path, &data).unwrap();
18625        }
18626
18627        // A clean edit-and-commit cycle heals it.
18628        {
18629            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
18630                .expect("the gate skips a v2 superblock, so this opens");
18631            let mut b = DatasetBuilder::new("e");
18632            b.with_i32_data(&[4, 5]);
18633            s.stage_created_dataset(&ObjectPathBuf::parse("e"), b)
18634                .unwrap();
18635            s.commit().unwrap();
18636        }
18637
18638        let data = std::fs::read(&path).unwrap();
18639        let off = signature::find_signature(&data).unwrap();
18640        assert_eq!(
18641            Superblock::parse(&data, off).unwrap().consistency_flags,
18642            0,
18643            "commit must clear the stale consistency flag"
18644        );
18645    }
18646
18647    #[test]
18648    fn add_vlen_string_dataset_with_null_elements_via_edit_session() {
18649        // Regression test for a silent-corruption bug (issue #105): a
18650        // VL-string dataset added via the in-place edit engine used to commit `Ok(())`
18651        // without ever writing its global heap collection or patching its
18652        // placeholder references, so the dataset failed to read back. A null
18653        // element (no heap object at all, distinct from an empty string) must
18654        // stay untouched by the patch — only heap-backed elements'
18655        // placeholder addresses are resolved; exercising both keeps the mask
18656        // itself, not just the common all-`Bytes` case, under test.
18657        use crate::type_builders::VlStringElement;
18658        use crate::writer::FileBuilder;
18659
18660        let dir = tempfile::tempdir().unwrap();
18661        let path = dir.path().join("vlen_null.h5");
18662
18663        let mut b = FileBuilder::new();
18664        b.create_dataset("seed").with_i32_data(&[0]);
18665        b.write(&path).unwrap();
18666
18667        let datatype =
18668            crate::type_builders::make_vlen_string_type(crate::datatype::CharacterSet::Utf8);
18669        let elements = vec![
18670            VlStringElement::Bytes(b"alpha".to_vec()),
18671            VlStringElement::Null,
18672            VlStringElement::Bytes(b"gamma".to_vec()),
18673        ];
18674
18675        {
18676            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
18677            let mut b = DatasetBuilder::new("labels");
18678            b.with_vlen_string_elements(datatype, &elements).unwrap();
18679            s.stage_created_dataset(&ObjectPathBuf::parse("labels"), b)
18680                .unwrap();
18681            s.commit().unwrap();
18682        }
18683
18684        let file = crate::reader::File::open(&path).unwrap();
18685        let ds = file.dataset("labels").unwrap();
18686        assert_eq!(
18687            ds.read_string().unwrap(),
18688            vec!["alpha".to_string(), String::new(), "gamma".to_string()]
18689        );
18690    }
18691
18692    #[test]
18693    fn edit_session_root_group_base_address_overflow_is_rejected() {
18694        // The edit-path sibling of issue #137. A userblock file has a nonzero base
18695        // address that `WriteEngine::open` adds to the stored root-group address.
18696        // A crafted address of HADDR_UNDEF must be rejected rather than overflow
18697        // (panicking in debug, wrapping in release).
18698        use crate::writer::FileBuilder;
18699
18700        let dir = tempfile::tempdir().unwrap();
18701        let path = dir.path().join("edit_root_overflow.h5");
18702
18703        const UB: u64 = 512;
18704        let mut b = FileBuilder::new();
18705        b.with_userblock(UB);
18706        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
18707        b.write(&path).unwrap();
18708
18709        // Rewrite the stored (base-relative) root-group address to HADDR_UNDEF,
18710        // recomputing the superblock checksum via `serialize`. The base address
18711        // still equals the superblock offset, so the file stays editable and the
18712        // editor reaches the `root_group_address + base` normalization.
18713        let mut data = std::fs::read(&path).unwrap();
18714        let off = signature::find_signature(&data).unwrap();
18715        let mut sb = Superblock::parse(&data, off).unwrap();
18716        assert_eq!(
18717            sb.base_address,
18718            BaseAddress::new(UB),
18719            "userblock file must have base == UB"
18720        );
18721        sb.root_group_address = u64::MAX;
18722        let bytes = sb.serialize();
18723        data[off..off + bytes.len()].copy_from_slice(&bytes);
18724        std::fs::write(&path, &data).unwrap();
18725
18726        let err = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
18727            .err()
18728            .expect("open must fail");
18729        match err {
18730            Error::Format(FormatError::OffsetOverflow { offset, length }) => {
18731                assert_eq!(offset, u64::MAX);
18732                assert_eq!(length, UB);
18733            }
18734            other => panic!("expected root-group address overflow, got {other:?}"),
18735        }
18736    }
18737
18738    use tempfile::tempdir;
18739
18740    // -----------------------------------------------------------------------
18741    // Bounded sessions: the same engine over a `HandleImage`, which holds no
18742    // whole-file mirror. These came across from the standalone bounded engine
18743    // deleted in issue #198; what they cover is unchanged, but they now exercise
18744    // the shared code the mirror sessions use.
18745    // -----------------------------------------------------------------------
18746
18747    /// Build a rank-1 unlimited chunked i32 dataset `d` seeded with `0..n`.
18748    fn build_appendable(path: &Path, n: i32, chunk: u64) {
18749        let data: Vec<i32> = (0..n).collect();
18750        let mut b = crate::writer::FileBuilder::new();
18751        b.create_dataset("d")
18752            .with_i32_data(&data)
18753            .with_shape(&[n as u64])
18754            .with_maxshape(&[MaxExtent::Unlimited])
18755            .with_chunks(&[chunk]);
18756        b.write(path).unwrap();
18757    }
18758
18759    fn open_bounded_session(path: &Path) -> WriteEngine {
18760        WriteEngine::open_rw_with_strategy(
18761            path,
18762            crate::source::MetadataCacheConfig::disabled(),
18763            FileLocking::Enabled,
18764            MemoryStrategy::Bounded,
18765        )
18766        .unwrap()
18767    }
18768
18769    fn dataset_addr(engine: &WriteEngine) -> u64 {
18770        crate::group_v2::resolve_path_any_from_source(
18771            &engine.image(),
18772            AccessMode::ReadWrite,
18773            engine.superblock(),
18774            &ObjectPath::parse("d"),
18775        )
18776        .unwrap()
18777    }
18778
18779    /// Crash consistency on a bounded session: stop the append after only the
18780    /// first `max_phase` durability phases (simulating a crash at that boundary)
18781    /// and assert the reopened file reads either the old length (phases 1-3) or
18782    /// the new one (phase 4), never a torn view. Layouts cover a partial trailing
18783    /// chunk (relocated tail) and a chunk-aligned start.
18784    #[test]
18785    fn bounded_append_crash_consistency_partial_tail_prefix() {
18786        let dir = tempdir().unwrap();
18787        for (case, (n, chunk, add)) in [(0usize, (6i32, 4u64, 5i32)), (1, (8, 2, 6))] {
18788            let base = dir.path().join(std::format!("base_{case}.h5"));
18789            build_appendable(&base, n, chunk);
18790            for max_phase in 1u8..=4 {
18791                let p = dir.path().join(std::format!("crash_{case}_{max_phase}.h5"));
18792                std::fs::copy(&base, &p).unwrap();
18793                {
18794                    let mut engine = open_bounded_session(&p);
18795                    let addr = dataset_addr(&engine);
18796                    let mut b = AppendBuilder::new();
18797                    b.append_i32(&(n..n + add).collect::<Vec<_>>());
18798                    engine
18799                        .append_inplace_gathered(AppendTarget::Header(addr), &b, max_phase)
18800                        .unwrap();
18801                    // Dropping the engine simulates the crash: no further phases,
18802                    // no close barrier.
18803                }
18804                let expected_len = if max_phase == 4 { n + add } else { n };
18805                let got = crate::File::open(&p)
18806                    .unwrap()
18807                    .dataset("d")
18808                    .unwrap()
18809                    .read_i32()
18810                    .unwrap();
18811                assert_eq!(
18812                    got,
18813                    (0..expected_len).collect::<Vec<_>>(),
18814                    "case {case} phase {max_phase}"
18815                );
18816            }
18817        }
18818    }
18819
18820    /// The batching loop only honors `max_phase < 4` on its first batch, and a
18821    /// full multi-batch append leaves every batch fully committed: after a large
18822    /// append the file reads the complete sequence.
18823    #[test]
18824    fn bounded_multi_batch_append_commits_every_batch() {
18825        let dir = tempdir().unwrap();
18826        let p = dir.path().join("multibatch.h5");
18827        build_appendable(&p, 5, 512);
18828        let total = 700_000i32;
18829        {
18830            let mut engine = open_bounded_session(&p);
18831            let addr = dataset_addr(&engine);
18832            let mut b = AppendBuilder::new();
18833            b.append_i32(&(5..total).collect::<Vec<_>>());
18834            engine
18835                .append_inplace_gathered(AppendTarget::Header(addr), &b, 4)
18836                .unwrap();
18837        }
18838        let got = crate::File::open(&p)
18839            .unwrap()
18840            .dataset("d")
18841            .unwrap()
18842            .read_i32()
18843            .unwrap();
18844        assert_eq!(got.len(), total as usize);
18845        assert!(got.iter().enumerate().all(|(i, &v)| v == i as i32));
18846    }
18847
18848    /// A bounded session batches; a mirror session does not. The distinction is
18849    /// a deliberate trade — bounded peak memory against whole-call crash
18850    /// atomicity — so it is asserted rather than left to the batching code's
18851    /// arithmetic.
18852    #[test]
18853    fn only_a_bounded_session_batches_a_large_append() {
18854        let dir = tempdir().unwrap();
18855        let p = dir.path().join("batching.h5");
18856        build_appendable(&p, 8, 4);
18857
18858        let bounded_batch = {
18859            let mut engine = open_bounded_session(&p);
18860            engine
18861                .append_geometry(AppendTarget::Path(&ObjectPathBuf::parse("d")))
18862                .unwrap()
18863                .full_batch_elems
18864        };
18865        let mirror_batch = {
18866            let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
18867            engine
18868                .append_geometry(AppendTarget::Path(&ObjectPathBuf::parse("d")))
18869                .unwrap()
18870                .full_batch_elems
18871        };
18872
18873        assert_eq!(
18874            mirror_batch,
18875            u64::MAX,
18876            "a mirror session must take the whole append as one crash-atomic batch"
18877        );
18878        assert!(
18879            bounded_batch < u64::MAX,
18880            "a bounded session must cap a batch, got {bounded_batch}"
18881        );
18882        assert_eq!(
18883            bounded_batch % 4,
18884            0,
18885            "a batch must be a whole number of chunks"
18886        );
18887    }
18888
18889    /// A persisting file appended through a bounded session and dropped WITHOUT
18890    /// `finalize_persist` (the true-crash case) still reads back every durable
18891    /// append. Dropping the engine releases the exclusive lock, so the reopen is
18892    /// portable (no leaked lock). The finalize-at-close path is covered by the
18893    /// `tests/main/bounded_append.rs` integration tests.
18894    #[test]
18895    fn bounded_persist_append_without_finalize_is_readable() {
18896        let dir = tempdir().unwrap();
18897        let p = dir.path().join("persist_crash.h5");
18898        let mut b = crate::writer::FileBuilder::new();
18899        b.with_file_space_strategy(crate::FileSpaceStrategy::FsmAggr, true, 1);
18900        b.create_dataset("d")
18901            .with_i32_data(&(0..6).collect::<Vec<i32>>())
18902            .with_shape(&[6])
18903            .with_maxshape(&[MaxExtent::Unlimited])
18904            .with_chunks(&[4]);
18905        b.write(&p).unwrap();
18906        {
18907            let mut engine = open_bounded_session(&p);
18908            assert!(engine.persist.is_some(), "persist state is armed at open");
18909            let addr = dataset_addr(&engine);
18910            let mut ab = AppendBuilder::new();
18911            ab.append_i32(&(6..20).collect::<Vec<_>>());
18912            engine
18913                .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18914                .unwrap();
18915            // Drop without finalizing: models a true crash and releases the lock.
18916        }
18917        let got = crate::File::open(&p)
18918            .unwrap()
18919            .dataset("d")
18920            .unwrap()
18921            .read_i32()
18922            .unwrap();
18923        assert_eq!(got, (0..20).collect::<Vec<_>>());
18924    }
18925
18926    /// A bounded session grows a PAGED persisting file and is killed before
18927    /// finalize (models a crash), leaving the file non-page-aligned. Reopening it
18928    /// must not panic, and the next append must re-align the crashed tail page
18929    /// before writing raw data (so no page mixes metadata and raw); a clean close
18930    /// then re-page-aligns the file and every row reads back.
18931    #[test]
18932    fn bounded_paged_reopen_after_crash_realigns_and_stays_readable() {
18933        let dir = tempdir().unwrap();
18934        let p = dir.path().join("paged_crash.h5");
18935        let mut b = crate::writer::FileBuilder::new();
18936        b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
18937            .with_file_space_page_size(4096);
18938        b.create_dataset("d")
18939            .with_i32_data(&(0..64).collect::<Vec<i32>>())
18940            .with_shape(&[64])
18941            .with_maxshape(&[MaxExtent::Unlimited])
18942            .with_chunks(&[64]);
18943        b.write(&p).unwrap();
18944
18945        // Grow enough to force extensible-array index growth, so the last write of
18946        // the session is metadata and the tail page is a partial metadata page.
18947        {
18948            let mut engine = open_bounded_session(&p);
18949            let addr = dataset_addr(&engine);
18950            let mut ab = AppendBuilder::new();
18951            ab.append_i32(&(64..2000).collect::<Vec<_>>());
18952            engine
18953                .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18954                .unwrap();
18955            // Drop without finalize: models a crash and releases the OS lock.
18956        }
18957        assert_ne!(
18958            std::fs::metadata(&p).unwrap().len() % 4096,
18959            0,
18960            "a crashed (un-finalized) paged session leaves the file non-page-aligned"
18961        );
18962
18963        // Reopen must not panic on the non-aligned file; the next append re-aligns
18964        // the crashed tail page, and finalize re-page-aligns the whole file.
18965        {
18966            let mut engine = open_bounded_session(&p);
18967            let addr = dataset_addr(&engine);
18968            let mut ab = AppendBuilder::new();
18969            ab.append_i32(&(2000..2500).collect::<Vec<_>>());
18970            engine
18971                .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
18972                .unwrap();
18973            engine.finalize_persist().unwrap();
18974            engine.barrier().unwrap();
18975        }
18976        assert_eq!(
18977            std::fs::metadata(&p).unwrap().len() % 4096,
18978            0,
18979            "reopen + append + finalize re-aligns the paged file"
18980        );
18981        let got = crate::File::open(&p)
18982            .unwrap()
18983            .dataset("d")
18984            .unwrap()
18985            .read_i32()
18986            .unwrap();
18987        assert_eq!(got, (0..2500).collect::<Vec<_>>());
18988    }
18989
18990    /// A staged commit on a bounded session must stay bounded: it may read the
18991    /// metadata it edits, but never the file's bulk. Measured rather than
18992    /// asserted from the design — the engine is shared with the mirror sessions
18993    /// now, and a single slice-taking read added anywhere on the commit path
18994    /// would silently make a bounded open cost as much as a mirrored one.
18995    #[test]
18996    fn a_bounded_commit_reads_far_less_than_the_file() {
18997        use std::sync::Arc;
18998        use std::sync::atomic::{AtomicU64, Ordering};
18999
19000        let dir = tempdir().unwrap();
19001        let p = dir.path().join("bulk.h5");
19002        // ~8 MiB of chunked data, so "reads the whole file" and "reads only the
19003        // metadata" differ by orders of magnitude rather than by a margin.
19004        let rows = 2_000_000i32;
19005        build_appendable(&p, rows, 8192);
19006        let file_len = std::fs::metadata(&p).unwrap().len();
19007        assert!(file_len > 4 << 20, "file is only {file_len} bytes");
19008
19009        let read_bytes = Arc::new(AtomicU64::new(0));
19010        {
19011            let mut engine =
19012                WriteEngine::open_bounded_counting(&p, Arc::clone(&read_bytes)).unwrap();
19013            engine.create_group(&ObjectPathBuf::parse("g")).unwrap();
19014            engine.commit().unwrap();
19015        }
19016        let read = read_bytes.load(Ordering::Relaxed);
19017
19018        assert!(
19019            read > 0,
19020            "the commit read nothing, so the test proves nothing"
19021        );
19022        // Measured at 310 bytes here. The bound is loose enough to survive a
19023        // changed header layout and still orders of magnitude below the file.
19024        assert!(
19025            read < 64 << 10,
19026            "a bounded commit read {read} bytes of a {file_len}-byte file"
19027        );
19028    }
19029
19030    /// An in-place append leaves a partially-filled **raw** page, so the next
19031    /// commit's metadata must pad it rather than pack into it.
19032    ///
19033    /// This is what keeps [`PagedEdit::begin`] reachable from [`EditStore`] now
19034    /// that an append allocates raw pages only: the append's job is to record that
19035    /// the tail page turned raw, and the commit's job is to act on it. It is also
19036    /// the interleaving that a single session-level page tracker makes possible —
19037    /// with a tracker per engine, the commit path could not see what the append
19038    /// path had done, which is why the whole-file editor refused an in-place append
19039    /// to a paged file at all (issue #198).
19040    #[test]
19041    fn a_commit_after_an_append_pads_the_raw_page_the_append_left() {
19042        const PAGE: u64 = 4096;
19043        let dir = tempdir().unwrap();
19044        let p = dir.path().join("paged_interleave.h5");
19045        let mut b = crate::writer::FileBuilder::new();
19046        b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
19047            .with_file_space_page_size(PAGE);
19048        b.create_dataset("d")
19049            .with_i32_data(&(0..64).collect::<Vec<i32>>())
19050            .with_shape(&[64])
19051            .with_maxshape(&[MaxExtent::Unlimited])
19052            .with_chunks(&[64]);
19053        b.write(&p).unwrap();
19054
19055        let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
19056        let mut ab = AppendBuilder::new();
19057        ab.append_i32(&(64..2000).collect::<Vec<_>>());
19058        engine
19059            .append_inplace_gathered(AppendTarget::Path(&ObjectPathBuf::parse("d")), &ab, 4)
19060            .unwrap();
19061
19062        assert_eq!(
19063            engine.paged.as_ref().unwrap().last,
19064            Some(PageType::Raw),
19065            "the append must record that the tail page now holds raw data"
19066        );
19067        assert_ne!(
19068            engine.image.len() % PAGE,
19069            0,
19070            "the append must leave a partially-filled page for the commit to pad"
19071        );
19072
19073        engine.create_group(&ObjectPathBuf::parse("g")).unwrap();
19074        engine.commit().unwrap();
19075
19076        // The commit padded the raw tail before laying down metadata, and folded
19077        // that padding into the raw list (`meta_pad`/`raw_pad` are cleared into the
19078        // free lists as part of the paged tail).
19079        let pg = engine.paged.as_ref().expect("the file is paged");
19080        let raw_free = pg.raw.sections();
19081        assert!(
19082            !raw_free.is_empty(),
19083            "the commit packed metadata into the raw page the append left open"
19084        );
19085        for (addr, len) in raw_free {
19086            assert_eq!(
19087                (addr + len) % PAGE,
19088                0,
19089                "padding {addr}+{len} does not reach a page boundary"
19090            );
19091        }
19092
19093        drop(engine);
19094        assert_eq!(
19095            crate::File::open(&p)
19096                .unwrap()
19097                .dataset("d")
19098                .unwrap()
19099                .read_i32()
19100                .unwrap(),
19101            (0..2000).collect::<Vec<_>>()
19102        );
19103    }
19104
19105    /// An in-place append to a paged file must allocate **only raw pages** — the
19106    /// chunk data and the extensible-array blocks indexing it alike.
19107    ///
19108    /// The reclaim path (`chunked_storage_spans`) reports both halves of a chunked
19109    /// dataset as raw free space, because that is where this crate places them. An
19110    /// append that put its index blocks in a metadata page instead would make the
19111    /// reclaim advertise metadata-page bytes for raw reuse, mixing the page a paged
19112    /// file exists to keep homogeneous. Measured here rather than through the
19113    /// reference C library, which reads a mixed-page file without complaint: an
19114    /// interop test proves interop and says nothing about segregation.
19115    #[test]
19116    fn an_inplace_append_to_a_paged_file_allocates_only_raw_pages() {
19117        const PAGE: u64 = 4096;
19118        let dir = tempdir().unwrap();
19119        let p = dir.path().join("paged_raw.h5");
19120        let mut b = crate::writer::FileBuilder::new();
19121        b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
19122            .with_file_space_page_size(PAGE);
19123        b.create_dataset("d")
19124            .with_i32_data(&(0..64).collect::<Vec<i32>>())
19125            .with_shape(&[64])
19126            .with_maxshape(&[MaxExtent::Unlimited])
19127            .with_chunks(&[64]);
19128        b.write(&p).unwrap();
19129
19130        let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
19131        let before = engine.image().len();
19132        // Two appends, each large enough to grow the extensible-array index, so the
19133        // run allocates index blocks as well as chunk data.
19134        for range in [64..2000, 2000..4000] {
19135            let mut ab = AppendBuilder::new();
19136            ab.append_i32(&range.collect::<Vec<_>>());
19137            engine
19138                .append_inplace_gathered(AppendTarget::Path(&ObjectPathBuf::parse("d")), &ab, 4)
19139                .unwrap();
19140        }
19141
19142        let pg = engine.paged.as_ref().expect("the file is paged");
19143        assert_eq!(
19144            pg.last,
19145            Some(PageType::Raw),
19146            "the append left the tail page holding something other than raw data"
19147        );
19148        assert!(
19149            pg.meta_pad.is_empty() && pg.raw_pad.is_empty(),
19150            "an in-place append switched page type: meta_pad={:?} raw_pad={:?}",
19151            pg.meta_pad,
19152            pg.raw_pad
19153        );
19154
19155        // Not vacuous: the append really did allocate index structure above the
19156        // pre-append end-of-file, which is what would have opened a metadata page.
19157        let addr = crate::group_v2::resolve_path_any_from_source(
19158            &engine.image(),
19159            AccessMode::ReadWrite,
19160            engine.superblock(),
19161            &ObjectPath::parse("d"),
19162        )
19163        .unwrap();
19164        let spans = engine
19165            .chunked_storage_spans(addr)
19166            .expect("a chunked dataset has reclaimable spans");
19167        let fresh = spans.iter().filter(|&&(a, _, _)| a >= before).count();
19168        assert!(
19169            fresh > 0,
19170            "the append allocated nothing above {before}, so the assertion above proves nothing"
19171        );
19172        assert!(
19173            spans
19174                .iter()
19175                .all(|&(_, _, class)| class == FreeClass::Page(PageType::Raw)),
19176            "the reclaim tags every chunked span raw; a metadata or dead tag here would \
19177             need the placement rule above to change with it"
19178        );
19179    }
19180
19181    /// Write a small file of `tables` unlimited chunked datasets, paged when
19182    /// asked, for the write-gathering tests below.
19183    fn gather_fixture(path: &std::path::Path, tables: usize, paged: bool) {
19184        use crate::writer::FileBuilder;
19185        let mut b = FileBuilder::new();
19186        if paged {
19187            // Deliberately *not* DEFAULT_GATHER_PAGE: a paged fixture at the
19188            // default page size cannot tell a session that reads the file's page
19189            // size from one that assumes the default.
19190            b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19191                .with_file_space_page_size(16 * 1024);
19192        }
19193        for t in 0..tables {
19194            b.create_dataset(&std::format!("t{t}"))
19195                .with_i32_data(&(0..256).collect::<Vec<_>>())
19196                .with_shape(&[256])
19197                .with_maxshape(&[MaxExtent::Unlimited])
19198                .with_chunks(&[64]);
19199        }
19200        b.write(path).unwrap();
19201    }
19202
19203    /// Run the same appends and the same commit on `session`, and report what
19204    /// each cost in writes: the in-place appends, then the staged commit that
19205    /// follows them. They are counted apart because the gathering earns its keep
19206    /// in only one of them — see the caller. The file the workload leaves is the
19207    /// other half of what the callers compare, and they read it off the path
19208    /// themselves.
19209    fn gather_workload(session: &mut WriteEngine) -> (u64, u64) {
19210        let before = session.image.issued_writes();
19211        for round in 0..4 {
19212            for t in 0..4 {
19213                session
19214                    .append_inplace_i32_phased(&std::format!("t{t}"), &[round; 64], 4)
19215                    .unwrap();
19216            }
19217        }
19218        for t in 0..4 {
19219            let mut db = crate::type_builders::DatasetBuilder::new(&std::format!("n{t}"));
19220            db.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19221            session
19222                .stage_created_dataset(&ObjectPathBuf::parse(&std::format!("/n{t}")), db)
19223                .unwrap();
19224        }
19225        let after_appends = session.image.issued_writes();
19226        session.commit().unwrap();
19227        (
19228            after_appends - before,
19229            session.image.issued_writes() - after_appends,
19230        )
19231    }
19232
19233    /// Gathering a session's writes lowers what it costs and changes nothing
19234    /// about what it produces (issue #288).
19235    ///
19236    /// Both halves matter and neither implies the other. A gatherer that dropped
19237    /// a run, wrote one twice in the wrong order, or filled the space between two
19238    /// runs sharing a page with zeros would lower the count exactly as well — so
19239    /// the two files are compared **byte for byte**, which is the only assertion
19240    /// a wrong merge cannot pass. And a gatherer that merged nothing would keep
19241    /// them identical, which is what the count is for.
19242    ///
19243    /// The comparison is against this same engine with the gathering turned off,
19244    /// rather than against a recorded number: how many writes a commit needs is
19245    /// an implementation detail that should be free to fall further.
19246    #[test]
19247    fn gathering_writes_costs_fewer_of_them_and_changes_no_byte() {
19248        use tempfile::tempdir;
19249
19250        let dir = tempdir().unwrap();
19251        for paged in [false, true] {
19252            let straight = dir.path().join(std::format!("straight_{paged}.h5"));
19253            let gathered = dir.path().join(std::format!("gathered_{paged}.h5"));
19254            gather_fixture(&straight, 4, paged);
19255            gather_fixture(&gathered, 4, paged);
19256
19257            let mut a = WriteEngine::open_with_locking(&straight, FileLocking::Enabled).unwrap();
19258            a.set_sync_policy(SyncPolicy::OnClose);
19259            a.image
19260                .set_write_buffering(WriteBuffering::Unbuffered)
19261                .unwrap();
19262            let (straight_appends, straight_commit) = gather_workload(&mut a);
19263            a.force_sync().unwrap();
19264            drop(a);
19265
19266            let mut b = WriteEngine::open_with_locking(&gathered, FileLocking::Enabled).unwrap();
19267            b.set_sync_policy(SyncPolicy::OnClose);
19268            let (gathered_appends, gathered_commit) = gather_workload(&mut b);
19269            b.force_sync().unwrap();
19270            drop(b);
19271
19272            // The commit tail is where the gathering earns its keep, and the half
19273            // to assert a ratio on. Measured: 2 writes against 10 unpaged, 4
19274            // against 16 paged. A commit rebuilds a group, repoints a root and
19275            // re-homes the free-space managers, all inside one phase and all into
19276            // a handful of pages, which is what merging within a barrier is for.
19277            // Merging *across* barriers goes further and is what an explicit page
19278            // buffer does; `a_page_buffer_holds_dirty_pages_across_operations`
19279            // measures that.
19280            assert!(
19281                gathered_commit * 3 < straight_commit,
19282                "paged={paged}: gathering must cost meaningfully fewer writes for a \
19283                 commit, but cost {gathered_commit} against {straight_commit}"
19284            );
19285            // The appends are the other half, and since issue #307 they are a
19286            // near-tie: 88 against 92 unpaged, 92 against 92 paged. Publishing a
19287            // checksummed structure is one write from the engine now rather than
19288            // two the gatherer had to rejoin, so the buffering has almost nothing
19289            // left to merge here — before that fix this half was 92 against 144.
19290            // What is still worth pinning is that it never costs *more*. Note the
19291            // limit of that: a publish write made wider still merges the same way,
19292            // so widening one to the whole structure it sits in changes no count
19293            // here or anywhere — `a_publish_writes_from_the_byte_it_changed` is
19294            // what holds that, by counting bytes rather than writes.
19295            assert!(
19296                gathered_appends <= straight_appends,
19297                "paged={paged}: gathering must not cost more writes for the appends, \
19298                 but cost {gathered_appends} against {straight_appends}"
19299            );
19300            assert_eq!(
19301                std::fs::read(&straight).unwrap(),
19302                std::fs::read(&gathered).unwrap(),
19303                "paged={paged}: gathering changed the file it produced"
19304            );
19305        }
19306    }
19307
19308    /// A session merges writes within the page its *file* was laid out on, and
19309    /// falls back to the format default only when the file is not paged.
19310    ///
19311    /// Asserted directly because the fixtures cannot assert it indirectly: the
19312    /// merge quantum is only visible in which writes coalesce, and any fixture
19313    /// built at the default page size makes the two answers identical. Reading
19314    /// the file's page size is the whole point of resolving this after the open
19315    /// rather than at it.
19316    #[test]
19317    fn the_merge_page_follows_the_file_rather_than_the_default() {
19318        use tempfile::tempdir;
19319
19320        let dir = tempdir().unwrap();
19321        let paged = dir.path().join("paged.h5");
19322        let plain = dir.path().join("plain.h5");
19323        gather_fixture(&paged, 1, true);
19324        gather_fixture(&plain, 1, false);
19325
19326        let s = WriteEngine::open_with_locking(&paged, FileLocking::Enabled).unwrap();
19327        assert_eq!(
19328            s.gather_page_size(),
19329            16 * 1024,
19330            "a paged file merges within its own file-space page"
19331        );
19332        assert_ne!(
19333            16 * 1024,
19334            DEFAULT_GATHER_PAGE,
19335            "the fixture must not be built at the default, or the assertion above \
19336             holds for a session that ignores the file entirely"
19337        );
19338        drop(s);
19339
19340        let s = WriteEngine::open_with_locking(&plain, FileLocking::Enabled).unwrap();
19341        assert_eq!(
19342            s.gather_page_size(),
19343            DEFAULT_GATHER_PAGE,
19344            "an unpaged file has no page size of its own"
19345        );
19346    }
19347
19348    /// Writing the status-flags byte on a file with a userblock changes that byte
19349    /// and nothing else.
19350    ///
19351    /// `set_consistency_flags` serializes the whole superblock to rewrite one
19352    /// byte of it, and the root address it carries is held absolute in memory but
19353    /// stored relative to the base address. Getting that wrong repoints the root
19354    /// past the end of the file, so a call that promised to touch a flag silently
19355    /// makes the file unreadable.
19356    ///
19357    /// Driven directly rather than through a caller, because for a long time
19358    /// neither caller could reach a non-zero base: SWMR refuses a userblock
19359    /// outright, and a page buffer required persisted free space, which is
19360    /// declined for a non-zero base. Both were refusals about something else that
19361    /// settled this as a side effect — and the day one of them moved is the day a
19362    /// test routed through it would have gone quiet. Issue #357 moved the second,
19363    /// so `a_page_buffer_marks_a_userblock_file_without_repointing_its_root`
19364    /// exercises the same conversion through a real session; this one stays as
19365    /// the direct statement of the rule.
19366    #[test]
19367    fn a_status_flag_write_leaves_a_userblock_files_root_alone() {
19368        use crate::writer::FileBuilder;
19369        use tempfile::tempdir;
19370
19371        let dir = tempdir().unwrap();
19372        let path = dir.path().join("userblock.h5");
19373        let mut b = FileBuilder::new();
19374        b.with_userblock(4096);
19375        b.create_dataset("d")
19376            .with_i32_data(&[1, 2, 3, 4])
19377            .with_shape(&[4]);
19378        b.write(&path).unwrap();
19379        let before = std::fs::read(&path).unwrap();
19380
19381        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19382        assert_eq!(
19383            s.superblock.base_address,
19384            BaseAddress::new(4096),
19385            "the fixture must have a userblock, or this test holds for a file \
19386             whose absolute and relative roots are the same number"
19387        );
19388        // Up and back down, which is the sequence a marked session performs.
19389        s.set_consistency_flags(file_lock::WRITE_ACCESS).unwrap();
19390        s.set_consistency_flags(0).unwrap();
19391        s.force_sync().unwrap();
19392        drop(s);
19393
19394        assert_eq!(
19395            std::fs::read(&path).unwrap(),
19396            before,
19397            "raising and clearing the flags must leave the file as it was"
19398        );
19399        assert_eq!(
19400            crate::reader::File::open(&path)
19401                .unwrap()
19402                .dataset("d")
19403                .unwrap()
19404                .read_i32()
19405                .unwrap(),
19406            vec![1, 2, 3, 4],
19407            "and the file must still read"
19408        );
19409    }
19410
19411    /// The budget a caller asks for is the budget the gatherer is given, so a
19412    /// session that outruns it flushes rather than growing without bound.
19413    ///
19414    /// `set_page_buffer_size` spends most of its length on refusals, and the one
19415    /// line that does the work hands `max_bytes` on. Passing `usize::MAX` instead
19416    /// fails nothing else in the suite: every other page-buffer test runs a
19417    /// workload smaller than the budget, so it holds everything to the end either
19418    /// way and the file it leaves is identical. What that would cost is a
19419    /// long-running session holding the whole of what it wrote in memory, which
19420    /// is the one thing the budget is for.
19421    ///
19422    /// Asserted as "some writes went out before the session ended" rather than as
19423    /// a count, since how many a 4 MiB append needs is free to change.
19424    #[test]
19425    fn a_page_buffered_session_flushes_when_it_outruns_its_budget() {
19426        use tempfile::tempdir;
19427
19428        let dir = tempdir().unwrap();
19429        let path = dir.path().join("outrun.h5");
19430        gather_fixture(&path, 1, true);
19431
19432        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19433        s.set_sync_policy(SyncPolicy::OnClose);
19434        s.set_page_buffer_size(WRITE_GATHER_BYTES).unwrap();
19435        let before = s.image.issued_writes();
19436        // Four times the budget, in one append, so nothing but the budget can
19437        // account for a write going out before the teardown below.
19438        s.append_inplace_i32_phased("t0", &vec![7i32; 1 << 20], 4)
19439            .unwrap();
19440        let during = s.image.issued_writes() - before;
19441        s.force_sync().unwrap();
19442        s.release_status_flags().unwrap();
19443        drop(s);
19444
19445        assert!(
19446            during > 0,
19447            "a session that wrote four times its budget issued nothing until it \
19448             closed, so the budget it was given is not the one it asked for"
19449        );
19450        assert_eq!(
19451            crate::reader::File::open(&path)
19452                .unwrap()
19453                .dataset("t0")
19454                .unwrap()
19455                .read_i32()
19456                .unwrap()
19457                .len(),
19458            256 + (1 << 20),
19459            "and the flushed append must still read back whole"
19460        );
19461    }
19462
19463    /// A page-buffer budget below the 1 MiB a session already gathers under
19464    /// issues *more* writes — on the workload a page buffer is sold for, and on
19465    /// the one it is worst at.
19466    ///
19467    /// This is the price the public property quotes for a small budget, so it
19468    /// belongs in the suite rather than in a comment. Such a budget was refused
19469    /// outright until issue #391, on the strength of exactly this measurement;
19470    /// what changed is who decides, not what it costs. A claim about a cost that
19471    /// nothing checks is one that quietly stops being true, and a caller sizing a
19472    /// buffer against a memory cap is who pays for that.
19473    ///
19474    /// The C library charges nothing for a small buffer: `H5PB_write` sends any
19475    /// I/O of a page or more straight to the driver. Nothing bypasses this
19476    /// gatherer, so the budget is also the point at which a long run is flushed
19477    /// and restarted — which is why the second workload is one long run.
19478    ///
19479    /// Asserted as an **ordering** rather than as counts. The counts are
19480    /// deterministic on one target and are quoted on the public property, but how
19481    /// many writes a commit needs is free to fall; what must not change is which
19482    /// budget issues fewer (issues #357, #391).
19483    #[test]
19484    fn a_smaller_budget_issues_more_writes() {
19485        use tempfile::tempdir;
19486
19487        let dir = tempdir().unwrap();
19488        let fixture = |path: &std::path::Path, tables: usize| {
19489            use crate::writer::FileBuilder;
19490            let mut b = FileBuilder::new();
19491            b.with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19492                .with_file_space_page_size(4096);
19493            for t in 0..tables {
19494                b.create_dataset(&std::format!("t{t}"))
19495                    .with_i32_data(&(0..256).collect::<Vec<_>>())
19496                    .with_shape(&[256])
19497                    .with_maxshape(&[MaxExtent::Unlimited])
19498                    .with_chunks(&[64]);
19499            }
19500            b.write(path).unwrap();
19501        };
19502
19503        // The public path, which now accepts every budget down to this fixture's
19504        // 4 KiB page — so the arms differ in the budget a caller asked for and in
19505        // nothing else.
19506        let run = |path: &std::path::Path, budget: usize, workload: &dyn Fn(&mut WriteEngine)| {
19507            let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
19508            s.set_sync_policy(SyncPolicy::OnClose);
19509            s.set_page_buffer_size(budget).unwrap();
19510            let before = s.image.issued_writes();
19511            workload(&mut s);
19512            s.force_sync().unwrap();
19513            s.release_status_flags().unwrap();
19514            let issued = s.image.issued_writes() - before;
19515            drop(s);
19516            issued
19517        };
19518
19519        // Many small scattered writes — what the property is for — and one long
19520        // contiguous run, which is where a small budget collapses.
19521        let workload = |scattered: bool| {
19522            move |s: &mut WriteEngine| {
19523                if scattered {
19524                    for round in 0..4 {
19525                        for t in 0..8 {
19526                            s.append_inplace_i32_phased(&std::format!("t{t}"), &[round; 64], 4)
19527                                .unwrap();
19528                        }
19529                    }
19530                } else {
19531                    s.append_inplace_i32_phased("t0", &vec![7i32; 1 << 20], 4)
19532                        .unwrap();
19533                }
19534            }
19535        };
19536
19537        for (label, tables, scattered) in [("scattered", 8, true), ("one long run", 1, false)] {
19538            let workload = workload(scattered);
19539            let at = |budget: usize| {
19540                let path = dir
19541                    .path()
19542                    .join(std::format!("{}_{budget}.h5", label.replace(' ', "_")));
19543                fixture(&path, tables);
19544                run(&path, budget, &workload)
19545            };
19546            let ample = at(WRITE_GATHER_BYTES);
19547            let smallest = at(4096);
19548            for smaller in [4096, 64 * 1024] {
19549                let below = at(smaller);
19550                assert!(
19551                    ample <= below,
19552                    "{label}: a {smaller}-byte budget issued {below} writes against \
19553                     {ample} at {WRITE_GATHER_BYTES}, so a smaller budget is not the \
19554                     trade the property documents"
19555                );
19556            }
19557            // Not vacuous: a gatherer that ignored `max_bytes` outright would
19558            // report the same count at every budget and satisfy the ordering
19559            // above without honoring any of them. One page is a quarter of the
19560            // smallest step measured here, so a workload that does not separate
19561            // there is one this assertion cannot speak for.
19562            assert!(
19563                smallest > ample,
19564                "{label}: the budget changed nothing — {smallest} writes at 4 KiB \
19565                 against {ample} at 1 MiB — so this workload cannot say which \
19566                 budget is better"
19567            );
19568        }
19569    }
19570
19571    /// A page-buffered session on a file with a userblock raises and clears its
19572    /// crash mark without disturbing the root address that mark's superblock
19573    /// rewrite carries — and the userblock's own bytes stay put.
19574    ///
19575    /// The caller-side half of
19576    /// `a_status_flag_write_leaves_a_userblock_files_root_alone`, reachable only
19577    /// since issue #357 scoped the persisted-free-space refusal to paged files.
19578    /// A userblock file persists no free space whatever its creation properties
19579    /// asked for, so before that the mark could not be raised on one at all, and
19580    /// `set_consistency_flags`'s base conversion had no caller that needed it.
19581    #[test]
19582    fn a_page_buffer_marks_a_userblock_file_without_repointing_its_root() {
19583        use crate::writer::FileBuilder;
19584        use tempfile::tempdir;
19585
19586        let dir = tempdir().unwrap();
19587        let path = dir.path().join("ub_buffered.h5");
19588        let mut b = FileBuilder::new();
19589        b.with_userblock(4096);
19590        b.create_dataset("t0")
19591            .with_i32_data(&(0..256).collect::<Vec<_>>())
19592            .with_shape(&[256]);
19593        b.write(&path).unwrap();
19594        let userblock_before = std::fs::read(&path).unwrap()[..4096].to_vec();
19595
19596        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19597        assert_eq!(
19598            s.superblock.base_address,
19599            BaseAddress::new(4096),
19600            "the fixture must have a userblock, or this test says nothing about \
19601             the conversion it exists for"
19602        );
19603        s.set_sync_policy(SyncPolicy::OnClose);
19604        s.set_page_buffer_size(1 << 20)
19605            .expect("an unpaged userblock file must accept a page buffer");
19606        // The mark is the point: a session that accepted the property and raised
19607        // nothing would pass every assertion below, since the conversion under
19608        // test runs only when the flags byte is written, and `raise_crash_mark`
19609        // is the only thing that writes it here. Asserted in memory rather than
19610        // off the disk because the session holds an exclusive OS lock on the
19611        // file, which is mandatory on Windows.
19612        assert_eq!(
19613            s.held_status_flags,
19614            file_lock::WRITE_ACCESS,
19615            "a page-buffered session must hold the crash mark"
19616        );
19617        // A staged commit rather than an in-place append: in-place appending is
19618        // refused on a userblock file for reasons of its own, which is a
19619        // different subject and would only mask this one.
19620        for t in 0..3 {
19621            let mut db = crate::type_builders::DatasetBuilder::new(&std::format!("n{t}"));
19622            db.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19623            s.stage_created_dataset(&ObjectPathBuf::parse(&std::format!("/n{t}")), db)
19624                .unwrap();
19625        }
19626        s.commit().unwrap();
19627        s.force_sync().unwrap();
19628        s.release_status_flags().unwrap();
19629        drop(s);
19630
19631        assert_eq!(
19632            &std::fs::read(&path).unwrap()[..4096],
19633            &userblock_before[..],
19634            "the page-buffered session rewrote the userblock"
19635        );
19636        let f = crate::reader::File::open(&path).unwrap();
19637        assert_eq!(
19638            f.dataset("t0").unwrap().read_i32().unwrap(),
19639            (0..256).collect::<Vec<_>>(),
19640            "the commit must have left the original dataset alone"
19641        );
19642        for t in 0..3 {
19643            assert_eq!(
19644                f.dataset(&std::format!("n{t}"))
19645                    .unwrap()
19646                    .read_f64()
19647                    .unwrap(),
19648                vec![2.5f64; 32],
19649                "n{t}: the committed dataset must read back"
19650            );
19651        }
19652    }
19653
19654    /// A page buffer keeps dirty pages across operations, so a workload that
19655    /// touches the same pages again and again pays for them once rather than
19656    /// once per operation (issue #288) — and still produces the same file.
19657    ///
19658    /// This is what the default gathering deliberately does *not* do, so the
19659    /// comparison is against that default rather than against no gathering at
19660    /// all: what is being measured is the second reduction, not the first.
19661    ///
19662    /// Run on a paged file **and an unpaged one**. `H5Pset_page_buffer_size`
19663    /// requires the paged allocator because the C page buffer is a page cache
19664    /// whose `min_meta_perc`/`min_raw_perc` reservations count pages that
19665    /// allocator keeps segregated by kind; this is a write gatherer, which needs
19666    /// only a window to merge within, and
19667    /// [`gather_page_size`](WriteEngine::gather_page_size) supplies one either
19668    /// way. The unpaged arm is what says so (issue #357), and it is the arm that
19669    /// matters most in practice: unpaged is the default strategy, so before this
19670    /// the property was reachable only from a file deliberately created paged.
19671    #[test]
19672    fn a_page_buffer_holds_dirty_pages_across_operations() {
19673        use tempfile::tempdir;
19674
19675        let dir = tempdir().unwrap();
19676        let run = |path: &std::path::Path, page_buffer: usize| {
19677            let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
19678            s.set_sync_policy(SyncPolicy::OnClose);
19679            if page_buffer != 0 {
19680                s.set_page_buffer_size(page_buffer).unwrap();
19681            }
19682            let (appends, commit) = gather_workload(&mut s);
19683            // The teardown `File::close` and `FileInner::drop` perform, in their
19684            // order. A bare engine owes it too — it is what takes the crash mark
19685            // down — and nothing does it for one: this crate deliberately keeps
19686            // teardown writes off `WriteEngine::drop`, so that dropping a probe
19687            // session (as the bounded/mirrored dispatch does) writes nothing.
19688            // Without it the buffered file ends still marked, and the byte
19689            // comparison below is what says so.
19690            s.force_sync().unwrap();
19691            s.release_status_flags().unwrap();
19692            drop(s);
19693            appends + commit
19694        };
19695
19696        for paged in [true, false] {
19697            let label = if paged { "paged" } else { "unpaged" };
19698            let per_op = dir.path().join(std::format!("{label}_per_op.h5"));
19699            let buffered = dir.path().join(std::format!("{label}_buffered.h5"));
19700            gather_fixture(&per_op, 4, paged);
19701            gather_fixture(&buffered, 4, paged);
19702
19703            let per_op_writes = run(&per_op, 0);
19704            let buffered_writes = run(&buffered, 1 << 20);
19705
19706            assert!(
19707                buffered_writes * 4 < per_op_writes,
19708                "{label}: a page buffer must cost meaningfully fewer writes than the \
19709                 per-operation default, but cost {buffered_writes} against {per_op_writes}"
19710            );
19711            assert_eq!(
19712                std::fs::read(&per_op).unwrap(),
19713                std::fs::read(&buffered).unwrap(),
19714                "{label}: a page buffer changed the file it produced"
19715            );
19716        }
19717    }
19718
19719    /// A budget below the 1 MiB a session already gathers under is honored end
19720    /// to end, and leaves the same file 1 MiB leaves (issue #391).
19721    ///
19722    /// The floor this replaces was a write-count argument with no correctness
19723    /// claim behind it, so the assertion that carries the weight is the byte
19724    /// comparison rather than the read-back: a buffer that flushes and restarts
19725    /// more often must produce the *same* file, not merely a readable one.
19726    ///
19727    /// Run through the public API — create, append, commit, close, reopen —
19728    /// because `File::create_with_options` is where the refusal used to fire, on
19729    /// a pair it had already decided it could not reopen with. A 16 KiB-paged
19730    /// file at 256 KiB is the case the issue reported.
19731    #[test]
19732    fn a_page_buffer_below_the_gather_budget_writes_the_same_file() {
19733        use tempfile::tempdir;
19734
19735        let dir = tempdir().unwrap();
19736        let run = |path: &std::path::Path, budget: usize| -> Vec<i32> {
19737            let file = crate::reader::File::create_with_options(
19738                path,
19739                crate::FileCreateProperties::new()
19740                    .with_file_space_strategy(FileSpaceStrategy::Page, true, 1)
19741                    .with_file_space_page_size(16 * 1024),
19742                crate::FileAccessProperties::new()
19743                    .with_sync_policy(SyncPolicy::OnClose)
19744                    .with_page_buffer_size(budget),
19745            )
19746            .unwrap();
19747            file.root()
19748                .create_dataset("d", |b| {
19749                    b.with_i32_data(&(0..64).collect::<Vec<i32>>())
19750                        .with_shape(&[64])
19751                        .with_maxshape(&[MaxExtent::Unlimited])
19752                        .with_chunks(&[64]);
19753                })
19754                .unwrap();
19755            file.commit().unwrap();
19756            for round in 0..8i32 {
19757                let mut ds = file.dataset("d").unwrap();
19758                ds.append(&vec![round; 64]).unwrap();
19759            }
19760            file.root()
19761                .create_dataset("added", |b| {
19762                    b.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19763                })
19764                .unwrap();
19765            file.commit().unwrap();
19766            file.close().unwrap();
19767
19768            let reopened = crate::reader::File::open(path).unwrap();
19769            assert_eq!(
19770                reopened.dataset("added").unwrap().read_f64().unwrap(),
19771                vec![2.5f64; 32],
19772                "the dataset committed through the buffer must read back"
19773            );
19774            reopened.dataset("d").unwrap().read_i32().unwrap()
19775        };
19776
19777        let small = dir.path().join("small_budget.h5");
19778        let ample = dir.path().join("ample_budget.h5");
19779        let from_small = run(&small, 256 * 1024);
19780        let from_ample = run(&ample, 1 << 20);
19781
19782        let mut expected: Vec<i32> = (0..64).collect();
19783        for round in 0..8i32 {
19784            expected.extend(std::iter::repeat_n(round, 64));
19785        }
19786        assert_eq!(
19787            from_small, expected,
19788            "a 256 KiB page buffer did not append what it was given"
19789        );
19790        assert_eq!(
19791            from_ample, expected,
19792            "and neither did the 1 MiB one, so the file comparison below would be \
19793             two arms agreeing on the wrong answer"
19794        );
19795        assert_eq!(
19796            std::fs::read(&small).unwrap(),
19797            std::fs::read(&ample).unwrap(),
19798            "a 256 KiB page buffer produced a different file from a 1 MiB one"
19799        );
19800    }
19801
19802    /// A barrier issues what has been gathered under **every** policy, so the
19803    /// bytes a publish point names are on the disk before the publish point is.
19804    ///
19805    /// Gathered writes are issued in address order, and the superblock lives at
19806    /// address 0 — so a commit whose barriers issued nothing would put its new
19807    /// root pointer on the disk *first* and the content it names last. A write
19808    /// that then failed, or a process that died mid-flush, would leave a
19809    /// superblock naming bytes that are not in the file, where before this
19810    /// gathering existed it left the previous file intact.
19811    ///
19812    /// `SyncPolicy::Always` cannot see this: its barriers are `fsync`s, which
19813    /// flush on their way out. Every crash-consistency test in this crate runs on
19814    /// that default, which is exactly why nothing caught it (issue #288). So this
19815    /// runs on `OnClose`, and asserts the order rather than a count — a count
19816    /// would be satisfied by a session that issued everything at the wrong time.
19817    ///
19818    /// It covers two of the three ordering sites: `barrier` (the commit tail) and
19819    /// `EditStore::sync` (the append phases). The third is `barrier_data`, which
19820    /// `EditStore::sync` now shares rather than duplicates, so it is correct by
19821    /// identity rather than by argument. It is covered too, one test over:
19822    /// mutating its `OnClose` arm to `Ok(())` fails
19823    /// `sync_policy_governs_the_persisting_and_flag_barriers` and nothing else.
19824    #[test]
19825    fn a_barrier_orders_the_publish_point_last_under_every_policy() {
19826        use tempfile::tempdir;
19827
19828        let dir = tempdir().unwrap();
19829        for policy in [SyncPolicy::Always, SyncPolicy::OnClose] {
19830            let path = dir.path().join(std::format!("order_{policy:?}.h5"));
19831            gather_fixture(&path, 1, false);
19832
19833            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19834            s.set_sync_policy(policy);
19835
19836            let mut db = crate::type_builders::DatasetBuilder::new("added");
19837            db.with_f64_data(&[2.5f64; 64]).with_shape(&[64]);
19838            s.stage_created_dataset(&ObjectPathBuf::parse("/added"), db)
19839                .unwrap();
19840            let before = s.image.issued_write_order().len();
19841            s.commit().unwrap();
19842
19843            let order = s.image.issued_write_order()[before..].to_vec();
19844            let superblock = order
19845                .iter()
19846                .position(|&(at, _)| at == s.sb_sig_off)
19847                .unwrap_or_else(|| panic!("{policy:?}: the commit never wrote the superblock"));
19848            let content = order
19849                .iter()
19850                .rposition(|&(at, _)| at != s.sb_sig_off)
19851                .unwrap_or_else(|| panic!("{policy:?}: the commit wrote nothing but a superblock"));
19852            assert!(
19853                superblock > content,
19854                "{policy:?}: the superblock was issued at position {superblock} of \
19855                 {order:?}, ahead of content at {content} — a failure in that window \
19856                 leaves a root pointing at bytes that are not in the file"
19857            );
19858
19859            // The same for the append engine's four phases, whose publish point is
19860            // the dataspace dimension in the object header — a *low* address, with
19861            // the chunk bytes it makes visible at end-of-file. Stated as "the last
19862            // write is not the highest one", which is precisely what a single
19863            // address-ordered flush of the whole append would make it.
19864            let before = s.image.issued_write_order().len();
19865            s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
19866            let order = s.image.issued_write_order()[before..].to_vec();
19867            let highest = order
19868                .iter()
19869                .map(|&(at, _)| at)
19870                .max()
19871                .expect("the append wrote");
19872            assert!(
19873                order.last().expect("the append wrote").0 < highest,
19874                "{policy:?}: the append's last write is its highest-addressed one, so \
19875                 the whole append went out in address order and the dimension that \
19876                 publishes the new rows preceded the chunk bytes: {order:?}"
19877            );
19878        }
19879    }
19880
19881    /// The page buffer produces the same bytes through the **bounded** backing,
19882    /// which is the one `File::open_rw` actually picks for a latest-format file.
19883    ///
19884    /// The test above drives the whole-file mirror, whose reads come from memory
19885    /// and so never meet the pending writes at all. The bounded image has no
19886    /// mirror: every read it serves goes to the disk and is then patched with
19887    /// whatever is still gathered, so it is the backing where a wrong overlay
19888    /// silently plans the next edit against bytes that are neither on the disk nor
19889    /// in the buffer. Byte identity against the same session without the buffer is
19890    /// the assertion a wrong overlay cannot pass.
19891    #[test]
19892    fn a_page_buffer_changes_no_byte_through_the_bounded_backing() {
19893        use crate::reader::File;
19894        use tempfile::tempdir;
19895
19896        let dir = tempdir().unwrap();
19897        let run = |name: &str, page_buffer: usize| {
19898            let path = dir.path().join(name);
19899            gather_fixture(&path, 4, true);
19900            {
19901                let props = crate::FileAccessProperties::new()
19902                    .with_sync_policy(SyncPolicy::OnClose)
19903                    .with_memory_strategy(MemoryStrategy::Bounded)
19904                    .with_page_buffer_size(page_buffer);
19905                let file = File::open_rw_with_options(&path, props).unwrap();
19906                assert_eq!(
19907                    file.edit_backing(),
19908                    Some(crate::EditBacking::Bounded),
19909                    "{name}: this must exercise the mirrorless backing"
19910                );
19911                let root = file.root();
19912                for round in 0..4u8 {
19913                    for t in 0..4 {
19914                        let mut ds = file.dataset(&std::format!("t{t}")).unwrap();
19915                        ds.append(&[i32::from(round); 64]).unwrap();
19916                    }
19917                }
19918                for t in 0..4 {
19919                    root.create_dataset(&std::format!("n{t}"), |b| {
19920                        b.with_f64_data(&[2.5f64; 32]).with_shape(&[32]);
19921                    })
19922                    .unwrap();
19923                }
19924                file.commit().unwrap();
19925                root.set_attr("tag", crate::AttrValue::I32(1)).unwrap();
19926                file.commit().unwrap();
19927                file.close().unwrap();
19928            }
19929            std::fs::read(&path).unwrap()
19930        };
19931
19932        assert_eq!(
19933            run("bounded_plain.h5", 0),
19934            run("bounded_buffered.h5", 1 << 20),
19935            "a page buffer changed the file the bounded backing produced"
19936        );
19937    }
19938
19939    /// An operation that has returned has put its bytes in the operating system,
19940    /// whatever the [`SyncPolicy`] says.
19941    ///
19942    /// This is what makes the default gathering free rather than a trade: the
19943    /// bytes are held only *inside* a commit or an append, never across one. It
19944    /// is asserted as "a forced sync afterwards finds nothing left to write",
19945    /// because the alternative — reading the file through a second handle — is
19946    /// what the session's own exclusive lock exists to prevent, mandatorily so on
19947    /// Windows.
19948    #[test]
19949    fn a_finished_operation_has_nothing_left_to_write() {
19950        use tempfile::tempdir;
19951
19952        let dir = tempdir().unwrap();
19953        let path = dir.path().join("finished_op.h5");
19954        gather_fixture(&path, 1, false);
19955
19956        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
19957        // The policy that issues no `fsync` at all, so the ordering barriers are
19958        // the only thing that can be draining the buffer.
19959        s.set_sync_policy(SyncPolicy::OnClose);
19960
19961        let before_append = s.image.issued_writes();
19962        s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
19963        let after_append = s.image.issued_writes();
19964        assert!(
19965            after_append > before_append,
19966            "the append issued nothing at all, so the equality below holds for a \
19967             session that did no work"
19968        );
19969        s.force_sync().unwrap();
19970        assert_eq!(
19971            s.image.issued_writes(),
19972            after_append,
19973            "a finished append left writes in this process's memory"
19974        );
19975
19976        let mut db = crate::type_builders::DatasetBuilder::new("added");
19977        db.with_f64_data(&[1.5f64; 8]).with_shape(&[8]);
19978        s.stage_created_dataset(&ObjectPathBuf::parse("/added"), db)
19979            .unwrap();
19980        s.commit().unwrap();
19981        let after_commit = s.image.issued_writes();
19982        assert!(
19983            after_commit > after_append,
19984            "the commit issued nothing at all"
19985        );
19986        s.force_sync().unwrap();
19987        assert_eq!(
19988            s.image.issued_writes(),
19989            after_commit,
19990            "a finished commit left writes in this process's memory"
19991        );
19992
19993        // And the bytes are the ones they were meant to be — an image that issued
19994        // writes at the right moments but the wrong contents passes everything
19995        // above.
19996        drop(s);
19997        let f = crate::reader::File::open(&path).unwrap();
19998        assert_eq!(f.dataset("t0").unwrap().read_i32().unwrap().len(), 320);
19999        assert_eq!(
20000            f.dataset("added").unwrap().read_f64().unwrap(),
20001            vec![1.5f64; 8]
20002        );
20003    }
20004
20005    /// Overwriting the root group is refused by name.
20006    ///
20007    /// The refusal lives in `stage_dataset_write` rather than in the commit,
20008    /// because the staged dataset is flattened as it is staged and the root's
20009    /// empty path would otherwise be reported as a dataset with no name. No
20010    /// public entry point can reach it — `Dataset::write_staged` runs off a
20011    /// resolved dataset path — so this is where it stays covered.
20012    #[test]
20013    fn overwriting_the_root_group_is_refused_at_staging() {
20014        use crate::writer::FileBuilder;
20015        use tempfile::tempdir;
20016
20017        let dir = tempdir().unwrap();
20018        let path = dir.path().join("root_overwrite.h5");
20019        let mut b = FileBuilder::new();
20020        b.create_dataset("d").with_i32_data(&[1]);
20021        b.write(&path).unwrap();
20022
20023        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20024        let mut db = crate::type_builders::DatasetBuilder::new("whatever");
20025        db.with_i32_data(&[1]);
20026        let err = s
20027            .stage_dataset_write(&ObjectPathBuf::parse("/"), db)
20028            .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
20064            .stage_created_dataset(&ObjectPathBuf::parse("/sparse"), db)
20065            .unwrap_err();
20066        assert!(
20067            matches!(&err, Error::EditUnsupported(m) if m.contains("unallocated storage")),
20068            "unexpected error: {err:?}"
20069        );
20070        assert!(!s.has_staged_edits());
20071    }
20072
20073    /// The same obligation on the one operation that does not go through
20074    /// [`commit`](WriteEngine::commit): the free-space finalize a session owes at
20075    /// teardown.
20076    ///
20077    /// Its two callers force a sync straight after it, so nothing observable
20078    /// breaks when it forgets — which is exactly why it needs its own test. It is
20079    /// `pub(crate)`, and the next caller would inherit the omission silently.
20080    #[test]
20081    fn finalize_persist_has_nothing_left_to_write() {
20082        use crate::writer::FileBuilder;
20083        use tempfile::tempdir;
20084
20085        let dir = tempdir().unwrap();
20086        let path = dir.path().join("persisting.h5");
20087        let mut b = FileBuilder::new();
20088        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20089        b.create_dataset("t0")
20090            .with_i32_data(&(0..256).collect::<Vec<_>>())
20091            .with_shape(&[256])
20092            .with_maxshape(&[MaxExtent::Unlimited])
20093            .with_chunks(&[64]);
20094        b.write(&path).unwrap();
20095
20096        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20097        s.set_sync_policy(SyncPolicy::OnClose);
20098        // Grow the file past the managers, which is what gives the finalize
20099        // something to re-home.
20100        s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
20101
20102        s.finalize_persist().unwrap();
20103        let after = s.image.issued_writes();
20104        s.force_sync().unwrap();
20105        assert_eq!(
20106            s.image.issued_writes(),
20107            after,
20108            "finalize_persist returned with writes still gathered"
20109        );
20110    }
20111
20112    /// [`SpaceAccounting::reusable_free_space`] stays coalesced once an in-place
20113    /// append holds a reserve (issue #387).
20114    ///
20115    /// The reserve is drawn *out of* the session's free list, so what is left of
20116    /// the hole it came from sits immediately beside it — and as the append
20117    /// spends the reserve down, the spent end walks toward that remainder until
20118    /// the two abut exactly. Reporting them as two regions would break the
20119    /// field's documented contract ("no two regions touch or overlap") in the one
20120    /// way that misleads: a caller sizing an allocation against the largest
20121    /// region would be told nothing that big fits when it does.
20122    ///
20123    /// Sixteen chunk-sized appends, checked after every one, because the abutment
20124    /// appears only at a particular point in spending a batch rather than at the
20125    /// first append.
20126    #[test]
20127    fn the_append_reserve_is_reported_as_one_coalesced_free_list() {
20128        use crate::writer::FileBuilder;
20129        use tempfile::tempdir;
20130
20131        /// Elements of the dataset the delete vacates: three reserve batches, so
20132        /// the hole outlives several draws.
20133        const VICTIM: usize = (APPEND_RESERVE_BYTES as usize * 3) / 4;
20134        /// Elements per chunk: 64 KiB, so sixteen appends spend a whole batch.
20135        const CHUNK: u64 = 16384;
20136
20137        let dir = tempdir().unwrap();
20138        let path = dir.path().join("reserve_coalesced.h5");
20139        let mut b = FileBuilder::new();
20140        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20141        b.create_dataset("t0")
20142            .with_i32_data(&[0i32])
20143            .with_shape(&[1])
20144            .with_maxshape(&[MaxExtent::Unlimited])
20145            .with_chunks(&[CHUNK]);
20146        b.create_dataset("victim")
20147            .with_i32_data(&vec![7i32; VICTIM]);
20148        b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20149        b.write(&path).unwrap();
20150
20151        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20152        s.set_sync_policy(SyncPolicy::OnClose);
20153        s.delete(&ObjectPathBuf::parse("victim")).unwrap();
20154        s.commit().unwrap();
20155
20156        let batch: Vec<i32> = (0..CHUNK as i32).collect();
20157        let mut reserved_ever = false;
20158        for round in 0..16 {
20159            s.append_inplace_i32_phased("t0", &batch, 4).unwrap();
20160            reserved_ever |= !s.reserved.is_empty();
20161            let acct = s.space_accounting();
20162            let regions = &acct.reusable_free_space;
20163            for w in regions.windows(2) {
20164                let ((a_addr, a_len), (b_addr, _)) = (w[0], w[1]);
20165                assert!(
20166                    a_addr + a_len < b_addr,
20167                    "round {round}: [{a_addr}, {}) and [{b_addr}, ..) touch or overlap, \
20168                     though reusable_free_space is documented as fully coalesced: {regions:?}",
20169                    a_addr + a_len
20170                );
20171            }
20172            assert_eq!(
20173                acct.reusable_free_bytes,
20174                regions.iter().map(|&(_, len)| len).sum::<u64>(),
20175                "round {round}: the total must be the summed length of the regions"
20176            );
20177        }
20178        assert!(
20179            reserved_ever,
20180            "no reserve was ever held, so this measured the plain free list"
20181        );
20182    }
20183
20184    /// A persisting session gives its **unspent** append reserve back to the
20185    /// on-disk managers before it closes (issue #387).
20186    ///
20187    /// An append on such a file takes a batch out of the managers before it may
20188    /// spend any of it, and takes a whole batch however little the append needs.
20189    /// What is left over is space nothing occupies, so leaving it out of the
20190    /// managers would turn every appending session into a leak of up to
20191    /// [`APPEND_RESERVE_BYTES`] — invisible from inside the session, since its own
20192    /// accounting still counts the reserve as reusable.
20193    ///
20194    /// Measured from outside the session for that reason: the persisted free
20195    /// space after the close, against what it was before the append.
20196    #[test]
20197    fn an_unspent_append_reserve_goes_back_to_the_managers() {
20198        use crate::writer::FileBuilder;
20199        use tempfile::tempdir;
20200
20201        /// Elements of the dataset the delete below vacates. Its blocks are the
20202        /// hole the append draws from, so it has to exceed one reserve batch or
20203        /// nothing is drawn and this measures the old behaviour.
20204        const VICTIM: usize = (APPEND_RESERVE_BYTES as usize * 2) / 4;
20205
20206        let dir = tempdir().unwrap();
20207        let path = dir.path().join("reserve_return.h5");
20208        let mut b = FileBuilder::new();
20209        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20210        b.create_dataset("t0")
20211            .with_i32_data(&(0..256).collect::<Vec<_>>())
20212            .with_shape(&[256])
20213            .with_maxshape(&[MaxExtent::Unlimited])
20214            .with_chunks(&[64]);
20215        b.create_dataset("victim")
20216            .with_i32_data(&vec![7i32; VICTIM]);
20217        // Above the hole, so the delete leaves an interior region rather than a
20218        // run reaching end-of-file that the commit truncates away.
20219        b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20220        b.write(&path).unwrap();
20221
20222        let persisted = |p: &std::path::Path| -> u64 {
20223            crate::reader::File::open(p)
20224                .unwrap()
20225                .persisted_free_space()
20226                .iter()
20227                .map(|&(_, len)| len)
20228                .sum()
20229        };
20230
20231        {
20232            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20233            s.set_sync_policy(SyncPolicy::OnClose);
20234            s.delete(&ObjectPathBuf::parse("victim")).unwrap();
20235            s.commit().unwrap();
20236        }
20237        let before = persisted(&path);
20238        assert!(
20239            before > APPEND_RESERVE_BYTES,
20240            "the fixture must leave more than one reserve batch on disk, not {before} bytes"
20241        );
20242
20243        // A few kilobytes of append against a megabyte of reserve.
20244        let appended: Vec<i32> = (0..1024).collect();
20245        {
20246            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20247            s.set_sync_policy(SyncPolicy::OnClose);
20248            s.append_inplace_i32_phased("t0", &appended, 4).unwrap();
20249            s.finalize_persist().unwrap();
20250        }
20251
20252        let after = persisted(&path);
20253        // What the append actually placed: its chunks and the index blocks beside
20254        // them, plus the manager rewrite's own churn. Generous, and still an order
20255        // of magnitude under the batch that would go missing.
20256        let spent = 64 * 1024;
20257        assert!(
20258            after + spent >= before,
20259            "the session reserved {APPEND_RESERVE_BYTES} bytes and spent a few of them, \
20260             so the managers should still describe nearly all of the {before} they did \
20261             before — they describe {after}"
20262        );
20263        assert!(
20264            after < before,
20265            "the append placed bytes inside the hole, so the managers must describe \
20266             less than the {before} they did (they describe {after})"
20267        );
20268
20269        let f = crate::reader::File::open(&path).unwrap();
20270        let mut want = (0..256).collect::<Vec<i32>>();
20271        want.extend_from_slice(&appended);
20272        assert_eq!(f.dataset("t0").unwrap().read_i32().unwrap(), want);
20273        assert_eq!(f.dataset("ceiling").unwrap().read_i32().unwrap(), [9, 9, 9]);
20274    }
20275
20276    /// A persisting session's fixture for the draw tests: `t0` to append onto,
20277    /// `victims` datasets of `victim_elems` `i32`s each, every one followed by a
20278    /// small keeper so that deleting the victims leaves that many *separate*
20279    /// holes, and a `ceiling` above them all so none of the holes is trailing.
20280    fn fragmented_persisting_fixture(
20281        path: &std::path::Path,
20282        victims: usize,
20283        victim_elems: usize,
20284    ) -> WriteEngine {
20285        use crate::writer::FileBuilder;
20286
20287        let mut b = FileBuilder::new();
20288        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 1);
20289        b.create_dataset("t0")
20290            .with_i32_data(&[0i32])
20291            .with_shape(&[1])
20292            .with_maxshape(&[MaxExtent::Unlimited])
20293            .with_chunks(&[64]);
20294        for v in 0..victims {
20295            b.create_dataset(&std::format!("victim{v}"))
20296                .with_i32_data(&vec![7i32; victim_elems]);
20297            b.create_dataset(&std::format!("keeper{v}"))
20298                .with_i32_data(&[v as i32]);
20299        }
20300        b.create_dataset("ceiling").with_i32_data(&[9i32, 9, 9]);
20301        b.write(path).unwrap();
20302
20303        let mut s = WriteEngine::open_with_locking(path, FileLocking::Enabled).unwrap();
20304        s.set_sync_policy(SyncPolicy::OnClose);
20305        for v in 0..victims {
20306            s.delete(&ObjectPathBuf::parse(&std::format!("victim{v}")))
20307                .unwrap();
20308        }
20309        s.commit().unwrap();
20310        s
20311    }
20312
20313    /// One draw takes every hole the append fits in, not only the largest, so a
20314    /// file fragmented into small holes pays one manager rewrite for all of them
20315    /// rather than one per hole (issue #413).
20316    ///
20317    /// Three holes of a few chunks each, each far under a batch, separated by
20318    /// live objects so they cannot coalesce. One chunk-sized append then has to
20319    /// leave the reserve holding runs from more than one of them: a draw that
20320    /// took the largest hole alone would have served the chunk just as well and
20321    /// held exactly one run, so the count is what tells the two apart.
20322    #[test]
20323    fn a_draw_gathers_every_hole_the_append_fits_in() {
20324        use tempfile::tempdir;
20325
20326        let dir = tempdir().unwrap();
20327        let path = dir.path().join("draw_gathers.h5");
20328        let mut s = fragmented_persisting_fixture(&path, 3, 4 * 64);
20329        assert!(
20330            s.free.sections().len() >= 3,
20331            "the fixture must leave three separate holes, not {:?}",
20332            s.free.sections()
20333        );
20334        let before = s.free.sections();
20335
20336        s.append_inplace_i32_phased("t0", &[1i32; 64], 4).unwrap();
20337
20338        let held = s.reserved.sections();
20339        // A run per hole, less what the chunk and the rewrite's tail took out of
20340        // them: the chunk out of one, the tail out of at most one more.
20341        assert!(
20342            held.len() >= 2,
20343            "one draw should have gathered runs from several of the holes {before:?}, \
20344             not only {held:?}"
20345        );
20346        let drawn: u64 = held.iter().map(|&(_, len)| len).sum();
20347        assert!(
20348            drawn > before.iter().map(|&(_, len)| len).max().unwrap(),
20349            "the reserve holds {drawn} bytes, no more than the largest hole alone"
20350        );
20351    }
20352
20353    /// The manager rewrite that publishes a draw places its own tail inside the
20354    /// space the draw is taking when the draw has emptied the free list, rather
20355    /// than appending it (issue #413).
20356    ///
20357    /// On a flat file the draw and the tail are served from the same list, and
20358    /// a draw takes every hole the append fits in, so the case is the ordinary
20359    /// one, not a corner: without the fallback every draw appended one tail at
20360    /// end-of-file and freed the previous one, a persisting file's whole growth
20361    /// under churn once the floor on the draw was gone. Measured as the file's
20362    /// length across the append, which a tail landing anywhere inside leaves
20363    /// alone.
20364    ///
20365    /// Measured as the tail's own address rather than as an unchanged length: the
20366    /// rewrite supersedes the fixture's tail, which sat at end-of-file, so
20367    /// releasing that trailing run leaves the file *shorter* than it started
20368    /// (issue #418). An appended tail would land at exactly the pre-append
20369    /// end-of-file, which is the case both assertions rule out.
20370    #[test]
20371    fn a_draws_rewrite_places_its_tail_inside_the_draw() {
20372        use tempfile::tempdir;
20373
20374        let dir = tempdir().unwrap();
20375        let path = dir.path().join("draw_tail.h5");
20376        let mut s = fragmented_persisting_fixture(&path, 1, 8 * 64);
20377        let len_before = s.image.len();
20378
20379        s.append_inplace_i32_phased("t0", &[1i32; 64], 4).unwrap();
20380
20381        assert!(
20382            !s.reserved.is_empty(),
20383            "no reserve was drawn, so this measured an ordinary end-of-file append"
20384        );
20385        let ext = s.superblock.superblock_extension_address.unwrap();
20386        assert!(
20387            ext < len_before,
20388            "the rewrite appended its tail at {ext}, the pre-append end-of-file \
20389             {len_before}, instead of placing it in the hole it was publishing as taken"
20390        );
20391        assert!(
20392            s.image.len() <= len_before,
20393            "a rewrite that placed its tail inside the file must not have grown it \
20394             (was {len_before}, now {})",
20395            s.image.len()
20396        );
20397    }
20398
20399    /// One in-place append costs a small constant number of writes even where
20400    /// nothing gathers them — the case the Extensible-Array header's six
20401    /// statistics were written separately for.
20402    ///
20403    /// Under gathering, six adjacent writes and one merge into the same page
20404    /// write, so the whole suite is blind to which one the engine made. The SWMR
20405    /// writer is the regime where it still shows: it gathers nothing by design,
20406    /// so every write the engine makes is a syscall. Stated as a ceiling on the
20407    /// count rather than as an exact figure, since what an append needs is free to
20408    /// fall — and it has: for the append this measures, the first into a dataset,
20409    /// 12 before issue #307 published each checksummed structure in one write and
20410    /// 8 after; an append that reuses the data block that one allocates costs 5.
20411    /// Six statistics written singly puts it five over; a checksum written apart
20412    /// from the value it covers, four.
20413    #[test]
20414    fn an_unbuffered_append_costs_a_small_constant_number_of_writes() {
20415        use tempfile::tempdir;
20416
20417        let dir = tempdir().unwrap();
20418        let path = dir.path().join("swmr_cost.h5");
20419        gather_fixture(&path, 1, false);
20420
20421        let mut s = WriteEngine::open_swmr_writer(&path, SyncPolicy::OnClose).unwrap();
20422        let before = s.image.issued_writes();
20423        s.append_inplace_i32_phased("t0", &[7; 64], 4).unwrap();
20424        let cost = s.image.issued_writes() - before;
20425
20426        assert!(
20427            cost > 0,
20428            "the append issued nothing, so the ceiling below proves nothing"
20429        );
20430        assert!(
20431            cost <= 10,
20432            "an unbuffered append costs {cost} writes; the array header's six \
20433             statistics belong in one write, not six, and each checksum belongs in \
20434             the write that changed what it covers (measured at 8)"
20435        );
20436    }
20437
20438    /// A SWMR writer gathers nothing: its readers follow the ordered phases as
20439    /// they become visible, and a phase that has not reached the operating system
20440    /// is a phase the reader cannot see.
20441    ///
20442    /// Stopping inside the durability sequence is what makes this selective. A
20443    /// completed append ends with a barrier, so it would land on disk under
20444    /// either setting; only a stop *inside* the sequence distinguishes a writer
20445    /// that gathers from one that does not. The file is read through a second handle, which SWMR
20446    /// permits precisely because it takes no lock.
20447    #[test]
20448    fn the_swmr_writer_holds_no_write_back() {
20449        use tempfile::tempdir;
20450
20451        let dir = tempdir().unwrap();
20452        let path = dir.path().join("swmr.h5");
20453        gather_fixture(&path, 1, false);
20454
20455        let mut s = WriteEngine::open_swmr_writer(&path, SyncPolicy::OnClose).unwrap();
20456        let before = std::fs::read(&path).unwrap().len();
20457        // Phase 1 only: the chunk bytes and the superblock's end-of-file.
20458        s.append_inplace_i32_phased("t0", &[7; 64], 1).unwrap();
20459
20460        assert!(
20461            std::fs::read(&path).unwrap().len() > before,
20462            "a SWMR reader must see the phase-1 chunk bytes as soon as they are written"
20463        );
20464    }
20465
20466    /// The `fsync` cadence belongs to whoever the fapl says: every durability
20467    /// point in the engine — an immediate append's ordered barriers, a commit's
20468    /// barrier and repoint, the barrier a commit issues after truncating, the
20469    /// same-length-overwrite fast path, and the barrier `close` issues — answers
20470    /// to the session's [`SyncPolicy`], while `force_sync` (the explicit
20471    /// `File::sync`) answers to nobody (issue #263).
20472    ///
20473    /// The counts are compared as a table across the two policies rather than
20474    /// pinned to literals: how many `fsync`s a commit costs is an implementation
20475    /// detail that may fall, but that `OnClose` costs *none* and `Always` costs
20476    /// some at each of those points is the contract. The file is read back
20477    /// under both, since a skipped barrier must cost durability and nothing else
20478    /// — the bytes have already reached the operating system.
20479    ///
20480    /// Every stage here is a *distinct* barrier site. A commit tail that syncs
20481    /// twice does not stand in for the fast path that syncs once, nor for the
20482    /// post-truncate barrier that only a shrinking commit reaches: each is its
20483    /// own `self.barrier()` call that a later edit could regress to a bare
20484    /// `self.image.sync_all()` on its own — verified by mutating each site
20485    /// separately and watching this fail. The persisting tails and the
20486    /// consistency-flag write are covered by the test below.
20487    ///
20488    /// One site is left uncovered: the version 0/1 repoint branch of the
20489    /// non-persisting tail. A pre-v2 file cannot be produced by this crate's own
20490    /// writer — the crosscheck tests get one from the C library — and the
20491    /// counting image is a bounded one, which such a file needs the mirror
20492    /// instead of. Covering it needs a checked-in fixture and a second counting
20493    /// opener; it is named here so the gap is a known one rather than an
20494    /// assumed-covered one.
20495    #[test]
20496    fn sync_policy_governs_every_barrier() {
20497        use crate::writer::FileBuilder;
20498        use std::sync::Arc;
20499        use std::sync::atomic::{AtomicU64, Ordering};
20500        use tempfile::tempdir;
20501
20502        let dir = tempdir().unwrap();
20503        // [after an immediate append, after a staged commit, after a same-length
20504        // overwrite, after a commit that truncates, after the close barrier,
20505        // after an explicit sync].
20506        let run = |name: &str, policy: SyncPolicy| -> [u64; 5] {
20507            let path = dir.path().join(name);
20508            let mut b = FileBuilder::new();
20509            b.create_dataset("d")
20510                .with_i32_data(&(0..8).collect::<Vec<_>>())
20511                .with_shape(&[8])
20512                .with_maxshape(&[MaxExtent::Unlimited])
20513                .with_chunks(&[4]);
20514            b.write(&path).unwrap();
20515
20516            let syncs = Arc::new(AtomicU64::new(0));
20517            let mut s = WriteEngine::open_sync_counting(&path, policy, Arc::clone(&syncs)).unwrap();
20518            s.append_inplace_i32_phased("d", &[8, 9, 10, 11], 4)
20519                .unwrap();
20520            let after_append = syncs.load(Ordering::Relaxed);
20521            let mut db = crate::type_builders::DatasetBuilder::new("added");
20522            db.with_f64_data(&[2.5f64; 8]).with_shape(&[8]);
20523            s.stage_created_dataset(&ObjectPathBuf::parse("/added"), db)
20524                .unwrap();
20525            s.commit().unwrap();
20526            let after_commit = syncs.load(Ordering::Relaxed);
20527
20528            // Same-length value overwrite: the commit fast path, which patches
20529            // the bytes where they lie and syncs without repointing anything.
20530            let mut ow = crate::type_builders::DatasetBuilder::new("added");
20531            ow.with_f64_data(&[4.5f64; 8]).with_shape(&[8]);
20532            s.stage_dataset_write(&ObjectPathBuf::parse("/added"), ow)
20533                .unwrap();
20534            s.commit().unwrap();
20535            let after_overwrite = syncs.load(Ordering::Relaxed);
20536
20537            // A delete whose freed run reaches end-of-file, so the commit
20538            // truncates and takes the barrier that only a shrinking commit does.
20539            s.delete(&ObjectPathBuf::parse("/added")).unwrap();
20540            s.commit().unwrap();
20541            let after_truncate = syncs.load(Ordering::Relaxed);
20542
20543            s.force_sync().unwrap();
20544            let after_forced = syncs.load(Ordering::Relaxed);
20545
20546            // Release the exclusive OS lock before reading the file back; those
20547            // locks are mandatory on Windows.
20548            drop(s);
20549            let f = crate::reader::File::open(&path).unwrap();
20550            assert_eq!(
20551                f.dataset("d").unwrap().read_i32().unwrap(),
20552                (0..12).collect::<Vec<_>>(),
20553                "the append must land under {policy:?}"
20554            );
20555            assert!(
20556                f.dataset("added").is_err(),
20557                "the deleting commit must land under {policy:?}"
20558            );
20559            [
20560                after_append,
20561                after_commit,
20562                after_overwrite,
20563                after_truncate,
20564                after_forced,
20565            ]
20566        };
20567
20568        let always = run("always.h5", SyncPolicy::Always);
20569        assert!(always[0] > 0, "an immediate append syncs under Always");
20570        assert!(always[1] > always[0], "so does a commit");
20571        assert!(
20572            always[2] > always[1],
20573            "so does the same-length-overwrite fast path"
20574        );
20575        assert!(
20576            always[3] > always[2],
20577            "so does a commit that truncates the file"
20578        );
20579        assert_eq!(
20580            always[4],
20581            always[3] + 1,
20582            "and a forced sync is exactly one more"
20583        );
20584
20585        let deferred = run("on_close.h5", SyncPolicy::OnClose);
20586        assert_eq!(
20587            &deferred[..4],
20588            &[0, 0, 0, 0],
20589            "OnClose must leave every one of those in-session barriers unissued"
20590        );
20591        assert_eq!(
20592            deferred[4], 1,
20593            "a forced sync is issued whatever the policy says — it is what the \
20594             teardown path and File::sync both take"
20595        );
20596    }
20597
20598    /// The two barrier sites the table above cannot reach: the commit tail of a
20599    /// file that *persists* its free space (a different tail, with its own
20600    /// barrier and repoint, plus the manager re-homing `close` owes), and the
20601    /// superblock consistency-flag write a SWMR session makes on open and close.
20602    ///
20603    /// Both are `self.barrier()`/`self.barrier_data()` calls on paths the
20604    /// ordinary non-persisting session never executes, so without this a
20605    /// regression at either would pass the whole suite (issue #263).
20606    #[test]
20607    fn sync_policy_governs_the_persisting_and_flag_barriers() {
20608        use crate::writer::FileBuilder;
20609        use std::sync::Arc;
20610        use std::sync::atomic::{AtomicU64, Ordering};
20611        use tempfile::tempdir;
20612
20613        let dir = tempdir().unwrap();
20614        // [after a commit on a persisting file, after the flag write, after the
20615        // close barrier and its manager re-homing].
20616        let run = |name: &str, strategy: FileSpaceStrategy, policy: SyncPolicy| -> [u64; 3] {
20617            let path = dir.path().join(name);
20618            let mut b = FileBuilder::new();
20619            b.create_dataset("d")
20620                .with_i32_data(&(0..8).collect::<Vec<_>>())
20621                .with_shape(&[8])
20622                .with_maxshape(&[MaxExtent::Unlimited])
20623                .with_chunks(&[4]);
20624            b.create_dataset("victim")
20625                .with_f64_data(&[1.5f64; 64])
20626                .with_shape(&[64]);
20627            // A paged file takes the page-aware tail, a different pair of barrier
20628            // calls from the plain persisting one; `0` asks for the default page
20629            // size, which the plain strategy ignores.
20630            b.with_file_space_strategy(
20631                strategy,
20632                true,
20633                if strategy == FileSpaceStrategy::Page {
20634                    0
20635                } else {
20636                    1
20637                },
20638            );
20639            b.write(&path).unwrap();
20640
20641            let syncs = Arc::new(AtomicU64::new(0));
20642            let mut s = WriteEngine::open_sync_counting(&path, policy, Arc::clone(&syncs)).unwrap();
20643            assert!(
20644                s.persist.is_some(),
20645                "the fixture must persist its free space, or this tests the wrong tail"
20646            );
20647            // A delete on a persisting file takes `commit_persisting`: the free
20648            // space is recorded on disk rather than truncated away.
20649            s.delete(&ObjectPathBuf::parse("/victim")).unwrap();
20650            s.commit().unwrap();
20651            let after_commit = syncs.load(Ordering::Relaxed);
20652
20653            s.set_consistency_flags(0).unwrap();
20654            let after_flags = syncs.load(Ordering::Relaxed);
20655            // The flag write's barrier orders as well as syncs, like the other
20656            // two. Its production callers all force a sync straight after, which
20657            // is why nothing else would notice it stopping — the same reason
20658            // `finalize_persist_has_nothing_left_to_write` exists.
20659            let issued = s.image.issued_writes();
20660            s.force_sync().unwrap();
20661            assert_eq!(
20662                s.image.issued_writes(),
20663                issued,
20664                "{policy:?}: the consistency-flag write was left gathered"
20665            );
20666
20667            // An immediate append leaves the on-disk managers mid-file, which is
20668            // the debt `finalize_persist` settles with a second commit tail at
20669            // close — the writes no earlier `sync` can have covered.
20670            s.append_inplace_i32_phased("d", &[8, 9, 10, 11], 4)
20671                .unwrap();
20672            let before_close = syncs.load(Ordering::Relaxed);
20673            s.finalize_persist().unwrap();
20674            let after_close = syncs.load(Ordering::Relaxed) - before_close;
20675
20676            drop(s);
20677            let f = crate::reader::File::open(&path).unwrap();
20678            assert!(
20679                f.dataset("victim").is_err(),
20680                "the delete must land under {policy:?}"
20681            );
20682            assert_eq!(
20683                f.dataset("d").unwrap().read_i32().unwrap(),
20684                (0..12).collect::<Vec<_>>(),
20685                "and so must the append under {policy:?}"
20686            );
20687            [after_commit, after_flags, after_close]
20688        };
20689
20690        // Both persisting tails: the plain one and the page-aware one, which is a
20691        // separate pair of barrier calls reached only by a paged file.
20692        for (label, strategy) in [
20693            ("fsmaggr", FileSpaceStrategy::FsmAggr),
20694            ("paged", FileSpaceStrategy::Page),
20695        ] {
20696            let always = run(&format!("{label}_always.h5"), strategy, SyncPolicy::Always);
20697            assert!(
20698                always[0] > 0,
20699                "the {label} persisting commit tail syncs under Always"
20700            );
20701            assert!(
20702                always[1] > always[0],
20703                "so does the consistency-flag write a SWMR session makes ({label})"
20704            );
20705            assert!(
20706                always[2] > 0,
20707                "so does the manager re-homing close owes ({label})"
20708            );
20709
20710            assert_eq!(
20711                run(
20712                    &format!("{label}_on_close.h5"),
20713                    strategy,
20714                    SyncPolicy::OnClose
20715                ),
20716                [0, 0, 0],
20717                "OnClose must leave the {label} persisting tail, the flag write, and the \
20718                 close-time manager re-homing with no fsync at all"
20719            );
20720        }
20721    }
20722
20723    /// A write that alters the file and *then* reports failure is a real device
20724    /// behaviour — it is the one `TornWriteImage` models — so a `write_at`
20725    /// returning an error does not mean its bytes missed the disk. When the
20726    /// write in question is the superblock repoint, that makes "did this commit
20727    /// publish?" unanswerable from inside the engine, and every rollback
20728    /// unsound: undoing the values would tear a commit that did land.
20729    ///
20730    /// So the flag that gates the rollback is raised *before* the publish is
20731    /// issued, not after it returns, and this pins that. The commit here does
20732    /// publish — `/extra` is readable afterwards, which is only possible
20733    /// through the new root — while its `commit` returns the write's error.
20734    /// Rolling back under it would leave `/nums` holding the pre-commit value
20735    /// inside a tree that is otherwise entirely the new one (issue #344).
20736    #[test]
20737    fn a_commit_whose_publish_write_fails_rolls_nothing_back() {
20738        use crate::writer::FileBuilder;
20739        use tempfile::tempdir;
20740
20741        let dir = tempdir().unwrap();
20742        let path = dir.path().join("torn_publish.h5");
20743        let mut b = FileBuilder::new();
20744        b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20745        b.write(&path).unwrap();
20746
20747        // The superblock sits at offset 0 on a file with no userblock.
20748        let mut s = WriteEngine::open_torn_writes(&path, 0..48).unwrap();
20749        s.stage_dataset_write(&ObjectPathBuf::parse("/nums"), {
20750            let mut db = crate::type_builders::DatasetBuilder::new("");
20751            db.with_i32_data(&[9, 9, 9]);
20752            db
20753        })
20754        .unwrap();
20755        s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
20756            let mut db = crate::type_builders::DatasetBuilder::new("");
20757            db.with_i32_data(&[42]);
20758            db
20759        })
20760        .unwrap();
20761        let failed = s.commit();
20762        assert!(
20763            matches!(failed, Err(Error::Io(_))),
20764            "the publish write is what failed: {failed:?}"
20765        );
20766        drop(s);
20767
20768        let f = crate::reader::File::open(&path).unwrap();
20769        assert_eq!(
20770            f.dataset("extra").unwrap().read_i32().unwrap(),
20771            vec![42],
20772            "the publish landed despite reporting failure, so this commit is live"
20773        );
20774        assert_eq!(
20775            f.dataset("nums").unwrap().read_i32().unwrap(),
20776            vec![9, 9, 9],
20777            "a rollback under a commit that did publish would tear it"
20778        );
20779    }
20780
20781    /// Two hard links to one dataset are two paths over one data block, and the
20782    /// commit's duplicate guard is by path — so one commit can journal the same
20783    /// address twice, the second entry holding what the first write put there.
20784    /// The undo therefore has to replay newest-first; oldest-first finishes on a
20785    /// value from the very batch being rolled back (issue #344).
20786    ///
20787    /// A same-length overwrite through either link is deliberately allowed —
20788    /// it rewrites the block every link sees, so unlike a relocating write it
20789    /// needs no single-hard-link rule (`write_dataset_shared_hard_link_crosscheck`
20790    /// is the interop half of that). Which is what makes this reachable.
20791    ///
20792    /// The second link is made with the reference C library because this crate
20793    /// has no API that creates one: the file is a committed one, see
20794    /// `crates/crosscheck/tests/main/c_test_data.rs`.
20795    #[test]
20796    fn an_undo_replays_two_hard_links_to_one_block_newest_first() {
20797        use tempfile::tempdir;
20798
20799        let dir = tempdir().unwrap();
20800        let path = crate::test_data::copy("c/hard_link_undo.h5", dir.path());
20801
20802        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20803        // Fail the commit in its tail, after both writes have landed.
20804        s.superblock.superblock_extension_address = Some(0);
20805        for (name, data) in [("/aa", [9, 9, 9]), ("/bb", [8, 8, 8])] {
20806            s.stage_dataset_write(&ObjectPathBuf::parse(name), {
20807                let mut db = crate::type_builders::DatasetBuilder::new("");
20808                db.with_i32_data(&data);
20809                db
20810            })
20811            .unwrap();
20812        }
20813        // A second kind of edit, so the batch takes the full commit path and has
20814        // a tail to fail in.
20815        s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
20816            let mut db = crate::type_builders::DatasetBuilder::new("");
20817            db.with_i32_data(&[42]);
20818            db
20819        })
20820        .unwrap();
20821        assert!(s.commit().is_err());
20822        drop(s);
20823
20824        let f = crate::reader::File::open(&path).unwrap();
20825        assert_eq!(
20826            f.dataset("aa").unwrap().read_i32().unwrap(),
20827            vec![1, 2, 3],
20828            "the block both links share must hold the value it held before"
20829        );
20830        assert_eq!(f.dataset("bb").unwrap().read_i32().unwrap(), vec![1, 2, 3]);
20831    }
20832
20833    /// The chunked in-place path (`WritePlan::InPlaceChunks`) journals one entry
20834    /// per chunk, and — when the chunks shrink — the rewritten chunk index
20835    /// beside them. A partial restore would leave the index recording the new
20836    /// sizes against the old bytes, so this is the case where the rollback has
20837    /// most to put back (issue #344).
20838    ///
20839    /// Filtered, and overwritten with data that compresses to almost nothing, so
20840    /// every chunk's recorded size changes and the index write is part of what
20841    /// is rolled back rather than an unchanged block.
20842    #[test]
20843    fn a_failed_commit_puts_back_every_chunk_it_overwrote() {
20844        use crate::writer::FileBuilder;
20845        use tempfile::tempdir;
20846
20847        let dir = tempdir().unwrap();
20848        let path = dir.path().join("chunked_undo.h5");
20849        let original: Vec<i32> = (0..4096i32)
20850            .map(|i| i.wrapping_mul(2_654_435_761u32 as i32) ^ i)
20851            .collect();
20852        let mut b = FileBuilder::new();
20853        b.create_dataset("grid")
20854            .with_i32_data(&original)
20855            .with_shape(&[4096])
20856            .with_chunks(&[512])
20857            .with_deflate(6);
20858        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20859        b.write(&path).unwrap();
20860
20861        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20862        s.superblock.superblock_extension_address = Some(0);
20863        s.stage_dataset_write(&ObjectPathBuf::parse("/grid"), {
20864            // Values only: an overwrite that restated the chunking or the
20865            // filter would be refused as asking for more than an overwrite
20866            // (issue #318). The layout comes from the dataset already there.
20867            let mut db = crate::type_builders::DatasetBuilder::new("");
20868            db.with_i32_data(&vec![0i32; 4096]);
20869            db
20870        })
20871        .unwrap();
20872        s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
20873            let mut db = crate::type_builders::DatasetBuilder::new("");
20874            db.with_i32_data(&[42]);
20875            db
20876        })
20877        .unwrap();
20878        assert!(s.commit().is_err());
20879        drop(s);
20880
20881        let f = crate::reader::File::open(&path).unwrap();
20882        assert_eq!(
20883            f.dataset("grid").unwrap().read_i32().unwrap(),
20884            original,
20885            "every chunk, and the index sizing them, must read as it did before"
20886        );
20887    }
20888
20889    /// A commit clears the journal on entry, so it can never replay entries it
20890    /// did not write itself (issue #344).
20891    ///
20892    /// Only an attempt that *unwound* leaves any — a panic caught by the session
20893    /// lock, which recovers from poisoning — and the state it leaves is what
20894    /// this constructs directly rather than by finding something to panic in:
20895    /// an entry naming an address whose contents have moved on. Replaying it
20896    /// would write bytes captured before the unwind over whatever lives there
20897    /// now, and the entry clear on the way *out* cannot help, because that is
20898    /// the step the unwind skipped.
20899    ///
20900    /// `superseded_heaps` two fields up clears at entry for the same reason and
20901    /// says so; this is that rule applied to the journal.
20902    #[test]
20903    fn a_commit_does_not_replay_a_journal_it_did_not_write() {
20904        use crate::writer::FileBuilder;
20905        use tempfile::tempdir;
20906
20907        let dir = tempdir().unwrap();
20908        let path = dir.path().join("stale_journal.h5");
20909        let mut b = FileBuilder::new();
20910        b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20911        b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20912        b.write(&path).unwrap();
20913
20914        let nums_block = {
20915            let f = crate::reader::File::open(&path).unwrap();
20916            match f.dataset("nums").unwrap().layout().unwrap() {
20917                crate::Layout::Contiguous {
20918                    address: Some(a), ..
20919                } => a,
20920                other => panic!("expected a contiguous dataset: {other:?}"),
20921            }
20922        };
20923
20924        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
20925        // Exactly what an attempt that unwound past `commit` leaves behind.
20926        let stale: Vec<u8> = [7i32, 7, 7].iter().flat_map(|v| v.to_le_bytes()).collect();
20927        s.inplace_undo.push((nums_block, stale));
20928
20929        // An unrelated commit, failed in its tail so that it rolls back at all.
20930        s.superblock.superblock_extension_address = Some(0);
20931        s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
20932            let mut db = crate::type_builders::DatasetBuilder::new("");
20933            db.with_i32_data(&[42]);
20934            db
20935        })
20936        .unwrap();
20937        assert!(s.commit().is_err());
20938        drop(s);
20939
20940        let f = crate::reader::File::open(&path).unwrap();
20941        assert_eq!(
20942            f.dataset("nums").unwrap().read_i32().unwrap(),
20943            vec![1, 2, 3],
20944            "the commit replayed a journal entry that was not its own"
20945        );
20946    }
20947
20948    /// Putting the values back is a write like any other, so it has to be
20949    /// ordered like one: a restore left sitting in the gathering buffer can be
20950    /// issued after a later write to the same block, which is the reordering
20951    /// hazard write gathering introduced (issue #288).
20952    ///
20953    /// Measured as a difference rather than a total, so it states the rule and
20954    /// not one commit's arithmetic: a refused commit with nothing to put back
20955    /// issues no barrier at all — it fails before the one it would have made —
20956    /// and the same refusal carrying a value overwrite issues exactly the one
20957    /// that orders the restore.
20958    #[test]
20959    fn a_rollback_orders_the_values_it_puts_back() {
20960        use crate::writer::FileBuilder;
20961        use std::sync::Arc;
20962        use std::sync::atomic::{AtomicU64, Ordering};
20963        use tempfile::tempdir;
20964
20965        let mut counted = Vec::new();
20966        for with_overwrite in [false, true] {
20967            let dir = tempdir().unwrap();
20968            let path = dir.path().join("rollback_syncs.h5");
20969            let mut b = FileBuilder::new();
20970            b.create_dataset("nums").with_i32_data(&[1, 2, 3]);
20971            b.with_file_space_strategy(FileSpaceStrategy::FsmAggr, true, 0);
20972            b.write(&path).unwrap();
20973
20974            let syncs = Arc::new(AtomicU64::new(0));
20975            let mut s =
20976                WriteEngine::open_sync_counting(&path, SyncPolicy::Always, Arc::clone(&syncs))
20977                    .unwrap();
20978            // Fail the commit in its tail, after the apply phase has written.
20979            s.superblock.superblock_extension_address = Some(0);
20980            if with_overwrite {
20981                s.stage_dataset_write(&ObjectPathBuf::parse("/nums"), {
20982                    let mut db = crate::type_builders::DatasetBuilder::new("");
20983                    db.with_i32_data(&[9, 9, 9]);
20984                    db
20985                })
20986                .unwrap();
20987            }
20988            s.stage_created_dataset(&ObjectPathBuf::parse("/extra"), {
20989                let mut db = crate::type_builders::DatasetBuilder::new("");
20990                db.with_i32_data(&[42]);
20991                db
20992            })
20993            .unwrap();
20994            let before = syncs.load(Ordering::Relaxed);
20995            assert!(s.commit().is_err());
20996            counted.push(syncs.load(Ordering::Relaxed) - before);
20997        }
20998
20999        assert_eq!(
21000            counted[0], 0,
21001            "a refusal with nothing to put back reaches no barrier"
21002        );
21003        assert_eq!(
21004            counted[1],
21005            counted[0] + 1,
21006            "a refusal that put values back must order them: {counted:?}"
21007        );
21008    }
21009
21010    /// The journal reads back what it is about to overwrite, so a bounded
21011    /// commit's reads scale with the value being replaced rather than staying
21012    /// near the metadata-only figure
21013    /// [`a_bounded_commit_reads_far_less_than_the_file`] pins for a commit that
21014    /// overwrites nothing.
21015    ///
21016    /// This is the cost of the guarantee in issue #344 and it is charged even
21017    /// when the commit succeeds, since whether the bytes will be needed is not
21018    /// known until the attempt ends. Stated as a rule — reads land between the
21019    /// payload and a small multiple of it — rather than as one target's exact
21020    /// count, so it holds wherever the suite runs.
21021    #[test]
21022    fn a_bounded_commit_reads_the_value_it_is_replacing() {
21023        use crate::writer::FileBuilder;
21024        use std::sync::Arc;
21025        use std::sync::atomic::{AtomicU64, Ordering};
21026        use tempfile::tempdir;
21027
21028        let dir = tempdir().unwrap();
21029        let p = dir.path().join("journal_reads.h5");
21030        let data: Vec<i32> = (0..250_000).collect();
21031        let payload = (data.len() * 4) as u64;
21032        let mut b = FileBuilder::new();
21033        b.create_dataset("nums").with_i32_data(&data);
21034        b.write(&p).unwrap();
21035
21036        let read_bytes = Arc::new(AtomicU64::new(0));
21037        let mut engine = WriteEngine::open_bounded_counting(&p, Arc::clone(&read_bytes)).unwrap();
21038        engine
21039            .stage_dataset_write(&ObjectPathBuf::parse("/nums"), {
21040                let mut db = crate::type_builders::DatasetBuilder::new("");
21041                db.with_i32_data(&data);
21042                db
21043            })
21044            .unwrap();
21045        let before = read_bytes.load(Ordering::Relaxed);
21046        engine.commit().unwrap();
21047        let read = read_bytes.load(Ordering::Relaxed) - before;
21048
21049        assert!(
21050            read >= payload,
21051            "the journal must read the whole value it replaces: {read} of {payload}"
21052        );
21053        assert!(
21054            read < payload + (64 << 10),
21055            "and not much more than it: {read} against {payload}"
21056        );
21057    }
21058}
21059
21060#[cfg(test)]
21061mod staged_query_tests {
21062    //! The queries a handle asks about objects a session has staged and not
21063    //! committed (issue #392).
21064
21065    use rstest::rstest;
21066    use tempfile::tempdir;
21067
21068    use super::*;
21069    use crate::object_path::ObjectPath;
21070    use crate::type_builders::DatasetBuilder;
21071
21072    /// A session over a file holding one dataset, `existing`.
21073    fn open_session(path: &Path) -> WriteEngine {
21074        let mut b = crate::writer::FileBuilder::new();
21075        b.create_dataset("existing").with_i32_data(&[1, 2, 3]);
21076        b.write(path).unwrap();
21077        WriteEngine::open_rw_with_strategy(
21078            path,
21079            crate::source::MetadataCacheConfig::disabled(),
21080            FileLocking::Enabled,
21081            MemoryStrategy::Mirrored,
21082        )
21083        .unwrap()
21084    }
21085
21086    fn i32_dataset(data: &[i32]) -> DatasetBuilder {
21087        let mut b = DatasetBuilder::new("");
21088        b.with_i32_data(data);
21089        b
21090    }
21091
21092    fn kind(e: &WriteEngine, path: &str) -> Option<StagedKind> {
21093        e.staged_object(&ObjectPathBuf::parse(path)).map(|o| o.kind)
21094    }
21095
21096    fn child_names(e: &WriteEngine, parent: &str) -> Vec<(String, StagedKind)> {
21097        e.staged_children(&ObjectPathBuf::parse(parent))
21098            .into_iter()
21099            .map(|c| (c.name, c.kind))
21100            .collect()
21101    }
21102
21103    #[test]
21104    fn a_staged_creation_is_named_at_every_level_of_its_path() {
21105        let dir = tempdir().unwrap();
21106        let mut e = open_session(&dir.path().join("q.h5"));
21107        e.create_group(&ObjectPathBuf::parse("a")).unwrap();
21108        let mut col = DatasetBuilder::new("");
21109        col.with_i32_data(&[1, 2])
21110            .with_maxshape(&[MaxExtent::Unlimited])
21111            .with_chunks(&[2]);
21112        e.stage_created_dataset(&ObjectPathBuf::parse("a/b/col"), col)
21113            .unwrap();
21114
21115        assert_eq!(kind(&e, "a"), Some(StagedKind::Group));
21116        assert_eq!(kind(&e, "a/b/col"), Some(StagedKind::Dataset));
21117        // Nothing stages `a/b`, and a commit builds only what the session
21118        // staged, so a batch reaching through `a/b` needs it staged at its own
21119        // path.
21120        assert_eq!(kind(&e, "a/b"), None);
21121        // The root always exists, an on-disk object is not staged, and neither
21122        // is a name nothing reaches.
21123        assert_eq!(kind(&e, ""), None);
21124        assert_eq!(kind(&e, "existing"), None);
21125        assert_eq!(kind(&e, "a/missing"), None);
21126
21127        assert_eq!(
21128            child_names(&e, ""),
21129            vec![("a".to_string(), StagedKind::Group)]
21130        );
21131        assert_eq!(
21132            child_names(&e, "a/b"),
21133            vec![("col".to_string(), StagedKind::Dataset)]
21134        );
21135        // `b` under `a` is the unnamed intermediate again.
21136        assert!(child_names(&e, "a").is_empty());
21137        assert!(child_names(&e, "existing").is_empty());
21138
21139        // Named outright, it answers at every level.
21140        e.create_group(&ObjectPathBuf::parse("a/b")).unwrap();
21141        assert_eq!(kind(&e, "a/b"), Some(StagedKind::Group));
21142        assert_eq!(
21143            child_names(&e, "a"),
21144            vec![("b".to_string(), StagedKind::Group)]
21145        );
21146    }
21147
21148    #[test]
21149    fn a_staged_dataset_reports_what_its_builder_settled() {
21150        let dir = tempdir().unwrap();
21151        let mut e = open_session(&dir.path().join("q.h5"));
21152        let mut col = DatasetBuilder::new("");
21153        col.with_i32_data(&[1, 2, 3, 4])
21154            .with_maxshape(&[MaxExtent::Unlimited])
21155            .with_chunks(&[2])
21156            .with_deflate(4);
21157        e.stage_created_dataset(&ObjectPathBuf::parse("col"), col)
21158            .unwrap();
21159        e.stage_created_dataset(&ObjectPathBuf::parse("plain"), i32_dataset(&[5]))
21160            .unwrap();
21161
21162        let meta = e.staged_dataset_meta(&ObjectPathBuf::parse("col")).unwrap();
21163        assert_eq!(meta.dimensions, vec![4]);
21164        assert_eq!(meta.maxshape, Some(vec![MaxExtent::Unlimited]));
21165        assert_eq!(meta.datatype.type_size(), 4);
21166        assert!(meta.chunked);
21167        assert_eq!(meta.filters, vec![(1u16, false)]);
21168
21169        // A fixed-shape, unfiltered dataset is contiguous and reports no
21170        // maximum, the way `Dataset::maxshape` reports an on-disk one.
21171        let plain = e
21172            .staged_dataset_meta(&ObjectPathBuf::parse("plain"))
21173            .unwrap();
21174        assert_eq!(plain.dimensions, vec![1]);
21175        assert_eq!(plain.maxshape, None);
21176        assert!(!plain.chunked);
21177        assert!(plain.filters.is_empty());
21178
21179        // Only datasets answer.
21180        e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21181        assert!(e.staged_dataset_meta(&ObjectPathBuf::parse("g")).is_none());
21182        assert!(
21183            e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21184                .is_none()
21185        );
21186    }
21187
21188    #[test]
21189    fn a_deletion_hides_nothing_until_a_creation_replaces_it() {
21190        let dir = tempdir().unwrap();
21191        let mut e = open_session(&dir.path().join("q.h5"));
21192        e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21193        // The object is still in the file, and still readable, so a handle must
21194        // not be told it is staged.
21195        assert_eq!(kind(&e, "existing"), None);
21196        assert!(e.staged_children(&ObjectPathBuf::parse("")).is_empty());
21197
21198        e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[9]))
21199            .unwrap();
21200        let staged = e.staged_object(&ObjectPathBuf::parse("existing")).unwrap();
21201        assert_eq!(staged.kind, StagedKind::Dataset);
21202        assert!(staged.replaces_link, "the same commit removes the link");
21203        assert_eq!(
21204            e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21205                .unwrap()
21206                .dimensions,
21207            vec![1]
21208        );
21209        assert!(e.staged_children(&ObjectPathBuf::parse(""))[0].replaces_link);
21210    }
21211
21212    #[test]
21213    fn a_creation_colliding_with_a_surviving_link_is_refused_where_it_is_staged() {
21214        let dir = tempdir().unwrap();
21215        let mut e = open_session(&dir.path().join("q.h5"));
21216        // No deletion beside it, so this creation collides with a link the
21217        // commit keeps. That is the commit's own refusal, raised where the call
21218        // is made so no handle onto the creation is ever handed back: such a
21219        // handle would have addressed the file's `existing` while claiming to
21220        // address the new one.
21221        let err = e
21222            .stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[9]))
21223            .unwrap_err();
21224        assert!(
21225            matches!(&err, Error::EditUnsupported(m) if m.contains("already exists")),
21226            "got: {err}"
21227        );
21228        // The same rule for a group, which is also the kind change a listing
21229        // would otherwise misreport as turning a dataset into a group.
21230        assert!(e.create_group(&ObjectPathBuf::parse("existing")).is_err());
21231
21232        // Nothing was staged, so the file's own object is still what the name
21233        // means, and the session has nothing left to refuse at commit time.
21234        assert_eq!(kind(&e, "existing"), None);
21235        assert!(
21236            e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21237                .is_none()
21238        );
21239        assert!(e.staged_children(&ObjectPathBuf::parse("")).is_empty());
21240        e.commit().unwrap();
21241    }
21242
21243    #[test]
21244    fn a_second_creation_at_a_staged_path_is_refused_where_it_is_staged() {
21245        let dir = tempdir().unwrap();
21246        let mut e = open_session(&dir.path().join("q.h5"));
21247        e.stage_created_dataset(&ObjectPathBuf::parse("fresh"), i32_dataset(&[1, 2, 3]))
21248            .unwrap();
21249
21250        // The staged set is indexed by path and keeps the first record there, so
21251        // a second creation would be staged behind the first and the handle this
21252        // call hands back would answer for the first one's shape and datatype.
21253        let err = e
21254            .stage_created_dataset(&ObjectPathBuf::parse("fresh"), i32_dataset(&[9]))
21255            .unwrap_err();
21256        assert!(
21257            matches!(&err, Error::EditUnsupported(m) if m.contains("already stages")),
21258            "got: {err}"
21259        );
21260        // Refused, not replaced: the first creation is exactly as it was, and it
21261        // is still the only thing staged at that name.
21262        assert_eq!(
21263            e.staged_dataset_meta(&ObjectPathBuf::parse("fresh"))
21264                .unwrap()
21265                .dimensions,
21266            vec![3]
21267        );
21268        assert_eq!(
21269            child_names(&e, ""),
21270            vec![("fresh".to_string(), StagedKind::Dataset)]
21271        );
21272
21273        // The two kinds collide with each other for the same reason: one path
21274        // cannot name both, and whichever the index found first is what every
21275        // handle onto it would report.
21276        assert!(e.create_group(&ObjectPathBuf::parse("fresh")).is_err());
21277        e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21278        assert!(
21279            e.stage_created_dataset(&ObjectPathBuf::parse("g"), i32_dataset(&[9]))
21280                .is_err()
21281        );
21282        assert_eq!(kind(&e, "g"), Some(StagedKind::Group));
21283
21284        e.commit().unwrap();
21285    }
21286
21287    #[test]
21288    fn a_replacement_is_staged_once_and_a_withdrawal_frees_the_name_again() {
21289        let dir = tempdir().unwrap();
21290        let mut e = open_session(&dir.path().join("q.h5"));
21291        // A creation over a link this session deletes is a replacement, which
21292        // the file arm admits — but only one of them, since the second would
21293        // still be staged behind the first.
21294        e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21295        e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[9]))
21296            .unwrap();
21297        assert!(
21298            e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[8, 8]))
21299                .is_err()
21300        );
21301        assert_eq!(
21302            e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21303                .unwrap()
21304                .dimensions,
21305            vec![1]
21306        );
21307
21308        // Deleting a staged creation withdraws it, which is what puts the name
21309        // back within reach of another creation.
21310        e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21311        e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[8, 8]))
21312            .unwrap();
21313        assert_eq!(
21314            e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21315                .unwrap()
21316                .dimensions,
21317            vec![2]
21318        );
21319        e.commit().unwrap();
21320    }
21321
21322    #[test]
21323    fn a_group_staged_twice_is_one_group_and_stays_allowed() {
21324        let dir = tempdir().unwrap();
21325        let mut e = open_session(&dir.path().join("q.h5"));
21326        // Two group creations at one path name one node: the commit builds a
21327        // single group from them, so a handle onto either addresses it and
21328        // nothing is misreported. Re-staging is how attributes and children are
21329        // added to a group already staged.
21330        e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21331        e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21332        assert_eq!(kind(&e, "g"), Some(StagedKind::Group));
21333        assert_eq!(
21334            child_names(&e, ""),
21335            vec![("g".to_string(), StagedKind::Group)]
21336        );
21337        e.stage_created_dataset(&ObjectPathBuf::parse("g/inner"), i32_dataset(&[7]))
21338            .unwrap();
21339        e.commit().unwrap();
21340    }
21341
21342    #[test]
21343    fn a_creation_under_a_deleted_group_the_session_does_not_rebuild_shadows_nothing() {
21344        // `delete("g")` carries `g/inner` away with it, but only a commit that
21345        // builds `g` again can put anything back under that path — and this one
21346        // does not, so the commit refuses the batch. Until then the file's own
21347        // `g/inner` is what the name means: calling the staged creation beside
21348        // it a replacement would make a live, readable dataset answer
21349        // `NotCommitted` and vanish from its group's listing (issue #392).
21350        let dir = tempdir().unwrap();
21351        let path = dir.path().join("prefix.h5");
21352        let mut b = crate::writer::FileBuilder::new();
21353        let mut g = b.create_group("g");
21354        g.create_dataset("inner").with_i32_data(&[1]);
21355        b.add_group(g.finish());
21356        b.write(&path).unwrap();
21357
21358        let mut e = WriteEngine::open_rw_with_strategy(
21359            &path,
21360            crate::source::MetadataCacheConfig::disabled(),
21361            FileLocking::Enabled,
21362            MemoryStrategy::Mirrored,
21363        )
21364        .unwrap();
21365        e.delete(&ObjectPathBuf::parse("g")).unwrap();
21366        // The name `g/inner` is still taken: a deletion of `g` alone hands it
21367        // over to nobody, so this is the collision it looks like rather than a
21368        // replacement.
21369        let err = e
21370            .stage_created_dataset(&ObjectPathBuf::parse("g/inner"), i32_dataset(&[9]))
21371            .unwrap_err();
21372        assert!(
21373            matches!(&err, Error::EditUnsupported(m) if m.contains("already exists")),
21374            "got: {err}"
21375        );
21376        assert_eq!(kind(&e, "g/inner"), None, "the file's own dataset");
21377
21378        // A name the file does not hold stages, and is not a replacement of
21379        // anything; the commit refuses the batch for the overlap itself.
21380        e.stage_created_dataset(&ObjectPathBuf::parse("g/other"), i32_dataset(&[9]))
21381            .unwrap();
21382        assert!(
21383            !e.staged_children(&ObjectPathBuf::parse("g"))[0].replaces_link,
21384            "nothing rebuilds `g`, so its names are not handed over"
21385        );
21386        assert!(e.commit().is_err(), "a deletion overlapping an addition");
21387    }
21388
21389    #[test]
21390    fn a_deleted_group_rebuilt_in_the_same_commit_hands_its_names_over() {
21391        // The other side of that rule: `g` is replaced, so a creation under it
21392        // owns its name from the moment it is staged and the object the commit
21393        // removes is no longer what the path means.
21394        let dir = tempdir().unwrap();
21395        let path = dir.path().join("replaced.h5");
21396        let mut b = crate::writer::FileBuilder::new();
21397        let mut g = b.create_group("g");
21398        g.create_dataset("inner").with_i32_data(&[1]);
21399        b.add_group(g.finish());
21400        b.write(&path).unwrap();
21401
21402        let mut e = WriteEngine::open_rw_with_strategy(
21403            &path,
21404            crate::source::MetadataCacheConfig::disabled(),
21405            FileLocking::Enabled,
21406            MemoryStrategy::Mirrored,
21407        )
21408        .unwrap();
21409        e.delete(&ObjectPathBuf::parse("g")).unwrap();
21410        e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21411        e.stage_created_dataset(&ObjectPathBuf::parse("g/inner"), i32_dataset(&[9]))
21412            .unwrap();
21413        assert_eq!(kind(&e, "g/inner"), Some(StagedKind::Dataset));
21414        assert!(
21415            e.staged_object(&ObjectPathBuf::parse("g/inner"))
21416                .unwrap()
21417                .replaces_link
21418        );
21419        assert!(e.staged_children(&ObjectPathBuf::parse("g"))[0].replaces_link);
21420        e.commit().unwrap();
21421    }
21422
21423    #[rstest]
21424    #[case("")]
21425    #[case("/")]
21426    #[case(".")]
21427    fn the_root_cannot_be_deleted(#[case] spelling: &str) {
21428        // Nothing links to the root, so there is no link to remove — and an
21429        // empty path is a prefix of every other, so a deletion staged there
21430        // would make every creation in the session look like a replacement.
21431        let dir = tempdir().unwrap();
21432        let mut e = open_session(&dir.path().join("q.h5"));
21433        let err = e.delete(&ObjectPathBuf::parse(spelling)).unwrap_err();
21434        let Error::EditUnsupported(message) = &err else {
21435            panic!("expected EditUnsupported, got {err:?}");
21436        };
21437        assert_eq!(
21438            *message,
21439            "cannot delete the root group; delete its members instead"
21440        );
21441        e.stage_created_dataset(&ObjectPathBuf::parse("fresh"), i32_dataset(&[1]))
21442            .unwrap();
21443        assert!(
21444            !e.staged_object(&ObjectPathBuf::parse("fresh"))
21445                .unwrap()
21446                .replaces_link,
21447            "the refused deletion staged nothing, so this replaces nothing"
21448        );
21449    }
21450
21451    #[rstest]
21452    #[case("")]
21453    #[case("/")]
21454    #[case(".")]
21455    fn the_root_group_cannot_be_created(#[case] spelling: &str) {
21456        let dir = tempdir().unwrap();
21457        let mut e = open_session(&dir.path().join("q.h5"));
21458        let err = e.create_group(&ObjectPathBuf::parse(spelling)).unwrap_err();
21459        let Error::EditUnsupported(message) = &err else {
21460            panic!("expected EditUnsupported, got {err:?}");
21461        };
21462        assert_eq!(*message, "cannot create the root group");
21463        assert!(
21464            !e.has_staged_edits(),
21465            "the refusal staged nothing for a later commit to find"
21466        );
21467    }
21468
21469    #[test]
21470    fn an_append_onto_a_staged_dataset_grows_the_pending_creation() {
21471        let dir = tempdir().unwrap();
21472        let mut e = open_session(&dir.path().join("q.h5"));
21473        e.stage_created_dataset(&ObjectPathBuf::parse("col"), i32_dataset(&[1, 2]))
21474            .unwrap();
21475
21476        let mut b = AppendBuilder::new();
21477        b.append_i32(&[3, 4]);
21478        e.stage_dataset_append_pending(&ObjectPathBuf::parse("col"), b)
21479            .unwrap();
21480        assert_eq!(
21481            e.staged_dataset_meta(&ObjectPathBuf::parse("col"))
21482                .unwrap()
21483                .dimensions,
21484            vec![4]
21485        );
21486        // Folded into the creation rather than queued beside it, so the commit
21487        // has one dataset to write and no append to apply to it.
21488        assert!(e.staged.appends.is_empty());
21489
21490        e.commit().unwrap();
21491        let addr = crate::group_v2::resolve_path_any_from_source(
21492            &e.image(),
21493            AccessMode::ReadWrite,
21494            e.superblock(),
21495            &ObjectPath::parse("col"),
21496        )
21497        .unwrap();
21498        assert!(addr > 0);
21499    }
21500
21501    #[test]
21502    fn an_append_through_a_handle_onto_the_replaced_object_is_refused() {
21503        let dir = tempdir().unwrap();
21504        let mut e = open_session(&dir.path().join("q.h5"));
21505        e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21506        e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[100]))
21507            .unwrap();
21508
21509        // `stage_dataset_append` is the entry point a handle onto the object in
21510        // the file uses. Growing the replacement under it would silently move
21511        // the elements to another object.
21512        let mut b = AppendBuilder::new();
21513        b.append_i32(&[200]);
21514        assert!(matches!(
21515            e.stage_dataset_append(&ObjectPathBuf::parse("existing"), b),
21516            Err(Error::EditUnsupported(_))
21517        ));
21518        assert_eq!(
21519            e.staged_dataset_meta(&ObjectPathBuf::parse("existing"))
21520                .unwrap()
21521                .dimensions,
21522            vec![1],
21523            "the refusal must leave the replacement alone"
21524        );
21525        assert!(e.staged.appends.is_empty());
21526    }
21527
21528    #[test]
21529    fn an_append_the_staged_dataset_cannot_carry_is_refused_without_changing_it() {
21530        let dir = tempdir().unwrap();
21531        let mut e = open_session(&dir.path().join("q.h5"));
21532        e.stage_created_dataset(&ObjectPathBuf::parse("col"), i32_dataset(&[1, 2]))
21533            .unwrap();
21534
21535        let mut wrong_type = AppendBuilder::new();
21536        wrong_type.append_f64(&[1.0]);
21537        assert!(matches!(
21538            e.stage_dataset_append_pending(&ObjectPathBuf::parse("col"), wrong_type),
21539            Err(Error::AppendUnsupported(_))
21540        ));
21541
21542        let mut partial = AppendBuilder::new();
21543        partial.append_raw(&[0u8, 1, 2]);
21544        assert!(matches!(
21545            e.stage_dataset_append_pending(&ObjectPathBuf::parse("col"), partial),
21546            Err(Error::AppendUnsupported(_))
21547        ));
21548
21549        // A finite maximum is a promise the commit would refuse to break.
21550        let mut capped = DatasetBuilder::new("");
21551        capped
21552            .with_i32_data(&[1, 2])
21553            .with_maxshape(&[MaxExtent::Fixed(3)])
21554            .with_chunks(&[2]);
21555        e.stage_created_dataset(&ObjectPathBuf::parse("capped"), capped)
21556            .unwrap();
21557        let mut over = AppendBuilder::new();
21558        over.append_i32(&[3, 4]);
21559        assert!(matches!(
21560            e.stage_dataset_append_pending(&ObjectPathBuf::parse("capped"), over),
21561            Err(Error::AppendUnsupported(_))
21562        ));
21563
21564        // Nothing the refusals touched changed.
21565        assert_eq!(
21566            e.staged_dataset_meta(&ObjectPathBuf::parse("col"))
21567                .unwrap()
21568                .dimensions,
21569            vec![2]
21570        );
21571        assert_eq!(
21572            e.staged_dataset_meta(&ObjectPathBuf::parse("capped"))
21573                .unwrap()
21574                .dimensions,
21575            vec![2]
21576        );
21577    }
21578
21579    #[test]
21580    fn an_append_onto_a_committed_dataset_still_stages_an_append() {
21581        let dir = tempdir().unwrap();
21582        let mut e = open_session(&dir.path().join("q.h5"));
21583        let mut b = AppendBuilder::new();
21584        b.append_i32(&[4]);
21585        e.stage_dataset_append(&ObjectPathBuf::parse("existing"), b)
21586            .unwrap();
21587        assert_eq!(e.staged.appends.len(), 1);
21588
21589        // And so does one made through a handle whose creation was committed
21590        // between the caller's check and this lock.
21591        let mut b = AppendBuilder::new();
21592        b.append_i32(&[5]);
21593        e.stage_dataset_append_pending(&ObjectPathBuf::parse("existing"), b)
21594            .unwrap();
21595        assert_eq!(e.staged.appends.len(), 2);
21596    }
21597
21598    #[test]
21599    fn deleting_a_staged_creation_withdraws_it() {
21600        let dir = tempdir().unwrap();
21601        let mut e = open_session(&dir.path().join("q.h5"));
21602        e.stage_created_dataset(&ObjectPathBuf::parse("col"), i32_dataset(&[1, 2]))
21603            .unwrap();
21604        e.delete(&ObjectPathBuf::parse("col")).unwrap();
21605        // Withdrawn, not queued for a deletion the commit could not perform.
21606        assert_eq!(kind(&e, "col"), None);
21607        assert!(e.staged.deletes.is_empty());
21608        assert!(!e.has_staged_edits());
21609        e.commit().unwrap();
21610    }
21611
21612    #[test]
21613    fn deleting_a_staged_group_withdraws_its_staged_subtree() {
21614        let dir = tempdir().unwrap();
21615        let mut e = open_session(&dir.path().join("q.h5"));
21616        e.create_group(&ObjectPathBuf::parse("g")).unwrap();
21617        e.create_group(&ObjectPathBuf::parse("g/inner")).unwrap();
21618        e.stage_created_dataset(&ObjectPathBuf::parse("g/inner/col"), i32_dataset(&[1]))
21619            .unwrap();
21620        e.set_group_attr(&ObjectPathBuf::parse("g"), "kind", AttrValue::I64(1))
21621            .unwrap();
21622
21623        e.delete(&ObjectPathBuf::parse("g")).unwrap();
21624        assert_eq!(kind(&e, "g"), None);
21625        assert_eq!(kind(&e, "g/inner"), None);
21626        assert_eq!(kind(&e, "g/inner/col"), None);
21627        assert!(
21628            !e.has_staged_edits(),
21629            "the attribute and the subtree go with the group"
21630        );
21631        e.commit().unwrap();
21632    }
21633
21634    #[test]
21635    fn deleting_a_staged_replacement_leaves_the_plain_deletion() {
21636        let dir = tempdir().unwrap();
21637        let mut e = open_session(&dir.path().join("q.h5"));
21638        e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21639        e.stage_created_dataset(&ObjectPathBuf::parse("existing"), i32_dataset(&[9]))
21640            .unwrap();
21641        // Changing one's mind about the replacement leaves the deletion of the
21642        // object in the file, which is what was asked for first.
21643        e.delete(&ObjectPathBuf::parse("existing")).unwrap();
21644        assert_eq!(kind(&e, "existing"), None);
21645        assert_eq!(e.staged.deletes.len(), 1);
21646        e.commit().unwrap();
21647        assert!(
21648            crate::group_v2::resolve_path_any_from_source(
21649                &e.image(),
21650                AccessMode::ReadWrite,
21651                e.superblock(),
21652                &ObjectPath::parse("existing"),
21653            )
21654            .is_err()
21655        );
21656    }
21657
21658    #[test]
21659    fn a_batch_that_fails_leaves_the_staged_index_matching_the_staged_set() {
21660        let dir = tempdir().unwrap();
21661        let mut e = open_session(&dir.path().join("q.h5"));
21662        e.stage_created_dataset(&ObjectPathBuf::parse("kept"), i32_dataset(&[1]))
21663            .unwrap();
21664        let refused: Result<(), Error> = e.stage_atomically(|s| {
21665            s.create_group(&ObjectPathBuf::parse("gone"))?;
21666            s.stage_created_dataset(&ObjectPathBuf::parse("gone/col"), i32_dataset(&[2]))?;
21667            Err(Error::EditUnsupported("refused on purpose"))
21668        });
21669        assert!(refused.is_err());
21670        // The index must forget what the rewind dropped, or a lookup would hand
21671        // back a handle onto an entry that is no longer there.
21672        assert_eq!(kind(&e, "gone"), None);
21673        assert_eq!(kind(&e, "gone/col"), None);
21674        assert_eq!(kind(&e, "kept"), Some(StagedKind::Dataset));
21675        assert!(e.staged_children(&ObjectPathBuf::parse("gone")).is_empty());
21676
21677        // And it must forget them for good: an index entry left behind would be
21678        // found by the *next* creation at that path and keep it pointing at the
21679        // position the rewind dropped, so the retry would stage a dataset no
21680        // lookup could see.
21681        // Staged behind an unrelated creation, so the retry lands at a
21682        // *different* position than the one the rewind dropped: an index entry
21683        // left behind would still name the old one.
21684        e.create_group(&ObjectPathBuf::parse("other")).unwrap();
21685        e.stage_created_dataset(&ObjectPathBuf::parse("other/col"), i32_dataset(&[3]))
21686            .unwrap();
21687        e.create_group(&ObjectPathBuf::parse("gone")).unwrap();
21688        e.stage_created_dataset(&ObjectPathBuf::parse("gone/col"), i32_dataset(&[2]))
21689            .unwrap();
21690        assert_eq!(kind(&e, "gone"), Some(StagedKind::Group));
21691        assert_eq!(kind(&e, "gone/col"), Some(StagedKind::Dataset));
21692        assert_eq!(
21693            e.staged_dataset_meta(&ObjectPathBuf::parse("gone/col"))
21694                .unwrap()
21695                .dimensions,
21696            vec![1]
21697        );
21698        e.commit().unwrap();
21699    }
21700
21701    #[test]
21702    fn an_edit_that_changes_staged_work_is_refused_inside_a_batch() {
21703        let dir = tempdir().unwrap();
21704        let mut e = open_session(&dir.path().join("q.h5"));
21705        e.stage_created_dataset(&ObjectPathBuf::parse("col"), i32_dataset(&[1, 2]))
21706            .unwrap();
21707        // `rewind` undoes a batch by truncating, which is exact only while
21708        // staging appends. Both operations that do otherwise say so here rather
21709        // than leaving half of themselves behind in a refused batch.
21710        let folded: Result<(), Error> = e.stage_atomically(|s| {
21711            let mut b = AppendBuilder::new();
21712            b.append_i32(&[3]);
21713            s.stage_dataset_append_pending(&ObjectPathBuf::parse("col"), b)
21714        });
21715        assert!(matches!(folded, Err(Error::EditUnsupported(_))));
21716        let withdrawn: Result<(), Error> =
21717            e.stage_atomically(|s| s.delete(&ObjectPathBuf::parse("col")));
21718        assert!(matches!(withdrawn, Err(Error::EditUnsupported(_))));
21719        assert_eq!(
21720            e.staged_dataset_meta(&ObjectPathBuf::parse("col"))
21721                .unwrap()
21722                .dimensions,
21723            vec![2]
21724        );
21725    }
21726}
21727
21728#[cfg(test)]
21729mod object_header_wrap_tests {
21730    use super::*;
21731
21732    /// A region whose messages cannot be walked is reported, not asserted away.
21733    ///
21734    /// The `debug_assert!(false)` this replaced split the behavior by build
21735    /// profile: a test build panicked, and a release build wrote the header with
21736    /// no Attribute Info message — the zero-`num_attrs` defect the normalization
21737    /// exists to prevent. Asserted as an `Err` because that is the one answer
21738    /// both profiles can give.
21739    #[test]
21740    fn an_unwalkable_region_is_refused_rather_than_wrapped() {
21741        // One version 2 header message — type byte, 2-byte size, flags byte —
21742        // whose size field claims far more body than the region holds.
21743        let mut region = vec![0x0Cu8]; // Attribute
21744        region.extend_from_slice(&0xFFFFu16.to_le_bytes());
21745        region.push(0); // flags
21746        region.extend_from_slice(&[0u8; 4]); // a body far shorter than declared
21747
21748        let err = build_v2_object_header(&OhRegion::new(region, OhHeaderProps::PLAIN)).unwrap_err();
21749        assert!(
21750            matches!(err, Error::EditUnsupported(_)),
21751            "an unwalkable region gave {err:?}"
21752        );
21753    }
21754
21755    /// The walkable case still normalizes: a header carrying inline attributes
21756    /// comes back with the Attribute Info message that declares their count.
21757    #[test]
21758    fn a_walkable_region_still_gains_its_attribute_info() {
21759        let body = [0u8; 8];
21760        let mut region = vec![0x0Cu8]; // Attribute
21761        region.extend_from_slice(&(body.len() as u16).to_le_bytes());
21762        region.push(0); // flags
21763        region.extend_from_slice(&body);
21764
21765        let oh =
21766            build_v2_object_header(&OhRegion::new(region.clone(), OhHeaderProps::PLAIN)).unwrap();
21767        assert_eq!(&oh[..4], b"OHDR");
21768        assert!(
21769            oh.len() > 8 + region.len() + 4,
21770            "the wrapped header did not grow by an Attribute Info message"
21771        );
21772    }
21773}