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).
25//! Object copy ([`File::copy`](crate::File::copy), the HDF5 `H5Ocopy`) deep-copies
26//! a source subtree — appending fresh copies of every object, repointing internal
27//! links and the contiguous data address — and links the copy in like an
28//! addition; the headers are reproduced from their verbatim message bytes, so
29//! datatypes, dataspaces, and attributes stay byte-exact. A chunked (and filtered)
30//! dataset is copied with its chunk payloads and filter pipeline preserved
31//! byte-for-byte, its index rebuilt at the new location. The same machinery,
32//! [`File::copy_from`](crate::File::copy_from), copies an object **across two open files** — the
33//! source being a separate [`File`](crate::File) reader rather than the file being
34//! edited. Because the copy is byte-for-byte, the cross-file path refuses anything
35//! that embeds a source-file absolute address (variable-length or reference data,
36//! a committed datatype), which an in-file copy keeps valid by sharing the source
37//! file's heaps and objects.
38//!
39//! Value overwrite ([`Dataset::write`](crate::Dataset::write), the HDF5 `H5Dwrite`) replaces
40//! an **existing** dataset's values. The replacement's datatype and shape must
41//! match the on-disk dataset (an overwrite, not a reshape or retype); contiguous,
42//! compact, and chunked (including filtered) datasets are all supported, the chunk
43//! geometry and filter pipeline taken from the on-disk header. A same-length
44//! contiguous overwrite is the cheapest edit there is — the new bytes go straight
45//! into the existing data block, so no header is rewritten and the superblock root
46//! is not flipped, and the synced data write is the commit's linearization point.
47//! A chunked overwrite takes the same in-place path when every (re-encoded) chunk
48//! still fits its slot — always for unfiltered storage (chunk sizes are fixed by
49//! the unchanged shape), and for filtered storage when the re-encoded chunks match.
50//! When a length differs (a resized contiguous block, a filtered chunk that no
51//! longer fits, or a compact dataset) the dataset's storage is rebuilt and its
52//! header relocated like an addition: the new data and a rewritten header are
53//! appended, the data-layout message is repointed, the old storage is freed, and
54//! the parent group's link is patched. A relocating overwrite of a dataset
55//! reachable through more than one hard link is refused, since only the one named
56//! link could be repointed at the moved header.
57//!
58//! # Scope
59//!
60//! It is deliberately strict: rather than silently produce a degraded file, it
61//! refuses with [`Error::EditUnsupported`] any case it cannot reproduce
62//! faithfully. Requirements:
63//!
64//! - The file uses 8-byte offsets/lengths. A **userblock** (non-zero base
65//!   address, as every MATLAB v7.3 `.mat` file has) is supported: addresses are
66//!   read and written relative to the base and the userblock bytes are preserved
67//!   verbatim. Every edit works on a userblock file — value overwrites, additions
68//!   of contiguous and chunked/filtered datasets, in-place and relocating
69//!   overwrites of every layout (with the old storage reclaimed), object deletion
70//!   (with base-aware subtree reclaim), in-file copy, cross-file copy into a
71//!   userblock destination, group creation, compact attributes, and free-space
72//!   reuse. The one userblock-specific limitation left is cross-file copy *from* a
73//!   userblock source (the source must have base 0; see [`copy_from`](crate::File::copy_from)).
74//!   Any superblock version (0–3) is accepted: a version 0/1
75//!   (symbol-table) file is edited by converting each group on the edited path
76//!   to the latest format and repointing the superblock's root symbol-table
77//!   entry.
78//! - A version 2/3 group on an edited path stores its links compactly (not in a
79//!   dense fractal heap) and does not track message creation order; headers
80//!   split across continuation chunks (as the reference C library often writes)
81//!   are collapsed into a single chunk when rewritten. A version 1 group is
82//!   converted to a compact-link v2 header, carrying its links and attributes
83//!   over (other group messages — symbol table, modification time — are
84//!   dropped); an attribute it cannot reproduce is refused.
85//! - Added datasets may be contiguous *or* chunked, with any filter the
86//!   whole-file writer supports (deflate, shuffle, fletcher32, scale-offset,
87//!   LZF, ZFP), and may declare extensible (maximum, optionally unlimited)
88//!   dimensions. A chunked dataset's data and index — and any filtered chunks —
89//!   are produced by the same builder the whole-file writer uses and appended at
90//!   end-of-file, so its object header is byte-identical to a freshly written
91//!   one. A contiguous dataset may be empty (zero-element); chunking an empty
92//!   shape is not supported. A provenance dataset (`with_provenance`) is
93//!   supported, its attributes computed the same way the whole-file writer
94//!   computes them. A contiguous dataset may carry a variable-length-string
95//!   payload (`with_vlen_strings`) or per-element object-reference targets
96//!   (`with_path_references`); chunking either is not supported. A
97//!   path-resolved reference may target any object this commit is not itself
98//!   still writing (an ancestor group, a same-depth sibling group ordered
99//!   later in the same commit, a copy destination or its interior, a
100//!   `write_dataset` target, or an object this commit deletes) — targeting
101//!   one of those is refused, up front and before any byte of the commit is
102//!   written, rather than resolved to a stale or wrong address; a path that
103//!   resolves nowhere at all becomes an undefined reference, matching the
104//!   whole-file writer. Every
105//!   added dataset must have a fixed-size datatype, few enough attributes
106//!   (compact or variable-length) to stay in compact storage. Group, root, and
107//!   **dataset** attribute edits (`set_group_attr` / `set_dataset_attr`) may
108//!   likewise be fixed-size or variable-length, under the same compact-storage
109//!   limit; dense (fractal-heap) attribute storage is not editable. A dataset
110//!   attribute edit relocates the dataset header and so requires a single hard
111//!   link.
112//! - A new group's parent must already exist or be created in the same session
113//!   (each level created explicitly); intermediate groups are not auto-created.
114//! - Rows can be appended to an existing chunked, unlimited, Extensible-Array
115//!   dataset **immediately and in place** with an in-place append (amortized O(1),
116//!   crash-atomic, no `commit`), interleaved with the staged edits above. A
117//!   target the fast path cannot handle — a userblock or pre-v2 file, an
118//!   unallocated index, a non-Extensible-Array or multi-hard-link dataset, a
119//!   non-chunk-aligned filtered append — is refused with
120//!   [`Error::AppendInPlaceUnsupported`]; use the staged `append_dataset` instead.
121//!
122//! # Free-space reuse (issue #21)
123//!
124//! Each commit vacates space: the object headers it rewrites are superseded, and
125//! a deletion abandons its target's blocks. Those regions are recorded in a
126//! session-local free list and reused by later commits in the same session —
127//! a new object is written into a fitting freed region instead of growing the
128//! file, and when freed space forms a run reaching end-of-file the file is
129//! physically truncated. The reuse is crash-safe: it only ever overwrites space
130//! freed by an *earlier*, already-durable commit (never space the current commit
131//! is mid-way through freeing), and truncation happens only after the superblock
132//! recording the smaller end-of-file is itself durable.
133//!
134//! Reclaim is best-effort and conservative. Contiguous and chunked datasets
135//! (chunk index plus chunk data) and whole group subtrees are reclaimed; a
136//! deleted object whose blocks cannot be enumerated exhaustively —
137//! variable-length global-heap storage, dense attribute/link heaps, a
138//! non–version-2 header, a version 2 B-tree chunk index — is left as dead bytes
139//! rather than risk freeing a region that is still in use; under-reclaiming only
140//! wastes space, while over-reclaiming would corrupt.
141//!
142//! Whether the free list outlives the session depends on how the file was
143//! created. For the default (non-persisting) file it is **not** persisted: it is
144//! forgotten on close, so reuse and shrinkage apply to churn within a session,
145//! and a single delete-then-close shrinks the file only when the freed bytes
146//! reach end-of-file. A file created with
147//! `H5Pset_file_space_strategy(persist = true)` instead **persists** its free
148//! space: `open` seeds the list from the on-disk free-space managers (the
149//! `FSHD`/`FSSE` blocks the superblock-extension File Space Info message points
150//! at), and each commit rewrites those managers, so freed regions survive
151//! close/reopen and are reused across sessions — by this crate and the reference
152//! C library alike. A persisting commit *retains* freed space (recording it on
153//! disk) rather than truncating it; the blocks holding the managers are appended
154//! past all live data and the superblock is repointed last, so a crash before the
155//! repoint leaves the prior file wholly intact. Whole-file compaction that
156//! reclaims every hole at once is still the separate repack path.
157
158use std::collections::{BTreeMap, HashMap, HashSet};
159use std::fs;
160use std::io::{Read, Seek, SeekFrom};
161use std::path::Path;
162
163use crate::checksum::jenkins_lookup3;
164use crate::chunk_index_inplace::{Located, Store, apply_ea_append, plan_ea_append};
165use crate::chunked_read::{
166    chunk_index_spans_from_source, enumerate_chunks_from_source, plan_dense_grid,
167};
168use crate::chunked_write::{
169    ChunkMeta, ChunkOptions, ChunkProvider, WrittenChunk, build_chunked_data_at_ext,
170    build_extensible_array_at, emit_chunked_data_verbatim, plan_chunked_data_verbatim,
171    serialize_v4_extensible_array, split_into_chunks,
172};
173use crate::convert::TryToUsize;
174use crate::data_layout::DataLayout;
175use crate::dataspace::{Dataspace, DataspaceType};
176use crate::datatype::{Datatype, DatatypeByteOrder};
177use crate::error::{Error, FormatError, OBJECT_HEADER_MESSAGE_MAX};
178use crate::extensible_array::ExtensibleArrayHeader;
179use crate::file_create_properties::FileCreateProperties;
180use crate::file_lock::{self, FileLocking};
181use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy, NUM_FILE_FSM_MANAGERS};
182use crate::file_writer::{
183    LENGTH_SIZE, OFFSET_SIZE, build_chunked_dataset_oh, build_dataset_oh, make_link,
184};
185use crate::filter_pipeline::{
186    FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZF, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
187    FilterPipeline,
188};
189use crate::filters::{ChunkContext, compress_chunk, decompress_chunk};
190use crate::free_space::FreeList;
191use crate::free_space_manager::{
192    self, FreeSection, FsmHeader, PageType, SECT_CLASS_SIMPLE, align_up, free_sections, fshd_len,
193    plan_paged_managers, serialize_file_fsm,
194};
195use crate::group_v2::resolve_group_entries_from_source;
196use crate::image::{FileImage, HandleImage, MirrorImage};
197use crate::link_message::{LinkMessage, LinkTarget};
198use crate::message_type::MessageType;
199use crate::object_header::ObjectHeader;
200use crate::reader::FileAccessProperties;
201use crate::signature;
202use crate::source::{BaseOffsetSource, BytesSource, MetadataCacheConfig, Source};
203use crate::superblock::Superblock;
204use crate::type_builders::{
205    AttrValue, DatasetBuilder, ObjectRefPatch, ObjectRefTarget, VlStringStaging,
206    build_attr_message, build_global_heap_collections, make_f32_type, make_f64_type, make_i8_type,
207    make_i16_type, make_i32_type, make_i64_type, make_u8_type, make_u16_type, make_u32_type,
208    make_u64_type, patch_vl_refs, patch_vl_refs_masked, write_reference_address,
209};
210
211/// An undefined on-disk address (all bits set), HDF5's "no address" sentinel.
212const UNDEF: u64 = u64::MAX;
213
214/// Maximum number of compact attributes; beyond this HDF5 switches a dataset to
215/// dense (fractal-heap) attribute storage, which this engine does not emit.
216/// Mirrors `DENSE_ATTR_THRESHOLD` in `file_writer`.
217const MAX_COMPACT_ATTRS: usize = 8;
218
219/// Recursion-depth cap for object copy, guarding against a stack overflow on a
220/// pathological or cyclic hard-link graph (HDF5 hard links can form cycles).
221/// Far deeper than any real group hierarchy.
222const MAX_COPY_DEPTH: u32 = 1000;
223
224/// Upper bound on the number of object headers walked when counting hard links
225/// across the file (issue #77 / reclaim safety). Far beyond any real file; a
226/// graph larger than this aborts the count, and the commit then leaves deleted
227/// objects unreclaimed (a safe leak) rather than risk an unbounded walk.
228const MAX_LINK_GRAPH_NODES: u32 = 1 << 24;
229
230/// Maximum number of object-header chunks to follow when gathering a header that
231/// spans continuation blocks, guarding against a cyclic continuation chain.
232/// Matches the reader's continuation-depth cap.
233const MAX_OH_CHUNKS: usize = 256;
234
235/// Maximum length of a version 2 object header's fixed prefix: signature (4) +
236/// version (1) + flags (1) + optional access/modification/change/birth times
237/// (16) + optional attribute phase-change thresholds (4) + the chunk-0 size
238/// field (up to 8). Reading this many bytes always covers the prefix, so
239/// [`oh_region_at`] can be handed one bounded window instead of a whole-file
240/// image.
241const OH_PREFIX_MAX: usize = 34;
242
243/// A path identified by its components (no leading/trailing empties); the root
244/// group is the empty vector.
245type PathKey = Vec<String>;
246
247/// Variable-length group/root attributes staged by [`apply_group_attr_ops`],
248/// each an (attribute message still carrying a placeholder heap address, its
249/// global heap collections) pair, resolved in the apply loop.
250type PendingVlAttrs = Vec<(crate::attribute::AttributeMessage, Vec<Vec<u8>>)>;
251
252/// Accumulates elements to append to an existing chunked, unlimited dataset via
253/// [`Dataset::append_staged`](crate::Dataset::append_staged), in call order along the dataset's first
254/// (axis-0) dimension.
255///
256/// It mirrors [`DatasetBuilder`]'s typed/generic vocabulary. Repeated typed or
257/// [`append_raw`](Self::append_raw) calls concatenate; each typed method also
258/// records the element datatype it implies, which `commit` checks against the
259/// dataset's on-disk datatype (a mismatch — including a mix of element types in
260/// one builder — is refused with [`Error::AppendUnsupported`], never written as
261/// garbage).
262pub struct AppendBuilder {
263    /// Accumulated little-endian element bytes to append, in call order.
264    raw: Vec<u8>,
265    /// The element datatype implied by the typed `append_*` calls, if any were
266    /// used. `None` when only [`append_raw`](Self::append_raw) was called (a raw
267    /// append is checked structurally — element-size alignment and little-endian
268    /// on-disk order — rather than by datatype equality).
269    elem_dt: Option<Datatype>,
270    /// Set when two typed calls implied different element datatypes; `commit`
271    /// refuses such a builder rather than write a mix of encodings.
272    dt_conflict: bool,
273}
274
275impl AppendBuilder {
276    pub(crate) fn new() -> Self {
277        Self {
278            raw: Vec::new(),
279            elem_dt: None,
280            dt_conflict: false,
281        }
282    }
283
284    /// Accumulated little-endian element bytes (for the general append writer,
285    /// which reuses this builder to gather typed/generic appends).
286    pub(crate) fn raw(&self) -> &[u8] {
287        &self.raw
288    }
289
290    /// The element datatype implied by typed appends, if any.
291    pub(crate) fn elem_dt(&self) -> Option<&Datatype> {
292        self.elem_dt.as_ref()
293    }
294
295    /// Whether two typed appends implied conflicting element datatypes.
296    pub(crate) fn dt_conflict(&self) -> bool {
297        self.dt_conflict
298    }
299
300    /// Record the datatype a typed append implies, flagging a conflict if an
301    /// earlier typed call implied a different one.
302    fn set_dt(&mut self, dt: Datatype) {
303        match &self.elem_dt {
304            Some(prev) if *prev != dt => self.dt_conflict = true,
305            Some(_) => {}
306            None => self.elem_dt = Some(dt),
307        }
308    }
309
310    /// Append already-little-endian element bytes verbatim. The concatenated
311    /// length must be a whole multiple of the dataset's on-disk element size, and
312    /// the dataset's element datatype must be little-endian; no datatype is
313    /// otherwise inferred. Prefer the typed methods when the element type is known.
314    pub fn append_raw(&mut self, bytes: &[u8]) -> &mut Self {
315        self.raw.extend_from_slice(bytes);
316        self
317    }
318
319    /// Generic append of a flat slice of any supported scalar type — the
320    /// counterpart of [`DatasetBuilder::with_data`](crate::DatasetBuilder::with_data).
321    pub fn append<T: crate::element::H5Element>(&mut self, data: &[T]) -> &mut Self {
322        T::append_into(self, data);
323        self
324    }
325}
326
327/// Generate the typed `append_*` methods: serialize each value little-endian and
328/// record the implied element datatype.
329macro_rules! append_typed {
330    ($($method:ident, $ty:ty, $make:ident;)*) => {
331        impl AppendBuilder {
332            $(
333                #[doc = concat!("Append `", stringify!($ty), "` values to the dataset.")]
334                pub fn $method(&mut self, data: &[$ty]) -> &mut Self {
335                    self.set_dt($make());
336                    for &v in data {
337                        self.raw.extend_from_slice(&v.to_le_bytes());
338                    }
339                    self
340                }
341            )*
342        }
343    };
344}
345
346append_typed! {
347    append_f64, f64, make_f64_type;
348    append_f32, f32, make_f32_type;
349    append_i8, i8, make_i8_type;
350    append_i16, i16, make_i16_type;
351    append_i32, i32, make_i32_type;
352    append_i64, i64, make_i64_type;
353    append_u8, u8, make_u8_type;
354    append_u16, u16, make_u16_type;
355    append_u32, u32, make_u32_type;
356    append_u64, u64, make_u64_type;
357}
358
359/// The in-place write engine behind the owned read-write [`File`](crate::File)
360/// (its `Backend::Edit`).
361///
362/// Reads and edits the file through a [`FileImage`], which owns the writable
363/// handle and decides how much of the file is resident. It carries two commit
364/// models: staged tree edits applied by [`commit`](Self::commit), and immediate
365/// crash-atomic in-place appends ([`append_inplace_gathered`](Self::append_inplace_gathered)).
366pub(crate) struct WriteEngine {
367    /// The file bytes this session reads and edits, behind the [`FileImage`]
368    /// abstraction: reads go through its [`Source`] impl, and the write side —
369    /// the end-of-file cursor, `append`, `write_at`, `truncate`, and the
370    /// durability barriers — through its own primitives.
371    ///
372    /// Nothing in the engine assumes the whole file is resident, so one engine
373    /// serves both a whole-file mirror and a file-backed image that holds only
374    /// what it is reading (issue #198). [`image_slice`](Self::image_slice)
375    /// exposes the mirror's buffer where a caller can exploit it.
376    image: Box<dyn FileImage>,
377    /// Absolute offset of the superblock signature in the file.
378    sb_sig_off: usize,
379    /// Parsed superblock. On-disk addresses are stored relative to `base_address`;
380    /// the in-memory `root_group_address` is normalized to an absolute file offset
381    /// on open and converted back to a base-relative address when serialized on
382    /// commit. `base_address` equals the superblock's file location (`sb_sig_off`):
383    /// 0 for a plain file, the userblock size for one with a userblock.
384    superblock: Superblock,
385    /// Datasets staged by `create_dataset`, as (parent group path, builder).
386    pending_datasets: Vec<(PathKey, DatasetBuilder)>,
387    /// Value overwrites staged by `write_dataset`, as (full dataset path,
388    /// builder). Each replaces an existing dataset's values in place; the new
389    /// datatype and shape must match the on-disk ones byte-exactly (this is a
390    /// value overwrite, not a reshape/retype). Applied on the next `commit`.
391    pending_writes: Vec<(PathKey, DatasetBuilder)>,
392    /// Appends staged by `append_dataset`, as (full dataset path, builder). Each
393    /// grows an existing chunked, unlimited, Extensible-Array-indexed dataset
394    /// along axis 0 by keeping its existing chunk data in place and rebuilding the
395    /// index over the kept plus newly-appended (and any rewritten trailing) chunks.
396    /// Applied on the next `commit`.
397    pending_appends: Vec<(PathKey, AppendBuilder)>,
398    /// New groups staged by `create_group`, as full paths.
399    pending_groups: Vec<PathKey>,
400    /// Group attribute edits staged as (group path, operation). The path may be
401    /// a group created in this same session.
402    pending_group_attrs: Vec<(PathKey, AttrOp)>,
403    /// Dataset attribute edits staged as (full dataset path, operation), applied
404    /// on the next `commit`. Each relocates the dataset's object header (like a
405    /// relocating overwrite): the header is rebuilt with the compact-attribute
406    /// change, its single naming link is patched, and the old header freed — the
407    /// dataset's data and chunk index stay in place. The target must be an existing,
408    /// single-hard-link dataset using compact (not dense fractal-heap) attributes.
409    pending_dataset_attrs: Vec<(PathKey, AttrOp)>,
410    /// Links staged for removal by `delete`, as full paths.
411    pending_deletes: Vec<PathKey>,
412    /// Object copies staged by `copy`, as (source path, destination full path).
413    pending_copies: Vec<(PathKey, PathKey)>,
414    /// Cross-file object copies staged by `copy_from`, as (destination full path,
415    /// the source subtree already read out of the other file). The subtree is read
416    /// — and foreign-address-screened — eagerly in `copy_from` (the source file is
417    /// borrowed only for that call), then linked in at the next `commit`.
418    pending_cross_copies: Vec<(PathKey, CopyTree)>,
419    /// Session-local free-space tracker (issue #21). Holds regions vacated by
420    /// prior commits in this session — superseded object headers and the blocks
421    /// of deleted objects — so later commits reuse them instead of growing the
422    /// file, and so a freed run reaching end-of-file can be truncated away. It
423    /// starts empty on `open` for a non-persisting file: holes already present
424    /// from earlier sessions or other tools are not tracked. When the file
425    /// persists its free space (`persist` is `Some`), `open` instead seeds it
426    /// from the on-disk free-space managers, so reuse spans sessions.
427    free: FreeList,
428    /// Free-space persistence read from the file's superblock extension on
429    /// `open` (the file-creation `H5Pset_file_space_strategy(persist = true)`
430    /// setting). `None` for the default non-persisting file; when `Some`, every
431    /// [`commit`](Self::commit) rewrites the on-disk free-space managers so the
432    /// free list survives close/reopen.
433    persist: Option<PersistState>,
434    /// Per-dataset geometry cache for the immediate O(1) in-place append
435    /// ([`append_inplace_gathered`](Self::append_inplace_gathered)), keyed by the dataset's resolved
436    /// **object-header address** (not its path, so two hard links to one dataset
437    /// share one entry). Populated on the first append to a dataset and maintained
438    /// across appends; cleared wholesale at the entry of every non-trivial
439    /// [`commit`](Self::commit), since a commit can relocate a cached header or
440    /// free the region it points into (see `commit`).
441    located: HashMap<u64, LocatedState>,
442    /// True when this engine was opened for SWMR writing
443    /// ([`open_swmr_writer`](Self::open_swmr_writer)): the append engine then
444    /// enforces the SWMR subset (unfiltered, chunk-aligned) so a concurrent
445    /// reader never observes a torn view. `false` for an ordinary edit session.
446    swmr_mode: bool,
447    /// Paged-file state (`H5F_FSPACE_STRATEGY_PAGE`), read from the superblock
448    /// extension at `open` regardless of the persist flag; `None` for the common
449    /// non-paged file. When `Some`, [`commit`](Self::commit) takes a page-aware
450    /// tail that keeps pages homogeneous and rewrites the per-page-type managers
451    /// (issue #198). A paged file that does not *persist* its free space is still
452    /// refused: see [`PagedEdit`].
453    paged: Option<PagedEdit>,
454    /// Set by the first [`commit`](Self::commit) that does any work. A commit can
455    /// relocate an object header, and nothing on disk distinguishes a relocated
456    /// header from the intact bytes it vacated — the old header still parses, and
457    /// its data-layout message still points at the live chunk index. An
458    /// [`AppendTarget::Header`] captured before that commit would therefore append
459    /// successfully *into the dead header*, growing its dataspace while the live
460    /// dataset stayed put, and report `Ok`. A path is re-resolved on every append
461    /// and so survives a commit; a raw address does not, so it is refused once one
462    /// has run.
463    committed: bool,
464    /// Object-header address for each path an in-place append has resolved in
465    /// this session. A single `Dataset::append` asks for the target's geometry
466    /// and then appends to it, and a loop of appends repeats that; without this
467    /// the path would be walked from the root on every one of those steps.
468    ///
469    /// An in-place append never moves an object header — that is why
470    /// [`located`](Self::located) can be keyed by address and survive appends —
471    /// so only a commit can stale an entry, and it clears both together.
472    resolved: HashMap<String, u64>,
473    /// Whether this session splits a large in-place append into batches, trading
474    /// whole-call crash atomicity for a peak memory that does not scale with the
475    /// call. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy); see
476    /// [`batch_elems`](Self::batch_elems).
477    batched_appends: bool,
478    /// Whether this session reads through a handle rather than a whole-file
479    /// mirror. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy), and reported by
480    /// [`File::edit_backing`](crate::File::edit_backing) so a caller who
481    /// asked for [`MemoryStrategy::Auto`] can tell which one it got. Distinct
482    /// from [`batched_appends`](Self::batched_appends), which is a crash-atomicity
483    /// trade the bounded engine happens to make, not a statement about memory.
484    bounded: bool,
485    /// The file length when the on-disk free-space managers were last written,
486    /// for a file that persists them. Every immediate in-place append grows the
487    /// file past those managers and leaves them mid-file, so a session that ends
488    /// with `image.len() != fsm_len` owes a rewrite; that is what
489    /// [`finalize_persist`](Self::finalize_persist) settles at close. Meaningless
490    /// (and untouched) when `persist` is `None`.
491    fsm_len: u64,
492}
493
494/// How much memory a read-write open may use to hold the file being edited.
495///
496/// The two read-write backends differ in memory, not in what they can express: a
497/// *bounded* session reads through a handle and holds only what a commit is
498/// building, while a *mirrored* session materializes the whole file in memory.
499/// Bounded is the better default when it applies, but it cannot yet edit every
500/// file — a pre-v2 (non-latest-format) superblock or a userblock still needs the
501/// mirror.
502///
503/// This is what a caller says about that trade-off, on
504/// [`FileAccessProperties::with_memory_strategy`](crate::FileAccessProperties::with_memory_strategy).
505/// Leaving it unset lets the entry point decide: [`File::open_rw`](crate::File::open_rw)
506/// prefers the bounded engine and falls back to the mirror ([`Auto`](Self::Auto)),
507/// while the deprecated [`File::open_rw_bounded`](crate::File::open_rw_bounded)
508/// refuses instead of falling back ([`Bounded`](Self::Bounded)).
509///
510/// This is a *request*, so it is deliberately not the type a file answers with:
511/// [`File::edit_backing`](crate::File::edit_backing) returns an [`EditBacking`],
512/// which cannot express [`Auto`](Self::Auto).
513///
514/// Sealed: unlike [`FileLocking`] or [`FileSpaceStrategy`](crate::FileSpaceStrategy),
515/// whose variant sets mirror a closed C-library enum, this is a policy this crate
516/// invented, so a fourth strategy must not be a breaking change.
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
518#[non_exhaustive]
519pub enum MemoryStrategy {
520    /// Never build a whole-file mirror. A file the bounded engine cannot edit is
521    /// refused at open with [`Error::EditUnsupported`], before anything is
522    /// staged. This is what
523    /// [`File::open_rw_bounded`](crate::File::open_rw_bounded) has always done.
524    Bounded,
525    /// Prefer the bounded engine, but fall back to the whole-file mirror for a
526    /// file it cannot edit, rather than refusing. Memory then scales with the
527    /// file, which is the cost of the file opening at all. What
528    /// [`File::open_rw`](crate::File::open_rw) uses when nothing is asked for.
529    Auto,
530    /// Always build the whole-file mirror, whatever the file looks like. What
531    /// [`File::open_rw`](crate::File::open_rw) did before it learned to dispatch.
532    Mirrored,
533}
534
535/// Which of the two read-write backends a file's editing session is actually
536/// using, from [`File::edit_backing`](crate::File::edit_backing).
537///
538/// Deliberately a different type from [`MemoryStrategy`]: that one is what a
539/// caller *asks* for and includes [`Auto`](MemoryStrategy::Auto), which is a
540/// preference between these two rather than a third thing a session can be. A
541/// single shared type would make `file.backing() == Auto` a comparison that
542/// compiles and is false forever.
543///
544/// The two also evolve at different rates. A future `MemoryStrategy` may name a
545/// new *policy* — a byte budget, a size threshold — without the set of backends
546/// changing at all. Sealed for the rarer case that a third backend does appear.
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548#[non_exhaustive]
549pub enum EditBacking {
550    /// Reads through a file handle, holding only what a commit is building.
551    /// Memory does not scale with the file.
552    Bounded,
553    /// Holds the whole file in memory for the life of the session.
554    Mirrored,
555}
556
557impl From<EditBacking> for MemoryStrategy {
558    /// Turns an outcome back into the request that pins it, so a caller can
559    /// reopen a file onto the backing it got the first time:
560    /// `with_memory_strategy(file.edit_backing().unwrap().into())`.
561    fn from(backing: EditBacking) -> Self {
562        match backing {
563            EditBacking::Bounded => Self::Bounded,
564            EditBacking::Mirrored => Self::Mirrored,
565        }
566    }
567}
568
569/// Why the bounded engine cannot edit a file, when the whole-file mirror can.
570///
571/// Kept separate from the refusals that apply to *both* engines: a fallback is
572/// only ever worth taking for a limitation the mirror does not share. A paged
573/// file with no persisted free-space managers, for instance, is refused by the
574/// staged commit as well, so mirroring it would trade a clear error at open for
575/// the same error later with work already staged.
576fn bounded_only_limitation(session: &WriteEngine) -> Option<&'static str> {
577    if session.superblock.version < 2 {
578        return Some(
579            "bounded read-write access requires a latest-format file (v2/v3 superblock); \
580             leave MemoryStrategy unset, or pass MemoryStrategy::Auto, to fall back to \
581             the whole-file mirror here",
582        );
583    }
584    if session.superblock.base_address != 0 {
585        return Some(
586            "bounded read-write access does not support a file with a userblock \
587             (non-zero base address); leave MemoryStrategy unset, or pass \
588             MemoryStrategy::Auto, to fall back to the whole-file mirror here",
589        );
590    }
591    None
592}
593
594/// Whether a file built from `create` could not then be opened read-write under
595/// `access`, and why.
596///
597/// [`File::create_with_options`](crate::File::create_with_options) writes a file
598/// and hands back an open read-write handle, so a creation/access pair that
599/// cannot survive that second half must be caught *before* the write — otherwise
600/// the call leaves a file on disk and returns `Err`, which reads like a failed
601/// create but is not one.
602///
603/// This mirrors the open-time refusals above and must be kept in step with them:
604/// [`bounded_only_limitation`] for the userblock, and the shared paged check in
605/// [`open_rw_with_strategy`](WriteEngine::open_rw_with_strategy) for a paged file
606/// with no persisted free space. Both are stated here in terms of the properties
607/// that *cause* them, because the open-time wording tells the caller to recreate
608/// the file — advice that is circular when the caller is creating it.
609pub(crate) fn create_would_refuse_reopen(
610    create: &FileCreateProperties,
611    access: &FileAccessProperties,
612) -> Option<&'static str> {
613    if let Some((FileSpaceStrategy::Page, false, _)) = create.file_space_strategy() {
614        return Some(
615            "a paged file (FileSpaceStrategy::Page) with persist = false cannot be reopened \
616             read-write, so creating one this way would write the file and then fail to open \
617             it; pass persist = true to with_file_space_strategy, or build the file with \
618             FileBuilder if it is only ever going to be read",
619        );
620    }
621    if create.userblock() != 0 && access.memory_strategy() == Some(MemoryStrategy::Bounded) {
622        return Some(
623            "a userblock cannot be combined with MemoryStrategy::Bounded: the bounded engine \
624             cannot edit a file with a non-zero base address, so creating one this way would \
625             write the file and then refuse to open it; drop the userblock, or leave \
626             MemoryStrategy unset to mirror this file",
627        );
628    }
629    None
630}
631
632/// Paged-file bookkeeping for the whole-file editor (issue #198, step 1).
633///
634/// A paged file never mixes metadata and raw data within one page, so this tracks
635/// free space per page type — matching the three managers such a file records —
636/// and keeps the commit's appends homogeneous by padding a tail page whenever the
637/// page type changes.
638///
639/// The free lists are seeded only when the file *persists* its free space. A paged
640/// non-persisting file has no on-disk record of which pages hold metadata and
641/// which hold raw data, so there is nothing to seed and no way to stay segregated;
642/// [`commit`](EditSession::commit) refuses it outright, exactly as the bounded
643/// backend does.
644struct PagedEdit {
645    page_size: u64,
646    /// Metadata free space: the SUPER manager, slot 0.
647    meta: FreeList,
648    /// Small-raw free space: the DRAW manager, slot 2.
649    raw_small: FreeList,
650    /// Large-raw fragments: the generic-large manager, slot 6.
651    raw_large: FreeList,
652    /// Page type of the current tail page. `None` until this session's first
653    /// typed append; the file is page-aligned at open, so the first append never
654    /// needs to pad regardless of this.
655    last: Option<PageType>,
656    /// Free tails left by padding a metadata page before a raw append.
657    meta_pad: Vec<(u64, u64)>,
658    /// Free tails left by padding a raw page before a metadata append.
659    raw_pad: Vec<(u64, u64)>,
660}
661
662impl PagedEdit {
663    /// Ensure the next allocation on `image` begins in a page holding page type
664    /// `ty`: when the tail page holds the *other* type and is only partially
665    /// filled, pad it to a page boundary and record the padding as free space of
666    /// the outgoing type.
667    ///
668    /// This is the whole of the paged-append rule, and it lives here so the two
669    /// places that grow a paged file — the staged commit through
670    /// [`WriteEngine::begin_page`](WriteEngine::begin_page), and the shared
671    /// Extensible-Array append engine through [`EditStore`] — cannot drift. They
672    /// used to keep separate copies of this state, one per engine, which is what
673    /// made an in-place append to a paged file unsafe from the whole-file editor
674    /// (issue #198).
675    ///
676    /// Call it **before** reading the image's end-of-file to compute an address
677    /// that will be embedded in the bytes being built: several callers build
678    /// content whose interior addresses assume it lands at the current
679    /// end-of-file, and padding inserted after that read would shift the landing
680    /// address out from under them.
681    fn begin(&mut self, image: &mut dyn FileImage, ty: PageType) -> Result<(), Error> {
682        let len = image.len();
683        if len % self.page_size != 0 {
684            let pad_len = self.page_size - len % self.page_size;
685            // `prev` is the outgoing page type to record the padding under, or
686            // `None` for a crash-recovery pad whose tail-page type is unknown.
687            let pad = match self.last {
688                // Normal case: the tail page holds a known type; pad only on a
689                // type switch, recording the tail as free of the outgoing type.
690                Some(prev) if prev != ty => Some(Some(prev)),
691                Some(_) => None, // same type: keep packing the tail page
692                // A previous session grew this paged file and was killed before
693                // its tail was page-aligned, so the file opened non-page-aligned
694                // with no known tail type. Pad it up (extending whatever the tail
695                // page holds, so the page stays homogeneous) and leave the padding
696                // untracked, since recording it under the wrong page type could
697                // let a reader reuse it and mix the page.
698                None => Some(None),
699            };
700            if let Some(prev) = pad {
701                let pad_at = len;
702                image.append(&vec![0u8; pad_len.to_usize()?])?;
703                match prev {
704                    Some(PageType::Meta) => self.meta_pad.push((pad_at, pad_len)),
705                    Some(PageType::Raw) => self.raw_pad.push((pad_at, pad_len)),
706                    None => {} // crash-recovery pad: untracked (tail type unknown)
707                }
708            }
709        }
710        self.last = Some(ty);
711        Ok(())
712    }
713
714    fn new(page_size: u64) -> Self {
715        PagedEdit {
716            page_size,
717            meta: FreeList::new(),
718            raw_small: FreeList::new(),
719            raw_large: FreeList::new(),
720            last: None,
721            meta_pad: Vec::new(),
722            raw_pad: Vec::new(),
723        }
724    }
725
726    /// Record `(addr, size)` as free space of page type `ty` in the given lists.
727    /// A raw region at least a page long is a large-raw fragment (the
728    /// generic-large manager); everything else is small enough to stay in its
729    /// per-type SMALL manager. [`plan_paged_managers`] re-splits and re-classes at
730    /// serialization time, so this only has to route to the right *kind* of list.
731    ///
732    /// Takes the three lists rather than `&mut self` because a commit routes into
733    /// *copies* of them — nothing is free until the superblock repoint — and the
734    /// rule must not be restated at that call site.
735    fn route_free(
736        meta: &mut FreeList,
737        raw_small: &mut FreeList,
738        raw_large: &mut FreeList,
739        page_size: u64,
740        addr: u64,
741        size: u64,
742        ty: PageType,
743    ) {
744        match ty {
745            PageType::Meta => meta.free(addr, size),
746            PageType::Raw if size >= page_size => raw_large.free(addr, size),
747            PageType::Raw => raw_small.free(addr, size),
748        }
749    }
750
751    /// Every tracked free region across the three managers, ascending by address.
752    /// Used for space accounting, where the caller wants one total rather than a
753    /// per-page-type breakdown.
754    fn all_sections(&self) -> Vec<(u64, u64)> {
755        let mut out = self.meta.sections();
756        out.extend(self.raw_small.sections());
757        out.extend(self.raw_large.sections());
758        out.sort_by_key(|&(addr, _)| addr);
759        out
760    }
761}
762
763/// What an in-place append names its target by.
764///
765/// A handle with a resolvable path uses it, which lets the session compare the
766/// target against its own staged edits. A handle reached by object reference has
767/// no path, so it names the dataset by the object-header address it was reached
768/// through — the same key the geometry cache uses.
769#[derive(Clone, Copy)]
770pub(crate) enum AppendTarget<'a> {
771    Path(&'a str),
772    Header(u64),
773}
774
775/// Byte budget for one append batch on a session that batches: a large append is
776/// split into whole-chunk batches of at most this many raw bytes (always at least
777/// one chunk), each applied as its own crash-atomic fsync-barriered sequence, so
778/// peak append memory never scales with the caller's slice.
779const APPEND_BATCH_BYTES: u64 = 1 << 20;
780
781/// One dataset's append geometry, handed to the public append path so it can
782/// slice a large call into aligned batches without materializing the whole
783/// call's bytes first.
784pub(crate) struct AppendGeometry {
785    /// Elements per chunk along axis 0 (>= 1).
786    pub(crate) chunk_elems: u64,
787    /// Bytes per on-disk element.
788    pub(crate) element_size: usize,
789    /// Current length along the unlimited dimension.
790    pub(crate) current_dim: u64,
791    /// Whether a filter pipeline applies (whole-chunk appends only).
792    pub(crate) filtered: bool,
793    /// Whole-chunk elements in one full batch (>= one chunk's worth), or
794    /// [`u64::MAX`] when the session does not batch.
795    pub(crate) full_batch_elems: u64,
796}
797
798/// Superblock consistency-flag bits raised while a SWMR writer is active: bit 0
799/// (write access) | bit 2 (SWMR write access). Cleared on a clean close. Matches
800/// the reference C library, h5py, and [`crate::File::open_swmr_writer`]. These
801/// are the bits every open path checks — see [`file_lock::check_status_flags`].
802const SWMR_WRITE_FLAGS: u32 = file_lock::WRITE_ACCESS | file_lock::SWMR_WRITE_ACCESS;
803
804/// A dataset located once for [`Dataset::append`](crate::Dataset::append) (or the bounded
805/// backend's immediate append), then maintained across appends. Mirrors the
806/// append writer's per-dataset state.
807pub(crate) struct LocatedState {
808    pub(crate) loc: Located,
809    /// The dataset's on-disk element datatype (for the append type check and the
810    /// filter chunk context).
811    pub(crate) datatype: Datatype,
812    /// Spatial (rank-length) chunk dimensions in elements: `[chunk_elems]`.
813    pub(crate) spatial: Vec<u64>,
814    /// Bytes per element (datatype size).
815    pub(crate) element_size: usize,
816    /// The re-encodable filter pipeline, when the dataset is filtered.
817    pub(crate) pipeline: Option<FilterPipeline>,
818}
819
820/// State for a file that persists its free space on disk. Carries the file's
821/// fixed file-space parameters and the extents of the free-space-manager blocks
822/// (and superblock extension) the *current* on-disk file uses, so the next
823/// persisting commit can reclaim them when it writes fresh ones.
824struct PersistState {
825    strategy: FileSpaceStrategy,
826    threshold: u64,
827    page_size: u64,
828    /// `(addr, len)` of the on-disk superblock-extension header and every
829    /// free-space-manager `FSHD`/`FSSE` block currently in use. Superseded — and
830    /// therefore freed — by the next persisting commit.
831    old_blocks: Vec<(u64, u64)>,
832}
833
834/// A snapshot of a writable file's live space usage (issue #150).
835///
836/// This is the mutating-session counterpart of the read-only accounting on
837/// [`File`](crate::File) ([`file_size`](crate::File::file_size) and
838/// [`persisted_free_space`](crate::File::persisted_free_space)): it describes the
839/// file *as the session currently holds it*, taken atomically at the moment of
840/// the [`space_accounting`](crate::File::space_accounting) call.
841///
842/// It reflects the committed file plus any immediate in-place appends
843/// ([`append`](crate::Dataset::append)), but **not** edits still
844/// staged for the next [`commit`](crate::File::commit) — `create_group`,
845/// `create_dataset`, `write_dataset`, `append_dataset`, `delete`, `copy`,
846/// `copy_from`, and attribute edits change these figures only when they are
847/// applied at commit. Use [`has_staged_edits`](crate::File::has_staged_edits) to
848/// tell whether such pending work exists.
849#[derive(Debug, Clone, PartialEq, Eq)]
850#[non_exhaustive]
851pub struct SpaceAccounting {
852    /// The session's current logical size in bytes: the byte length of the file
853    /// as the session holds it. It equals what
854    /// [`File::file_size`](crate::File::file_size) reports for the file on disk
855    /// right now (the HDF5 `H5Fget_filesize` value), because the session keeps its
856    /// in-memory mirror byte-for-byte identical to the file — every committed
857    /// write and every immediate in-place append
858    /// ([`append`](crate::Dataset::append)) updates both together.
859    ///
860    /// It is not monotonic: [`commit`](crate::File::commit) can reclaim trailing
861    /// free space and *shrink* the file. It can also exceed the superblock's
862    /// recorded end-of-file address when the file was opened carrying unaccounted
863    /// trailing bytes (the same slack [`File::file_size`](crate::File::file_size)
864    /// surfaces), since opening does not rewrite that address.
865    pub logical_size: u64,
866    /// Total reusable free bytes the next allocation or [`commit`](crate::File::commit)
867    /// can draw from before the file has to grow — the summed length of
868    /// [`reusable_free_space`](Self::reusable_free_space).
869    ///
870    /// Counts holes left inside [`logical_size`](Self::logical_size) by this
871    /// session's earlier commits (superseded object headers, the blocks of
872    /// deleted objects) and, for a file created with
873    /// `H5Pset_file_space_strategy(persist = true)` and no userblock, the regions
874    /// seeded from the on-disk free-space managers when the session was opened (so
875    /// reuse spans sessions). A fresh non-persisting session reports `0` even if
876    /// the file contains holes left by other tools — those are never tracked. It
877    /// is neither a lower bound on the next write's growth nor a promise of
878    /// shrinkage: a region counted here may be truncated away at commit — rather
879    /// than reused — if adjacent space is later freed and the coalesced run
880    /// reaches end-of-file.
881    pub reusable_free_bytes: u64,
882    /// The reusable free regions as `(offset, length)` pairs, sorted ascending by
883    /// offset and fully coalesced (no two regions touch or overlap).
884    ///
885    /// The offsets are **absolute** file offsets (from byte 0, including any
886    /// userblock prefix), matching [`logical_size`](Self::logical_size). This
887    /// differs from [`File::persisted_free_space`](crate::File::persisted_free_space),
888    /// whose pairs are relative to the superblock base address; the two coincide
889    /// for a file with no userblock (base address 0), which is the only kind whose
890    /// persisted free space a session seeds. Empty when nothing is reusable.
891    pub reusable_free_space: Vec<(u64, u64)>,
892}
893
894impl WriteEngine {
895    /// Open an existing HDF5 file for in-place editing under an explicit
896    /// file-locking policy.
897    ///
898    /// Reads the file into memory and retains a read/write handle. Under
899    /// [`FileLocking::Enabled`] it takes an exclusive OS advisory lock so the file
900    /// cannot be opened concurrently by another writer or reader; the lock is
901    /// released automatically when the session is dropped or the process exits
902    /// (including on a crash). Fails with [`Error::FileLocked`] if the file is
903    /// already locked, or [`Error::EditUnsupported`] if the file is not a
904    /// supported target; its documentation enumerates the exact requirements.
905    /// `HDF5_USE_FILE_LOCKING` overrides the requested policy, as in the C
906    /// library.
907    pub fn open_with_locking<P: AsRef<Path>>(path: P, locking: FileLocking) -> Result<Self, Error> {
908        Self::open_inner(path.as_ref(), Some(locking))
909    }
910
911    /// Open exactly as [`open_with_locking`](Self::open_with_locking) does, but
912    /// behind an image that withholds its whole-file slice, so every read takes
913    /// the [`Source`] path rather than the slice fast path.
914    ///
915    /// Distinct from [`open_rw_with_strategy`](Self::open_rw_with_strategy), which withholds the
916    /// slice *and* the residency: this one still mirrors the file, so a test can
917    /// compare the two read forms on a file the bounded open would refuse.
918    #[cfg(test)]
919    pub(crate) fn open_source_only(path: &Path) -> Result<Self, Error> {
920        Self::open_imaged(path, Some(FileLocking::Enabled), |handle, _len| {
921            Ok(Box::new(crate::image::SourceOnlyImage::new(
922                Self::read_mirror(handle)?,
923            )))
924        })
925    }
926
927    /// Open a bounded session whose image counts the bytes read through it, so a
928    /// test can assert that an operation touches only a small part of the file.
929    /// The counter is shared with the caller.
930    #[cfg(test)]
931    pub(crate) fn open_bounded_counting(
932        path: &Path,
933        read_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
934    ) -> Result<Self, Error> {
935        let mut session = Self::open_imaged(path, Some(FileLocking::Enabled), |handle, len| {
936            Ok(Box::new(crate::image::CountingImage::new(
937                Box::new(HandleImage::new(
938                    handle,
939                    len,
940                    crate::source::MetadataCacheConfig::disabled(),
941                )),
942                read_bytes,
943            )))
944        })?;
945        session.batched_appends = true;
946        Ok(session)
947    }
948
949    /// Open an existing file for read-write editing under `strategy`: the one
950    /// place that picks between the bounded backing (a [`HandleImage`] keeping no
951    /// whole-file mirror, so resident memory is the metadata-cache budget plus
952    /// whatever is being parsed) and the whole-file mirror. Backs
953    /// [`File::open_rw`](crate::File::open_rw) and the deprecated
954    /// [`File::open_rw_bounded`](crate::File::open_rw_bounded), which differ only
955    /// in the strategy they default to.
956    ///
957    /// The eligibility rules are checked here rather than deferred, because a
958    /// caller who asked for bounded memory cannot be silently given the mirror
959    /// instead. Under [`MemoryStrategy::Bounded`] a file the bounded engine
960    /// cannot edit is refused up front; [`MemoryStrategy::Auto`] opts in to
961    /// falling back to the mirror instead (issue #198, steps 3 and 4).
962    ///
963    /// Only a *bounded-only* limitation is worth falling back for — see
964    /// [`bounded_only_limitation`]. Non-8-byte offsets or lengths are refused by
965    /// [`open_imaged`](Self::open_imaged) for both engines, and so is an
966    /// unsupported superblock version; a paged file without persisted free space
967    /// is refused below for both.
968    pub(crate) fn open_rw_with_strategy(
969        path: &Path,
970        cache: MetadataCacheConfig,
971        locking: FileLocking,
972        strategy: MemoryStrategy,
973    ) -> Result<Self, Error> {
974        if strategy == MemoryStrategy::Mirrored {
975            return Self::open_with_locking(path, locking);
976        }
977        let mut session = Self::open_imaged(path, Some(locking), |handle, len| {
978            Ok(Box::new(HandleImage::new(handle, len, cache)))
979        })?;
980        session.batched_appends = true;
981        session.bounded = true;
982        // Refusals that apply to *both* backings come first, or falling back for a
983        // bounded-only limitation would skip them and hand back a session that
984        // cannot commit. A paged file with no persisted managers has no on-disk
985        // record of which pages hold metadata and which hold raw data, so nothing
986        // can keep the pages segregated; the staged commit refuses it too, so
987        // deferring would only trade this error for the same one later, with work
988        // already staged. A userblock is one way to reach this state without the
989        // file saying `persist = false`: persisted free space is declined for a
990        // non-zero base address, which leaves the managers unseeded all the same.
991        if session.paged.is_some() && session.persist.is_none() {
992            return Err(Error::EditUnsupported(
993                "read-write access to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
994                 persisted free space; recreate the file with \
995                 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to grow it in place",
996            ));
997        }
998        if let Some(reason) = bounded_only_limitation(&session) {
999            if strategy == MemoryStrategy::Bounded {
1000                return Err(Error::EditUnsupported(reason));
1001            }
1002            // Release the handle and its exclusive lock before reopening, or the
1003            // mirrored open would contend with the probe we are discarding —
1004            // fatally so on Windows, where the OS lock is mandatory. Dropping a
1005            // bare `WriteEngine` writes nothing: the free-space finalize that a
1006            // dropped writer owes lives on `FileInner`, which this is not yet.
1007            // Another writer can take the lock in that window; the reopen then
1008            // reports `Error::FileLocked`, which is the truthful answer.
1009            drop(session);
1010            return Self::open_with_locking(path, locking);
1011        }
1012        Ok(session)
1013    }
1014
1015    /// Open an existing file for SWMR (single-writer/multiple-reader) writing:
1016    /// take **no** OS lock at all and raise the superblock's SWMR-write
1017    /// consistency flag. Backs [`File::open_swmr_writer`](crate::File::open_swmr_writer).
1018    ///
1019    /// The no-lock is unconditional — `lock = None` never reaches
1020    /// `acquire_exclusive`, so `HDF5_USE_FILE_LOCKING` cannot reintroduce a lock
1021    /// that would block the concurrent readers SWMR exists to permit (fatally so
1022    /// on Windows, where OS locks are mandatory). Requires a latest-format
1023    /// (version-3 superblock) file with no userblock and no persisted
1024    /// free-space, so the superblock can be rewritten in place.
1025    ///
1026    /// The version-3 requirement is the C library's (`H5F__super_read`: "superblock
1027    /// version for SWMR is less than 3"), and it is what keeps the SWMR-write flag
1028    /// meaningful: neither library reads the status-flags byte back on an older
1029    /// superblock, so a flag raised there would announce a live writer to nobody.
1030    /// This crate's writer emits version 3, so no file it produces is affected.
1031    pub(crate) fn open_swmr_writer<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
1032        let mut session = Self::open_inner(path.as_ref(), None)?;
1033        if session.superblock.version < 3
1034            || session.superblock.base_address != 0
1035            || session.persist.is_some()
1036        {
1037            return Err(Error::SwmrAppendUnsupported(
1038                "SWMR writing requires a latest-format file (v3 superblock) with no userblock \
1039                 and no persisted free-space",
1040            ));
1041        }
1042        session.swmr_mode = true;
1043        session.set_consistency_flags(SWMR_WRITE_FLAGS)?;
1044        Ok(session)
1045    }
1046
1047    /// Set the superblock's consistency flags in the mirror and on disk, then
1048    /// flush. Used to raise the SWMR-write flag on open and clear it on close.
1049    /// Requires a base-0, version-2/3 file (checked by `open_swmr_writer`), since
1050    /// [`Superblock::serialize`] emits the v2/v3 layout at the base address.
1051    pub(crate) fn set_consistency_flags(&mut self, flags: u32) -> Result<(), Error> {
1052        self.superblock.consistency_flags = flags;
1053        let bytes = self.superblock.serialize();
1054        self.write_at(self.sb_sig_off, &bytes)?;
1055        self.image.sync_data()?;
1056        Ok(())
1057    }
1058
1059    /// Shared open path. `lock = Some(policy)` acquires an exclusive OS lock under
1060    /// that policy (the ordinary read-write session); `lock = None` takes no lock
1061    /// at all (the SWMR writer — see [`open_swmr_writer`](Self::open_swmr_writer)).
1062    fn open_inner(path: &Path, lock: Option<FileLocking>) -> Result<Self, Error> {
1063        Self::open_imaged(path, lock, |handle, _len| {
1064            Ok(Box::new(Self::read_mirror(handle)?))
1065        })
1066    }
1067
1068    /// Read `handle` whole into a [`MirrorImage`]. The one place the engine
1069    /// still assumes it can hold the file, kept behind a named constructor so
1070    /// the mirrorless opens visibly do not call it.
1071    ///
1072    /// `read_to_end` reads from the handle's *current* cursor, and the open path
1073    /// has already read the superblock through it, so this rewinds first. Reading
1074    /// from wherever the last read landed would mirror a truncated file — with no
1075    /// error to say so, since a short mirror is a valid `Vec<u8>`.
1076    fn read_mirror(mut handle: fs::File) -> Result<MirrorImage, Error> {
1077        handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
1078        let mut data = Vec::new();
1079        handle.read_to_end(&mut data).map_err(Error::Io)?;
1080        Ok(MirrorImage::new(handle, data))
1081    }
1082
1083    /// Shared open path over any backing: acquire the handle (and, when asked,
1084    /// the exclusive lock), parse and validate the superblock through a borrowed
1085    /// view of the handle, and only then let `build` decide how the bytes are
1086    /// held.
1087    ///
1088    /// Every refusal comes before `build`, because `build` may read the whole
1089    /// file: reaching a refusal after it would spend `O(file size)` on a file
1090    /// that is then rejected — a 20 GB flagged file read into memory and thrown
1091    /// away. The superblock reads themselves are a few bounded windows either
1092    /// way, so nothing is read twice.
1093    ///
1094    /// `build` receives the file's length as well as the handle because a
1095    /// mirrorless image has to be told its end-of-file — it has no buffer whose
1096    /// length implies it.
1097    ///
1098    /// Nothing below this point reads the file as a slice, which is what lets
1099    /// one engine open a whole-file mirror, a mirrorless handle, and (in tests)
1100    /// a mirror that withholds its slice.
1101    fn open_imaged(
1102        path: &Path,
1103        lock: Option<FileLocking>,
1104        build: impl FnOnce(fs::File, u64) -> Result<Box<dyn FileImage>, Error>,
1105    ) -> Result<Self, Error> {
1106        let handle = fs::OpenOptions::new()
1107            .read(true)
1108            .write(true)
1109            .open(path)
1110            .map_err(Error::Io)?;
1111        // Acquire the exclusive lock before reading or mutating; the retained
1112        // `handle` holds it for the session's life. A `None` policy (SWMR) never
1113        // reaches `acquire_exclusive`, so no lock is ever taken.
1114        if let Some(policy) = lock {
1115            file_lock::acquire_exclusive(&handle, policy, path)?;
1116        }
1117        let len = handle.metadata().map_err(Error::Io)?.len();
1118        // Read the superblock through the handle itself, before any image owns
1119        // it. `probe` borrows, so it is gone by the time `build` takes the
1120        // handle; it leaves the handle's cursor wherever its last read ended,
1121        // which is why the mirror positions the handle before reading it whole.
1122        let probe = crate::image::BorrowedHandle::new(&handle, len);
1123        let sb_sig_off = signature::find_signature_in(&probe)?.to_usize()?;
1124        let mut superblock = Superblock::parse_from_source(&probe, sb_sig_off as u64)?;
1125
1126        if superblock.version > 3 {
1127            return Err(Error::EditUnsupported("unsupported superblock version"));
1128        }
1129        // Refuse a file a writer already holds, before anything is mutated. This
1130        // is the one exclusion the OS lock above cannot make: a SWMR writer
1131        // takes no lock, so its file is lock-free but flagged (issue #245).
1132        file_lock::check_status_flags(&superblock, file_lock::OpenIntent::Write, path)?;
1133        if superblock.offset_size != OFFSET_SIZE || superblock.length_size != LENGTH_SIZE {
1134            return Err(Error::EditUnsupported(
1135                "only 8-byte offsets and lengths are supported for in-place editing",
1136            ));
1137        }
1138        // A userblock shifts the whole HDF5 image forward by `base_address`: the
1139        // superblock sits at the base address and every stored address is relative
1140        // to it (the end-of-file address is the sole absolute field). The editor
1141        // supports this by reading at `stored + base` and writing back
1142        // `file_offset - base`. Only the canonical layout — superblock located
1143        // exactly at the base address (e.g. a MATLAB v7.3 `.mat` file's 512-byte
1144        // userblock) — is accepted; a base address that disagrees with the
1145        // superblock's location is a relocated or malformed file we will not rewrite.
1146        if superblock.base_address != sb_sig_off as u64 {
1147            return Err(Error::EditUnsupported(
1148                "a file whose superblock is not located at its base address is not editable in place",
1149            ));
1150        }
1151        // Normalize the root group address to an absolute file offset, exactly as
1152        // the reader does (`reader::parse_superblock`), so `resolve_path_any` and
1153        // the link-graph walk index the image correctly. It is converted back to a
1154        // stored (base-relative) address only when the superblock is serialized on
1155        // commit.
1156        superblock.root_group_address = superblock
1157            .root_group_address
1158            .checked_add(superblock.base_address)
1159            .ok_or(FormatError::OffsetOverflow {
1160                offset: superblock.root_group_address,
1161                length: superblock.base_address,
1162            })?;
1163
1164        // Everything that can refuse this file has run; only now is it worth
1165        // holding the bytes.
1166        let image = build(handle, len)?;
1167
1168        let mut session = Self {
1169            image,
1170            sb_sig_off,
1171            superblock,
1172            pending_datasets: Vec::new(),
1173            pending_writes: Vec::new(),
1174            pending_appends: Vec::new(),
1175            pending_groups: Vec::new(),
1176            pending_group_attrs: Vec::new(),
1177            pending_dataset_attrs: Vec::new(),
1178            pending_deletes: Vec::new(),
1179            pending_copies: Vec::new(),
1180            pending_cross_copies: Vec::new(),
1181            free: FreeList::new(),
1182            persist: None,
1183            located: HashMap::new(),
1184            swmr_mode: false,
1185            paged: None,
1186            committed: false,
1187            resolved: HashMap::new(),
1188            batched_appends: false,
1189            bounded: false,
1190            fsm_len: len,
1191        };
1192        // If the file persists its free space, seed the free list from the
1193        // on-disk managers and arm persistence for future commits. Best-effort:
1194        // an unreadable or non-persisting extension simply leaves the session in
1195        // the default, non-persisting mode.
1196        session.load_persisted_free_space();
1197        Ok(session)
1198    }
1199
1200    /// Read the superblock-extension File Space Info message; if it requests
1201    /// persistence, seed [`self.free`](Self::free) from the on-disk free-space
1202    /// managers and record the manager/extension block extents for reclamation on
1203    /// the next commit. Silent on any malformed or absent metadata — persistence
1204    /// is then simply off for this session.
1205    fn load_persisted_free_space(&mut self) {
1206        if self.superblock.version < 2 {
1207            return; // no superblock extension exists before v2
1208        }
1209        let Some(ext_rel) = self.superblock.superblock_extension_address else {
1210            return;
1211        };
1212        if ext_rel == UNDEF {
1213            return;
1214        }
1215        // The extension address is stored relative to the base address, so it is
1216        // shifted to an absolute file offset before the header is read. This is a
1217        // no-op on the base-0 file every path below the userblock check sees, but
1218        // that check itself needs the strategy of a *userblock* file.
1219        let Ok(ext_addr) = ext_rel
1220            .checked_add(self.superblock.base_address)
1221            .ok_or(())
1222            .and_then(|a| usize::try_from(a).map_err(|_| ()))
1223        else {
1224            return;
1225        };
1226        let Some(info) = self.extension_fsinfo(ext_addr) else {
1227            return;
1228        };
1229        // Free-space reuse and persistence are not yet base-address aware: the
1230        // persisted section addresses (and the extension/manager block walk below)
1231        // are read as absolute, so on a userblock file they would seed `self.free`
1232        // with wrong regions that `alloc_or_append` could later hand out into live
1233        // data. Leave persistence off for such a file — the on-disk managers stay
1234        // untouched and valid, this session simply appends rather than reusing.
1235        //
1236        // A *paged* userblock file is a different matter: appending without page
1237        // awareness would mix metadata and raw data in its pages and leave its end
1238        // of allocation unaligned, quietly producing a file that still claims the
1239        // paged strategy but no longer satisfies it. Install the paged marker
1240        // without persistence so the commit refusal below catches it, which is the
1241        // same rule a paged non-persisting file already takes.
1242        if self.superblock.base_address != 0 {
1243            if info.strategy == FileSpaceStrategy::Page && info.page_size > 0 {
1244                self.paged = Some(PagedEdit::new(info.page_size));
1245            }
1246            return;
1247        }
1248        // Record the paged strategy regardless of the persist flag: a paged commit
1249        // needs page-aware bookkeeping, and a paged file that does not persist its
1250        // free space is refused outright (see `PagedEdit` and the commit refusal).
1251        //
1252        // A zero page size is refused rather than installed: every page calculation
1253        // divides by it, so a corrupt or hostile file declaring `Page` with a page
1254        // size of 0 would panic the editor. Leaving `paged` unset makes the file
1255        // take the ordinary flat path, which needs no page geometry.
1256        let paged = info.strategy == FileSpaceStrategy::Page && info.page_size > 0;
1257        if paged {
1258            self.paged = Some(PagedEdit::new(info.page_size));
1259        }
1260        if !info.persist {
1261            return;
1262        }
1263        let os = self.superblock.offset_size;
1264        let file_len = self.image.len();
1265
1266        // Seed the free list(s) with every persisted section (addresses are stored
1267        // relative to the base address, which this editor requires to be 0).
1268        // Defensive against a malformed or corrupt manager: skip a section that is
1269        // empty, runs past end-of-file, or overlaps one already taken. A
1270        // well-formed file (this crate's or the C library's) has none of these;
1271        // tolerating them keeps a bad file from seeding a bogus or double-counted
1272        // free region that a later commit would hand out into live data.
1273        if paged {
1274            // A paged file's free space is segregated across per-page-type
1275            // managers, so read each slot on its own and keep the page type its
1276            // slot implies: SUPER (slot 0) is metadata, DRAW (slot 2) is small raw,
1277            // and the generic-large manager (slot 6) holds large-raw fragments.
1278            // Flattening them (as the non-paged path below does) would lose exactly
1279            // the distinction the commit has to preserve.
1280            let mut tagged: Vec<(FreeSection, PageType, bool)> = Vec::new();
1281            for (slot, &m) in info.manager_addrs.iter().enumerate() {
1282                if m == UNDEF {
1283                    continue;
1284                }
1285                let Ok(sections) =
1286                    free_space_manager::read_persisted_sections_source(&self.image(), &[m], 0, os)
1287                        .map(|(sections, _)| sections)
1288                else {
1289                    continue;
1290                };
1291                // slot 6 is the generic-large manager; slot 2 is small raw; every
1292                // other slot a genuine paged file uses is metadata.
1293                let (ty, large) = match slot {
1294                    2 => (PageType::Raw, false),
1295                    6 => (PageType::Raw, true),
1296                    _ => (PageType::Meta, false),
1297                };
1298                for s in sections {
1299                    tagged.push((s, ty, large));
1300                }
1301            }
1302            tagged.sort_by_key(|(s, _, _)| s.addr);
1303            let mut prev_end = 0u64;
1304            for (s, ty, large) in tagged {
1305                let Some(end) = s.addr.checked_add(s.size) else {
1306                    continue;
1307                };
1308                if s.size == 0 || end > file_len || s.addr < prev_end {
1309                    continue;
1310                }
1311                prev_end = end;
1312                let pg = self
1313                    .paged
1314                    .as_mut()
1315                    .expect("the paged state was just installed");
1316                // Keep a section in the manager it came from rather than
1317                // re-deriving its class from its size: a large-raw fragment is
1318                // smaller than a page, so size alone cannot tell it from a DRAW
1319                // section.
1320                if large {
1321                    pg.raw_large.free(s.addr, s.size);
1322                } else {
1323                    match ty {
1324                        PageType::Meta => pg.meta.free(s.addr, s.size),
1325                        PageType::Raw => pg.raw_small.free(s.addr, s.size),
1326                    }
1327                }
1328            }
1329        } else if let Ok(mut sections) = free_space_manager::read_persisted_sections_source(
1330            &self.image(),
1331            &info.manager_addrs,
1332            0,
1333            os,
1334        )
1335        .map(|(sections, _)| sections)
1336        {
1337            sections.sort_by_key(|s| s.addr);
1338            let mut prev_end = 0u64;
1339            for s in sections {
1340                let Some(end) = s.addr.checked_add(s.size) else {
1341                    continue;
1342                };
1343                if s.size == 0 || end > file_len || s.addr < prev_end {
1344                    continue;
1345                }
1346                prev_end = end;
1347                self.free.free(s.addr, s.size);
1348            }
1349        }
1350
1351        // Record the byte extents of the blocks the live file uses so the next
1352        // persisting commit frees them when it writes replacements: the
1353        // extension header, and each defined manager's FSHD + FSSE.
1354        let mut old_blocks = Vec::new();
1355        if let Ok(spans) = self.oh_chunk_spans(ext_addr) {
1356            old_blocks.extend(spans);
1357        }
1358        for &m in &info.manager_addrs {
1359            if m == UNDEF {
1360                continue;
1361            }
1362            let Ok(hdr_len) = fshd_len(os).to_usize() else {
1363                continue;
1364            };
1365            let Ok(fshd) = self.image().read_metadata_at(m, hdr_len) else {
1366                continue;
1367            };
1368            if let Ok(h) = FsmHeader::parse(&fshd, os) {
1369                // `FsmHeader::parse` succeeding guarantees the header's own bytes
1370                // are present, so the FSHD extent is in-bounds; validate the
1371                // section-info extent before recording it, so a malformed
1372                // `fsse_used` can't later free a region running past end-of-file.
1373                old_blocks.push((m, fshd_len(os)));
1374                if h.fsse_addr != UNDEF
1375                    && h.fsse_addr
1376                        .checked_add(h.fsse_used)
1377                        .is_some_and(|end| end <= file_len)
1378                {
1379                    old_blocks.push((h.fsse_addr, h.fsse_used));
1380                }
1381            }
1382        }
1383
1384        self.persist = Some(PersistState {
1385            strategy: info.strategy,
1386            threshold: info.threshold,
1387            page_size: info.page_size,
1388            old_blocks,
1389        });
1390    }
1391
1392    /// Parse the File Space Info message out of the superblock-extension object
1393    /// header at `ext_addr`, if present and readable.
1394    fn extension_fsinfo(&self, ext_addr: usize) -> Option<FileSpaceInfo> {
1395        let os = self.superblock.offset_size;
1396        let ls = self.superblock.length_size;
1397        let base = self.superblock.base_address;
1398        let oh =
1399            ObjectHeader::parse_from_source(&self.image(), ext_addr as u64, os, ls, base).ok()?;
1400        let msg = oh
1401            .messages
1402            .iter()
1403            .find(|m| m.msg_type == MessageType::FileSpaceInfo)?;
1404        FileSpaceInfo::parse(&msg.data, os, ls).ok()
1405    }
1406
1407    /// Stage a new dataset, added on the next [`commit`](Self::commit).
1408    ///
1409    /// `path` is the dataset's full path; everything before the last component
1410    /// names the parent group, which must exist (or be created in this session).
1411    /// `builder` is the same [`DatasetBuilder`] [`FileBuilder`](crate::FileBuilder)
1412    /// uses, configured by the caller; its name is taken from `path`, so the two
1413    /// cannot disagree.
1414    ///
1415    /// The builder is passed in finished rather than handed out as a `&mut` into
1416    /// this engine. That is deliberate: a borrow into the engine forces the
1417    /// caller to hold it — and, above this layer, its lock — for as long as the
1418    /// builder is being configured, which is what let a user closure deadlock
1419    /// against the same file it was reading (issue #200).
1420    ///
1421    /// The dataset may be contiguous or chunked, and chunked datasets may be
1422    /// filtered (`with_deflate`, `with_shuffle`, `with_fletcher32`,
1423    /// `with_scale_offset`, `with_zfp`) and/or extensible (`with_maxshape`). An
1424    /// empty (zero-element) contiguous dataset is supported (chunking one is
1425    /// not), a provenance dataset (`with_provenance`) is supported, and a
1426    /// contiguous dataset may carry variable-length attributes, a
1427    /// variable-length-string payload (`with_vlen_strings`), or path-resolved
1428    /// object-reference elements (`with_path_references`; chunking any of
1429    /// these is not supported, and dense attributes remain unsupported).
1430    pub(crate) fn stage_created_dataset(&mut self, path: &str, mut builder: DatasetBuilder) {
1431        let mut comps = split_path(path);
1432        builder.name = comps.pop().unwrap_or_default();
1433        self.pending_datasets.push((comps, builder));
1434    }
1435
1436    /// Stage an in-place overwrite of an **existing** dataset's values (the HDF5
1437    /// `H5Dwrite` whole-dataset write), applied on the next
1438    /// [`commit`](Self::commit).
1439    ///
1440    /// `path` is the full path of a dataset that must already exist; `builder`
1441    /// supplies the replacement data and is named from `path`, as in
1442    /// [`stage_created_dataset`](Self::stage_created_dataset).
1443    ///
1444    /// This is a *value* overwrite, not a reshape or retype: the new data's
1445    /// datatype and shape must match the on-disk dataset's exactly (byte-for-byte
1446    /// after serialization, so endianness and compound layout must agree), or
1447    /// `commit` reports [`Error::EditUnsupported`]. Contiguous, compact, and
1448    /// chunked (including filtered) datasets are all supported; the dataset's
1449    /// existing chunk geometry, filter pipeline, and chunk index are taken from the
1450    /// on-disk header (a builder that itself requests chunking/filtering is refused
1451    /// as "not a value overwrite"). A chunk index this engine cannot enumerate (a
1452    /// version-2 B-tree) is refused. Partial / sub-region writes are out of scope —
1453    /// the whole dataset is replaced.
1454    ///
1455    /// When the new data is the same length as the existing contiguous data block
1456    /// (the common case), the bytes are written straight into that block: no
1457    /// object header is rewritten and the superblock root is not flipped, so the
1458    /// commit's linearization point is the synced data write itself. A chunked
1459    /// dataset is handled the same way when every (re-encoded) chunk is the same
1460    /// byte length as the slot it replaces — an unfiltered overwrite (chunk sizes
1461    /// are fixed by the unchanged shape) or a filtered one whose re-encoded chunks
1462    /// match — so it too writes straight into the existing chunk slots. When the
1463    /// length differs (a resized contiguous block, or a filtered chunk that no
1464    /// longer fits), the dataset's storage is rebuilt at end-of-file (or in
1465    /// reusable freed space), the old extent is freed, the data-layout message is
1466    /// repointed, the object header is rewritten, and the parent group's link is
1467    /// patched — exactly like an addition relocates the path up to the root. A
1468    /// relocating overwrite moves the object header, so it is refused unless the
1469    /// dataset has a single hard link.
1470    pub(crate) fn stage_dataset_write(&mut self, path: &str, mut builder: DatasetBuilder) {
1471        let comps = split_path(path);
1472        builder.name = comps.last().cloned().unwrap_or_default();
1473        self.pending_writes.push((comps, builder));
1474    }
1475
1476    /// Stage an append of new elements to an **existing** chunked, unlimited
1477    /// dataset, applied on the next [`commit`](Self::commit).
1478    ///
1479    /// `path` names a dataset that must already exist; `builder` supplies the
1480    /// elements to add via its typed / generic / raw `append_*` methods.
1481    ///
1482    /// Unlike [`stage_dataset_write`](Self::stage_dataset_write) (a value
1483    /// overwrite that forbids any shape change) this **grows** the dataset along
1484    /// its first (axis-0) dimension. It works on **filtered** datasets: the
1485    /// appended chunks are compressed through the dataset's own on-disk filter
1486    /// pipeline (deflate / shuffle / fletcher32 / scale-offset / LZF, and ZFP
1487    /// with the `zfp` feature), and the pipeline, datatype, fill value, and attributes are
1488    /// preserved verbatim. Appends of any length are supported — when the
1489    /// dataset's current length is not a whole multiple of the chunk length, the
1490    /// single trailing partial chunk is read, extended, and re-encoded; every
1491    /// other existing chunk is carried by metadata alone, so the existing data is
1492    /// not rewritten and the file does not grow by the whole dataset per append.
1493    ///
1494    /// This does **not** use SWMR and sets no consistency flag. Like every other
1495    /// staged edit it commits by appending the new chunks and a rebuilt
1496    /// index at end-of-file and repointing the superblock last (under the
1497    /// session's exclusive lock), so a crash leaves either the original dataset or
1498    /// the fully-grown one, never a torn state.
1499    ///
1500    /// The first release supports the Extensible-Array chunk index (the index the
1501    /// reference C library and h5py select for a single unlimited dimension under
1502    /// the latest format, and the one this crate writes for every unlimited
1503    /// dataset), rank-1 datasets, and datasets with a single hard link. A dataset
1504    /// that is not chunked, not unlimited along axis 0, not Extensible-Array
1505    /// indexed, higher than rank 1, uses a filter this engine cannot re-encode,
1506    /// has a sparse chunk grid, or (for [`append_raw`](AppendBuilder::append_raw))
1507    /// has a big-endian element datatype is refused with
1508    /// [`Error::AppendUnsupported`]. Use [`Dataset::is_chunked`](crate::Dataset::is_chunked),
1509    /// [`maxshape`](crate::Dataset::maxshape), and [`filters`](crate::Dataset::filters)
1510    /// to check eligibility up front.
1511    pub(crate) fn stage_dataset_append(&mut self, path: &str, builder: AppendBuilder) {
1512        self.pending_appends.push((split_path(path), builder));
1513    }
1514
1515    /// Whether any staged tree edit is still uncommitted. In-place appends
1516    /// ([`append_inplace_gathered`](Self::append_inplace_gathered)) are applied immediately and are
1517    /// never staged, so they never affect this; it reflects only edits awaiting
1518    /// [`commit`](Self::commit) — `create_group`, `create_dataset`,
1519    /// `write_dataset`, `append_dataset`, group and dataset attribute edits,
1520    /// `delete`, `copy`, and `copy_from`. Dropping the session silently discards
1521    /// any staged edits.
1522    pub fn has_staged_edits(&self) -> bool {
1523        !self.pending_datasets.is_empty()
1524            || !self.pending_writes.is_empty()
1525            || !self.pending_appends.is_empty()
1526            || !self.pending_groups.is_empty()
1527            || !self.pending_group_attrs.is_empty()
1528            || !self.pending_dataset_attrs.is_empty()
1529            || !self.pending_deletes.is_empty()
1530            || !self.pending_copies.is_empty()
1531            || !self.pending_cross_copies.is_empty()
1532    }
1533
1534    /// This session's file image as one slice, when its backing holds the whole
1535    /// file in memory; `None` for a file-backed image.
1536    ///
1537    /// The slice reflects committed state plus immediate in-place appends, not
1538    /// edits still staged for `commit`. The owned read-write
1539    /// [`File`](crate::File) uses it to serve reads by borrowing rather than
1540    /// copying, and falls back to [`image`](Self::image) when it is absent.
1541    pub(crate) fn image_slice(&self) -> Option<&[u8]> {
1542        self.image.as_slice()
1543    }
1544
1545    /// A random-access [`Source`] view of this session's file image, for the
1546    /// parsers the edit engine drives.
1547    ///
1548    /// Every read the engine performs against the file goes through this, so the
1549    /// image needs to be no more than a source of bytes — whole-file mirror or
1550    /// not — without the parsers knowing which (issue #198).
1551    pub(crate) fn image(&self) -> &dyn Source {
1552        self.image.as_ref()
1553    }
1554
1555    /// This session's parsed superblock, with `root_group_address` normalized to
1556    /// an absolute file offset (the open-time convention) and `base_address` the
1557    /// userblock size. A relocating commit updates it, so a caller holding a
1558    /// clone from an earlier moment may be reading a stale root.
1559    pub(crate) fn superblock(&self) -> &Superblock {
1560        &self.superblock
1561    }
1562
1563    /// Which backend this session resolved to: [`Bounded`] when it reads through
1564    /// a handle, [`Mirrored`] when it holds a whole-file image.
1565    ///
1566    /// [`Bounded`]: EditBacking::Bounded
1567    /// [`Mirrored`]: EditBacking::Mirrored
1568    pub(crate) fn edit_backing(&self) -> EditBacking {
1569        if self.bounded {
1570            EditBacking::Bounded
1571        } else {
1572            EditBacking::Mirrored
1573        }
1574    }
1575
1576    /// A snapshot of this session's live space usage — the current file size and
1577    /// the free space it can reuse — as a [`SpaceAccounting`].
1578    ///
1579    /// This is the mutating-session analogue of the read-only accounting on
1580    /// [`File`](crate::File): it answers "how big is the file right now, and how
1581    /// much space can be reused before it must grow?" from the session's own live
1582    /// state. The snapshot reflects the committed file plus any immediate in-place
1583    /// appends ([`append_inplace_gathered`](Self::append_inplace_gathered)) but excludes edits still
1584    /// staged for the next [`commit`](Self::commit); see [`SpaceAccounting`] for
1585    /// the field-by-field semantics and [`has_staged_edits`](Self::has_staged_edits)
1586    /// for detecting pending work.
1587    ///
1588    /// On a paged file (`H5F_FSPACE_STRATEGY_PAGE`) the reported regions are the
1589    /// union of the per-page-type managers. They are recorded and handed back to
1590    /// the reference library, but a commit does not draw on them: a hole belongs
1591    /// to one page type, and reusing it for the other kind of allocation would
1592    /// re-mix its page, so such a commit appends instead.
1593    ///
1594    /// ```no_run
1595    /// use hdf5_pure::File;
1596    ///
1597    /// let file = File::open_rw("existing.h5")?;
1598    /// let acct = file.space_accounting()?;
1599    /// println!(
1600    ///     "{} bytes on disk, {} reusable in {} free region(s)",
1601    ///     acct.logical_size,
1602    ///     acct.reusable_free_bytes,
1603    ///     acct.reusable_free_space.len(),
1604    /// );
1605    /// # Ok::<(), hdf5_pure::Error>(())
1606    /// ```
1607    #[must_use]
1608    pub fn space_accounting(&self) -> SpaceAccounting {
1609        // A paged file tracks its free space per page type; report the union, since
1610        // the caller wants one total rather than a per-manager breakdown.
1611        let reusable_free_space = match &self.paged {
1612            Some(pg) => pg.all_sections(),
1613            None => self.free.sections(),
1614        };
1615        let reusable_free_bytes = reusable_free_space.iter().map(|(_, len)| len).sum();
1616        SpaceAccounting {
1617            logical_size: self.image.len(),
1618            reusable_free_bytes,
1619            reusable_free_space,
1620        }
1621    }
1622
1623    /// The shared Extensible-Array append engine's view of this session: the
1624    /// image, the superblock, and the paged-file state, paired as [`EditStore`].
1625    ///
1626    /// Takes `&mut self`, so a caller that also needs [`located`](Self::located)
1627    /// borrowed at the same time must destructure the fields itself rather than
1628    /// call this.
1629    fn store(&mut self) -> EditStore<'_> {
1630        EditStore {
1631            image: self.image.as_mut(),
1632            superblock: &mut self.superblock,
1633            sb_sig_off: self.sb_sig_off,
1634            paged: self.paged.as_mut(),
1635        }
1636    }
1637
1638    /// Flush this session's writes durably, data and metadata both: the final
1639    /// barrier [`File::close`](crate::File::close) issues before sealing the
1640    /// file. Each immediate append is already durable on its own; this covers
1641    /// the file length a preceding truncate or manager rewrite changed.
1642    pub(crate) fn sync(&mut self) -> Result<(), Error> {
1643        self.image.sync_all()
1644    }
1645
1646    /// Rewrite the on-disk free-space managers into canonical (manager-at-tail)
1647    /// shape for a file that persists them, if this session left them stale.
1648    ///
1649    /// Immediate in-place appends grow the file at end-of-file, which pushes the
1650    /// managers into the middle of it with live data after them. A staged
1651    /// [`commit`](Self::commit) re-homes them as part of its tail, but a session
1652    /// that only appends never runs one, so [`File::close`](crate::File::close)
1653    /// and `FileInner::drop` call this instead. It is the same tail the commit
1654    /// writes — appended past everything live, with the superblock repoint as the
1655    /// crash-atomic linearization point — with nothing to free and the root
1656    /// unchanged.
1657    ///
1658    /// A no-op for a non-persisting file, and skipped when the file has not grown
1659    /// past the managers since they were last written, so an unchanged session
1660    /// never grows the file.
1661    ///
1662    /// If a session that grew the file ends without `close` or `drop` running (a
1663    /// true crash — `SIGKILL`, power loss), the managers are left mid-file. Every
1664    /// append was durable and crash-atomic, so no data is lost, and both this
1665    /// crate and the reference C library reopen the file and read it correctly;
1666    /// the managers are simply non-canonical until a clean rewrite.
1667    pub(crate) fn finalize_persist(&mut self) -> Result<(), Error> {
1668        if self.persist.is_none() || self.image.len() == self.fsm_len {
1669            return Ok(());
1670        }
1671        self.commit_persisting(self.superblock.root_group_address, Vec::new())
1672    }
1673
1674    /// Resolve and locate an in-place append target, applying every rule that
1675    /// does not depend on the bytes being appended: the file-level eligibility
1676    /// guards, the staged-edit conflict check, and the geometry lookup that
1677    /// populates [`located`](Self::located). Returns the dataset's object-header
1678    /// address, which is that cache's key.
1679    ///
1680    /// Split out from the append itself so [`append_geometry`](Self::append_geometry)
1681    /// can report a dataset's batching geometry under exactly the same rules the
1682    /// append will apply — a caller slicing a large append into batches must be
1683    /// refused before the first batch, not part-way through.
1684    fn append_prepare(&mut self, target: AppendTarget<'_>) -> Result<u64, Error> {
1685        // The fast in-place append is only sound on a base-0 latest-format file:
1686        // the slot math assumes absolute addresses and the superblock is patched in
1687        // place per call. A userblock or pre-v2 file falls back to the staged
1688        // `append_dataset`, which rebuilds the index and repoints the superblock
1689        // last.
1690        if self.superblock.base_address != 0 {
1691            return Err(Error::AppendInPlaceUnsupported(
1692                "in-place append does not support a file with a userblock (non-zero base \
1693                 address); use Dataset::append_staged",
1694            ));
1695        }
1696        if self.superblock.version < 2 {
1697            return Err(Error::AppendInPlaceUnsupported(
1698                "in-place append requires a latest-format file (v2/v3 superblock); use \
1699                 Dataset::append_staged",
1700            ));
1701        }
1702        // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
1703        // space has no on-disk record of which pages hold metadata and which hold
1704        // raw data, so neither this immediate append nor the staged commit can keep
1705        // the two segregated; refuse it outright. A paged *persisting* file appends
1706        // through the page-aware `EditStore`, which pads a tail page whenever the
1707        // page type changes, and has its managers rewritten at the next commit or
1708        // at close (issue #198).
1709        if self.paged.is_some() && self.persist.is_none() {
1710            return Err(Error::AppendInPlaceUnsupported(
1711                "in-place append is not supported on a paged file \
1712                 (H5F_FSPACE_STRATEGY_PAGE) without persisted free space; recreate the \
1713                 file with with_file_space_strategy(FileSpaceStrategy::Page, true, ..)",
1714            ));
1715        }
1716
1717        // Refuse an append against a dataset (or a subtree) that a still-staged edit
1718        // in this same session will relocate, replace, or delete — which would
1719        // strand the durably-appended rows or plan against a header the commit
1720        // moves. The caller must commit those edits first.
1721        //
1722        // A target named by object-header address cannot be compared against the
1723        // staged paths, so any staged edit at all disqualifies it. That is a
1724        // superset of the path check, and the remedy is the same one.
1725        match target {
1726            AppendTarget::Path(dataset) => {
1727                if self.append_conflicts_with_pending(&split_path(dataset)) {
1728                    return Err(Error::AppendInPlaceUnsupported(
1729                        "the dataset or an ancestor has a staged edit pending in this session; \
1730                         commit the staged edits before appending in place, or use \
1731                         Dataset::append_staged",
1732                    ));
1733                }
1734            }
1735            AppendTarget::Header(_) if self.has_staged_edits() || self.committed => {
1736                return Err(Error::AppendInPlaceUnsupported(
1737                    "this append target was reached by object reference, so it names a dataset \
1738                     by object-header address, and this session has staged or committed edits \
1739                     that can move that header; re-open the dataset by path to append to it",
1740                ));
1741            }
1742            AppendTarget::Header(_) => {}
1743        }
1744
1745        // Resolve the dataset's object-header address — the geometry cache key.
1746        // base == 0 here, so a resolved address is absolute; two hard links to
1747        // one dataset share the one entry.
1748        let oh_addr = match target {
1749            AppendTarget::Path(dataset) => match self.resolved.get(dataset) {
1750                Some(&addr) => addr,
1751                None => {
1752                    let addr = crate::group_v2::resolve_path_any_from_source(
1753                        &self.image(),
1754                        &self.superblock,
1755                        dataset,
1756                    )
1757                    .map_err(|_| {
1758                        Error::AppendInPlaceUnsupported("nothing to append to at the given path")
1759                    })?;
1760                    self.resolved.insert(dataset.to_string(), addr);
1761                    addr
1762                }
1763            },
1764            AppendTarget::Header(addr) => addr,
1765        };
1766
1767        // Locate the dataset on the first append (cache miss) against the session's
1768        // own image — no second lock, no second view of the file, no re-read.
1769        if !self.located.contains_key(&oh_addr) {
1770            let store = self.store();
1771            let state = locate_dataset_state(&store, oh_addr)?;
1772            self.located.insert(oh_addr, state);
1773        }
1774        Ok(oh_addr)
1775    }
1776
1777    /// The append geometry of the dataset `target` names, so a caller can slice a
1778    /// large append into aligned batches *before* materializing each batch's
1779    /// bytes — which is what keeps a bounded session's peak memory at one batch
1780    /// rather than the whole call.
1781    pub(crate) fn append_geometry(
1782        &mut self,
1783        target: AppendTarget<'_>,
1784    ) -> Result<AppendGeometry, Error> {
1785        let oh_addr = self.append_prepare(target)?;
1786        let st = &self.located[&oh_addr];
1787        let chunk_elems = st.loc.chunk_elems.max(1);
1788        Ok(AppendGeometry {
1789            chunk_elems,
1790            element_size: st.element_size,
1791            current_dim: st.loc.current_dim,
1792            filtered: st.pipeline.is_some(),
1793            full_batch_elems: self.batch_elems(st.loc.chunk_bytes, chunk_elems),
1794        })
1795    }
1796
1797    /// Whole-chunk elements in one append batch.
1798    ///
1799    /// A bounded session caps a batch at [`APPEND_BATCH_BYTES`] of raw data (at
1800    /// least one chunk) so peak memory is independent of the call size, at the
1801    /// cost of splitting one crash-atomic append into several: a crash between
1802    /// batches leaves a valid shorter dataset, exactly as if the caller had
1803    /// looped. A mirror session already holds the whole file, so bounding the
1804    /// call buys nothing there and would trade that atomicity away for free;
1805    /// it takes the whole append as one batch.
1806    fn batch_elems(&self, chunk_bytes: usize, chunk_elems: u64) -> u64 {
1807        if !self.batched_appends {
1808            return u64::MAX;
1809        }
1810        (APPEND_BATCH_BYTES / (chunk_bytes.max(1) as u64)).max(1) * chunk_elems
1811    }
1812
1813    /// Apply a gathered in-place append (typed / generic / raw bytes) to the
1814    /// dataset `target` names, immediately and crash-atomically, driving the
1815    /// shared Extensible-Array engine against the session's own image through an
1816    /// [`EditStore`] adapter. Runs only the first `max_phase` durability phases;
1817    /// production callers pass 4, the crash-consistency tests stop at a boundary
1818    /// to simulate a crash.
1819    ///
1820    /// A bounded session splits the call into whole-chunk batches (see
1821    /// [`batch_elems`](Self::batch_elems)), each its own crash-atomic apply.
1822    /// Every predictable refusal is raised before the first batch, so a rejected
1823    /// append leaves the file untouched rather than partly grown.
1824    ///
1825    /// `Dataset::append` slices its own call the same way before it reaches here,
1826    /// so through the public API this loop runs once per call; it batches for the
1827    /// benefit of a caller that hands the engine one large builder directly, whose
1828    /// bytes are already materialized but whose plan need not be.
1829    pub(crate) fn append_inplace_gathered(
1830        &mut self,
1831        target: AppendTarget<'_>,
1832        b: &AppendBuilder,
1833        max_phase: u8,
1834    ) -> Result<(), Error> {
1835        if b.dt_conflict() {
1836            return Err(Error::AppendInPlaceUnsupported(
1837                "append mixes element types in one call; use one element type per append",
1838            ));
1839        }
1840        let oh_addr = self.append_prepare(target)?;
1841
1842        // Validate the appended bytes against the on-disk datatype.
1843        let raw = b.raw();
1844        let new_elems = validate_gathered_append(&self.located[&oh_addr], b)?;
1845        if new_elems == 0 {
1846            return Ok(());
1847        }
1848
1849        // In SWMR mode, hold to the subset a concurrent reader can follow safely:
1850        // unfiltered (a filtered element is a multi-field record whose in-place
1851        // repoint is not power-loss atomic) and chunk-aligned (so an append only
1852        // ever inserts new, not-yet-visible elements and never rewrites a visible
1853        // trailing chunk out from under a reader).
1854        if self.swmr_mode {
1855            let st = &self.located[&oh_addr];
1856            if st.pipeline.is_some() {
1857                return Err(Error::SwmrAppendUnsupported(
1858                    "filtered datasets are not supported for SWMR append",
1859                ));
1860            }
1861            let chunk_elems = st.loc.chunk_elems;
1862            if chunk_elems == 0
1863                || st.loc.current_dim % chunk_elems != 0
1864                || new_elems % chunk_elems != 0
1865            {
1866                return Err(Error::SwmrAppendUnsupported(
1867                    "SWMR append must be chunk-aligned: the current length and the appended \
1868                     length must both be whole multiples of the chunk length",
1869                ));
1870            }
1871        }
1872
1873        let (chunk_elems, elem_bytes, full_batch_elems, filtered, current_dim) = {
1874            let st = &self.located[&oh_addr];
1875            (
1876                st.loc.chunk_elems.max(1),
1877                st.element_size as u64,
1878                self.batch_elems(st.loc.chunk_bytes, st.loc.chunk_elems.max(1)),
1879                st.pipeline.is_some(),
1880                st.loc.current_dim,
1881            )
1882        };
1883        // Refuse a non-chunk-aligned filtered append before ANY batch applies, so
1884        // the refusal is as atomic as an unbatched one. Left to `plan_ea_append`
1885        // it would surface only when the final (unaligned) batch was reached,
1886        // after earlier batches had durably committed.
1887        if filtered && (current_dim % chunk_elems != 0 || new_elems % chunk_elems != 0) {
1888            return Err(Error::AppendInPlaceUnsupported(
1889                "a filtered dataset can only be appended in place in whole chunks (the current \
1890                 length and the appended length must both be multiples of the chunk length); \
1891                 use Dataset::append_staged for a non-chunk-aligned filtered append",
1892            ));
1893        }
1894
1895        let mut done = 0u64;
1896        while done < new_elems {
1897            // Fill the trailing partial chunk first (so later batches start
1898            // chunk-aligned and never rewrite it again), then whole-chunk batches.
1899            // Filtered datasets are chunk-aligned by contract, so every batch stays
1900            // chunk-aligned there too.
1901            let current_dim = self.located[&oh_addr].loc.current_dim;
1902            let to_boundary = (chunk_elems - current_dim % chunk_elems) % chunk_elems;
1903            let take = (new_elems - done).min(to_boundary.saturating_add(full_batch_elems));
1904            let batch =
1905                &raw[(done * elem_bytes).to_usize()?..((done + take) * elem_bytes).to_usize()?];
1906
1907            // Read/plan phase (immutable borrows only, nothing published yet), then
1908            // the ordered, fsync-barriered write phase — both shared with
1909            // `Dataset::append` through the chunk-index engine. `EditStore` borrows
1910            // only the image-carrying fields, so `self.located` stays independently
1911            // borrowable.
1912            let plan_result = {
1913                let Self {
1914                    image,
1915                    superblock,
1916                    sb_sig_off,
1917                    paged,
1918                    located,
1919                    ..
1920                } = self;
1921                let st = &located[&oh_addr];
1922                let store = EditStore {
1923                    image: image.as_mut(),
1924                    superblock,
1925                    sb_sig_off: *sb_sig_off,
1926                    paged: paged.as_mut(),
1927                };
1928                plan_ea_append(
1929                    &store,
1930                    &st.loc,
1931                    &st.datatype,
1932                    &st.spatial,
1933                    st.element_size,
1934                    st.pipeline.as_ref(),
1935                    batch,
1936                    take,
1937                )
1938            };
1939            let plan = plan_result.map_err(as_inplace_error)?;
1940            {
1941                let Self {
1942                    image,
1943                    superblock,
1944                    sb_sig_off,
1945                    paged,
1946                    located,
1947                    ..
1948                } = self;
1949                let st = located.get_mut(&oh_addr).expect("dataset located above");
1950                let mut store = EditStore {
1951                    image: image.as_mut(),
1952                    superblock,
1953                    sb_sig_off: *sb_sig_off,
1954                    paged: paged.as_mut(),
1955                };
1956                apply_ea_append(&mut store, &mut st.loc, &plan, max_phase)
1957                    .map_err(as_inplace_error)?;
1958            }
1959            if max_phase < 4 {
1960                // Crash-consistency hook: the caller asked to stop inside the first
1961                // batch's durability sequence, so there is no next batch.
1962                return Ok(());
1963            }
1964            done += take;
1965        }
1966        Ok(())
1967    }
1968
1969    /// Test-only phased in-place append (stops after `max_phase` durability phases)
1970    /// used by the crash-consistency tests, mirroring `Dataset::append`'s harness.
1971    #[cfg(test)]
1972    fn append_inplace_i32_phased(
1973        &mut self,
1974        dataset: &str,
1975        values: &[i32],
1976        max_phase: u8,
1977    ) -> Result<(), Error> {
1978        let mut b = AppendBuilder::new();
1979        b.append_i32(values);
1980        self.append_inplace_gathered(AppendTarget::Path(dataset), &b, max_phase)
1981    }
1982
1983    /// Whether `target` (an [`append_inplace_gathered`](Self::append_inplace_gathered) dataset path)
1984    /// or any of its ancestors is named by a staged edit that a later
1985    /// [`commit`](Self::commit) would relocate, replace, or delete. `create_group`
1986    /// and group-attribute edits are excluded: they rewrite a group header without
1987    /// moving a descendant dataset's header or freeing its storage, so they cannot
1988    /// stale the append geometry cache.
1989    fn append_conflicts_with_pending(&self, target: &[String]) -> bool {
1990        let hits = |p: &[String]| paths_overlap(target, p);
1991        self.pending_writes.iter().any(|(p, _)| hits(p))
1992            || self.pending_appends.iter().any(|(p, _)| hits(p))
1993            || self.pending_deletes.iter().any(|p| hits(p))
1994            || self.pending_copies.iter().any(|(_, dst)| hits(dst))
1995            || self.pending_cross_copies.iter().any(|(dst, _)| hits(dst))
1996            || self.pending_dataset_attrs.iter().any(|(p, _)| hits(p))
1997            || self.pending_datasets.iter().any(|(parent, db)| {
1998                let mut full = parent.clone();
1999                full.push(db.name.clone());
2000                paths_overlap(target, &full)
2001            })
2002    }
2003
2004    /// Stage a new (empty) group at `path`, created on the next
2005    /// [`commit`](Self::commit). The parent must already exist or be created in
2006    /// the same session; populate the group with datasets via
2007    /// [`create_dataset`](Self::create_dataset) using a path under it.
2008    pub fn create_group(&mut self, path: &str) {
2009        self.pending_groups.push(split_path(path));
2010    }
2011
2012    /// Stage an attribute add or replacement on a group, applied on the next
2013    /// [`commit`](Self::commit).
2014    ///
2015    /// `path` names the group to edit; `""` or `"/"` names the root group. The
2016    /// group may already exist or may be created earlier in the same session
2017    /// with [`create_group`](Self::create_group). Attributes — fixed-size or
2018    /// variable-length (`AttrValue::VarLenAsciiArray`) — are stored compactly in
2019    /// the rebuilt group header; an edit that would exceed the compact-attribute
2020    /// limit, or a group using dense (fractal-heap) attribute storage, is
2021    /// refused before any file bytes are changed.
2022    pub fn set_group_attr(&mut self, path: &str, name: &str, value: AttrValue) -> &mut Self {
2023        self.pending_group_attrs.push((
2024            split_path(path),
2025            AttrOp::Set {
2026                name: name.to_string(),
2027                value,
2028            },
2029        ));
2030        self
2031    }
2032
2033    /// Stage removal of a compact attribute from a group, applied on the next
2034    /// [`commit`](Self::commit).
2035    ///
2036    /// `path` names the group to edit; `""` or `"/"` names the root group. The
2037    /// named attribute must exist in the committed group state after any earlier
2038    /// staged attribute operations for the same group have been applied.
2039    pub fn remove_group_attr(&mut self, path: &str, name: &str) -> &mut Self {
2040        self.pending_group_attrs.push((
2041            split_path(path),
2042            AttrOp::Remove {
2043                name: name.to_string(),
2044            },
2045        ));
2046        self
2047    }
2048
2049    /// Stage an attribute add or replacement on an **existing dataset**, applied on
2050    /// the next [`commit`](Self::commit).
2051    ///
2052    /// `path` names the dataset to edit. Attributes — fixed-size or variable-length
2053    /// (`AttrValue::VarLenAsciiArray`) — are stored compactly in the rebuilt dataset
2054    /// header. Applying it relocates the dataset's object header (the header is
2055    /// rewritten and its single naming link repointed; the dataset's data and chunk
2056    /// index stay in place), so it is supported only when the dataset has a **single
2057    /// hard link**. An edit that would exceed the compact-attribute limit, or a
2058    /// dataset using dense (fractal-heap) attribute storage, is refused before any
2059    /// file bytes change. To set attributes on a dataset being *created* in this
2060    /// session, use the builder's [`set_attr`](crate::DatasetBuilder::set_attr)
2061    /// instead.
2062    pub fn set_dataset_attr(&mut self, path: &str, name: &str, value: AttrValue) -> &mut Self {
2063        self.pending_dataset_attrs.push((
2064            split_path(path),
2065            AttrOp::Set {
2066                name: name.to_string(),
2067                value,
2068            },
2069        ));
2070        self
2071    }
2072
2073    /// Stage removal of a compact attribute from an **existing dataset**, applied on
2074    /// the next [`commit`](Self::commit).
2075    ///
2076    /// `path` names the dataset to edit; the named attribute must exist in the
2077    /// committed dataset state after any earlier staged attribute operations for the
2078    /// same dataset have been applied. Like [`set_dataset_attr`](Self::set_dataset_attr)
2079    /// it relocates the dataset header and requires a single hard link.
2080    pub fn remove_dataset_attr(&mut self, path: &str, name: &str) -> &mut Self {
2081        self.pending_dataset_attrs.push((
2082            split_path(path),
2083            AttrOp::Remove {
2084                name: name.to_string(),
2085            },
2086        ));
2087        self
2088    }
2089
2090    /// Stage removal of the link at `path` (the HDF5 `H5Ldelete`), applied on the
2091    /// next [`commit`](Self::commit). The link's object — and, for a group, its
2092    /// whole subtree — becomes unreachable. The bytes it occupied are returned to
2093    /// this session's free list (issue #21): a later commit reuses them for new
2094    /// objects instead of growing the file, and if a freed run reaches
2095    /// end-of-file the file is truncated. Contiguous and chunked datasets (their
2096    /// chunk index and chunk data blocks) and whole group subtrees are all
2097    /// reclaimed. Reclaim is best-effort — an object whose blocks this engine
2098    /// cannot enumerate exhaustively (variable-length global-heap storage, dense
2099    /// attribute/link heaps, a version 2 B-tree chunk index) is left as dead
2100    /// bytes rather than risk freeing a region that is still in use. Freed space is
2101    /// reused within the open session; for a file created with
2102    /// `H5Pset_file_space_strategy(persist = true)` it is also recorded on disk so
2103    /// it survives reopen, otherwise it is forgotten
2104    /// on close. After reuse, an object reference to a deleted object may resolve
2105    /// to an unrelated object (deleting a referenced object is undefined in HDF5).
2106    ///
2107    /// The path must exist. A deletion may not overlap another staged change in
2108    /// the same commit (e.g. delete `/a` while adding `/a/b`); split such
2109    /// edits into separate commits. The link's parent group must itself be
2110    /// editable in place (compact links, single-chunk header); the target being
2111    /// removed has no such restriction.
2112    pub fn delete(&mut self, path: &str) {
2113        self.pending_deletes.push(split_path(path));
2114    }
2115
2116    /// Stage a deep copy of the object at `src` to a new link at `dst` (the HDF5
2117    /// `H5Ocopy`), applied on the next [`commit`](Self::commit). The source — a
2118    /// dataset or a whole group subtree — is duplicated: fresh copies of every
2119    /// object's data and header are written, internal links and the contiguous
2120    /// data address are repointed to the copies, and a link named by `dst`'s last
2121    /// component is added to `dst`'s parent group. The original is untouched.
2122    ///
2123    /// The copy reflects the file's on-disk state at commit time. `src` must
2124    /// exist and `dst` must not (and may not lie inside `src`). A chunked (and
2125    /// filtered) dataset is copied with its chunk payloads and filter pipeline
2126    /// preserved byte-for-byte (the index is rebuilt at the new location, so a
2127    /// source using a B-tree-v1 or implicit index is reproduced with an equivalent
2128    /// v4 index). The source subtree must otherwise be copyable in place: compact
2129    /// links and attributes, single-chunk headers, and a chunk index this engine
2130    /// can enumerate (a version-2 B-tree, or a sparse/unallocated chunk grid, is
2131    /// refused) — otherwise `commit` reports [`Error::EditUnsupported`].
2132    pub fn copy(&mut self, src: &str, dst: &str) {
2133        self.pending_copies.push((split_path(src), split_path(dst)));
2134    }
2135
2136    /// Stage a deep copy of the object at `src` in another open file `source` to a
2137    /// new link at `dst` in this file — a *cross-file* HDF5 `H5Ocopy` — applied on
2138    /// the next [`commit`](Self::commit). Like [`copy`](Self::copy) but the source
2139    /// lives in a separate, independently-opened [`File`](crate::File) reader
2140    /// rather than the file being edited.
2141    ///
2142    /// The source — a dataset or a whole group subtree — is duplicated faithfully:
2143    /// fresh, byte-identical copies of every object's header and data are appended
2144    /// to this file, internal links repointed, and a link named by `dst`'s last
2145    /// component added to `dst`'s parent group (which must already exist or be
2146    /// created earlier in this session). Both files are left otherwise untouched;
2147    /// the destination only changes on `commit`.
2148    ///
2149    /// Unlike the same-file [`copy`](Self::copy), the source is read **eagerly**
2150    /// here (the `source` borrow need not outlive the call), so this returns
2151    /// `Result`: the source subtree is resolved, validated, and read out before
2152    /// returning, and only an already-validated copy is queued for `commit`.
2153    ///
2154    /// # Errors
2155    ///
2156    /// Returns [`Error::EditUnsupported`] if the copy cannot be reproduced exactly
2157    /// in another file. Because the copy is byte-for-byte verbatim, anything that
2158    /// embeds a *source-file* absolute address is refused (it would dangle here):
2159    /// **variable-length** or **reference** datasets and attributes (including a
2160    /// chunked dataset whose elements are variable-length or references, whose
2161    /// chunk payloads embed such addresses), and any **shared header message** (a
2162    /// committed datatype, or an SOHM-shared dataspace, fill value, or filter
2163    /// pipeline). As with [`copy`](Self::copy) a chunked/filtered source is copied
2164    /// with its chunk payloads and pipeline preserved (index rebuilt at the new
2165    /// location); the source must use compact links and attributes, single-chunk
2166    /// version-2 headers, and a chunk index this engine can enumerate (a
2167    /// version-2 B-tree, or a sparse chunk grid, is refused). The
2168    /// `source` must be a buffered file ([`File::open`](crate::File::open) or
2169    /// [`File::from_bytes`](crate::File::from_bytes), not
2170    /// [`open_streaming`](crate::File::open_streaming)) using 8-byte offsets and no
2171    /// userblock, and `src` must exist in it and not be the root group.
2172    pub fn copy_from(
2173        &mut self,
2174        source: &crate::reader::File,
2175        src: &str,
2176        dst: &str,
2177    ) -> Result<(), Error> {
2178        // The source bytes must be addressable: a streaming file is refused.
2179        let src_data = source.in_memory_image().ok_or(Error::EditUnsupported(
2180            "cross-file copy requires a buffered source file (File::open or File::from_bytes), not a streaming one",
2181        ))?;
2182        let src_sb = source.superblock();
2183        if src_sb.offset_size != OFFSET_SIZE || src_sb.length_size != LENGTH_SIZE {
2184            return Err(Error::EditUnsupported(
2185                "cross-file copy requires the source file to use 8-byte offsets and lengths",
2186            ));
2187        }
2188        if source.base_address() != 0 {
2189            return Err(Error::EditUnsupported(
2190                "cross-file copy requires the source file to have no userblock (base address 0)",
2191            ));
2192        }
2193
2194        let src = split_path(src);
2195        if src.is_empty() {
2196            return Err(Error::EditUnsupported("cannot copy the root group"));
2197        }
2198        let dst = split_path(dst);
2199        if dst.is_empty() {
2200            return Err(Error::EditUnsupported("copy destination path is empty"));
2201        }
2202
2203        let src_addr = crate::group_v2::resolve_path_any(src_data, src_sb, &src.join("/"))
2204            .map_err(|_| Error::EditUnsupported("copy source does not exist in the source file"))?;
2205        // Read (and foreign-address-screen) the whole subtree now, while `source`
2206        // is borrowed; the owned tree carries every byte the commit will write. The
2207        // source is gated to base 0 above, so its stored addresses are absolute.
2208        let tree = Self::read_copy_subtree(&BytesSource::new(src_data), src_addr, 0, true, 0)?;
2209        self.pending_cross_copies.push((dst, tree));
2210        Ok(())
2211    }
2212
2213    /// Apply all staged additions and deletions to the file in place and flush.
2214    ///
2215    /// Appends each new dataset (its data — a contiguous blob, or the chunk data
2216    /// and index for a chunked/filtered dataset — plus its object header) and
2217    /// each new group, then appends rewritten object headers for every touched
2218    /// group and its ancestors up to the root (omitting any deleted links), then
2219    /// repoints the superblock at the new root. On success the staged set is
2220    /// cleared and the session can be reused. On any [`Error::EditUnsupported`]
2221    /// the file on disk is left untouched: the checks that raise it — including
2222    /// each dataset's filter-pipeline and chunk-geometry validation — all run
2223    /// before the first byte is written. Should a later step fail mid-apply (an
2224    /// I/O error, or a residual build error), the superblock — repointed last —
2225    /// still names the prior root, so the file stays valid and the appended bytes
2226    /// are unreferenced slack.
2227    pub fn commit(&mut self) -> Result<(), Error> {
2228        if self.pending_datasets.is_empty()
2229            && self.pending_writes.is_empty()
2230            && self.pending_appends.is_empty()
2231            && self.pending_groups.is_empty()
2232            && self.pending_group_attrs.is_empty()
2233            && self.pending_dataset_attrs.is_empty()
2234            && self.pending_deletes.is_empty()
2235            && self.pending_copies.is_empty()
2236            && self.pending_cross_copies.is_empty()
2237        {
2238            return Ok(());
2239        }
2240
2241        // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
2242        // space has no on-disk record of which pages hold metadata and which hold
2243        // raw data, so this commit could not keep the two segregated and would
2244        // silently degrade the paging. Refuse up front, before any writes, exactly
2245        // as the bounded backend does. A paged *persisting* file is committed
2246        // through the page-aware tail below (issue #198).
2247        if self.paged.is_some() && self.persist.is_none() {
2248            return Err(Error::EditUnsupported(
2249                "committing an edit to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
2250                 persisted free space; recreate the file with \
2251                 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to edit it in place",
2252            ));
2253        }
2254
2255        // Invalidate the in-place-append geometry cache before doing any work. A
2256        // commit that reaches here rewrites and relocates object headers, frees
2257        // vacated regions into `self.free`, and may truncate the file — any of
2258        // which can leave a cached `Located` pointing at a moved header or into a
2259        // now-free-eligible region. Clearing at *entry* (rather than the success
2260        // tail) means a later failure — including one after the durable root flip,
2261        // which leaves the session reusable — never strands a stale cache. The
2262        // no-op fast return above does no such work, so it keeps the cache. The
2263        // The next in-place append re-locates against the fresh file.
2264        self.located.clear();
2265        self.resolved.clear();
2266        // Past this point the commit may relocate object headers, so every address
2267        // a caller captured earlier is suspect; see `committed`.
2268        self.committed = true;
2269
2270        // On a file with a userblock, stored addresses are relative to this base
2271        // and the editor converts at every disk boundary (read `stored + base`,
2272        // write `file_offset - base`). Userblock support covers value overwrites,
2273        // additions of contiguous and chunked/filtered datasets, in-place and
2274        // relocating overwrites of every layout (chunked, contiguous, compact) with
2275        // reclaim, object deletion (with base-aware subtree reclaim), object copy
2276        // (in-file, and cross-file into a userblock destination), group creation,
2277        // and compact group attributes. Cross-file copy still requires a base-0
2278        // *source* (see [`copy_from`](Self::copy_from)).
2279        let base = self.superblock.base_address;
2280
2281        // --- Preflight value overwrites (`write_dataset`) before any write, under
2282        // the same all-or-nothing contract as additions. Each is resolved,
2283        // validated (datatype and shape must match the on-disk dataset exactly),
2284        // and classified: a same-length contiguous overwrite is applied straight
2285        // in place (no header rewrite, no superblock flip), while a resize or
2286        // compact rewrite relocates the header and is staged against its parent
2287        // group so the commit below rebuilds it and patches the link. ---
2288        let writes = std::mem::take(&mut self.pending_writes);
2289        let mut inplace_writes: Vec<(usize, Vec<u8>)> = Vec::new();
2290        let mut moving_writes: Vec<(PathKey, String, MovingWrite)> = Vec::new();
2291        let mut write_targets: Vec<PathKey> = Vec::new();
2292        // The file-wide hard-link count, computed lazily the first time a write
2293        // relocates a header: such a write moves the dataset's object header and
2294        // patches only the one parent link that names it, so a dataset reachable
2295        // through more than one hard link would have its other links left pointing
2296        // at the stale header. Refuse that rather than silently diverge the aliases
2297        // (a same-length in-place overwrite is unaffected — it rewrites the shared
2298        // data block, which every link sees).
2299        let mut incoming_links: Option<Option<HashMap<u64, u32>>> = None;
2300        for (full, db) in writes {
2301            if full.is_empty() {
2302                return Err(Error::EditUnsupported("cannot overwrite the root group"));
2303            }
2304            // A path named twice in one commit would write it twice (and double-
2305            // free a resized extent); require separate commits.
2306            if write_targets.contains(&full) {
2307                return Err(Error::EditUnsupported(
2308                    "the same dataset is overwritten twice in one commit; use separate commits",
2309                ));
2310            }
2311            let path_str = full.join("/");
2312            let addr = crate::group_v2::resolve_path_any_from_source(
2313                &self.image(),
2314                &self.superblock,
2315                &path_str,
2316            )
2317            .map_err(|_| Error::EditUnsupported("nothing to overwrite at the given path"))?;
2318            let addr = usize::try_from(addr)
2319                .map_err(|_| Error::EditUnsupported("dataset address exceeds this platform"))?;
2320            let fd = flatten_dataset(db)?;
2321            match Self::prepare_write(&self.image(), addr as u64, &fd, base)? {
2322                WritePlan::InPlace { data_addr, raw } => inplace_writes.push((data_addr, raw)),
2323                WritePlan::InPlaceChunks { writes } => inplace_writes.extend(writes),
2324                WritePlan::Moving(mw) => {
2325                    // A relocating overwrite rewrites the dataset's header and data
2326                    // address. Every variant is base-aware on a userblock file: the
2327                    // chunked one rebuilds the chunk blob with stored addresses and
2328                    // reclaims the old storage base-relative, the contiguous one
2329                    // stores the relocated data address base-relative (and frees the
2330                    // old extent at its absolute offset), and the compact one carries
2331                    // its data inline. The parent link to the rewritten header is
2332                    // patched base-relative below.
2333                    //
2334                    // A relocating overwrite is safe only when this is the
2335                    // dataset's sole hard link. Compute the link graph once.
2336                    let counts = incoming_links
2337                        .get_or_insert_with(|| self.count_incoming_hard_links())
2338                        .as_ref();
2339                    match counts.and_then(|c| c.get(&(addr as u64))) {
2340                        Some(&1) => {}
2341                        _ => {
2342                            return Err(Error::EditUnsupported(
2343                                "overwriting a dataset that resizes or relocates its header is \
2344                                 only supported when it has a single hard link",
2345                            ));
2346                        }
2347                    }
2348                    let leaf = full.last().unwrap().clone();
2349                    let parent = full[..full.len() - 1].to_vec();
2350                    moving_writes.push((parent, leaf, mw));
2351                }
2352            }
2353            write_targets.push(full);
2354        }
2355
2356        // --- Preflight appends (`append_dataset`) under the same all-or-nothing,
2357        // single-hard-link contract. Each plans a relocating append — existing
2358        // chunk data stays in place; the appended (and any rewritten trailing)
2359        // chunks and a rebuilt Extensible-Array index are staged, and the whole is
2360        // treated like a relocating overwrite of the dataset's header (staged
2361        // against its parent group so the commit patches the link). A zero-length
2362        // append is a no-op and is dropped here. ---
2363        let appends = std::mem::take(&mut self.pending_appends);
2364        for (full, ab) in appends {
2365            if full.is_empty() {
2366                return Err(Error::AppendUnsupported("cannot append to the root group"));
2367            }
2368            if ab.raw.is_empty() {
2369                continue; // nothing to append
2370            }
2371            // A dataset overwritten or appended earlier in this commit would be
2372            // planned against a stale header and its old storage double-freed;
2373            // require separate commits.
2374            if write_targets.contains(&full) {
2375                return Err(Error::AppendUnsupported(
2376                    "the same dataset is edited more than once in one commit; use separate commits",
2377                ));
2378            }
2379            let path_str = full.join("/");
2380            let addr = crate::group_v2::resolve_path_any_from_source(
2381                &self.image(),
2382                &self.superblock,
2383                &path_str,
2384            )
2385            .map_err(|_| Error::AppendUnsupported("nothing to append to at the given path"))?;
2386            let addr = usize::try_from(addr)
2387                .map_err(|_| Error::AppendUnsupported("dataset address exceeds this platform"))?;
2388            let mw = Self::prepare_append(&self.image(), addr as u64, &ab, base)?;
2389            // A relocating append moves the dataset's object header and patches only
2390            // the one parent link that names it, so it is safe only when this is the
2391            // dataset's sole hard link (same rule as a relocating overwrite).
2392            let counts = incoming_links
2393                .get_or_insert_with(|| self.count_incoming_hard_links())
2394                .as_ref();
2395            match counts.and_then(|c| c.get(&(addr as u64))) {
2396                Some(&1) => {}
2397                _ => {
2398                    return Err(Error::AppendUnsupported(
2399                        "appending relocates the dataset header; only supported when it \
2400                         has a single hard link",
2401                    ));
2402                }
2403            }
2404            let leaf = full.last().unwrap().clone();
2405            let parent = full[..full.len() - 1].to_vec();
2406            moving_writes.push((parent, leaf, mw));
2407            write_targets.push(full);
2408        }
2409
2410        // --- Preflight dataset attribute edits (`set_dataset_attr` /
2411        // `remove_dataset_attr`) under the same all-or-nothing, single-hard-link
2412        // contract. Each gathers the dataset's verbatim object-header region,
2413        // applies the compact attribute ops to it, and stages a relocating
2414        // `AttrEdit` header rewrite against the parent group — like a value
2415        // overwrite, but the data-layout message (and thus the chunk data and index)
2416        // is preserved verbatim, so only the header moves. ---
2417        let dataset_attrs = std::mem::take(&mut self.pending_dataset_attrs);
2418        if !dataset_attrs.is_empty() {
2419            // Collect the ops per dataset in first-seen path order, so multiple edits
2420            // to one dataset produce a single relocating header rewrite.
2421            let mut order: Vec<PathKey> = Vec::new();
2422            let mut ops_by_path: HashMap<PathKey, Vec<AttrOp>> = HashMap::new();
2423            for (path, op) in dataset_attrs {
2424                if !ops_by_path.contains_key(&path) {
2425                    order.push(path.clone());
2426                }
2427                ops_by_path.entry(path).or_default().push(op);
2428            }
2429            for full in order {
2430                let ops = ops_by_path.remove(&full).unwrap();
2431                if full.is_empty() {
2432                    return Err(Error::EditUnsupported(
2433                        "cannot set a dataset attribute on the root group; use set_group_attr",
2434                    ));
2435                }
2436                // A dataset already overwritten or appended in this commit would be
2437                // planned against a stale header; require separate commits.
2438                if write_targets.contains(&full) {
2439                    return Err(Error::EditUnsupported(
2440                        "the same dataset is edited more than once in one commit (an attribute \
2441                         edit plus another edit); use separate commits",
2442                    ));
2443                }
2444                let path_str = full.join("/");
2445                let addr = crate::group_v2::resolve_path_any_from_source(
2446                    &self.image(),
2447                    &self.superblock,
2448                    &path_str,
2449                )
2450                .map_err(|_| {
2451                    Error::EditUnsupported("nothing to set an attribute on at the given path")
2452                })?;
2453                let addr = usize::try_from(addr)
2454                    .map_err(|_| Error::EditUnsupported("dataset address exceeds this platform"))?;
2455                // An attribute edit relocates the dataset's object header and patches
2456                // only the one naming link, so it is safe only when this is the
2457                // dataset's sole hard link (same rule as a relocating overwrite).
2458                let counts = incoming_links
2459                    .get_or_insert_with(|| self.count_incoming_hard_links())
2460                    .as_ref();
2461                match counts.and_then(|c| c.get(&(addr as u64))) {
2462                    Some(&1) => {}
2463                    _ => {
2464                        return Err(Error::EditUnsupported(
2465                            "editing a dataset attribute relocates its header; only supported \
2466                             when it has a single hard link",
2467                        ));
2468                    }
2469                }
2470                let region = Self::gather_oh_messages(&self.image(), addr as u64, base)?;
2471                let (region, pending_vl_attrs) = apply_group_attr_ops(&region, &ops)?;
2472                let leaf = full.last().unwrap().clone();
2473                let parent = full[..full.len() - 1].to_vec();
2474                moving_writes.push((
2475                    parent,
2476                    leaf,
2477                    MovingWrite::AttrEdit {
2478                        region,
2479                        pending_vl_attrs,
2480                    },
2481                ));
2482                write_targets.push(full);
2483            }
2484        }
2485
2486        // Fast path: when the only staged edits are same-length in-place
2487        // overwrites, apply them straight to their data blocks and return without
2488        // rebuilding any header or flipping the superblock root. The commit's
2489        // linearization point is the synced data write — there is no tree to
2490        // repoint, so each overwrite stands alone. (A persisting file takes the
2491        // same path: no free-space change occurs.)
2492        //
2493        // Because this path never rewrites the superblock, it deliberately leaves
2494        // it untouched — including a pre-existing stale consistency flag (e.g. one
2495        // left by a crashed SWMR writer). A lone same-length value overwrite does
2496        // not introduce any inconsistency, so it does not clear one either; an edit
2497        // that takes the full path below (any header/root change) clears the flag
2498        // as usual.
2499        if moving_writes.is_empty()
2500            && self.pending_datasets.is_empty()
2501            && self.pending_groups.is_empty()
2502            && self.pending_group_attrs.is_empty()
2503            && self.pending_deletes.is_empty()
2504            && self.pending_copies.is_empty()
2505            && self.pending_cross_copies.is_empty()
2506        {
2507            for (data_addr, raw) in &inplace_writes {
2508                self.write_at(*data_addr, raw)?;
2509            }
2510            self.image.sync_all()?;
2511            return Ok(());
2512        }
2513
2514        // --- Plan: build the tree of "dirty" groups (root plus every group on a
2515        // path to an addition or deletion), validating every target before any
2516        // write. `add_targets` records the full paths created this commit, used
2517        // to reject a deletion that overlaps an addition. ---
2518        let mut nodes: BTreeMap<PathKey, Node> = BTreeMap::new();
2519        nodes.entry(PathKey::new()).or_default(); // root is always dirty
2520        let mut add_targets: Vec<PathKey> = Vec::new();
2521        let mut attr_targets: Vec<PathKey> = Vec::new();
2522
2523        // Mark explicitly-created new groups, ensuring their ancestor chain.
2524        for path in std::mem::take(&mut self.pending_groups) {
2525            if path.is_empty() {
2526                return Err(Error::EditUnsupported("cannot create the root group"));
2527            }
2528            ensure_ancestors(&mut nodes, &path);
2529            nodes.entry(path.clone()).or_default().is_new = true;
2530            add_targets.push(path);
2531        }
2532
2533        // Attach datasets to their parent group nodes, ensuring ancestor chains.
2534        for (parent, db) in std::mem::take(&mut self.pending_datasets) {
2535            let mut full = parent.clone();
2536            full.push(db.name.clone());
2537            add_targets.push(full);
2538            ensure_ancestors(&mut nodes, &parent);
2539            nodes.entry(parent).or_default().datasets.push(db);
2540        }
2541
2542        // Attach relocating value overwrites (resized contiguous or compact) to
2543        // their parent group nodes: the new header is written below and the
2544        // parent's existing link patched to it, like an existing child group.
2545        for (parent, leaf, mw) in moving_writes {
2546            ensure_ancestors(&mut nodes, &parent);
2547            nodes.entry(parent).or_default().writes.push((leaf, mw));
2548        }
2549
2550        // Stage group attribute edits against their target groups. A target may
2551        // be a newly-created group from this same commit, but not a copied
2552        // destination or a dataset being added in the same commit.
2553        for (path, op) in std::mem::take(&mut self.pending_group_attrs) {
2554            ensure_ancestors(&mut nodes, &path);
2555            nodes.entry(path.clone()).or_default().attr_ops.push(op);
2556            attr_targets.push(path);
2557        }
2558
2559        // Stage copies: validate the source subtree is copyable (read-only),
2560        // then treat the destination like an addition to its parent group.
2561        for (src, dst) in std::mem::take(&mut self.pending_copies) {
2562            if src.is_empty() {
2563                return Err(Error::EditUnsupported("cannot copy the root group"));
2564            }
2565            if dst.is_empty() {
2566                return Err(Error::EditUnsupported("copy destination path is empty"));
2567            }
2568            if is_prefix(&src, &dst) {
2569                return Err(Error::EditUnsupported(
2570                    "cannot copy an object into itself or its own subtree",
2571                ));
2572            }
2573            let src_str = src.join("/");
2574            let src_addr = crate::group_v2::resolve_path_any_from_source(
2575                &self.image(),
2576                &self.superblock,
2577                &src_str,
2578            )
2579            .map_err(|_| Error::EditUnsupported("copy source does not exist"))?;
2580            let src_addr = usize::try_from(src_addr)
2581                .map_err(|_| Error::EditUnsupported("source address exceeds this platform"))?;
2582            // Read the source subtree from this file's own mirror (`cross_file`
2583            // false: same address space, so verbatim addresses stay valid). On a
2584            // userblock file the stored addresses are base-relative, so pass this
2585            // session's base for the read to absolutize them.
2586            let tree = Self::read_copy_subtree(&self.image(), src_addr as u64, 0, false, base)?;
2587            add_targets.push(dst.clone());
2588            let leaf = dst.last().unwrap().clone();
2589            let parent = dst[..dst.len() - 1].to_vec();
2590            ensure_ancestors(&mut nodes, &parent);
2591            nodes.entry(parent).or_default().copies.push((leaf, tree));
2592        }
2593
2594        // Stage cross-file copies: their subtrees were already read out of the
2595        // source file (with foreign-address screening) when `copy_from` was
2596        // called, so here they are simply linked into the destination parent like
2597        // any other addition.
2598        for (dst, tree) in std::mem::take(&mut self.pending_cross_copies) {
2599            if dst.is_empty() {
2600                return Err(Error::EditUnsupported("copy destination path is empty"));
2601            }
2602            add_targets.push(dst.clone());
2603            let leaf = dst.last().unwrap().clone();
2604            let parent = dst[..dst.len() - 1].to_vec();
2605            ensure_ancestors(&mut nodes, &parent);
2606            nodes.entry(parent).or_default().copies.push((leaf, tree));
2607        }
2608
2609        // Stage deletions: each must exist, must not overlap any other staged
2610        // change, and is recorded against its parent group (which becomes dirty).
2611        // `deleted_addrs` keeps each removed object's header address so its owned
2612        // blocks can be reclaimed after the commit lands (issue #21).
2613        let delete_targets = std::mem::take(&mut self.pending_deletes);
2614        let mut deleted_addrs: Vec<usize> = Vec::new();
2615        for (i, d) in delete_targets.iter().enumerate() {
2616            if d.is_empty() {
2617                return Err(Error::EditUnsupported("cannot delete the root group"));
2618            }
2619            let path_str = d.join("/");
2620            let del_addr = crate::group_v2::resolve_path_any_from_source(
2621                &self.image(),
2622                &self.superblock,
2623                &path_str,
2624            )
2625            .map_err(|_| Error::EditUnsupported("nothing to delete at the given path"))?;
2626            if let Ok(a) = usize::try_from(del_addr) {
2627                deleted_addrs.push(a);
2628            }
2629            for t in &add_targets {
2630                if is_prefix(d, t) || is_prefix(t, d) {
2631                    return Err(Error::EditUnsupported(
2632                        "a deletion overlaps an addition in the same commit; use separate commits",
2633                    ));
2634                }
2635            }
2636            for t in &attr_targets {
2637                if is_prefix(d, t) {
2638                    return Err(Error::EditUnsupported(
2639                        "a deletion overlaps a group-attribute edit in the same commit; use separate commits",
2640                    ));
2641                }
2642            }
2643            for t in &write_targets {
2644                if is_prefix(d, t) {
2645                    return Err(Error::EditUnsupported(
2646                        "a deletion overlaps a value overwrite in the same commit; use separate commits",
2647                    ));
2648                }
2649            }
2650            for (j, d2) in delete_targets.iter().enumerate() {
2651                if i != j && is_prefix(d, d2) {
2652                    return Err(Error::EditUnsupported(
2653                        "overlapping deletions in one commit; delete the common parent only",
2654                    ));
2655                }
2656            }
2657            let parent = d[..d.len() - 1].to_vec();
2658            ensure_ancestors(&mut nodes, &parent);
2659            nodes
2660                .entry(parent)
2661                .or_default()
2662                .deletes
2663                .push(d.last().unwrap().clone());
2664        }
2665
2666        // Resolve / validate each node's base object-header region up front.
2667        // Every existing dirty group is rewritten to a freshly-appended header,
2668        // so its old header becomes dead bytes once the superblock is repointed;
2669        // `superseded_addrs` records those old headers for reclamation (#21).
2670        let keys: Vec<PathKey> = nodes.keys().cloned().collect();
2671        let mut superseded_addrs: Vec<usize> = Vec::new();
2672        for key in &keys {
2673            let is_new = nodes[key].is_new;
2674            if is_new {
2675                nodes.get_mut(key).unwrap().base_region = fresh_group_region();
2676            } else {
2677                let path_str = key.join("/");
2678                let addr = crate::group_v2::resolve_path_any_from_source(
2679                    &self.image(),
2680                    &self.superblock,
2681                    &path_str,
2682                )
2683                .map_err(|_| {
2684                    Error::EditUnsupported(
2685                        "a target group does not exist; create it first in this session",
2686                    )
2687                })?;
2688                let addr = usize::try_from(addr)
2689                    .map_err(|_| Error::EditUnsupported("group address exceeds this platform"))?;
2690                let info = self.inspect_group(addr)?;
2691                superseded_addrs.push(addr);
2692                let node = nodes.get_mut(key).unwrap();
2693                node.base_region = info.region;
2694                node.existing_links = info.link_names;
2695            }
2696        }
2697
2698        // Apply and validate group attribute edits before any writes. This keeps
2699        // unsupported attribute edits under the same all-or-nothing preflight
2700        // contract as unsupported dataset additions. A variable-length attribute
2701        // is not fully resolved here — its global heap collection is built (it
2702        // is self-contained, no address needed yet) but placed and patched into
2703        // `base_region` only in the apply loop below, once its address is known.
2704        for key in &keys {
2705            let node = nodes.get_mut(key).unwrap();
2706            let ops = std::mem::take(&mut node.attr_ops);
2707            if !ops.is_empty() {
2708                let region = std::mem::take(&mut node.base_region);
2709                let (region, pending_vl_attrs) = apply_group_attr_ops(&region, &ops)?;
2710                node.base_region = region;
2711                node.pending_vl_attrs = pending_vl_attrs;
2712            }
2713        }
2714
2715        // Map each node to its direct child group nodes (for link wiring).
2716        let mut children: BTreeMap<PathKey, Vec<PathKey>> = BTreeMap::new();
2717        for key in &keys {
2718            if !key.is_empty() {
2719                let parent = key[..key.len() - 1].to_vec();
2720                children.entry(parent).or_default().push(key.clone());
2721            }
2722        }
2723
2724        // Validate names: no addition may collide with an existing link or with
2725        // another addition under the same parent.
2726        for key in &keys {
2727            let node = &nodes[key];
2728            let mut adding: Vec<&str> = Vec::new();
2729            for db in &node.datasets {
2730                adding.push(&db.name);
2731            }
2732            for child in children.get(key).into_iter().flatten() {
2733                if nodes[child].is_new {
2734                    adding.push(child.last().unwrap());
2735                }
2736            }
2737            for (leaf, _) in &node.copies {
2738                adding.push(leaf);
2739            }
2740            for (i, name) in adding.iter().enumerate() {
2741                if node.existing_links.iter().any(|n| n == name) || adding[..i].contains(name) {
2742                    return Err(Error::EditUnsupported(
2743                        "a link with this name already exists in the target group",
2744                    ));
2745                }
2746            }
2747        }
2748
2749        // Flatten datasets (more guards) before any write, so a rejected one
2750        // leaves the commit unapplied.
2751        let mut flat: BTreeMap<PathKey, Vec<FlatDataset>> = BTreeMap::new();
2752        for key in &keys {
2753            let dbs = std::mem::take(&mut nodes.get_mut(key).unwrap().datasets);
2754            let mut v = Vec::with_capacity(dbs.len());
2755            for db in dbs {
2756                v.push(flatten_dataset(db)?);
2757            }
2758            flat.insert(key.clone(), v);
2759        }
2760
2761        // Prove every object-reference target resolves before any write (see
2762        // `preflight_reference_targets`'s doc comment): otherwise a reference
2763        // resolution failure discovered mid-apply-loop would leave every
2764        // earlier-processed group's real writes (headers, data, copied
2765        // subtrees) orphaned in the file despite `commit()` returning `Err`.
2766        Self::preflight_reference_targets(
2767            &keys,
2768            &flat,
2769            &nodes,
2770            &add_targets,
2771            &write_targets,
2772            &delete_targets,
2773            &self.image(),
2774            &self.superblock,
2775        )?;
2776
2777        // Gather the regions this commit will vacate, read from the current
2778        // on-disk layout before any byte moves: every deleted object's owned
2779        // blocks plus every superseded group header. These are not added to the
2780        // free list until after the superblock repoint (they remain live until
2781        // then), so the appends below never reuse them. Enumeration is
2782        // best-effort — `collect_free_spans` simply omits anything it cannot
2783        // account for exhaustively, so the worst case is unreclaimed dead bytes,
2784        // never a freed-but-live region.
2785        let mut to_free: Vec<(u64, u64, PageType)> = Vec::new();
2786
2787        // An object's storage is reclaimed only when the link being removed is
2788        // its LAST hard link: HDF5 objects can have several hard links, and one
2789        // reachable through a surviving link is still live (freeing it would
2790        // corrupt the survivor). Count every hard link in the pre-commit file
2791        // and reclaim a deleted object only when its count is exactly 1.
2792        // `deleted_addrs` is de-duplicated first so two delete paths that are
2793        // hard links to the same object are not visited (and freed) twice. If
2794        // the link graph cannot be walked in full, no deleted object is
2795        // reclaimed (a safe leak), but superseded headers — always dead once the
2796        // root is repointed — still are.
2797        deleted_addrs.sort_unstable();
2798        deleted_addrs.dedup();
2799        if !deleted_addrs.is_empty() {
2800            if let Some(incoming) = self.count_incoming_hard_links() {
2801                for &a in &deleted_addrs {
2802                    self.collect_free_spans(a, 0, &incoming, &mut to_free);
2803                }
2804            }
2805        }
2806        // A superseded group header is dead once the root is repointed. Its chunk
2807        // spans are enumerated base-aware (`oh_chunk_spans` shifts continuation
2808        // addresses by the userblock base and returns absolute file offsets), as is
2809        // the delete path (`collect_free_spans`), so all of this reclamation works
2810        // on userblock files too.
2811        for &a in &superseded_addrs {
2812            if let Ok(spans) = self.oh_chunk_spans(a) {
2813                to_free.extend(spans.into_iter().map(|(a, l)| (a, l, PageType::Meta)));
2814            }
2815        }
2816
2817        // A relocating overwrite (`write_dataset` resize, or any compact rewrite)
2818        // vacates the dataset's old object header, and a resized contiguous one
2819        // also vacates its old data block: both become dead once the parent's
2820        // relinked header lands. `superseded_addrs` covers only the rebuilt group
2821        // headers, not the relocated dataset's own header, so record that here too.
2822        // The pre-commit dataset-header address is resolved from the live file; its
2823        // chunks and old data extent are freed only after the superblock repoint.
2824        // The single-hard-link guard in the write preflight makes freeing the old
2825        // header safe (no surviving link still points at it).
2826        for key in &keys {
2827            for (leaf, mw) in &nodes[key].writes {
2828                match mw {
2829                    MovingWrite::Contiguous {
2830                        old_extent: Some(extent),
2831                        ..
2832                    } => to_free.push((extent.0, extent.1, PageType::Raw)),
2833                    // A relocated chunked dataset vacates its old chunk index and
2834                    // chunk data blocks. `chunked_storage_spans` returns `None` for
2835                    // anything it cannot enumerate exhaustively (leaving dead bytes
2836                    // rather than freeing a region still in use); the old header
2837                    // chunks are freed generically below.
2838                    MovingWrite::Chunked { old_addr, .. } => {
2839                        if let Ok(a) = usize::try_from(*old_addr) {
2840                            if let Some(spans) = self.chunked_storage_spans(a) {
2841                                to_free.extend(spans);
2842                            }
2843                        }
2844                    }
2845                    // A relocating append keeps the existing chunk *data* in place
2846                    // (shared by both indexes during the commit), so only the old
2847                    // index structure and the relocated old trailing chunk are dead.
2848                    // The old header chunks are freed by the generic path below.
2849                    MovingWrite::AppendedChunks {
2850                        old_addr,
2851                        old_tail_extent,
2852                        ..
2853                    } => {
2854                        if let Ok(a) = usize::try_from(*old_addr) {
2855                            if let Some(spans) = self.chunked_index_spans(a) {
2856                                // A chunk index lives in a raw page in this crate's
2857                                // files; see `chunked_storage_spans` for why the tag
2858                                // has to follow the placement rather than the
2859                                // format's metadata/raw taxonomy.
2860                                to_free
2861                                    .extend(spans.into_iter().map(|(a, l)| (a, l, PageType::Raw)));
2862                            }
2863                        }
2864                        if let Some(ext) = old_tail_extent {
2865                            // The relocated old trailing chunk is raw data.
2866                            to_free.push((ext.0, ext.1, PageType::Raw));
2867                        }
2868                    }
2869                    _ => {}
2870                }
2871                // The relocated dataset's old header chunks are dead too.
2872                let mut full = key.clone();
2873                full.push(leaf.clone());
2874                let path_str = full.join("/");
2875                if let Ok(addr) = crate::group_v2::resolve_path_any_from_source(
2876                    &self.image(),
2877                    &self.superblock,
2878                    &path_str,
2879                ) {
2880                    if let Ok(a) = usize::try_from(addr) {
2881                        if let Ok(spans) = self.oh_chunk_spans(a) {
2882                            to_free.extend(spans.into_iter().map(|(a, l)| (a, l, PageType::Meta)));
2883                        }
2884                    }
2885                }
2886            }
2887        }
2888
2889        // Defense in depth: never hand the free list an out-of-bounds or
2890        // overlapping span. The last-link guard plus the per-object checks
2891        // should already make the accumulated spans disjoint; this enforces it
2892        // as a whole-commit invariant against the pre-commit end-of-file. Any
2893        // dropped span (which should not occur for a well-formed file) only
2894        // leaks, never corrupts.
2895        retain_disjoint_in_bounds(&mut to_free, self.image.len());
2896
2897        // --- Apply: process deepest groups first so each parent sees its
2898        // children's new addresses, then repoint the superblock last.
2899        // `path_addr` accumulates every group's and dataset's address as it is
2900        // placed — read by `resolve_reference_target` to resolve a same-commit
2901        // object-reference target (see the dataset-placement loop below for the
2902        // group/dataset key convention: a group's own path, or a dataset's
2903        // full parent+name path). ---
2904        let mut path_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
2905        let mut by_depth = keys.clone();
2906        by_depth.sort_by_key(|k| std::cmp::Reverse(k.len())); // deepest first
2907        for key in &by_depth {
2908            let (mut region, deletes, copies, writes, pending_vl_attrs) = {
2909                let node = nodes.get_mut(key).unwrap();
2910                (
2911                    std::mem::take(&mut node.base_region),
2912                    std::mem::take(&mut node.deletes),
2913                    std::mem::take(&mut node.copies),
2914                    std::mem::take(&mut node.writes),
2915                    std::mem::take(&mut node.pending_vl_attrs),
2916                )
2917            };
2918
2919            // Remove deleted links first (verbatim-preserving the rest).
2920            for name in &deletes {
2921                region = remove_link_from_region(&region, name)?;
2922            }
2923
2924            // Write each staged source subtree and link its root into this group.
2925            // `write_copy_subtree` returns an absolute header address; the parent
2926            // link stores it relative to the userblock base.
2927            for (leaf, tree) in copies {
2928                let root = self.write_copy_subtree(&tree)?;
2929                region.extend_from_slice(&encode_link_message(&leaf, root - base));
2930            }
2931
2932            // Datasets directly under this group. Appended addresses are absolute
2933            // file offsets; the contiguous data-layout address and the parent link
2934            // target are stored relative to the base address (`- base`). Placed
2935            // non-reference datasets first (recording each into `path_addr`), then
2936            // reference datasets — a reference to a *non-reference* sibling added
2937            // in the same group's batch resolves regardless of `pending_datasets`
2938            // call order (`Vec::sort_by_key` is stable, so within each of the two
2939            // groups the original order is preserved). Two reference datasets that
2940            // target each other in the same batch are still call-order-dependent —
2941            // whichever is placed first resolves the other, and the reverse
2942            // direction is safely refused as "still writing" (never corrupted),
2943            // caught up front by `preflight_reference_targets`.
2944            let mut group_datasets: Vec<FlatDataset> =
2945                flat.remove(key).into_iter().flatten().collect();
2946            group_datasets.sort_by_key(|fd| fd.reference_targets.is_some());
2947            for mut fd in group_datasets {
2948                // Place each variable-length attribute's global heap collection
2949                // and patch its placeholder heap address. Unlike VL-string
2950                // *data* (`vl_string_staging`, refused when chunked below), a
2951                // chunked/extensible dataset can carry a VL *attribute* just
2952                // fine — attributes live in the object header, not inside a
2953                // chunk, so patching them here before either apply branch runs
2954                // covers both.
2955                for (idx, collections) in std::mem::take(&mut fd.vl_attrs) {
2956                    let addrs = self.place_vl_collections(&collections)?;
2957                    patch_vl_refs(&mut fd.attrs[idx].raw_data, &addrs);
2958                }
2959                // Resolve an object-reference dataset's per-element targets now
2960                // that every earlier-placed object in this commit is in
2961                // `path_addr` (chunked datasets never carry these —
2962                // `flatten_dataset` refuses that combination).
2963                if let Some(patches) = fd.reference_targets.take() {
2964                    for patch in &patches {
2965                        let addr = Self::resolve_reference_target(
2966                            &patch.target,
2967                            &path_addr,
2968                            &nodes,
2969                            &add_targets,
2970                            &write_targets,
2971                            &delete_targets,
2972                            &self.image(),
2973                            &self.superblock,
2974                        )?;
2975                        write_reference_address(&mut fd.raw, patch.byte_offset, addr);
2976                    }
2977                }
2978                let oh = if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
2979                    self.build_chunked_dataset(&fd)?
2980                } else {
2981                    // A staged variable-length-string dataset's element
2982                    // references still carry a placeholder heap address; place
2983                    // its collection and patch them before `raw` is appended
2984                    // (chunked datasets never carry staging — refused above).
2985                    if let Some(staging) = fd.vl_string_staging.take() {
2986                        if !staging.collections.is_empty() {
2987                            let addrs = self.place_vl_collections(&staging.collections)?;
2988                            patch_vl_refs_masked(&mut fd.raw, &staging.patch_offsets, &addrs);
2989                        }
2990                    }
2991                    // A zero-element dataset has no data block to allocate; its
2992                    // layout address is the undefined-address sentinel (never
2993                    // base-relative — see `build_dataset_oh`'s empty-data callers
2994                    // in the whole-file writer), matching every reader's and the
2995                    // reference C library's convention for "no storage allocated".
2996                    let data_addr = if fd.raw.is_empty() {
2997                        u64::MAX
2998                    } else {
2999                        self.alloc_or_append_typed(&fd.raw, PageType::Raw)? - base
3000                    };
3001                    build_dataset_oh(
3002                        &fd.dt,
3003                        &fd.ds,
3004                        data_addr,
3005                        fd.raw.len() as u64,
3006                        &fd.attrs,
3007                        None,
3008                        fd.fill.as_deref(),
3009                    )?
3010                };
3011                let oh_addr = self.alloc_or_append_typed(&oh, PageType::Meta)?;
3012                region.extend_from_slice(&encode_link_message(&fd.name, oh_addr - base));
3013                let mut full = key.clone();
3014                full.push(fd.name.clone());
3015                path_addr.insert(full, oh_addr);
3016            }
3017
3018            // Relocating value overwrites under this group: write the new data and
3019            // rewritten header, then patch this group's existing link to it. The
3020            // link target is stored relative to the base address (`- base`); on a
3021            // userblock file only the chunked variant reaches here (contiguous and
3022            // compact resizes are refused in the write preflight).
3023            for (leaf, mw) in &writes {
3024                let new_oh = self.write_moving(mw)?;
3025                patch_link_target(&mut region, leaf, new_oh - base)?;
3026            }
3027
3028            // Wire links to dirty child groups (new → add a link; existing →
3029            // patch the existing link to the child's new address). Link targets are
3030            // stored relative to the base address.
3031            for child in children.get(key).into_iter().flatten() {
3032                let child_name = child.last().unwrap();
3033                let child_addr = path_addr[child] - base;
3034                if nodes[child].is_new {
3035                    region.extend_from_slice(&encode_link_message(child_name, child_addr));
3036                } else {
3037                    patch_link_target(&mut region, child_name, child_addr)?;
3038                }
3039            }
3040
3041            // Variable-length group/root attributes staged by
3042            // `apply_group_attr_ops`: place each collection and patch its
3043            // attribute message's placeholder heap address, then append the
3044            // resolved message to this group's header region.
3045            for (mut msg, collections) in pending_vl_attrs {
3046                let addrs = self.place_vl_collections(&collections)?;
3047                patch_vl_refs(&mut msg.raw_data, &addrs);
3048                region.extend_from_slice(&region_message(
3049                    MessageType::Attribute,
3050                    &msg.serialize(LENGTH_SIZE),
3051                ));
3052            }
3053
3054            let oh = build_v2_object_header(&region);
3055            let addr = self.alloc_or_append_typed(&oh, PageType::Meta)?;
3056            path_addr.insert(key.clone(), addr);
3057        }
3058
3059        // Same-length in-place overwrites (`write_dataset`) write straight into
3060        // their existing, already-referenced data blocks. Those blocks are
3061        // reachable from both the old and the new root (the dataset's header is
3062        // unchanged), so the write is independent of the superblock flip; it is
3063        // ordered before the barrier sync below so the new bytes are durable
3064        // alongside everything else this commit appended.
3065        for (data_addr, raw) in &inplace_writes {
3066            self.write_at(*data_addr, raw)?;
3067        }
3068
3069        // Repoint the superblock at the new root last: this is the commit's
3070        // linearization point. Until it lands, the file on disk still points at
3071        // the old root (the appended objects are merely unreferenced trailing
3072        // bytes), so a failure here leaves a valid file.
3073        //
3074        // That ordering is only crash-safe if the appended objects are durable
3075        // before the root pointer is flipped; otherwise a power loss could
3076        // persist the flip ahead of the data it references, leaving the root
3077        // pointing at bytes that never reached disk. `flush` on a plain `File`
3078        // does not force a write-back, so sync the appended bytes to disk first
3079        // (the barrier), then flip the pointer, then sync the flip.
3080        let new_root = path_addr[&PathKey::new()];
3081
3082        // A persisting file keeps its freed space recorded on disk rather than
3083        // truncating it away, so its commit takes a different, append-only tail.
3084        if self.persist.is_some() {
3085            return self.commit_persisting(new_root, to_free);
3086        }
3087
3088        // The new tree is fully written, so the regions this commit vacated are
3089        // now dead: hand them to the session free list. If the resulting free
3090        // space forms a run reaching end-of-file, the file can be physically
3091        // truncated to where that run starts; otherwise the end-of-file is
3092        // unchanged. `take_trailing` removes the trimmed run so it is not also
3093        // counted as reusable interior space.
3094        for (a, l, _) in to_free.drain(..) {
3095            self.free.free(a, l);
3096        }
3097        let cur_eof = self.image.len();
3098        let trunc_to = self.free.take_trailing(cur_eof);
3099        let new_eof = trunc_to.unwrap_or(cur_eof);
3100
3101        self.image.sync_all()?;
3102        // The root address is stored relative to the base address; the end-of-file
3103        // address is absolute. After writing the relative root to disk, keep the
3104        // in-memory `root_group_address` absolute (the open-time convention).
3105        if self.superblock.version >= 2 {
3106            // Build the new superblock off a clone and adopt it only once the
3107            // write succeeds, so a failed write does not desync the in-memory
3108            // state. The v2/v3 superblock carries its own checksum.
3109            let mut new_sb = self.superblock.clone();
3110            new_sb.root_group_address = new_root - base;
3111            new_sb.eof_address = new_eof;
3112            // Clear any write/SWMR consistency flag rather than re-emitting one
3113            // the source file carried (e.g. left set by a crashed SWMR writer):
3114            // this clean commit leaves the file properly closed for the C library
3115            // (issue #73). serialize() recomputes the v2/v3 checksum.
3116            new_sb.consistency_flags = 0;
3117            let sb_bytes = new_sb.serialize();
3118            self.write_at(self.sb_sig_off, &sb_bytes)?;
3119            self.image.sync_all()?;
3120            new_sb.root_group_address = new_root;
3121            self.superblock = new_sb;
3122        } else {
3123            self.repoint_v0v1_root(new_root - base, new_eof)?;
3124            self.image.sync_all()?;
3125            self.superblock.root_group_address = new_root;
3126            self.superblock.eof_address = new_eof;
3127        }
3128
3129        // Physically shrink the file only after the superblock — now carrying the
3130        // smaller end-of-file — is durable. A crash between the two leaves a file
3131        // whose superblock end-of-file is correct and whose trailing bytes are
3132        // mere unreferenced slack, which the next open ignores; the reverse order
3133        // could advertise an end-of-file past the actual file length.
3134        if let Some(cut) = trunc_to {
3135            self.image.truncate(cut)?;
3136            self.image.sync_all()?;
3137        }
3138        Ok(())
3139    }
3140
3141    /// Commit tail for a file that persists its free space (issue #21). Unlike
3142    /// the non-persisting path, freed space is *retained* and recorded on disk —
3143    /// matching the reference library's persistent free-space strategy — so a
3144    /// later reopen (by this crate or the C library) recovers it.
3145    ///
3146    /// The post-commit free list (this commit's vacated regions plus the now-dead
3147    /// old free-space-manager and extension blocks) is serialized into a fresh
3148    /// `FSHD`/`FSSE` pair and a rewritten superblock-extension File Space Info
3149    /// message, all appended at the current end-of-file. Nothing live or
3150    /// still-referenced is overwritten: the new blocks sit strictly past the old
3151    /// ones, and the superblock — repointed last — is the linearization point. A
3152    /// crash before it leaves the prior file (root, extension, and managers)
3153    /// wholly intact.
3154    fn commit_persisting(
3155        &mut self,
3156        new_root: u64,
3157        to_free: Vec<(u64, u64, PageType)>,
3158    ) -> Result<(), Error> {
3159        // A paged file records its free space in per-page-type managers and keeps
3160        // its allocation page-aligned, so it takes its own tail (issue #198).
3161        if self.paged.is_some() {
3162            return self.commit_persisting_paged(new_root, to_free);
3163        }
3164        let os = self.superblock.offset_size;
3165        let (strategy, threshold, page_size, old_blocks) = {
3166            // Copy what we need so no borrow of `self.persist` is held across the
3167            // `&mut self` writes below; the old state stays in place so a failure
3168            // leaves the session reusable.
3169            let ps = self
3170                .persist
3171                .as_ref()
3172                .expect("commit_persisting is only called when persistence is armed");
3173            (
3174                ps.strategy,
3175                ps.threshold,
3176                ps.page_size,
3177                ps.old_blocks.clone(),
3178            )
3179        };
3180
3181        // The free list the new managers will record: this commit's vacated
3182        // regions plus the superseded FSM/extension blocks (dead once we
3183        // repoint), coalesced. Built in a temp so `self.free` and the on-disk old
3184        // blocks stay untouched until after the superblock repoint.
3185        let mut post = self.free.clone();
3186        for &(a, l, _) in &to_free {
3187            post.free(a, l);
3188        }
3189        for &(a, l) in &old_blocks {
3190            post.free(a, l);
3191        }
3192        let sections: Vec<FreeSection> = post
3193            .sections()
3194            .into_iter()
3195            .map(|(addr, size)| FreeSection { addr, size })
3196            .collect();
3197
3198        let old_ext_rel = self
3199            .superblock
3200            .superblock_extension_address
3201            .filter(|&a| a != UNDEF)
3202            .ok_or(Error::EditUnsupported(
3203                "a persisting file has no superblock extension to update",
3204            ))?;
3205        let old_ext_addr = usize::try_from(old_ext_rel)
3206            .map_err(|_| Error::EditUnsupported("extension address exceeds this platform"))?;
3207
3208        // The persist File Space Info message is fixed-size, so the rewritten
3209        // extension's length is independent of the addresses it will carry: size
3210        // it with a placeholder to place the FSM blocks that follow it.
3211        let placeholder =
3212            FileSpaceInfo::persistent_single_manager(strategy, threshold, page_size, 0, 0);
3213        let ext_len =
3214            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)
3215                .len() as u64;
3216
3217        let ext_addr = self.image.len();
3218        let fshd_addr = ext_addr + ext_len;
3219
3220        // Build the real extension and the FSM blocks. With no free space to
3221        // record we still refresh the extension (persist on, managers undefined).
3222        let (ext_oh, fsm_blocks, final_eof) = if sections.is_empty() {
3223            let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
3224            let ext_oh =
3225                build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?);
3226            let final_eof = ext_addr + ext_oh.len() as u64;
3227            (ext_oh, None, final_eof)
3228        } else {
3229            let fsse_addr = fshd_addr + fshd_len(os);
3230            // `eoa_pre_fsm` is the end-of-allocation before the free-space-manager
3231            // section blocks (`FSHD`/`FSSE`) were allocated: a consumer may shrink
3232            // back to here and rebuild them. It points at the FSHD, not the
3233            // extension — the extension sits below it and persists, so shrinking
3234            // leaves the superblock and its extension pointer valid (only the
3235            // manager blocks, which are rewritten every commit, are discarded).
3236            // This matches the C library's convention of keeping the superblock
3237            // extension stable across closes, and is the value `H5Fget_freespace`
3238            // accounts for correctly (verified in the crosscheck).
3239            let eoa_pre_fsm = fshd_addr;
3240            let info = FileSpaceInfo::persistent_single_manager(
3241                strategy,
3242                threshold,
3243                page_size,
3244                fshd_addr,
3245                eoa_pre_fsm,
3246            );
3247            let ext_oh =
3248                build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?);
3249            debug_assert_eq!(
3250                ext_oh.len() as u64,
3251                ext_len,
3252                "extension length must be stable across the placeholder and real messages"
3253            );
3254            let (fshd, fsse) =
3255                serialize_file_fsm(&sections, fshd_addr, fsse_addr, os, SECT_CLASS_SIMPLE);
3256            let final_eof = fsse_addr + fsse.len() as u64;
3257            (ext_oh, Some((fshd, fsse)), final_eof)
3258        };
3259
3260        // Append the extension, then the FSM blocks, at end-of-file. They are
3261        // unreferenced until the superblock repoint, so a crash here is harmless.
3262        let written_ext = self.append(&ext_oh)?;
3263        debug_assert_eq!(written_ext, ext_addr);
3264        let mut new_old_blocks = vec![(ext_addr, ext_oh.len() as u64)];
3265        if let Some((fshd, fsse)) = fsm_blocks {
3266            let wf = self.append(&fshd)?;
3267            debug_assert_eq!(wf, fshd_addr);
3268            new_old_blocks.push((fshd_addr, fshd.len() as u64));
3269            let ws = self.append(&fsse)?;
3270            new_old_blocks.push((ws, fsse.len() as u64));
3271        }
3272
3273        // Barrier, then repoint the superblock (root, eof, and the new extension)
3274        // — the linearization point — and sync it.
3275        self.image.sync_all()?;
3276        let mut new_sb = self.superblock.clone();
3277        new_sb.root_group_address = new_root;
3278        new_sb.eof_address = final_eof;
3279        new_sb.superblock_extension_address = Some(ext_addr);
3280        // Clear any leftover write/SWMR consistency flag on a clean commit (see
3281        // the non-persisting path above and issue #73).
3282        new_sb.consistency_flags = 0;
3283        let sb_bytes = new_sb.serialize();
3284        self.write_at(self.sb_sig_off, &sb_bytes)?;
3285        self.image.sync_all()?;
3286        self.superblock = new_sb;
3287
3288        // The repoint is durable: the prior free list plus this commit's vacated
3289        // regions are now genuinely free, and the freshly written blocks become
3290        // the ones a future commit will supersede.
3291        self.free = post;
3292        self.persist = Some(PersistState {
3293            strategy,
3294            threshold,
3295            page_size,
3296            old_blocks: new_old_blocks,
3297        });
3298        // The managers now sit at the tail, so nothing is owed until the file
3299        // grows past them again.
3300        self.fsm_len = self.image.len();
3301        Ok(())
3302    }
3303
3304    /// Commit tail for a genuine paged file (`H5F_FSPACE_STRATEGY_PAGE`, issue
3305    /// #198). The paged counterpart of [`commit_persisting`](Self::commit_persisting).
3306    ///
3307    /// Two things differ from the flat tail. Free space is recorded in *per-page-type*
3308    /// managers — SUPER (slot 0) for metadata, DRAW (slot 2) for small raw, and the
3309    /// generic-large manager (slot 6) for whole free pages and large-raw fragments —
3310    /// rather than one generic manager, so a paged file reopened by the reference
3311    /// library still finds its free space segregated. And every allocation boundary
3312    /// is page-aligned: the file is padded to a page before the rewritten extension
3313    /// is laid down (so the extension and the manager blocks sit in metadata pages),
3314    /// and the end-of-allocation is the page-aligned end of those blocks, matching
3315    /// the paged file the from-scratch writer produces.
3316    ///
3317    /// Crash atomicity is identical to the flat path: everything is appended past
3318    /// the live file and is unreferenced until the superblock repoint, which is the
3319    /// linearization point.
3320    fn commit_persisting_paged(
3321        &mut self,
3322        new_root: u64,
3323        to_free: Vec<(u64, u64, PageType)>,
3324    ) -> Result<(), Error> {
3325        let os = self.superblock.offset_size;
3326        let (strategy, threshold, page_size, old_blocks) = {
3327            let ps = self
3328                .persist
3329                .as_ref()
3330                .expect("commit_persisting is only called when persistence is armed");
3331            (
3332                ps.strategy,
3333                ps.threshold,
3334                ps.page_size,
3335                ps.old_blocks.clone(),
3336            )
3337        };
3338
3339        // Page-align the file before anything else, so the rewritten extension and
3340        // the manager blocks begin on a page boundary and stay in metadata pages.
3341        // The padded tail becomes free space of whatever type that page held.
3342        self.pad_to_page()?;
3343
3344        // Fold this commit's vacated regions into their page-type managers, along
3345        // with the page-padding tails this commit's appends left behind and the
3346        // superseded extension/manager blocks (all metadata, dead once we repoint).
3347        //
3348        // Built in temporaries, exactly as the flat path builds `post`: every
3349        // region gathered here is still *live* until the superblock repoint below,
3350        // so the session's own lists must not learn about it until that repoint
3351        // succeeds. Everything between here and there can fail (the extension
3352        // rewrite, each append, each barrier), and a session that survived a failed
3353        // commit while believing live extents were free would hand them out on the
3354        // next commit — silently destroying the objects still occupying them.
3355        let (post_meta, post_raw_small, post_raw_large) = {
3356            let pg = self
3357                .paged
3358                .as_ref()
3359                .expect("commit_persisting_paged is only called on a paged file");
3360            let (mut meta, mut raw_small, mut raw_large) =
3361                (pg.meta.clone(), pg.raw_small.clone(), pg.raw_large.clone());
3362            for &(a, l) in &pg.meta_pad {
3363                meta.free(a, l);
3364            }
3365            for &(a, l) in &pg.raw_pad {
3366                raw_small.free(a, l);
3367            }
3368            for &(a, l, ty) in &to_free {
3369                PagedEdit::route_free(
3370                    &mut meta,
3371                    &mut raw_small,
3372                    &mut raw_large,
3373                    page_size,
3374                    a,
3375                    l,
3376                    ty,
3377                );
3378            }
3379            for &(a, l) in &old_blocks {
3380                meta.free(a, l);
3381            }
3382            (meta, raw_small, raw_large)
3383        };
3384
3385        let old_ext_rel = self
3386            .superblock
3387            .superblock_extension_address
3388            .filter(|&a| a != UNDEF)
3389            .ok_or(Error::EditUnsupported(
3390                "a persisting file has no superblock extension to update",
3391            ))?;
3392        let old_ext_addr = usize::try_from(old_ext_rel)
3393            .map_err(|_| Error::EditUnsupported("extension address exceeds this platform"))?;
3394
3395        // The 12-slot persist message is fixed-size, so a placeholder sizes the
3396        // rewritten extension before its manager addresses are known.
3397        let placeholder = FileSpaceInfo::persistent_managers(
3398            strategy,
3399            threshold,
3400            page_size,
3401            [UNDEF; NUM_FILE_FSM_MANAGERS],
3402            0,
3403        );
3404        let ext_len =
3405            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)
3406                .len() as u64;
3407
3408        let ext_addr = self.image.len();
3409        debug_assert_eq!(
3410            ext_addr % page_size,
3411            0,
3412            "the extension begins on a page boundary"
3413        );
3414
3415        // Class the free space into its managers and place their blocks after the
3416        // extension. Shared with the bounded backend so both lay out identically.
3417        let plan = plan_paged_managers(
3418            &free_sections(&post_meta),
3419            &free_sections(&post_raw_small),
3420            &free_sections(&post_raw_large),
3421            page_size,
3422            ext_addr + ext_len,
3423            os,
3424        );
3425
3426        let (ext_oh, final_eof) = if plan.is_empty() {
3427            // No free space to track: an empty persist message, page-aligned.
3428            let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
3429            let ext_oh =
3430                build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?);
3431            let final_eof = align_up(ext_addr + ext_oh.len() as u64, page_size);
3432            (ext_oh, final_eof)
3433        } else {
3434            let final_eof = align_up(plan.end_of_managers, page_size);
3435            // Paged convention (matching the from-scratch writer): the managers are
3436            // ordinary metadata below a page-aligned end-of-allocation.
3437            let info = FileSpaceInfo::persistent_managers(
3438                strategy, threshold, page_size, plan.slots, final_eof,
3439            );
3440            let ext_oh =
3441                build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?);
3442            debug_assert_eq!(
3443                ext_oh.len() as u64,
3444                ext_len,
3445                "extension length must be stable across the placeholder and real messages"
3446            );
3447            (ext_oh, final_eof)
3448        };
3449
3450        // Append the extension, then every manager block, at (page-aligned) EOF.
3451        // They are unreferenced until the repoint, so a crash here is harmless.
3452        let written_ext = self.append(&ext_oh)?;
3453        debug_assert_eq!(written_ext, ext_addr);
3454        let mut new_old_blocks = vec![(ext_addr, ext_oh.len() as u64)];
3455        for b in &plan.blocks {
3456            let (fshd, fsse) =
3457                serialize_file_fsm(&b.sections, b.fshd_addr, b.fsse_addr, os, b.class);
3458            let wf = self.append(&fshd)?;
3459            debug_assert_eq!(wf, b.fshd_addr);
3460            new_old_blocks.push((b.fshd_addr, fshd.len() as u64));
3461            let ws = self.append(&fsse)?;
3462            debug_assert_eq!(ws, b.fsse_addr);
3463            new_old_blocks.push((ws, fsse.len() as u64));
3464        }
3465        // Pad the final metadata page to its boundary. This trailing tail is left
3466        // untracked (a valid free-space under-report), keeping the manager layout
3467        // closed-form rather than self-referential.
3468        self.pad_zeros_to(final_eof)?;
3469
3470        // Barrier, then repoint the superblock (root, eof, and the new extension)
3471        // — the linearization point — and sync it.
3472        self.image.sync_all()?;
3473        let mut new_sb = self.superblock.clone();
3474        new_sb.root_group_address = new_root;
3475        new_sb.eof_address = final_eof;
3476        new_sb.superblock_extension_address = Some(ext_addr);
3477        new_sb.consistency_flags = 0;
3478        let sb_bytes = new_sb.serialize();
3479        self.write_at(self.sb_sig_off, &sb_bytes)?;
3480        self.image.sync_all()?;
3481        self.superblock = new_sb;
3482
3483        // The repoint is durable. Only now are this commit's vacated regions
3484        // genuinely free, so adopt the lists built above and drop the padding tails
3485        // they already account for. The blocks just written become the ones the next
3486        // commit supersedes, and the tail page is fresh metadata: the managers sit
3487        // in it, so a following append of metadata may keep packing that page.
3488        if let Some(pg) = self.paged.as_mut() {
3489            pg.meta = post_meta;
3490            pg.raw_small = post_raw_small;
3491            pg.raw_large = post_raw_large;
3492            pg.meta_pad.clear();
3493            pg.raw_pad.clear();
3494            pg.last = Some(PageType::Meta);
3495        }
3496        self.persist = Some(PersistState {
3497            strategy,
3498            threshold,
3499            page_size,
3500            old_blocks: new_old_blocks,
3501        });
3502        // The managers now sit at the tail, so nothing is owed until the file
3503        // grows past them again.
3504        self.fsm_len = self.image.len();
3505        Ok(())
3506    }
3507
3508    /// Pad a paged file to a page boundary if its tail page is partially filled,
3509    /// recording the padding as free space of the tail page's type. A no-op on a
3510    /// non-paged file or an already-aligned one.
3511    fn pad_to_page(&mut self) -> Result<(), Error> {
3512        let len = self.image.len();
3513        let pad = match &self.paged {
3514            Some(pg) if len % pg.page_size != 0 => {
3515                Some((pg.last, pg.page_size - len % pg.page_size))
3516            }
3517            _ => None,
3518        };
3519        if let Some((last, pad_len)) = pad {
3520            let pad_at = len;
3521            self.append(&vec![0u8; pad_len.to_usize()?])?;
3522            if let Some(pg) = self.paged.as_mut() {
3523                match last {
3524                    // A partially-filled tail page at commit time is a raw page (the
3525                    // last thing the apply loop writes for a dataset is its header,
3526                    // but a commit that only wrote raw data ends on one); default an
3527                    // unknown tail (no typed append this commit) to raw, matching the
3528                    // bounded backend.
3529                    Some(PageType::Meta) => pg.meta_pad.push((pad_at, pad_len)),
3530                    _ => pg.raw_pad.push((pad_at, pad_len)),
3531                }
3532            }
3533        }
3534        Ok(())
3535    }
3536
3537    /// Extend the file with zeros up to `target` (>= the current length), used by
3538    /// the paged tail to pad the final metadata page to its boundary.
3539    fn pad_zeros_to(&mut self, target: u64) -> Result<(), Error> {
3540        let len = self.image.len();
3541        if target > len {
3542            let pad = (target - len).to_usize()?;
3543            self.append(&vec![0u8; pad])?;
3544        }
3545        debug_assert_eq!(self.image.len(), target);
3546        Ok(())
3547    }
3548
3549    /// Rebuild the superblock-extension object header's message region with its
3550    /// File Space Info message replaced by `info` (every other message preserved
3551    /// verbatim), ready to wrap with [`build_v2_object_header`]. The persisting
3552    /// message is fixed-size, so this never changes the region's length.
3553    fn rewrite_extension_region(
3554        &self,
3555        ext_addr: usize,
3556        info: &FileSpaceInfo,
3557    ) -> Result<Vec<u8>, Error> {
3558        let region =
3559            Self::gather_oh_messages(&self.image(), ext_addr as u64, self.superblock.base_address)?;
3560        rewrite_extension_region_bytes(&region, info)
3561    }
3562
3563    /// Repoint a version 0/1 superblock at the rebuilt (now v2) root group and
3564    /// update its end-of-file field, patching the raw bytes in place — these
3565    /// superblocks carry no checksum. The root symbol-table entry is switched to
3566    /// cache type 0 (its scratch-pad B-tree / local-heap addresses, which
3567    /// describe the old symbol-table group, no longer apply). The
3568    /// object-header-address write is done last so it is the linearization point.
3569    fn repoint_v0v1_root(&mut self, new_root: u64, new_eof: u64) -> Result<(), Error> {
3570        let os = self.superblock.offset_size as usize;
3571        // Field layout after the fixed prefix: base / free-space / EOF / driver
3572        // addresses, then the root symbol-table entry (link-name offset, object
3573        // header address, cache type(4), reserved(4), scratch(16)). The prefix is
3574        // 24 bytes for v0 and 28 for v1 (the latter adds indexed-storage-K).
3575        let var_start = if self.superblock.version == 0 { 24 } else { 28 };
3576        let base = self.sb_sig_off + var_start;
3577        let eof_off = base + 2 * os;
3578        let ste = base + 4 * os;
3579        let oh_addr_off = ste + os;
3580        let cache_off = ste + 2 * os;
3581        self.write_at(eof_off, &new_eof.to_le_bytes()[..os])?;
3582        self.write_at(cache_off, &[0u8; 4])?; // cache type = none
3583        self.write_at(cache_off + 8, &[0u8; 16])?; // clear scratch-pad
3584        self.write_at(oh_addr_off, &new_root.to_le_bytes()[..os])?;
3585        Ok(())
3586    }
3587
3588    /// Collect every message of the object header at `addr` into one contiguous
3589    /// region, following continuation blocks across chunks and dropping the
3590    /// `Continuation` messages themselves. Re-emitting the result through
3591    /// [`build_v2_object_header`] collapses a multi-chunk header (as the
3592    /// reference C library often writes) into a single chunk, which is how this
3593    /// editor rebuilds headers. The chunk-0 prefix is validated by
3594    /// [`oh_region_at`]; each continuation block must be a well-formed `OCHK`
3595    /// block within the file.
3596    ///
3597    /// Reads each header chunk out of `src` as one bounded buffer rather than
3598    /// indexing a whole-file image, so this serves a session whose file is not
3599    /// mirrored in memory (issue #198).
3600    fn gather_oh_messages<S: Source + ?Sized>(
3601        src: &S,
3602        addr: u64,
3603        base: u64,
3604    ) -> Result<Vec<u8>, Error> {
3605        let mut out = Vec::new();
3606        for chunk in read_oh_chunks(src, addr, base)? {
3607            let (region, mut p) = chunk.message_region();
3608            while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
3609                if msg_type != MessageType::ObjectHeaderContinuation {
3610                    out.extend_from_slice(&region[p..body_end]);
3611                }
3612                p = body_end;
3613            }
3614        }
3615        Ok(out)
3616    }
3617
3618    /// Reconstruct a version-1 (symbol-table) group as a fresh v2 compact-link
3619    /// message region: a LinkInfo message, one Link message per existing child,
3620    /// and the group's existing attributes (re-wrapped as v2 messages). The
3621    /// symbol-table message and other non-link/non-attribute messages
3622    /// (modification time, comment, …) are dropped — editing a v0/v1 group
3623    /// converts it to the latest format. Refuses an attribute it cannot
3624    /// reproduce (shared, or larger than a v2 message can hold).
3625    fn reconstruct_v1_group(&self, addr: usize) -> Result<GroupInfo, Error> {
3626        let os = self.superblock.offset_size;
3627        let ls = self.superblock.length_size;
3628        let base = self.superblock.base_address;
3629        let oh = ObjectHeader::parse_from_source(&self.image(), addr as u64, os, ls, base)?;
3630        if oh
3631            .messages
3632            .iter()
3633            .any(|m| m.msg_type == MessageType::DataLayout)
3634        {
3635            return Err(Error::EditUnsupported(
3636                "a target path names a dataset, not a group",
3637            ));
3638        }
3639        let entries = resolve_group_entries_from_source(&self.image(), &oh, os, ls, base)?;
3640
3641        let mut region = fresh_group_region();
3642        let mut link_names = Vec::with_capacity(entries.len());
3643        for e in &entries {
3644            // Group-entry addresses are already stored relative to the base address,
3645            // matching how `encode_link_message` stores link targets — so they are
3646            // re-emitted verbatim, no base conversion needed.
3647            region.extend_from_slice(&encode_link_message(&e.name, e.object_header_address));
3648            link_names.push(e.name.clone());
3649        }
3650        for m in &oh.messages {
3651            if m.msg_type == MessageType::Attribute {
3652                if m.flags != 0 {
3653                    return Err(Error::EditUnsupported(
3654                        "a v0/v1 group has a shared attribute message (not convertible in place yet)",
3655                    ));
3656                }
3657                if m.data.len() > OBJECT_HEADER_MESSAGE_MAX {
3658                    return Err(Error::EditUnsupported(
3659                        "a v0/v1 group attribute is too large to convert in place",
3660                    ));
3661                }
3662                // Re-wrap the attribute message body (it is self-describing) in a
3663                // v2 message record.
3664                #[expect(
3665                    clippy::cast_possible_truncation,
3666                    reason = "message type ids are a small enum that fits the 1-byte v2 type field"
3667                )]
3668                region.push(MessageType::Attribute.to_u16() as u8);
3669                #[expect(
3670                    clippy::cast_possible_truncation,
3671                    reason = "attribute body length fits the 2-byte message-size field (oversized \
3672                              bodies are rejected above)"
3673                )]
3674                region.extend_from_slice(&(m.data.len() as u16).to_le_bytes());
3675                region.push(0); // message flags
3676                region.extend_from_slice(&m.data);
3677            }
3678        }
3679        Ok(GroupInfo { region, link_names })
3680    }
3681
3682    /// Parse and validate a group's object header, returning its message region
3683    /// — the bytes to copy when rewriting the header — and the names of its
3684    /// existing links. A version 2 header is rebuilt from its own message bytes
3685    /// (collapsing continuation chunks, preserving every message); a version 1
3686    /// symbol-table group is converted to v2 via [`reconstruct_v1_group`].
3687    fn inspect_group(&self, addr: usize) -> Result<GroupInfo, Error> {
3688        let sig = self.image().read_metadata_at(addr as u64, 4);
3689        if sig.as_deref() != Ok(&b"OHDR"[..]) {
3690            return self.reconstruct_v1_group(addr);
3691        }
3692        let mut region =
3693            Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)?;
3694        let mut p = 0;
3695        let mut has_link_info = false;
3696        let mut link_names = Vec::new();
3697        while let Some((msg_type, body, body_end)) = next_message(&region, p)? {
3698            match msg_type {
3699                MessageType::LinkInfo => {
3700                    has_link_info = true;
3701                    // LinkInfo: version(1) flags(1) [max_creation_index(8) if
3702                    // flags&0x01] fractal_heap_addr(8) … — dense storage has a
3703                    // defined fractal-heap address. Bound the read by this
3704                    // message's own body, not just the region, so a short or
3705                    // malformed LinkInfo can't make us read the next message.
3706                    let mut q = body + 2;
3707                    if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
3708                        q += 8;
3709                    }
3710                    if q + 8 <= body_end {
3711                        let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
3712                        if heap_addr != u64::MAX {
3713                            return Err(Error::EditUnsupported(
3714                                "a target group uses dense (fractal-heap) link storage (not supported in place yet)",
3715                            ));
3716                        }
3717                    }
3718                }
3719                MessageType::Link => {
3720                    if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
3721                        link_names.push(link.name);
3722                    }
3723                }
3724                MessageType::DataLayout => {
3725                    return Err(Error::EditUnsupported(
3726                        "a target path names a dataset, not a group",
3727                    ));
3728                }
3729                _ => {}
3730            }
3731            p = body_end;
3732        }
3733        if !has_link_info {
3734            return Err(Error::EditUnsupported(
3735                "a target group's object header has no link-info message",
3736            ));
3737        }
3738        // Heal headers written by older hdf5-pure releases that omitted the
3739        // Group Info message, so the rewritten group stays writable by the C
3740        // library.
3741        ensure_group_info(&mut region)?;
3742        Ok(GroupInfo { region, link_names })
3743    }
3744
3745    /// Preflight a staged value overwrite (`write_dataset`): resolve the dataset
3746    /// at `addr`, validate that the staged `fd` matches it byte-exactly in
3747    /// datatype and shape, and classify how the bytes will be applied. No file
3748    /// bytes are written here — this is part of the all-or-nothing preflight, so a
3749    /// rejected write leaves the commit unapplied.
3750    ///
3751    /// Contiguous, compact, and chunked (including filtered) datasets are all
3752    /// supported; the chunk geometry, filter pipeline, and chunk index come from
3753    /// the on-disk header (a staged builder that itself requests chunking/filters/an
3754    /// extensible shape is refused as "not a value overwrite", and a chunk index
3755    /// this engine cannot enumerate — a version-2 B-tree — is refused too). A
3756    /// datatype or shape that differs from the on-disk dataset's is likewise
3757    /// refused — this is a value overwrite, not a reshape or retype.
3758    fn prepare_write<S: Source + ?Sized>(
3759        src: &S,
3760        addr: u64,
3761        fd: &FlatDataset,
3762        base: u64,
3763    ) -> Result<WritePlan, Error> {
3764        // A value overwrite never introduces chunking, filters, or an extensible
3765        // shape: those would change the storage layout, not just the bytes.
3766        if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
3767            return Err(Error::EditUnsupported(
3768                "write_dataset overwrites values only; it cannot make a dataset \
3769                 chunked, filtered, or extensible",
3770            ));
3771        }
3772
3773        // `write_dataset` overwrites element bytes only; it does not touch the
3774        // object header's attribute messages. Attributes staged on the returned
3775        // builder would otherwise be silently dropped (the in-place path rewrites
3776        // only the data block, and the moving path reuses the verbatim on-disk
3777        // header), so refuse rather than degrade — set them in a separate edit.
3778        if !fd.attrs.is_empty() {
3779            return Err(Error::EditUnsupported(
3780                "write_dataset overwrites values only; it cannot set attributes \
3781                 (set them with a separate edit)",
3782            ));
3783        }
3784
3785        // `write_dataset` overwrites element bytes only; it reuses the dataset's
3786        // existing Fill Value message (the in-place path rewrites only the data
3787        // block, and the moving path keeps every header message but the layout
3788        // verbatim). A fill value staged on the returned builder would otherwise
3789        // be silently ignored, so refuse rather than degrade — set the fill value
3790        // when the dataset is first created.
3791        if fd.fill.is_some() {
3792            return Err(Error::EditUnsupported(
3793                "write_dataset overwrites values only; it cannot change the fill \
3794                 value (set it when the dataset is created)",
3795            ));
3796        }
3797
3798        // `with_vlen_strings` stages placeholder element references that only the
3799        // add path's apply loop knows how to resolve (place the global heap
3800        // collection, then patch the placeholders once its address is known,
3801        // before the data block itself is written). `prepare_write` runs during
3802        // preflight, before any bytes are written and without `&mut self`
3803        // access to place a heap collection, and its result can be flushed by
3804        // the same-length fast path with no apply loop at all — so refuse
3805        // rather than write unpatched (heap address 0) placeholders as if they
3806        // were final.
3807        if fd.vl_string_staging.is_some() {
3808            return Err(Error::EditUnsupported(
3809                "write_dataset cannot overwrite a variable-length-string dataset's \
3810                 data in place yet",
3811            ));
3812        }
3813
3814        let region = Self::gather_oh_messages(src, addr, base)?;
3815
3816        // Locate the datatype, dataspace, and data-layout messages, and detect a
3817        // filter pipeline (filtered storage is always chunked, never contiguous).
3818        let mut datatype: Option<(usize, usize)> = None;
3819        let mut dataspace: Option<(usize, usize)> = None;
3820        let mut layout: Option<(usize, usize)> = None;
3821        let mut filter: Option<(usize, usize)> = None;
3822        let mut has_link = false;
3823        let mut p = 0;
3824        while let Some((msg_type, body, body_end)) = next_message(&region, p)? {
3825            match msg_type {
3826                MessageType::Datatype => datatype = Some((body, body_end)),
3827                MessageType::Dataspace => dataspace = Some((body, body_end)),
3828                MessageType::DataLayout => layout = Some((body, body_end)),
3829                MessageType::FilterPipeline => filter = Some((body, body_end)),
3830                MessageType::Link | MessageType::LinkInfo | MessageType::SymbolTable => {
3831                    has_link = true;
3832                }
3833                _ => {}
3834            }
3835            p = body_end;
3836        }
3837
3838        if has_link {
3839            return Err(Error::EditUnsupported(
3840                "write_dataset target is a group, not a dataset",
3841            ));
3842        }
3843        let (dt_b, dt_e) =
3844            datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
3845        let (ds_b, ds_e) =
3846            dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
3847        let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
3848
3849        // Compare datatype and shape structurally against the staged data. A
3850        // value overwrite must keep both exactly: the datatype (including its
3851        // class, size, endianness, and any compound/array/enumeration layout) so
3852        // the bytes are interpreted the same, and the *current* dimensions so the
3853        // byte count is unchanged. Parsing both sides and comparing the decoded
3854        // values — rather than the raw message bytes — tolerates the harmless
3855        // encoding differences between this crate's writer and the reference C
3856        // library (e.g. the C library records a maximum-dimensions array equal to
3857        // the current dimensions, which this crate omits) while still refusing any
3858        // real retype or reshape.
3859        let (disk_dt, _) = crate::datatype::Datatype::parse(&region[dt_b..dt_e])
3860            .map_err(|_| Error::EditUnsupported("dataset header datatype could not be parsed"))?;
3861        if disk_dt != fd.dt {
3862            return Err(Error::EditUnsupported(
3863                "write_dataset datatype does not match the on-disk dataset (overwrite, not retype)",
3864            ));
3865        }
3866        let disk_ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE)
3867            .map_err(|_| Error::EditUnsupported("dataset header dataspace could not be parsed"))?;
3868        if disk_ds.space_type != fd.ds.space_type
3869            || disk_ds.rank != fd.ds.rank
3870            || disk_ds.dimensions != fd.ds.dimensions
3871        {
3872            return Err(Error::EditUnsupported(
3873                "write_dataset shape does not match the on-disk dataset (overwrite, not reshape)",
3874            ));
3875        }
3876
3877        // Classify the layout. Version 3/4 compact (class 0), contiguous (class
3878        // 1), and chunked (class 2) are supported; an old-version layout or a
3879        // virtual layout (class 3) is refused.
3880        if le - lb < 2 {
3881            return Err(Error::EditUnsupported("malformed data-layout message"));
3882        }
3883        let version = region[lb];
3884        if version != 3 && version != 4 {
3885            return Err(Error::EditUnsupported(
3886                "an unsupported data-layout version cannot be overwritten in place yet",
3887            ));
3888        }
3889        match region[lb + 1] {
3890            // Compact: the data is inline in the header. Rebuild the header with
3891            // the new inline bytes (relocating it), patching the parent link.
3892            0 => Ok(WritePlan::Moving(MovingWrite::Compact {
3893                region,
3894                raw: fd.raw.clone(),
3895            })),
3896            1 => {
3897                if le - lb < 18 {
3898                    return Err(Error::EditUnsupported("malformed contiguous data layout"));
3899                }
3900                let addr_off = lb + 2;
3901                let data_addr =
3902                    u64::from_le_bytes(region[addr_off..addr_off + 8].try_into().unwrap());
3903                let data_size = u64::from_le_bytes(region[lb + 10..lb + 18].try_into().unwrap());
3904
3905                // Same length and a defined, in-bounds data block: overwrite the
3906                // bytes straight in place. No header rewrite, no relink. The stored
3907                // address is base-relative; the in-place write targets the absolute
3908                // file offset `data_addr + base`.
3909                if data_addr != UNDEF && data_size == fd.raw.len() as u64 {
3910                    if let Some(start) = data_addr
3911                        .checked_add(base)
3912                        .and_then(|a| usize::try_from(a).ok())
3913                    {
3914                        if start
3915                            .checked_add(fd.raw.len())
3916                            .is_some_and(|e| e as u64 <= src.len())
3917                        {
3918                            return Ok(WritePlan::InPlace {
3919                                data_addr: start,
3920                                raw: fd.raw.clone(),
3921                            });
3922                        }
3923                    }
3924                }
3925
3926                // Length differs or the block was undefined/out of bounds: the new
3927                // data goes elsewhere and the old extent (if any) is freed. The
3928                // freed extent is recorded as an absolute file offset (`+ base`) to
3929                // match the session free list.
3930                let old_extent = if data_addr != UNDEF && data_size > 0 {
3931                    Some((data_addr + base, data_size))
3932                } else {
3933                    None
3934                };
3935                Ok(WritePlan::Moving(MovingWrite::Contiguous {
3936                    region,
3937                    addr_off,
3938                    raw: fd.raw.clone(),
3939                    old_extent,
3940                }))
3941            }
3942            // Chunked: overwrite each chunk in place when every new (re-encoded)
3943            // chunk is the same byte length as its slot, else rebuild and relocate
3944            // the whole chunk storage. The chunk geometry, filter pipeline, and
3945            // index type all come from the existing on-disk header (the staged
3946            // builder carries none — chunked/filtered/extensible builders are
3947            // refused at the top of this function as "not a value overwrite").
3948            2 => {
3949                // Chunked overwrite (in-place or relocating). On a userblock file
3950                // every stored chunk-index and chunk address is relative to `base`:
3951                // the in-place path below walks the index on a base-relative view of
3952                // the file and shifts the resulting write offsets back by `base`,
3953                // and the relocating path rebuilds the chunk blob with stored
3954                // addresses (see `write_chunked_relocatable`).
3955                let dl =
3956                    DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).map_err(|_| {
3957                        Error::EditUnsupported("dataset header data layout could not be parsed")
3958                    })?;
3959                let DataLayout::Chunked {
3960                    version: lversion,
3961                    chunk_index_type,
3962                    ..
3963                } = dl
3964                else {
3965                    return Err(Error::EditUnsupported("dataset is not chunked"));
3966                };
3967                if !chunk_index_enumerable(lversion, chunk_index_type) {
3968                    return Err(Error::EditUnsupported(
3969                        "a chunked dataset with a version-2 B-tree or unknown chunk index \
3970                         cannot be overwritten in place yet",
3971                    ));
3972                }
3973
3974                let ChunkedGeometry {
3975                    spatial,
3976                    element_size,
3977                    raw_size,
3978                    maxshape,
3979                } = chunked_geometry(&fd.dt, &disk_ds, &dl)?;
3980
3981                // Split the new value into full-size chunk buffers in dense
3982                // row-major grid order (edge overhang zero-filled, matching how
3983                // unfiltered chunks are stored), then re-encode through the on-disk
3984                // pipeline when the dataset is filtered.
3985                let split = split_into_chunks(&fd.raw, &disk_ds.dimensions, &spatial, element_size);
3986                let pipeline_message: Option<Vec<u8>> =
3987                    filter.map(|(fb, fe)| region[fb..fe].to_vec());
3988
3989                let new_chunk_bytes: Vec<Vec<u8>> = if let Some(pm) = &pipeline_message {
3990                    let pipeline = FilterPipeline::parse(pm).map_err(|_| {
3991                        Error::EditUnsupported("dataset filter pipeline could not be parsed")
3992                    })?;
3993                    if !pipeline_reencodable(&pipeline) {
3994                        return Err(Error::EditUnsupported(
3995                            "a chunked dataset using a filter this engine cannot re-encode \
3996                             cannot be overwritten in place yet",
3997                        ));
3998                    }
3999                    let ctx = ChunkContext::from_datatype(&spatial, &fd.dt);
4000                    let mut encoded = Vec::with_capacity(split.len());
4001                    for (_, buf) in &split {
4002                        encoded.push(compress_chunk(buf, &pipeline, ctx)?);
4003                    }
4004                    encoded
4005                } else {
4006                    split.into_iter().map(|(_, buf)| buf).collect()
4007                };
4008
4009                // Fast path: overwrite each chunk straight in its slot when every
4010                // new chunk fits. No header rewrite and no superblock flip — the
4011                // chunk (and index) blocks are reachable from both roots. The index
4012                // is left untouched when chunks keep their size and rebuilt in place
4013                // when they shrink. The index walk runs on a base-relative view of
4014                // the file (so the layout's stored addresses index correctly), and
4015                // the returned write offsets are shifted back to absolute file
4016                // offsets by adding `base` (a no-op on a base-0 file).
4017                let base_off = usize::try_from(base).map_err(|_| {
4018                    Error::EditUnsupported("userblock base address exceeds this platform")
4019                })?;
4020                if let Some(writes) = try_inplace_chunk_writes(
4021                    &BaseOffsetSource { inner: src, base },
4022                    &dl,
4023                    &disk_ds,
4024                    &spatial,
4025                    raw_size,
4026                    &new_chunk_bytes,
4027                ) {
4028                    let writes = writes
4029                        .into_iter()
4030                        .map(|(off, b)| (off + base_off, b))
4031                        .collect();
4032                    return Ok(WritePlan::InPlaceChunks { writes });
4033                }
4034
4035                // Otherwise relocate: rebuild a fresh chunk blob + index at
4036                // end-of-file (carrying the re-encoded chunk bytes and the source
4037                // pipeline verbatim), swap the data-layout message in the verbatim
4038                // header, and free the old chunk storage after the commit lands.
4039                let meta = new_chunk_bytes
4040                    .iter()
4041                    .map(|c| ChunkMeta {
4042                        compressed_size: c.len() as u64,
4043                        filter_mask: 0,
4044                    })
4045                    .collect();
4046                Ok(WritePlan::Moving(MovingWrite::Chunked {
4047                    region,
4048                    chunk_dims: spatial,
4049                    element_size,
4050                    raw_size,
4051                    maxshape,
4052                    pipeline_message,
4053                    meta,
4054                    chunk_bytes: new_chunk_bytes,
4055                    old_addr: addr,
4056                }))
4057            }
4058            _ => Err(Error::EditUnsupported(
4059                "an unsupported data-layout class cannot be overwritten in place yet",
4060            )),
4061        }
4062    }
4063
4064    /// Plan a relocating append to an existing chunked, unlimited,
4065    /// Extensible-Array-indexed dataset at `addr`. Validates the target, splits
4066    /// the appended elements into new (and one rewritten trailing) chunks —
4067    /// compressed through the on-disk pipeline when filtered — and gathers the
4068    /// existing complete chunks by metadata alone. Returns the
4069    /// [`MovingWrite::AppendedChunks`] plan; the commit machinery appends the new
4070    /// chunks and a rebuilt index and repoints the header (see
4071    /// [`write_appended_chunks`](Self::write_appended_chunks)).
4072    ///
4073    /// Reads only; no bytes are written here. `src` is the file image and `base`
4074    /// its userblock base; the dataset's stored (base-relative) structures are read
4075    /// through a `base`-shifted view.
4076    fn prepare_append<S: Source + ?Sized>(
4077        src: &S,
4078        addr: u64,
4079        ab: &AppendBuilder,
4080        base: u64,
4081    ) -> Result<MovingWrite, Error> {
4082        if ab.dt_conflict {
4083            return Err(Error::AppendUnsupported(
4084                "append mixes element types in one builder; use one element type per \
4085                 append_dataset call",
4086            ));
4087        }
4088
4089        let region = Self::gather_oh_messages(src, addr, base)?;
4090
4091        // Locate the datatype, dataspace, data-layout, and filter-pipeline
4092        // messages, and detect a group (link) header.
4093        let mut datatype: Option<(usize, usize)> = None;
4094        let mut dataspace: Option<(usize, usize)> = None;
4095        let mut layout: Option<(usize, usize)> = None;
4096        let mut filter: Option<(usize, usize)> = None;
4097        let mut has_link = false;
4098        let mut p = 0;
4099        while let Some((msg_type, body, body_end)) = next_message(&region, p)? {
4100            match msg_type {
4101                MessageType::Datatype => datatype = Some((body, body_end)),
4102                MessageType::Dataspace => dataspace = Some((body, body_end)),
4103                MessageType::DataLayout => layout = Some((body, body_end)),
4104                MessageType::FilterPipeline => filter = Some((body, body_end)),
4105                MessageType::Link | MessageType::LinkInfo | MessageType::SymbolTable => {
4106                    has_link = true;
4107                }
4108                _ => {}
4109            }
4110            p = body_end;
4111        }
4112        if has_link {
4113            return Err(Error::AppendUnsupported(
4114                "append target is a group, not a dataset",
4115            ));
4116        }
4117        let (dt_b, dt_e) =
4118            datatype.ok_or(Error::AppendUnsupported("dataset header has no datatype"))?;
4119        let (ds_b, ds_e) =
4120            dataspace.ok_or(Error::AppendUnsupported("dataset header has no dataspace"))?;
4121        let (lb, le) = layout.ok_or(Error::AppendUnsupported(
4122            "dataset header has no data layout",
4123        ))?;
4124
4125        let (disk_dt, _) = Datatype::parse(&region[dt_b..dt_e])
4126            .map_err(|_| Error::AppendUnsupported("dataset header datatype could not be parsed"))?;
4127        let disk_ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE).map_err(|_| {
4128            Error::AppendUnsupported("dataset header dataspace could not be parsed")
4129        })?;
4130        let dl = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).map_err(|_| {
4131            Error::AppendUnsupported("dataset header data layout could not be parsed")
4132        })?;
4133
4134        // Require chunked, data-layout version 4, Extensible-Array index (type 4).
4135        let DataLayout::Chunked {
4136            version: lversion,
4137            chunk_index_type,
4138            btree_address,
4139            ..
4140        } = &dl
4141        else {
4142            return Err(Error::AppendUnsupported(
4143                "append requires a chunked dataset",
4144            ));
4145        };
4146        if *lversion != 4 || *chunk_index_type != Some(4) {
4147            return Err(Error::AppendUnsupported(
4148                "append requires an Extensible-Array-indexed chunked dataset (a single \
4149                 unlimited dimension under the latest format)",
4150            ));
4151        }
4152
4153        // Require rank 1, unlimited along axis 0.
4154        if disk_ds.space_type != DataspaceType::Simple || disk_ds.dimensions.len() != 1 {
4155            return Err(Error::AppendUnsupported(
4156                "append requires a rank-1 dataset in this release",
4157            ));
4158        }
4159        match &disk_ds.max_dimensions {
4160            Some(md) if md.first() == Some(&u64::MAX) => {}
4161            _ => {
4162                return Err(Error::AppendUnsupported(
4163                    "append requires a dataset that is unlimited along its first dimension",
4164                ));
4165            }
4166        }
4167
4168        let ChunkedGeometry {
4169            spatial,
4170            element_size,
4171            raw_size,
4172            ..
4173        } = chunked_geometry(&disk_dt, &disk_ds, &dl)?;
4174        let chunk_elems = spatial[0];
4175        if chunk_elems == 0 {
4176            return Err(Error::AppendUnsupported(
4177                "append requires a nonzero chunk length",
4178            ));
4179        }
4180
4181        // Validate the appended bytes against the on-disk element type.
4182        if ab.raw.len() % element_size != 0 {
4183            return Err(Error::AppendUnsupported(
4184                "appended byte length is not a whole number of elements",
4185            ));
4186        }
4187        match &ab.elem_dt {
4188            // A typed append must match the on-disk datatype exactly (class, size,
4189            // and byte order) — this is a value append, not a retype.
4190            Some(expected) if *expected != disk_dt => {
4191                return Err(Error::AppendUnsupported(
4192                    "append datatype does not match the on-disk dataset (wrong element \
4193                     type or byte order)",
4194                ));
4195            }
4196            Some(_) => {}
4197            // A raw append trusts the caller's bytes but still refuses any datatype
4198            // whose flat little-endian bytes cannot be written verbatim: a
4199            // big-endian numeric leaf would silently misencode, and a
4200            // variable-length or reference leaf embeds heap/object addresses a byte
4201            // append cannot reproduce. A typed append is byte-order- and
4202            // class-checked by the datatype-equality arm above.
4203            None => {
4204                if !datatype_is_raw_appendable(&disk_dt) {
4205                    return Err(Error::AppendUnsupported(
4206                        "append_raw onto this dataset's datatype (non-little-endian, \
4207                         variable-length, or reference) could misencode the bytes; use a \
4208                         typed append",
4209                    ));
4210                }
4211            }
4212        }
4213
4214        let new_elems = (ab.raw.len() / element_size) as u64;
4215        let current_dim0 = disk_ds.dimensions[0];
4216        let new_dim0 = current_dim0
4217            .checked_add(new_elems)
4218            .ok_or(Error::AppendUnsupported(
4219                "append would overflow the dataset dimension",
4220            ))?;
4221
4222        // The filter pipeline is preserved verbatim in the rebuilt header; parse it
4223        // to re-encode the new chunks. An engine-unencodable filter is refused.
4224        let pipeline_message: Option<Vec<u8>> = filter.map(|(fb, fe)| region[fb..fe].to_vec());
4225        let has_filters = pipeline_message.is_some();
4226        let pipeline = match &pipeline_message {
4227            Some(pm) => {
4228                let parsed = FilterPipeline::parse(pm).map_err(|_| {
4229                    Error::AppendUnsupported("dataset filter pipeline could not be parsed")
4230                })?;
4231                if !pipeline_reencodable(&parsed) {
4232                    return Err(Error::AppendUnsupported(
4233                        "dataset uses a filter this engine cannot re-encode",
4234                    ));
4235                }
4236                Some(parsed)
4237            }
4238            None => None,
4239        };
4240
4241        if base > src.len() {
4242            return Err(Error::AppendUnsupported(
4243                "userblock base address past end-of-file",
4244            ));
4245        }
4246        let view = BaseOffsetSource { inner: src, base };
4247
4248        // The rebuilt index's element format (bare address vs address+size+mask) is
4249        // chosen by `has_filters`; it must agree with the source index's client id,
4250        // or the kept chunks — carried by metadata into the new index — would be
4251        // re-encoded in the wrong element width.
4252        if let Some(idx_addr) = *btree_address {
4253            let hdr =
4254                ExtensibleArrayHeader::parse_from_source(&view, idx_addr, OFFSET_SIZE, LENGTH_SIZE)
4255                    .map_err(|_| {
4256                        Error::AppendUnsupported(
4257                            "dataset extensible-array header could not be parsed",
4258                        )
4259                    })?;
4260            if (hdr.client_id == 1) != has_filters {
4261                return Err(Error::AppendUnsupported(
4262                    "dataset filter metadata is inconsistent (chunk-index client id \
4263                     disagrees with the filter pipeline)",
4264                ));
4265            }
4266        }
4267
4268        // Enumerate the existing chunks (base-relative addresses) and require a
4269        // dense grid: `plan_dense_grid` returns the chunks in index order and
4270        // `None` on any hole, duplicate, or count mismatch against the dimension.
4271        let infos = enumerate_chunks_from_source(&view, &dl, &disk_ds, OFFSET_SIZE, LENGTH_SIZE)
4272            .map_err(|_| Error::AppendUnsupported("dataset chunk index could not be enumerated"))?;
4273        let grid = plan_dense_grid(infos, &disk_ds.dimensions, &spatial).ok_or(
4274            Error::AppendUnsupported(
4275                "dataset has a sparse or inconsistent chunk grid; cannot append",
4276            ),
4277        )?;
4278        let grid_order = grid.grid_order;
4279
4280        // Complete chunks are kept by metadata; a trailing partial chunk (when the
4281        // current length is not chunk-aligned) is rewritten.
4282        let n_full = usize::try_from(current_dim0 / chunk_elems)
4283            .map_err(|_| Error::AppendUnsupported("chunk count exceeds this platform"))?;
4284        let has_partial = current_dim0 % chunk_elems != 0;
4285
4286        let mut kept_chunks: Vec<WrittenChunk> = Vec::with_capacity(n_full);
4287        for ci in grid_order.iter().take(n_full) {
4288            kept_chunks.push(WrittenChunk {
4289                address: ci.address,
4290                compressed_size: u64::from(ci.chunk_size),
4291                raw_size,
4292                // Preserve the source mask verbatim: a C/h5py file records a nonzero
4293                // mask for a chunk whose filter was skipped (e.g. deflate on
4294                // incompressible data), and forcing it to 0 would corrupt that chunk.
4295                filter_mask: ci.filter_mask,
4296            });
4297        }
4298
4299        // Build the raw byte region for the tail (from the last chunk boundary to
4300        // the new end): the live prefix of any rewritten partial chunk, then the
4301        // appended bytes.
4302        let mut tail_raw: Vec<u8> = Vec::new();
4303        let mut old_tail_extent: Option<(u64, u64)> = None;
4304        if has_partial {
4305            let partial = &grid_order[n_full];
4306            let len = partial.chunk_size as usize;
4307            partial
4308                .address
4309                .checked_add(len as u64)
4310                .filter(|&e| e <= view.len())
4311                .ok_or(Error::AppendUnsupported(
4312                    "trailing chunk extends past end-of-file",
4313                ))?;
4314            let stored = view
4315                .read_exact_at(partial.address, len)
4316                .map_err(|_| Error::AppendUnsupported("trailing chunk could not be read"))?;
4317            let full = if let Some(pl) = &pipeline {
4318                let ctx = ChunkContext::from_datatype(&spatial, &disk_dt);
4319                decompress_chunk(&stored, pl, ctx, partial.filter_mask).map_err(Error::Format)?
4320            } else {
4321                stored
4322            };
4323            let live_elems = usize::try_from(current_dim0 % chunk_elems)
4324                .map_err(|_| Error::AppendUnsupported("chunk length exceeds this platform"))?;
4325            let live_bytes = live_elems * element_size;
4326            if full.len() < live_bytes {
4327                return Err(Error::AppendUnsupported(
4328                    "trailing chunk decoded shorter than its live element count",
4329                ));
4330            }
4331            tail_raw.extend_from_slice(&full[..live_bytes]);
4332            // The old partial chunk's data block is dead once the new index lands.
4333            old_tail_extent = Some((partial.address + base, u64::from(partial.chunk_size)));
4334        }
4335        tail_raw.extend_from_slice(&ab.raw);
4336
4337        // Split the tail into full chunk buffers (edge overhang zero-filled) and
4338        // compress each through the pipeline when filtered.
4339        let tail_len_elems = new_dim0 - (n_full as u64) * chunk_elems;
4340        let split = split_into_chunks(&tail_raw, &[tail_len_elems], &spatial, element_size);
4341        let new_chunk_bytes: Vec<Vec<u8>> = if let Some(pl) = &pipeline {
4342            let ctx = ChunkContext::from_datatype(&spatial, &disk_dt);
4343            let mut out = Vec::with_capacity(split.len());
4344            for (_, buf) in &split {
4345                out.push(compress_chunk(buf, pl, ctx).map_err(Error::Format)?);
4346            }
4347            out
4348        } else {
4349            split.into_iter().map(|(_, buf)| buf).collect()
4350        };
4351
4352        // Grow the dataspace along axis 0, preserving the (unlimited) max-dims.
4353        let mut grown = disk_ds.clone();
4354        grown.dimensions[0] = new_dim0;
4355        let new_dataspace_body = grown.serialize(LENGTH_SIZE);
4356
4357        #[expect(
4358            clippy::cast_possible_truncation,
4359            reason = "spatial chunk dims come from the on-disk u32 chunk_dimensions, so they fit u32"
4360        )]
4361        let chunk_dims_u32: Vec<u32> = spatial.iter().map(|&dm| dm as u32).collect();
4362
4363        Ok(MovingWrite::AppendedChunks {
4364            region,
4365            new_dataspace_body,
4366            chunk_dims_u32,
4367            element_size,
4368            raw_size,
4369            has_filters,
4370            kept_chunks,
4371            new_chunk_bytes,
4372            old_addr: addr,
4373            old_tail_extent,
4374        })
4375    }
4376
4377    /// Parse the object header at `addr` into a copyable model, validating that
4378    /// every message can be reproduced faithfully (verbatim message bytes, with
4379    /// only the contiguous data address and child link targets repointed).
4380    /// Dense (fractal-heap) attribute storage is read out of the source heap into
4381    /// a parsed attribute set carried on the model (`dense_attrs`) and re-emitted
4382    /// into a fresh heap on write, within the bounds that heap declares (see
4383    /// `file_writer::dense_attrs_check`); an attribute too large to hold as a
4384    /// managed object is re-emitted as a *huge* object. Rejects multi-chunk
4385    /// headers, dense or soft/external links, chunked/old-version data layouts, and
4386    /// headers that are neither a dataset nor a group.
4387    fn read_object<S: Source + ?Sized>(src: &S, addr: u64, base: u64) -> Result<ObjModel, Error> {
4388        let region = Self::gather_oh_messages(src, addr, base)?;
4389
4390        // First pass: detect whether attributes are stored densely (a defined
4391        // fractal-heap address in the Attribute Info message). A dense object is
4392        // copied by reading its attributes out of the source heap and rebuilding
4393        // a fresh heap on write, so its Attribute Info message and any inline
4394        // Attribute messages are dropped from the verbatim region — the rebuilt
4395        // region carries neither, and `dense_attrs` carries the parsed set.
4396        let mut dense = false;
4397        let mut p = 0;
4398        while let Some((msg_type, body, body_end)) = next_message(&region, p)? {
4399            if msg_type == MessageType::AttributeInfo {
4400                // An Attribute Info message does not by itself mean dense
4401                // storage: the reference C library and h5py emit one (with an
4402                // *undefined* fractal-heap address) even for compact, inline
4403                // attributes in the latest format, to carry attribute
4404                // creation-order metadata. Only a *defined* heap address is real
4405                // dense (fractal-heap) storage. A message that cannot be parsed
4406                // is refused conservatively.
4407                let ai = crate::attribute_info::AttributeInfoMessage::parse(
4408                    &region[body..body_end],
4409                    OFFSET_SIZE,
4410                )
4411                .map_err(|_| {
4412                    Error::EditUnsupported(
4413                        "a source attribute-info message could not be parsed for copying",
4414                    )
4415                })?;
4416                if ai.fractal_heap_address.is_some() {
4417                    dense = true;
4418                }
4419            }
4420            p = body_end;
4421        }
4422
4423        // If dense, read the attribute set out of the source fractal heap now (so
4424        // the source buffer need not outlive the read) and validate it can be
4425        // re-emitted into a fresh heap on write. `extract_attributes_full` reads
4426        // both compact and dense attributes; a dense object carries no inline
4427        // Attribute messages, so it returns exactly the heap-resident set.
4428        let dense_attrs = if dense {
4429            let header =
4430                ObjectHeader::parse_from_source(src, addr, OFFSET_SIZE, LENGTH_SIZE, base).map_err(|_| {
4431                    Error::EditUnsupported(
4432                        "a source object header with dense attributes could not be parsed for copying",
4433                    )
4434                })?;
4435            // The heap address in the Attribute Info message is stored relative to
4436            // the base address, so the walk gets the source framed past its
4437            // userblock — the same view the reader uses. `base` is 0 for a plain
4438            // file, where this is `src` itself.
4439            if base > src.len() {
4440                return Err(Error::EditUnsupported(
4441                    "a source file's userblock is larger than the file itself",
4442                ));
4443            }
4444            let framed = BaseOffsetSource { inner: src, base };
4445            let attrs = crate::attribute::extract_attributes_full_from_source(
4446                &framed,
4447                &header,
4448                OFFSET_SIZE,
4449                LENGTH_SIZE,
4450            )
4451            .map_err(|_| {
4452                Error::EditUnsupported(
4453                    "a source object's dense (fractal-heap) attributes could not be read for copying",
4454                )
4455            })?;
4456            // The typed error names the offending attribute, which the previous
4457            // blanket `EditUnsupported` message could not.
4458            crate::file_writer::dense_attrs_check(&attrs).map_err(Error::Format)?;
4459            attrs
4460        } else {
4461            Vec::new()
4462        };
4463
4464        let mut layout: Option<(usize, usize)> = None; // (body offset in kept, size)
4465        let mut has_link_info = false;
4466        let mut children: Vec<(String, u64)> = Vec::new();
4467        // The rebuilt chunk-0 region: every message kept verbatim except hard
4468        // Link messages (carried as `children`) and, when dense, the Attribute
4469        // Info message and inline Attribute messages (carried as `dense_attrs`).
4470        let mut kept: Vec<u8> = Vec::new();
4471
4472        let mut p = 0;
4473        while let Some((msg_type, body, body_end)) = next_message(&region, p)? {
4474            let mut keep = true;
4475            match msg_type {
4476                MessageType::AttributeInfo => {
4477                    // Already parsed in the first pass; drop the dense Attribute
4478                    // Info message so the rebuilt header references the fresh heap
4479                    // (spliced in on write) rather than the source one. A compact
4480                    // (undefined-heap) Attribute Info message is kept verbatim.
4481                    if dense {
4482                        keep = false;
4483                    }
4484                }
4485                MessageType::Attribute => {
4486                    // A dense object should carry no inline Attribute messages,
4487                    // but drop any defensively so the rebuilt header's only
4488                    // attribute storage is the fresh heap.
4489                    if dense {
4490                        keep = false;
4491                    }
4492                }
4493                MessageType::LinkInfo => {
4494                    has_link_info = true;
4495                    let mut q = body + 2;
4496                    if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
4497                        q += 8;
4498                    }
4499                    if q + 8 <= body_end {
4500                        let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
4501                        if heap_addr != u64::MAX {
4502                            return Err(Error::EditUnsupported(
4503                                "a group uses dense (fractal-heap) link storage (not supported in place yet)",
4504                            ));
4505                        }
4506                    }
4507                }
4508                MessageType::Link => {
4509                    keep = false;
4510                    match LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
4511                        Ok(LinkMessage {
4512                            name,
4513                            link_target:
4514                                LinkTarget::Hard {
4515                                    object_header_address,
4516                                },
4517                            ..
4518                        }) => children.push((name, object_header_address)),
4519                        _ => {
4520                            return Err(Error::EditUnsupported(
4521                                "a group contains a soft/external link (not copyable in place yet)",
4522                            ));
4523                        }
4524                    }
4525                }
4526                MessageType::DataLayout => {
4527                    // Record the layout body offset within the *kept* region so a
4528                    // contiguous dataset's data-address field can be repointed
4529                    // even after earlier messages were dropped.
4530                    layout = Some((kept.len() + (body - p), body_end - body));
4531                }
4532                _ => {}
4533            }
4534            if keep {
4535                kept.extend_from_slice(&region[p..body_end]);
4536            }
4537            p = body_end;
4538        }
4539
4540        if let Some((lbody, lsize)) = layout {
4541            let version = kept[lbody];
4542            if !(version == 3 || version == 4) || lsize < 2 {
4543                return Err(Error::EditUnsupported(
4544                    "an unsupported data-layout version cannot be copied in place yet",
4545                ));
4546            }
4547            let class = kept[lbody + 1];
4548            match class {
4549                0 => Ok(ObjModel::DatasetVerbatim {
4550                    region: kept,
4551                    dense_attrs,
4552                }),
4553                1 => {
4554                    if lbody + 18 > kept.len() {
4555                        return Err(Error::EditUnsupported("malformed contiguous data layout"));
4556                    }
4557                    let data_addr =
4558                        u64::from_le_bytes(kept[lbody + 2..lbody + 10].try_into().unwrap());
4559                    let data_size =
4560                        u64::from_le_bytes(kept[lbody + 10..lbody + 18].try_into().unwrap());
4561                    Ok(ObjModel::DatasetContiguous {
4562                        region: kept,
4563                        addr_off: lbody + 2,
4564                        data_addr,
4565                        data_size,
4566                        dense_attrs,
4567                    })
4568                }
4569                // Chunked: the verbatim header carries the data-layout and filter-
4570                // pipeline messages; `read_copy_subtree` (which holds the source
4571                // buffer) enumerates and captures the chunk bytes and rebuilds the
4572                // index on write.
4573                2 => Ok(ObjModel::DatasetChunked {
4574                    region: kept,
4575                    dense_attrs,
4576                }),
4577                _ => Err(Error::EditUnsupported(
4578                    "an unsupported data-layout class cannot be copied in place yet",
4579                )),
4580            }
4581        } else if has_link_info {
4582            // A copied group must carry a Group Info message so the copy stays
4583            // writable by the C library, even when the source omitted it.
4584            ensure_group_info(&mut kept)?;
4585            Ok(ObjModel::Group {
4586                non_link_region: kept,
4587                children,
4588                dense_attrs,
4589            })
4590        } else {
4591            Err(Error::EditUnsupported(
4592                "an object is neither a contiguous/compact dataset nor a group",
4593            ))
4594        }
4595    }
4596
4597    /// Read the object at `addr` in the source buffer `d` — and, for a group, its
4598    /// whole subtree — into an owned [`CopyTree`], the read half of an object copy.
4599    /// No bytes are written; this both validates that the subtree is copyable and
4600    /// captures the bytes the write half ([`write_copy_subtree`](Self::write_copy_subtree))
4601    /// later appends, so the source buffer need not outlive the read.
4602    ///
4603    /// `src` is the image the source object lives in: this session's own file image
4604    /// for an in-file [`copy`](Self::copy), or another file's image for a cross-file
4605    /// [`copy_from`](Self::copy_from). `base` is that image's userblock base (the
4606    /// session's own base for an in-file copy, always 0 for a cross-file copy, whose
4607    /// source is gated to base 0): the stored, base-relative addresses read out of
4608    /// the source headers are shifted by it to index `src`. When `cross_file` is set,
4609    /// every copied object header is additionally screened by
4610    /// [`reject_foreign_addresses`] — verbatim bytes that embed a *source-file*
4611    /// absolute address (variable-length or reference data, a committed datatype)
4612    /// would dangle in another file and are refused, whereas an in-file copy keeps
4613    /// them valid by sharing the source file's heaps and objects.
4614    fn read_copy_subtree<S: Source + ?Sized>(
4615        src: &S,
4616        addr: u64,
4617        depth: u32,
4618        cross_file: bool,
4619        base: u64,
4620    ) -> Result<CopyTree, Error> {
4621        if depth >= MAX_COPY_DEPTH {
4622            return Err(Error::EditUnsupported(
4623                "copy source nests too deeply (possible hard-link cycle)",
4624            ));
4625        }
4626        // `base` is the userblock base of the image `src`: this session's own base
4627        // for an in-file copy, and always 0 for a cross-file copy (the source is
4628        // gated to base 0 in `copy_from`). `addr` is an absolute offset into `src`;
4629        // the stored (base-relative) addresses `read_object` returns for contiguous
4630        // data, chunk storage, and child links are converted to absolute offsets by
4631        // adding `base` before `src` is read or a child is descended into.
4632        match Self::read_object(src, addr, base)? {
4633            ObjModel::DatasetVerbatim {
4634                region,
4635                dense_attrs,
4636            } => {
4637                if cross_file {
4638                    reject_foreign_addresses(&region)?;
4639                    reject_foreign_dense_attrs(&dense_attrs)?;
4640                }
4641                Ok(CopyTree::DatasetVerbatim {
4642                    region,
4643                    dense_attrs,
4644                })
4645            }
4646            ObjModel::DatasetContiguous {
4647                region,
4648                addr_off,
4649                data_addr,
4650                data_size,
4651                dense_attrs,
4652            } => {
4653                if cross_file {
4654                    reject_foreign_addresses(&region)?;
4655                    reject_foreign_dense_attrs(&dense_attrs)?;
4656                }
4657                // The stored data address is base-relative; shift it to an absolute
4658                // offset into `src` before reading the data block out.
4659                let start = data_addr
4660                    .checked_add(base)
4661                    .ok_or(Error::EditUnsupported("data address exceeds this platform"))?;
4662                let len = usize::try_from(data_size)
4663                    .map_err(|_| Error::EditUnsupported("data size exceeds this platform"))?;
4664                start
4665                    .checked_add(len as u64)
4666                    .filter(|&e| e <= src.len())
4667                    .ok_or(Error::EditUnsupported("dataset data is out of bounds"))?;
4668                Ok(CopyTree::DatasetContiguous {
4669                    region,
4670                    addr_off,
4671                    data: src
4672                        .read_exact_at(start, len)
4673                        .map_err(|_| Error::EditUnsupported("dataset data is out of bounds"))?,
4674                    dense_attrs,
4675                })
4676            }
4677            ObjModel::DatasetChunked {
4678                region,
4679                dense_attrs,
4680            } => {
4681                // Screen the verbatim header on the cross-file path. This refuses a
4682                // variable-length or reference datatype (whose chunk payload embeds
4683                // source-file global-heap / object addresses that would dangle in
4684                // another file) and any shared message — exactly the forms repack
4685                // also refuses for a cross-file verbatim chunk copy. An in-file copy
4686                // keeps them valid by sharing the source file's heaps.
4687                if cross_file {
4688                    reject_foreign_addresses(&region)?;
4689                    reject_foreign_dense_attrs(&dense_attrs)?;
4690                }
4691                let ChunkedHeaderParts {
4692                    dt,
4693                    ds,
4694                    layout,
4695                    pipeline_message,
4696                } = parse_chunked_header(&region)?;
4697                let DataLayout::Chunked {
4698                    version: lversion,
4699                    chunk_index_type,
4700                    ..
4701                } = layout
4702                else {
4703                    return Err(Error::EditUnsupported("dataset is not chunked"));
4704                };
4705                if !chunk_index_enumerable(lversion, chunk_index_type) {
4706                    return Err(Error::EditUnsupported(
4707                        "a chunked dataset with a version-2 B-tree or unknown chunk index \
4708                         cannot be copied in place yet",
4709                    ));
4710                }
4711                let ChunkedGeometry {
4712                    spatial: chunk_dims,
4713                    element_size,
4714                    raw_size,
4715                    maxshape,
4716                } = chunked_geometry(&dt, &ds, &layout)?;
4717
4718                // The layout's chunk-index address and every chunk address it leads
4719                // to are stored base-relative, so enumerate and read on a
4720                // base-relative view of the source image (the identity on a base-0
4721                // file). The returned addresses are then offsets into `dview`.
4722                let dview = BaseOffsetSource { inner: src, base };
4723
4724                // Enumerate the source chunks and map them onto a dense grid; a
4725                // sparse (holed/unallocated) dataset cannot be reproduced by the
4726                // verbatim layout path, which needs every grid slot filled.
4727                let infos =
4728                    enumerate_chunks_from_source(&dview, &layout, &ds, OFFSET_SIZE, LENGTH_SIZE)?;
4729                let grid = plan_dense_grid(infos, &ds.dimensions, &chunk_dims).ok_or(
4730                    Error::EditUnsupported(
4731                        "a chunked dataset with unallocated (sparse) chunks cannot be copied in place yet",
4732                    ),
4733                )?;
4734                if grid.grid_order.is_empty() {
4735                    return Err(Error::EditUnsupported(
4736                        "an empty chunked dataset cannot be copied in place yet",
4737                    ));
4738                }
4739
4740                // Capture each chunk's already-compressed bytes (no decode) into an
4741                // owned buffer, in dense row-major grid order, so the copy can be
4742                // written after the source buffer is gone (cross-file copy reads at
4743                // staging time). Sizes and masks are carried verbatim.
4744                let mut meta = Vec::with_capacity(grid.grid_order.len());
4745                let mut chunk_bytes = Vec::with_capacity(grid.grid_order.len());
4746                for ci in &grid.grid_order {
4747                    let len = ci.chunk_size as usize;
4748                    ci.address
4749                        .checked_add(len as u64)
4750                        .filter(|&e| e <= dview.len())
4751                        .ok_or(Error::EditUnsupported("chunk data is out of bounds"))?;
4752                    chunk_bytes.push(
4753                        dview
4754                            .read_exact_at(ci.address, len)
4755                            .map_err(|_| Error::EditUnsupported("chunk data is out of bounds"))?,
4756                    );
4757                    meta.push(ChunkMeta {
4758                        compressed_size: ci.chunk_size as u64,
4759                        filter_mask: ci.filter_mask,
4760                    });
4761                }
4762
4763                Ok(CopyTree::DatasetChunked {
4764                    region,
4765                    chunk_dims,
4766                    element_size,
4767                    raw_size,
4768                    maxshape,
4769                    pipeline_message,
4770                    meta,
4771                    chunk_bytes,
4772                    dense_attrs,
4773                })
4774            }
4775            ObjModel::Group {
4776                non_link_region,
4777                children,
4778                dense_attrs,
4779            } => {
4780                if cross_file {
4781                    reject_foreign_addresses(&non_link_region)?;
4782                    reject_foreign_dense_attrs(&dense_attrs)?;
4783                }
4784                let mut kids = Vec::with_capacity(children.len());
4785                for (name, child) in children {
4786                    // Child link targets are stored base-relative; re-absolutize
4787                    // before descending so `addr` stays an absolute offset into `src`.
4788                    let child = child.checked_add(base).ok_or(Error::EditUnsupported(
4789                        "child address exceeds this platform",
4790                    ))?;
4791                    kids.push((
4792                        name,
4793                        Self::read_copy_subtree(src, child, depth + 1, cross_file, base)?,
4794                    ));
4795                }
4796                Ok(CopyTree::Group {
4797                    non_link_region,
4798                    children: kids,
4799                    dense_attrs,
4800                })
4801            }
4802        }
4803    }
4804
4805    /// Append the fresh copies described by `node` (data blobs and headers) into
4806    /// this session at end-of-file or into reusable freed regions, returning the
4807    /// new object-header address of the copied root. The write half of an object
4808    /// copy; children are written before their parent group so each parent links
4809    /// its children's new addresses, and a contiguous dataset's data-address field
4810    /// is repointed at the freshly-written copy. Every address the copy writes into
4811    /// a header (a contiguous data block, a child link) is stored relative to the
4812    /// userblock base (`- base`, a no-op on a base-0 file); the chunked storage and
4813    /// dense attribute heaps are laid out base-relative by their own builders.
4814    fn write_copy_subtree(&mut self, node: &CopyTree) -> Result<u64, Error> {
4815        let base = self.superblock.base_address;
4816        match node {
4817            CopyTree::DatasetVerbatim {
4818                region,
4819                dense_attrs,
4820            } => {
4821                let mut region = region.clone();
4822                self.append_dense_attrs(&mut region, dense_attrs)?;
4823                let oh = build_v2_object_header(&region);
4824                self.alloc_or_append_typed(&oh, PageType::Meta)
4825            }
4826            CopyTree::DatasetContiguous {
4827                region,
4828                addr_off,
4829                data,
4830                dense_attrs,
4831            } => {
4832                let new_data_addr = self.alloc_or_append_typed(data, PageType::Raw)?;
4833                let mut region = region.clone();
4834                // `alloc_or_append` returns an absolute offset; the data-layout
4835                // address field stores it relative to the userblock base.
4836                region[*addr_off..*addr_off + 8]
4837                    .copy_from_slice(&(new_data_addr - base).to_le_bytes());
4838                // Append the dense heap *after* the data so the heap's base
4839                // equals end-of-file (see `append_dense_attrs`).
4840                self.append_dense_attrs(&mut region, dense_attrs)?;
4841                let oh = build_v2_object_header(&region);
4842                self.alloc_or_append_typed(&oh, PageType::Meta)
4843            }
4844            CopyTree::DatasetChunked {
4845                region,
4846                chunk_dims,
4847                element_size,
4848                raw_size,
4849                maxshape,
4850                pipeline_message,
4851                meta,
4852                chunk_bytes,
4853                dense_attrs,
4854            } => self.write_chunked_relocatable(
4855                region,
4856                chunk_dims,
4857                *element_size,
4858                *raw_size,
4859                maxshape.as_deref(),
4860                pipeline_message.as_deref(),
4861                meta,
4862                chunk_bytes,
4863                dense_attrs,
4864            ),
4865            CopyTree::Group {
4866                non_link_region,
4867                children,
4868                dense_attrs,
4869            } => {
4870                let mut region = non_link_region.clone();
4871                for (name, child) in children {
4872                    let new_child = self.write_copy_subtree(child)?;
4873                    // The link target is stored relative to the userblock base.
4874                    region.extend_from_slice(&encode_link_message(name, new_child - base));
4875                }
4876                // Append the dense heap after the children's headers/data so its
4877                // base equals end-of-file (see `append_dense_attrs`).
4878                self.append_dense_attrs(&mut region, dense_attrs)?;
4879                let oh = build_v2_object_header(&region);
4880                self.alloc_or_append_typed(&oh, PageType::Meta)
4881            }
4882        }
4883    }
4884
4885    /// Write a chunked dataset's storage at end-of-file and return its new
4886    /// object-header address — the shared write half of a chunked copy
4887    /// ([`CopyTree::DatasetChunked`]) and a relocating chunked overwrite
4888    /// ([`MovingWrite::Chunked`]).
4889    ///
4890    /// A fresh chunk-data blob and index are laid out relocatably at the current
4891    /// end-of-file via [`plan_chunked_data_verbatim`] / [`emit_chunked_data_verbatim`],
4892    /// pulling each chunk's already-compressed bytes from `chunk_bytes` (in dense
4893    /// row-major grid order) and carrying `meta`'s sizes and filter masks and the
4894    /// source `pipeline_message` verbatim — no recompression, no filter-parameter
4895    /// reconstruction. The blob is *appended* (not placed via [`alloc_or_append`])
4896    /// because its embedded addresses assume `base == end-of-file`, exactly like
4897    /// [`build_chunked_dataset`](Self::build_chunked_dataset). The verbatim header
4898    /// `region`'s data-layout message is then swapped for the one the planner
4899    /// produced (every other message preserved), any dense attribute heap is
4900    /// appended after the blob, and the header is written into reusable freed space
4901    /// or at end-of-file.
4902    #[expect(
4903        clippy::too_many_arguments,
4904        reason = "the chunked rebuild needs the full geometry, \
4905        pipeline, and chunk payloads; bundling them into a struct would only move the list"
4906    )]
4907    fn write_chunked_relocatable(
4908        &mut self,
4909        region: &[u8],
4910        chunk_dims: &[u64],
4911        element_size: usize,
4912        raw_size: u64,
4913        maxshape: Option<&[u64]>,
4914        pipeline_message: Option<&[u8]>,
4915        meta: &[ChunkMeta],
4916        chunk_bytes: &[Vec<u8>],
4917        dense_attrs: &[crate::attribute::AttributeMessage],
4918    ) -> Result<u64, Error> {
4919        // The blob is raw data, and its embedded addresses are computed from the
4920        // end-of-file it lands at, so open a raw page *before* reading that offset.
4921        self.begin_page(PageType::Raw)?;
4922        let eof = self.image.len();
4923        // Build with the *stored* (base-relative) address the blob will occupy, so
4924        // its embedded addresses resolve to its real file offset once the reader adds
4925        // the userblock base back (see `build_chunked_dataset`). On a base-0 file this
4926        // equals `eof`.
4927        let stored_base = eof - self.superblock.base_address;
4928        let layout = plan_chunked_data_verbatim(
4929            meta,
4930            chunk_dims,
4931            element_size,
4932            raw_size,
4933            pipeline_message,
4934            stored_base,
4935            maxshape,
4936        )?;
4937        let mut buf = Vec::with_capacity(usize::try_from(layout.plan.total_len).unwrap_or(0));
4938        emit_chunked_data_verbatim(
4939            &mut buf,
4940            &layout.plan,
4941            &SliceChunkProvider {
4942                chunks: chunk_bytes,
4943            },
4944        )?;
4945        let written = self.append(&buf)?;
4946        debug_assert_eq!(written, eof, "chunk blob must land at end-of-file",);
4947        // Swap the data-layout message for the rebuilt one; keep every other header
4948        // message (datatype, dataspace, fill value, filter pipeline, attributes)
4949        // verbatim. A dense attribute heap, if any, is appended after the blob so
4950        // its base equals end-of-file (see `append_dense_attrs`).
4951        let mut new_region = replace_layout_message(region, &layout.layout_message)?;
4952        self.append_dense_attrs(&mut new_region, dense_attrs)?;
4953        let oh = build_v2_object_header(&new_region);
4954        self.alloc_or_append_typed(&oh, PageType::Meta)
4955    }
4956
4957    /// When `attrs` is non-empty, build a fresh dense (fractal-heap) attribute
4958    /// blob for it, append it at end-of-file, and splice the matching Attribute
4959    /// Info message onto `region`. A no-op for an empty set.
4960    ///
4961    /// The blob produced by [`file_writer::build_dense_attrs`] is fully
4962    /// relocatable: every address it embeds is `base + fixed offset`, so passing
4963    /// the current end-of-file as the base makes those addresses land exactly
4964    /// where the bytes are written. Like [`build_chunked_dataset`](Self::build_chunked_dataset)
4965    /// the blob is therefore *appended* (never placed into an interior freed
4966    /// region), and the caller must append it before any later append in the same
4967    /// node so `base == end-of-file` still holds. The freshly built heap is
4968    /// always same-file, so it never aliases the source heap even for an in-file
4969    /// copy. The caller has already validated [`file_writer::dense_attrs_check`].
4970    fn append_dense_attrs(
4971        &mut self,
4972        region: &mut Vec<u8>,
4973        attrs: &[crate::attribute::AttributeMessage],
4974    ) -> Result<(), Error> {
4975        if attrs.is_empty() {
4976            return Ok(());
4977        }
4978        // A fractal-heap attribute blob is metadata, and its embedded addresses are
4979        // computed from the end-of-file it lands at, so open a metadata page
4980        // *before* reading that offset.
4981        self.begin_page(PageType::Meta)?;
4982        let eof = self.image.len();
4983        // Build with the *stored* (base-relative) address the blob will occupy, so
4984        // every address it embeds resolves to its real file offset once the reader
4985        // adds the userblock base back (see `build_chunked_dataset`). On a base-0
4986        // file this equals `eof`.
4987        let stored_base = eof - self.superblock.base_address;
4988        let blob = crate::file_writer::build_dense_attrs(attrs, stored_base);
4989        let written = self.append(&blob.blob)?;
4990        debug_assert_eq!(
4991            written, eof,
4992            "dense attribute blob must land at end-of-file",
4993        );
4994        region.extend_from_slice(&region_message(
4995            MessageType::AttributeInfo,
4996            &blob.attr_info_message,
4997        ));
4998        Ok(())
4999    }
5000
5001    /// Apply a relocating value overwrite (`write_dataset` resize / compact
5002    /// rewrite): write the new data and a rewritten object header at end-of-file
5003    /// (or into reusable freed space) and return the new header address. The
5004    /// caller patches the parent group's link to this address. The old data
5005    /// extent (for a resized contiguous dataset) is freed separately, after the
5006    /// commit's superblock repoint, so it is never reused mid-commit.
5007    fn write_moving(&mut self, mw: &MovingWrite) -> Result<u64, Error> {
5008        let base = self.superblock.base_address;
5009        match mw {
5010            MovingWrite::Contiguous {
5011                region,
5012                addr_off,
5013                raw,
5014                ..
5015            } => {
5016                let new_data_addr = self.alloc_or_append_typed(raw, PageType::Raw)?;
5017                let mut region = region.clone();
5018                // `alloc_or_append` returns an absolute file offset; the contiguous
5019                // data-layout field stores it relative to the userblock base (`-
5020                // base`, a no-op on a base-0 file).
5021                region[*addr_off..*addr_off + 8]
5022                    .copy_from_slice(&(new_data_addr - base).to_le_bytes());
5023                // The data size field follows the 8-byte address in the contiguous
5024                // layout body; keep it in sync with the new length.
5025                let size_off = *addr_off + 8;
5026                region[size_off..size_off + 8].copy_from_slice(&(raw.len() as u64).to_le_bytes());
5027                let oh = build_v2_object_header(&region);
5028                self.alloc_or_append_typed(&oh, PageType::Meta)
5029            }
5030            MovingWrite::Compact { region, raw } => {
5031                let region = rebuild_compact_layout_region(region, raw)?;
5032                let oh = build_v2_object_header(&region);
5033                self.alloc_or_append_typed(&oh, PageType::Meta)
5034            }
5035            MovingWrite::Chunked {
5036                region,
5037                chunk_dims,
5038                element_size,
5039                raw_size,
5040                maxshape,
5041                pipeline_message,
5042                meta,
5043                chunk_bytes,
5044                ..
5045            } => self.write_chunked_relocatable(
5046                region,
5047                chunk_dims,
5048                *element_size,
5049                *raw_size,
5050                maxshape.as_deref(),
5051                pipeline_message.as_deref(),
5052                meta,
5053                chunk_bytes,
5054                &[],
5055            ),
5056            MovingWrite::AppendedChunks {
5057                region,
5058                new_dataspace_body,
5059                chunk_dims_u32,
5060                element_size,
5061                raw_size,
5062                has_filters,
5063                kept_chunks,
5064                new_chunk_bytes,
5065                ..
5066            } => self.write_appended_chunks(
5067                region,
5068                new_dataspace_body,
5069                chunk_dims_u32,
5070                *element_size,
5071                *raw_size,
5072                *has_filters,
5073                kept_chunks,
5074                new_chunk_bytes,
5075            ),
5076            MovingWrite::AttrEdit {
5077                region,
5078                pending_vl_attrs,
5079            } => {
5080                // `region` already carries the fixed-size attribute edits (applied
5081                // in the commit preflight). Place each variable-length attribute's
5082                // global heap collection, patch its placeholder heap address, and
5083                // append the resolved message — exactly as the group-attribute apply
5084                // loop does — then build and place the relocated dataset header. The
5085                // data-layout message is untouched, so the dataset's chunk data and
5086                // index stay in place; only the header moves.
5087                let mut region = region.clone();
5088                for (msg, collections) in pending_vl_attrs {
5089                    let mut msg = msg.clone();
5090                    let addrs = self.place_vl_collections(collections)?;
5091                    patch_vl_refs(&mut msg.raw_data, &addrs);
5092                    region.extend_from_slice(&region_message(
5093                        MessageType::Attribute,
5094                        &msg.serialize(LENGTH_SIZE),
5095                    ));
5096                }
5097                let oh = build_v2_object_header(&region);
5098                self.alloc_or_append_typed(&oh, PageType::Meta)
5099            }
5100        }
5101    }
5102
5103    /// Apply a relocating append ([`MovingWrite::AppendedChunks`]): append the new
5104    /// (and any rewritten trailing) chunk bytes at end-of-file, rebuild a fresh
5105    /// Extensible Array over the kept plus appended chunks, grow the dataspace and
5106    /// repoint the data layout in the verbatim header `region`, and write the
5107    /// relocated header. Returns the new header address; the caller patches the
5108    /// parent link. The kept chunk data is untouched (referenced by both the old
5109    /// and new index during the commit); the old index/header/trailing chunk are
5110    /// freed only after the superblock repoint.
5111    #[expect(
5112        clippy::too_many_arguments,
5113        reason = "the append rebuild needs the header region, grown dataspace, chunk \
5114        geometry, and both chunk sets; bundling them into a struct would only move the list"
5115    )]
5116    fn write_appended_chunks(
5117        &mut self,
5118        region: &[u8],
5119        new_dataspace_body: &[u8],
5120        chunk_dims_u32: &[u32],
5121        element_size: usize,
5122        raw_size: u64,
5123        has_filters: bool,
5124        kept_chunks: &[WrittenChunk],
5125        new_chunk_bytes: &[Vec<u8>],
5126    ) -> Result<u64, Error> {
5127        let base = self.superblock.base_address;
5128        // Append each new chunk at true end-of-file (never `alloc_or_append`: the
5129        // rebuilt index below records base-relative addresses computed from the
5130        // end-of-file the appends land at). Existing chunks keep their in-place
5131        // addresses and are carried by metadata alone.
5132        let mut combined: Vec<WrittenChunk> = kept_chunks.to_vec();
5133        if !new_chunk_bytes.is_empty() {
5134            // Chunk contents are raw data; one page switch covers the whole run.
5135            self.begin_page(PageType::Raw)?;
5136        }
5137        for cb in new_chunk_bytes {
5138            let abs = self.append(cb)?;
5139            combined.push(WrittenChunk {
5140                address: abs - base,
5141                compressed_size: cb.len() as u64,
5142                raw_size,
5143                // This engine applies every filter to a new chunk (no per-chunk
5144                // skipping), so an appended chunk's mask is always 0. Kept chunks
5145                // carry their own (possibly nonzero) mask in `combined` already.
5146                filter_mask: 0,
5147            });
5148        }
5149
5150        // Build the fresh Extensible Array at the current end-of-file. Its embedded
5151        // block addresses are computed from `ea_base` (base-relative), so appending
5152        // the blob at the matching file offset makes them resolve correctly, on a
5153        // userblock (`base != 0`) file too.
5154        //
5155        // The index goes in a *raw* page, not a metadata one: every other writer in
5156        // this crate places a chunk index in the same run as the chunk data, and
5157        // `chunked_storage_spans` reclaims every index as raw on that basis. Placing
5158        // this one in a metadata page would make it the single exception the reclaim
5159        // side then mis-files, advertising a metadata hole inside a raw page. Opening
5160        // the page before reading the offset keeps `begin_page`'s contract: no pad
5161        // may be inserted after an address the built bytes embed.
5162        self.begin_page(PageType::Raw)?;
5163        let ea_base = self.image.len() - base;
5164        let ea_bytes =
5165            build_extensible_array_at(&combined, OFFSET_SIZE, LENGTH_SIZE, has_filters, ea_base)
5166                .map_err(Error::Format)?;
5167        let written = self.append(&ea_bytes)?;
5168        debug_assert_eq!(
5169            written,
5170            ea_base + base,
5171            "extensible-array index must land at end-of-file",
5172        );
5173
5174        // Swap the dataspace (grown) and data-layout (repointed at the new index)
5175        // messages; every other header message is preserved verbatim.
5176        #[expect(
5177            clippy::cast_possible_truncation,
5178            reason = "element size is a datatype byte width that fits u32"
5179        )]
5180        let layout_body = serialize_v4_extensible_array(
5181            chunk_dims_u32,
5182            ea_base,
5183            OFFSET_SIZE,
5184            element_size as u32,
5185        );
5186        let region = replace_dataspace_message(region, new_dataspace_body)?;
5187        let region = replace_layout_message(&region, &layout_body)?;
5188        let oh = build_v2_object_header(&region);
5189        self.alloc_or_append_typed(&oh, PageType::Meta)
5190    }
5191
5192    /// Append `bytes` at end-of-file, returning the absolute address they were
5193    /// written at.
5194    fn append(&mut self, bytes: &[u8]) -> Result<u64, Error> {
5195        self.image.append(bytes)
5196    }
5197
5198    /// Overwrite bytes in place at `offset`. The caller guarantees the range
5199    /// already exists.
5200    fn write_at(&mut self, offset: usize, bytes: &[u8]) -> Result<(), Error> {
5201        self.image.write_at(offset as u64, bytes)
5202    }
5203
5204    /// Ensure the next allocation begins in a page holding page type `ty`, on a
5205    /// paged file. A no-op on the common non-paged file.
5206    ///
5207    /// A paged file never mixes metadata and raw data within one page, so when the
5208    /// tail page holds the *other* type and is only partially filled it is first
5209    /// padded to a page boundary, the padding being recorded as free space of the
5210    /// outgoing type.
5211    ///
5212    /// Call this **before** reading the image's end-of-file ([`Source::len`]) to compute an
5213    /// address that will be embedded in the bytes being built: several callers
5214    /// (the chunk blob, the extensible-array index, the dense-attribute blob)
5215    /// build content whose interior addresses assume it lands at the current
5216    /// end-of-file, and padding inserted after that read would shift the landing
5217    /// address out from under them.
5218    fn begin_page(&mut self, ty: PageType) -> Result<(), Error> {
5219        // Destructure so the page state and the image are borrowed as the
5220        // separate fields they are.
5221        let Self { image, paged, .. } = self;
5222        match paged.as_mut() {
5223            Some(pg) => pg.begin(image.as_mut(), ty),
5224            None => Ok(()),
5225        }
5226    }
5227
5228    /// Append `bytes` at end-of-file as page type `ty`: [`begin_page`](Self::begin_page)
5229    /// followed by [`append`](Self::append).
5230    fn append_typed(&mut self, bytes: &[u8], ty: PageType) -> Result<u64, Error> {
5231        self.begin_page(ty)?;
5232        self.append(bytes)
5233    }
5234
5235    /// Place `bytes` as page type `ty`, reusing a free region where that is safe.
5236    ///
5237    /// On a paged file this never reuses: a hole belongs to one page type, and
5238    /// handing it to an allocation of the other type would re-mix the page it sits
5239    /// in. Such a file appends into a page of the right type instead, exactly as
5240    /// the bounded backend does, and recovers the space at the next commit's
5241    /// manager rewrite rather than within the commit.
5242    fn alloc_or_append_typed(&mut self, bytes: &[u8], ty: PageType) -> Result<u64, Error> {
5243        if self.paged.is_some() {
5244            return self.append_typed(bytes, ty);
5245        }
5246        self.alloc_or_append(bytes)
5247    }
5248
5249    /// Place `bytes` either in a reusable free region left by a prior commit
5250    /// (overwriting it in place) or, failing that, by appending at end-of-file.
5251    /// Returns the address written to.
5252    ///
5253    /// Reuse only ever draws from [`self.free`](Self::free), which holds regions
5254    /// vacated by *earlier* commits in this session — never space the current
5255    /// commit is about to free — so the bytes it overwrites are already
5256    /// unreachable from the on-disk root and a mid-commit crash cannot corrupt
5257    /// the live tree (the superblock still points at the prior, intact root).
5258    ///
5259    /// Callers on the commit path go through [`alloc_or_append_typed`](Self::alloc_or_append_typed)
5260    /// so a paged file stays page-segregated; this is the non-paged primitive it
5261    /// delegates to.
5262    fn alloc_or_append(&mut self, bytes: &[u8]) -> Result<u64, Error> {
5263        debug_assert!(
5264            self.paged.is_none(),
5265            "a paged file must allocate through alloc_or_append_typed"
5266        );
5267        if let Some(addr) = self.free.alloc(bytes.len() as u64) {
5268            self.write_at(
5269                usize::try_from(addr).map_err(|_| {
5270                    Error::EditUnsupported("free-region address exceeds this platform")
5271                })?,
5272                bytes,
5273            )?;
5274            Ok(addr)
5275        } else {
5276            self.append(bytes)
5277        }
5278    }
5279
5280    /// Place one variable-length dataset's or attribute's already-built,
5281    /// self-contained global heap collections (from
5282    /// [`build_global_heap_collections`] or a
5283    /// [`VlStringStaging::collections`]) and return, in the same order, the
5284    /// base-relative addresses its variable-length references should be patched
5285    /// to. A `GCOL` blob embeds no addresses of its own, so it can be appended
5286    /// (or dropped into reused free space) at any point in the apply loop,
5287    /// unlike a group or dataset header, which must be built last so it can name
5288    /// its children's real addresses. Each collection is placed independently,
5289    /// so they need not land contiguously.
5290    fn place_vl_collections(&mut self, collections: &[Vec<u8>]) -> Result<Vec<u64>, Error> {
5291        collections
5292            .iter()
5293            .map(|collection| {
5294                let addr = self.alloc_or_append_typed(collection, PageType::Meta)?;
5295                Ok(addr - self.superblock.base_address)
5296            })
5297            .collect()
5298    }
5299
5300    /// Resolve one object-reference element's target to the base-relative
5301    /// address that should be stored on disk. [`ObjectRefTarget::Raw`] is
5302    /// written back verbatim (a null or undefined reference is a sentinel, not
5303    /// a real address, so it needs no base adjustment — mirrors the whole-file
5304    /// writer). [`ObjectRefTarget::Path`] resolves, in order:
5305    ///
5306    /// 1. Against `path_addr` — every group and dataset this commit has
5307    ///    already placed (a sibling dataset placed earlier in the same
5308    ///    group's batch — see the apply loop's non-reference-first ordering —
5309    ///    or a descendant subtree fully processed earlier in the deepest-first
5310    ///    walk).
5311    /// 2. Against the pre-commit on-disk file
5312    ///    ([`resolve_path_any`](crate::group_v2::resolve_path_any)), but only
5313    ///    when the path is untouched by this commit, so its pre-commit
5314    ///    address is guaranteed to still be valid post-commit. "Touched"
5315    ///    means: a dirty group (`nodes`, new or merely rewritten because an
5316    ///    addition lives under it — its own address changes either way); a
5317    ///    path this commit adds, or that lies under a subtree this commit
5318    ///    copies in (`add_targets`, checked by prefix so a copy's interior is
5319    ///    covered even though only its root is enumerated there); or a
5320    ///    `write_dataset` target (`write_targets`) — conservatively refused
5321    ///    even for a same-length overwrite that does not actually relocate,
5322    ///    since resolving that distinction here is not worth the complexity.
5323    /// 3. If the path resolves nowhere at all (neither this commit nor the
5324    ///    pre-commit file has ever heard of it), as an undefined reference
5325    ///    (`HADDR_UNDEF`) — mirroring [`ObjectRefTarget::Path`]'s existing
5326    ///    whole-file-writer resolution convention for the same builder type.
5327    ///
5328    /// A path that step 1 misses but step 2 identifies as commit-touched is
5329    /// refused with a clear [`Error::EditUnsupported`] rather than resolved to
5330    /// a stale or wrong address — the one case this engine cannot resolve
5331    /// without the whole-file writer's two-pass dummy/real-address scheme.
5332    /// "Touched" also covers a path this same commit deletes (`pending_deletes`):
5333    /// without that check the deleted object's pre-commit address would still
5334    /// resolve via step 2, and the reference would end up pointing at storage
5335    /// this same commit is about to reclaim and hand out to something else.
5336    fn resolve_reference_target(
5337        target: &ObjectRefTarget,
5338        path_addr: &BTreeMap<PathKey, u64>,
5339        nodes: &BTreeMap<PathKey, Node>,
5340        add_targets: &[PathKey],
5341        write_targets: &[PathKey],
5342        pending_deletes: &[PathKey],
5343        src: &(impl Source + ?Sized),
5344        superblock: &Superblock,
5345    ) -> Result<u64, Error> {
5346        let path = match target {
5347            ObjectRefTarget::Raw(addr) => return Ok(*addr),
5348            ObjectRefTarget::Path(path) => path,
5349        };
5350        let base = superblock.base_address;
5351        let key = split_path(path);
5352        if let Some(&addr) = path_addr.get(&key) {
5353            return Ok(addr - base);
5354        }
5355        if nodes.contains_key(&key)
5356            || add_targets.iter().any(|t| is_prefix(t, &key))
5357            || write_targets.contains(&key)
5358            || pending_deletes.contains(&key)
5359        {
5360            return Err(Error::EditUnsupported(
5361                "an object-reference dataset targets a path this commit is still writing; \
5362                 use separate commits",
5363            ));
5364        }
5365        match crate::group_v2::resolve_path_any_from_source(src, superblock, path) {
5366            Ok(addr) => Ok(addr - base),
5367            Err(_) => Ok(UNDEF),
5368        }
5369    }
5370
5371    /// Prove, before any byte of this commit is written, that every
5372    /// object-reference target across every staged dataset will resolve
5373    /// successfully — either against a pre-existing untouched object or
5374    /// against something this same commit places. [`resolve_reference_target`]
5375    /// classifies a target purely from *whether* a `PathKey` has been placed
5376    /// yet (`path_addr.get`), never from the address *value*, so replaying the
5377    /// apply loop's placement order here with placeholder addresses (`0`)
5378    /// standing in for "already placed" reproduces the exact same verdict the
5379    /// apply loop's own calls will reach later, without writing anything. If
5380    /// this preflight pass returns `Ok`, none of the apply loop's own
5381    /// `resolve_reference_target` calls can fail, so a reference-resolution
5382    /// error can no longer leave earlier-processed groups' real writes
5383    /// orphaned in the file (the failure surfaces here instead, before the
5384    /// apply loop's first `alloc_or_append`/`write_at`).
5385    fn preflight_reference_targets(
5386        keys: &[PathKey],
5387        flat: &BTreeMap<PathKey, Vec<FlatDataset>>,
5388        nodes: &BTreeMap<PathKey, Node>,
5389        add_targets: &[PathKey],
5390        write_targets: &[PathKey],
5391        pending_deletes: &[PathKey],
5392        src: &(impl Source + ?Sized),
5393        superblock: &Superblock,
5394    ) -> Result<(), Error> {
5395        let mut by_depth = keys.to_vec();
5396        by_depth.sort_by_key(|k| std::cmp::Reverse(k.len()));
5397        let mut sim_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
5398        for key in &by_depth {
5399            if let Some(datasets) = flat.get(key) {
5400                // Mirrors the apply loop's `group_datasets.sort_by_key(|fd|
5401                // fd.reference_targets.is_some())`: non-reference datasets are
5402                // placed (and so become resolvable) before any reference
5403                // dataset in the same group.
5404                let mut ordered: Vec<&FlatDataset> = datasets.iter().collect();
5405                ordered.sort_by_key(|fd| fd.reference_targets.is_some());
5406                for fd in ordered {
5407                    if let Some(patches) = &fd.reference_targets {
5408                        for patch in patches {
5409                            Self::resolve_reference_target(
5410                                &patch.target,
5411                                &sim_addr,
5412                                nodes,
5413                                add_targets,
5414                                write_targets,
5415                                pending_deletes,
5416                                src,
5417                                superblock,
5418                            )?;
5419                        }
5420                    }
5421                    let mut full = key.clone();
5422                    full.push(fd.name.clone());
5423                    sim_addr.insert(full, 0);
5424                }
5425            }
5426            sim_addr.insert(key.clone(), 0);
5427        }
5428        Ok(())
5429    }
5430
5431    /// Lay out a chunked / filtered / extensible dataset and return its object
5432    /// header bytes (which the caller links into the parent group).
5433    ///
5434    /// The chunk data and index (B-tree v1 / fixed-array / extensible-array, with
5435    /// any filter pipeline applied) are produced as one relocatable blob by
5436    /// [`build_chunked_data_at_ext`], whose internal layout — and therefore total
5437    /// size — is independent of the base address it is given. The blob is
5438    /// appended at end-of-file, so passing the current end-of-file as the base
5439    /// makes every absolute address it embeds (chunk addresses, index-structure
5440    /// addresses, the addresses in the data-layout message) land exactly where
5441    /// the bytes are written. The header is then built with
5442    /// [`build_chunked_dataset_oh`] — the same function the whole-file writer
5443    /// uses — so the header is byte-identical to one written fresh.
5444    ///
5445    /// Unlike the contiguous path the blob is always *appended* rather than
5446    /// placed via [`alloc_or_append`]: reusing an interior freed region would
5447    /// require knowing the blob's size before building it at that region's
5448    /// address, and appending keeps the address known up front. Freed space is
5449    /// still reused for the object header and for every other object in the
5450    /// commit.
5451    fn build_chunked_dataset(&mut self, fd: &FlatDataset) -> Result<Vec<u8>, Error> {
5452        // The blob is raw data whose embedded addresses are computed from the
5453        // end-of-file it lands at, so open a raw page *before* reading that offset.
5454        self.begin_page(PageType::Raw)?;
5455        let eof = self.image.len();
5456        // The blob embeds *stored* (base-relative) addresses, so the planner base is
5457        // the stored address the blob will occupy: its end-of-file offset minus the
5458        // userblock base. The reader recovers each as `stored + base_address`, which
5459        // resolves back to the blob's real file offset. On a base-0 file this is just
5460        // `eof`.
5461        let stored_base = eof - self.superblock.base_address;
5462        let chunk_dims = fd.chunk_options.resolve_chunk_dims(&fd.ds.dimensions);
5463        let ctx = ChunkContext::from_datatype(&chunk_dims, &fd.dt);
5464        let result = build_chunked_data_at_ext(
5465            &fd.raw,
5466            &fd.ds.dimensions,
5467            ctx,
5468            &fd.chunk_options,
5469            stored_base,
5470            fd.maxshape.as_deref(),
5471        )?;
5472        // `append` writes at the current end-of-file, which equals `eof`: the blob
5473        // lands exactly where its embedded (stored) addresses expect once the reader
5474        // adds the base back.
5475        let written = self.append(&result.data_bytes)?;
5476        debug_assert_eq!(written, eof, "chunk blob must land at end-of-file",);
5477        Ok(build_chunked_dataset_oh(
5478            &fd.dt,
5479            &fd.ds,
5480            &result.layout_message,
5481            result.pipeline_message.as_deref(),
5482            &fd.attrs,
5483            None,
5484            fd.fill.as_deref(),
5485        )?)
5486    }
5487
5488    /// On-disk byte spans `(addr, len)` of every chunk of the version 2 object
5489    /// header at `addr`: chunk 0 (signature, prefix, messages, checksum) plus
5490    /// each continuation (`OCHK`) block. Used to reclaim a header's storage when
5491    /// its object is deleted. An error (propagated from [`oh_region_at`] or a
5492    /// malformed continuation) means the header is not a plain v2 header this
5493    /// engine can fully account for, and the caller leaves it as dead bytes
5494    /// rather than guess its extent.
5495    fn oh_chunk_spans(&self, addr: usize) -> Result<Vec<(u64, u64)>, Error> {
5496        Ok(
5497            read_oh_chunks(&self.image(), addr as u64, self.superblock.base_address)?
5498                .into_iter()
5499                .map(|chunk| chunk.span)
5500                .collect(),
5501        )
5502    }
5503
5504    /// Count, for every object-header address reachable from the root, how many
5505    /// hard links in the *pre-commit* file point to it. The result drives the
5506    /// last-hard-link reclaim guard in [`collect_free_spans`](Self::collect_free_spans):
5507    /// an object is freed only when its count is 1.
5508    ///
5509    /// Walks the whole link graph from the root, following hard links through
5510    /// groups of any on-disk format (v0/v1 symbol-table, v2 compact, v2 dense)
5511    /// via [`resolve_group_entries`], tallying each hard-link edge. Datasets and
5512    /// other leaves contribute no edges. Returns `None` — so the caller reclaims
5513    /// nothing for the deletions, a safe leak — if the graph cannot be walked in
5514    /// full: an unparseable header, a group whose links cannot be enumerated, or
5515    /// more than [`MAX_LINK_GRAPH_NODES`] objects. Cycles are handled by visiting
5516    /// each object once. Base-aware: stored child addresses are shifted by the
5517    /// userblock base, so the returned keys are absolute file offsets.
5518    fn count_incoming_hard_links(&self) -> Option<HashMap<u64, u32>> {
5519        let os = self.superblock.offset_size;
5520        let ls = self.superblock.length_size;
5521        let base = self.superblock.base_address;
5522        let mut counts: HashMap<u64, u32> = HashMap::new();
5523        let mut visited: HashSet<u64> = HashSet::new();
5524        let mut stack: Vec<u64> = vec![self.superblock.root_group_address];
5525        let mut budget = MAX_LINK_GRAPH_NODES;
5526        while let Some(addr) = stack.pop() {
5527            if !visited.insert(addr) {
5528                continue; // already expanded (also breaks hard-link cycles)
5529            }
5530            if budget == 0 {
5531                return None; // graph larger than we will walk; leak conservatively
5532            }
5533            budget -= 1;
5534            let off = usize::try_from(addr).ok()?;
5535            let header =
5536                ObjectHeader::parse_from_source(&self.image(), off as u64, os, ls, base).ok()?;
5537            // Datasets and other leaves are not groups and own no links.
5538            let is_group = header.messages.iter().any(|m| {
5539                matches!(
5540                    m.msg_type,
5541                    MessageType::SymbolTable | MessageType::Link | MessageType::LinkInfo
5542                )
5543            });
5544            if !is_group {
5545                continue;
5546            }
5547            // A group we cannot enumerate fully would undercount incoming links
5548            // and risk over-reclaim; bail to the safe-leak fallback instead.
5549            let entries =
5550                resolve_group_entries_from_source(&self.image(), &header, os, ls, base).ok()?;
5551            for e in entries {
5552                let child = e.object_header_address.checked_add(base)?;
5553                *counts.entry(child).or_insert(0) += 1;
5554                stack.push(child);
5555            }
5556        }
5557        Some(counts)
5558    }
5559
5560    /// Best-effort enumeration of every on-disk block owned by the object at
5561    /// `addr` (and, for a group, its whole subtree), accumulating `(addr, len)`
5562    /// spans into `out` for reclamation after a delete.
5563    ///
5564    /// Contiguous datasets (header + data block), chunked datasets (header +
5565    /// chunk index + chunk data, via [`chunked_storage_spans`](Self::chunked_storage_spans)),
5566    /// and whole group subtrees are reclaimed. Deliberately conservative: any
5567    /// object whose layout it cannot fully account for — a non-v2 header, an
5568    /// unsupported or only-partially-enumerable chunk index, a group holding a
5569    /// soft/external link, dense attribute storage — contributes nothing and is
5570    /// not descended into, so `out` never names a region that might still be in
5571    /// use. Bounded by [`MAX_COPY_DEPTH`] against a hard-link cycle.
5572    /// Variable-length data in global-heap collections is never reclaimed here (a
5573    /// collection can be shared between objects), so it is simply left behind.
5574    ///
5575    /// `incoming` is the file-wide hard-link count per object-header address
5576    /// (from [`count_incoming_hard_links`](Self::count_incoming_hard_links)). An
5577    /// object is reclaimed — and, for a group, descended into — only when its
5578    /// count is exactly 1, i.e. the link being removed is its last: an object
5579    /// still reachable through another hard link is live and is left untouched
5580    /// (so is everything below a surviving group), which is what keeps deleting
5581    /// one of several hard links from corrupting the survivor.
5582    fn collect_free_spans(
5583        &self,
5584        addr: usize,
5585        depth: u32,
5586        incoming: &HashMap<u64, u32>,
5587        out: &mut Vec<(u64, u64, PageType)>,
5588    ) {
5589        // `addr` is an absolute file offset (the caller resolves it from the live
5590        // file, and the group recursion below re-absolutizes each child). `incoming`
5591        // is keyed by absolute offset, and `oh_chunk_spans`/`chunked_storage_spans`
5592        // both take an absolute address and return absolute spans, so the whole
5593        // walk works in absolute file offsets. The one shift this method must apply
5594        // itself is on the *stored* (base-relative) addresses `read_object` returns
5595        // for a contiguous data block and a group's child links: each is converted
5596        // to an absolute offset by adding `base` (a no-op on a base-0 file) before
5597        // it is bounds-checked, recorded, or descended into.
5598        let base = self.superblock.base_address;
5599        let file_len = self.image().len();
5600        if depth >= MAX_COPY_DEPTH {
5601            return;
5602        }
5603        // Reclaim only when this delete removes the object's last hard link. A
5604        // count other than 1 (it has surviving links, or the graph walk could
5605        // not account for it) means the object — and a group's whole subtree —
5606        // stays live and must not be freed.
5607        if incoming.get(&(addr as u64)) != Some(&1) {
5608            return;
5609        }
5610        // The header's own chunks. If they cannot be mapped, account for nothing.
5611        let spans = match self.oh_chunk_spans(addr) {
5612            Ok(s) => s,
5613            Err(_) => return,
5614        };
5615        match Self::read_object(&self.image(), addr as u64, self.superblock.base_address) {
5616            Ok(ObjModel::DatasetVerbatim { .. }) => out.extend(meta_spans(spans)),
5617            Ok(ObjModel::DatasetContiguous {
5618                data_addr,
5619                data_size,
5620                ..
5621            }) => {
5622                out.extend(meta_spans(spans));
5623                // A defined, in-bounds contiguous data block is owned outright;
5624                // an empty dataset stores the undefined address and owns none. The
5625                // stored address is base-relative, so shift it to an absolute file
5626                // offset before bounds-checking and recording it.
5627                if data_addr != u64::MAX && data_size > 0 {
5628                    if let (Some(abs), Ok(len)) =
5629                        (data_addr.checked_add(base), usize::try_from(data_size))
5630                    {
5631                        if let Ok(start) = usize::try_from(abs) {
5632                            if start.checked_add(len).is_some_and(|e| e as u64 <= file_len) {
5633                                // A contiguous data block is raw data.
5634                                out.push((abs, data_size, PageType::Raw));
5635                            }
5636                        }
5637                    }
5638                }
5639            }
5640            Ok(ObjModel::Group { children, .. }) => {
5641                out.extend(meta_spans(spans));
5642                // Child link targets are stored base-relative; re-absolutize each
5643                // before descending so the recursion keeps working in absolute
5644                // offsets (matching `incoming`'s keys and `oh_chunk_spans`).
5645                for (_, child) in children {
5646                    if let Some(c) = child
5647                        .checked_add(base)
5648                        .and_then(|a| usize::try_from(a).ok())
5649                    {
5650                        self.collect_free_spans(c, depth + 1, incoming, out);
5651                    }
5652                }
5653            }
5654            // A chunked dataset: reclaim its chunk index and chunk data blocks
5655            // alongside its header. `chunked_storage_spans` returns `None` for
5656            // anything it cannot account for exhaustively (an index type with no
5657            // walker, an undefined index address, or spans that fail the
5658            // bounds/overlap check), leaving the whole dataset as dead bytes
5659            // rather than freeing a region that might still be in use.
5660            Ok(ObjModel::DatasetChunked { .. }) => {
5661                if let Some(storage) = self.chunked_storage_spans(addr) {
5662                    out.extend(meta_spans(spans));
5663                    // Already page-typed: chunk data raw, index structure metadata.
5664                    out.extend(storage);
5665                }
5666            }
5667            // A truly unsupported object (one `read_object` cannot model): leave
5668            // its bytes in place rather than guess its extent.
5669            Err(_) => {}
5670        }
5671    }
5672
5673    /// Best-effort enumeration of every on-disk block a *chunked* dataset at
5674    /// `addr` owns: its chunk index structure (B-tree v1 nodes, or fixed- /
5675    /// extensible-array header, index, super, and data blocks) plus every
5676    /// allocated chunk data block. The object-header chunks are freed by the
5677    /// caller ([`collect_free_spans`](Self::collect_free_spans)); this returns
5678    /// only the storage the data-layout message points at.
5679    ///
5680    /// Returns `None` — contribute nothing, leave the object as dead bytes —
5681    /// whenever the dataset cannot be enumerated *exhaustively* and safely: a
5682    /// header that does not parse or is not a chunked dataset, a chunk index
5683    /// with no walker (a version 2 B-tree, index type 5), an undefined index
5684    /// address (an empty, never-written dataset), or any resulting span that
5685    /// falls outside the file image or overlaps another. This upholds the
5686    /// editor's invariant that reclaimed space is never a region still in use:
5687    /// under-reclaiming only wastes space, while over-reclaiming would corrupt.
5688    ///
5689    /// Chunk data addresses and sizes come from the same index walkers the
5690    /// reader uses, so they match the bytes the writer laid down exactly. The
5691    /// per-layout enumeration lives in
5692    /// [`chunked_read::collect_chunked_storage_spans`](crate::chunked_read::collect_chunked_storage_spans);
5693    /// this method only locates the layout and dataspace messages and validates
5694    /// the result. Variable-length data in global-heap collections is still
5695    /// never reclaimed (a collection can be shared between objects); see the
5696    /// [module docs](self).
5697    fn chunked_storage_spans(&self, addr: usize) -> Option<Vec<(u64, u64, PageType)>> {
5698        // Locate the data-layout and dataspace messages in the object header.
5699        let region =
5700            Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)
5701                .ok()?;
5702        let mut layout_msg: Option<(usize, usize)> = None;
5703        let mut dataspace_msg: Option<(usize, usize)> = None;
5704        let mut p = 0;
5705        loop {
5706            match next_message(&region, p) {
5707                Ok(Some((msg_type, body, body_end))) => {
5708                    match msg_type {
5709                        MessageType::DataLayout => layout_msg = Some((body, body_end)),
5710                        MessageType::Dataspace => dataspace_msg = Some((body, body_end)),
5711                        _ => {}
5712                    }
5713                    p = body_end;
5714                }
5715                Ok(None) => break,
5716                Err(_) => return None,
5717            }
5718        }
5719        let (lb, le) = layout_msg?;
5720        let (db, de) = dataspace_msg?;
5721
5722        let layout = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
5723        if !matches!(layout, DataLayout::Chunked { .. }) {
5724            return None;
5725        }
5726        let dataspace = Dataspace::parse(&region[db..de], LENGTH_SIZE).ok()?;
5727
5728        // Delegate the per-index-type enumeration to the chunked reader (the
5729        // single owner of chunk-storage layout knowledge), then validate: every
5730        // span must lie inside the current file image and be pairwise disjoint,
5731        // or the free list would later hand out live bytes (and a debug build
5732        // would panic on the double-free). On any error or violation, leave the
5733        // whole dataset unreclaimed rather than free a region still in use.
5734        //
5735        // The layout's stored addresses are relative to the userblock base, so the
5736        // enumeration runs on a base-relative view of the file and each returned
5737        // span address is shifted back to an absolute file offset by adding `base`
5738        // (a no-op on a base-0 file). The free list and the bounds check below both
5739        // work in absolute file offsets.
5740        let base = self.superblock.base_address;
5741        let split = crate::chunked_read::collect_chunked_storage_spans(
5742            &BaseOffsetSource {
5743                inner: &self.image(),
5744                base,
5745            },
5746            &layout,
5747            &dataspace,
5748            OFFSET_SIZE,
5749            LENGTH_SIZE,
5750        )
5751        .ok()?;
5752        // Both halves are tagged raw, because on a paged file both halves *live* in
5753        // raw pages: every writer in this crate places a chunked dataset's index
5754        // structure immediately after its chunk data in one run — the from-scratch
5755        // paged writer builds the whole blob inside the raw region, and
5756        // `build_chunked_dataset` / `write_chunked_relocatable` append it under a
5757        // single `begin_page(PageType::Raw)`.
5758        //
5759        // A chunk index is metadata by the format's taxonomy, so tagging it that way
5760        // is tempting; it is also wrong here. The tag decides which manager the
5761        // freed region is recorded in, and recording an index that sits among live
5762        // chunk data in the *metadata* manager would advertise space inside a raw
5763        // page — letting the reference library place metadata there and mixing the
5764        // page, which is the one thing a paged file must never do. The tag has to
5765        // follow the placement, so `write_appended_chunks` places its rebuilt index
5766        // in a raw page too, keeping every index in this crate's files raw.
5767        let mut spans: Vec<(u64, u64, PageType)> = Vec::new();
5768        for (addr, len) in split.data.into_iter().chain(split.index) {
5769            spans.push((addr.checked_add(base)?, len, PageType::Raw));
5770        }
5771        let mut plain: Vec<(u64, u64)> = spans.iter().map(|&(a, l, _)| (a, l)).collect();
5772        if !spans_disjoint_in_bounds(&mut plain, self.image.len()) {
5773            return None;
5774        }
5775        Some(spans)
5776    }
5777
5778    /// Every on-disk byte span of a chunked dataset's *index structure only* (not
5779    /// its chunk data), for reclaiming the old index after a relocating append
5780    /// ([`MovingWrite::AppendedChunks`]) that keeps the chunk data in place. Mirror
5781    /// of [`chunked_storage_spans`](Self::chunked_storage_spans) but delegating to
5782    /// [`chunk_index_spans_buffered`], which enumerates only the EA header/index/
5783    /// data/super blocks and never a chunk-data address, so the shared kept chunk
5784    /// data is never freed. Base-aware and validated disjoint/in-bounds; returns
5785    /// `None` (leave unreclaimed) on any error or violation.
5786    fn chunked_index_spans(&self, addr: usize) -> Option<Vec<(u64, u64)>> {
5787        let region =
5788            Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)
5789                .ok()?;
5790        let mut layout_msg: Option<(usize, usize)> = None;
5791        let mut p = 0;
5792        loop {
5793            match next_message(&region, p) {
5794                Ok(Some((msg_type, body, body_end))) => {
5795                    if msg_type == MessageType::DataLayout {
5796                        layout_msg = Some((body, body_end));
5797                    }
5798                    p = body_end;
5799                }
5800                Ok(None) => break,
5801                Err(_) => return None,
5802            }
5803        }
5804        let (lb, le) = layout_msg?;
5805        let layout = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
5806        if !matches!(layout, DataLayout::Chunked { .. }) {
5807            return None;
5808        }
5809        let base = self.superblock.base_address;
5810        let mut spans = chunk_index_spans_from_source(
5811            &BaseOffsetSource {
5812                inner: &self.image(),
5813                base,
5814            },
5815            &layout,
5816            OFFSET_SIZE,
5817            LENGTH_SIZE,
5818        )
5819        .ok()?;
5820        for (a, _) in &mut spans {
5821            *a = a.checked_add(base)?;
5822        }
5823        if !spans_disjoint_in_bounds(&mut spans, self.image.len()) {
5824            return None;
5825        }
5826        Some(spans)
5827    }
5828}
5829
5830/// A dirty group in the edit plan: its base object-header message region and the
5831/// additions targeting it.
5832#[derive(Default)]
5833struct Node {
5834    is_new: bool,
5835    datasets: Vec<DatasetBuilder>,
5836    /// Compact group-attribute operations to apply to this group.
5837    attr_ops: Vec<AttrOp>,
5838    /// Names of links to remove from this group (from `delete`).
5839    deletes: Vec<String>,
5840    /// Copies to add to this group: (new link name, the source subtree read out
5841    /// for writing). Built at staging time from either this file (an in-file
5842    /// [`copy`](crate::File::copy)) or another open file (a cross-file
5843    /// [`copy_from`](crate::File::copy_from)).
5844    copies: Vec<(String, CopyTree)>,
5845    /// Value overwrites whose dataset header relocates (a resize or compact
5846    /// rewrite by `write_dataset`), as (child link name, the relocation plan). On
5847    /// apply, the new data and header are written and this group's existing link
5848    /// to the moved header is patched to its new address — exactly like an
5849    /// existing child group's link.
5850    writes: Vec<(String, MovingWrite)>,
5851    base_region: Vec<u8>,
5852    existing_links: Vec<String>,
5853    /// Variable-length group/root attributes staged by [`apply_group_attr_ops`],
5854    /// each still carrying a placeholder heap address: (the attribute message,
5855    /// its global heap collection bytes). Resolved in the apply loop right
5856    /// before this node's header is built — [`WriteEngine::place_vl_collection`]
5857    /// appends the collection, then the patched message is appended to
5858    /// `base_region`.
5859    pending_vl_attrs: PendingVlAttrs,
5860}
5861
5862/// A staged compact attribute edit for a group or dataset (shared by
5863/// [`Group::set_attr`](crate::Group::set_attr)/`remove_group_attr` and
5864/// [`Dataset::set_attr`](crate::Dataset::set_attr)/`remove_dataset_attr`).
5865enum AttrOp {
5866    Set { name: String, value: AttrValue },
5867    Remove { name: String },
5868}
5869
5870/// A source object parsed for copying. Headers are reproduced from their
5871/// verbatim message bytes; only the contiguous data address and child link
5872/// targets are repointed to the freshly-written copies.
5873enum ObjModel {
5874    /// A compact dataset (data inline in the header): copy the region verbatim.
5875    /// `dense_attrs` is empty unless the source stored its attributes densely, in
5876    /// which case the Attribute Info message and inline Attribute messages have
5877    /// been stripped from `region` and the parsed set is carried here to be
5878    /// re-emitted into a fresh fractal heap on write.
5879    DatasetVerbatim {
5880        region: Vec<u8>,
5881        dense_attrs: Vec<crate::attribute::AttributeMessage>,
5882    },
5883    /// A contiguous dataset: copy the region, repointing the data address at
5884    /// `addr_off` (region-relative) to a fresh copy of `[data_addr, +data_size)`.
5885    /// See [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
5886    DatasetContiguous {
5887        region: Vec<u8>,
5888        addr_off: usize,
5889        data_addr: u64,
5890        data_size: u64,
5891        dense_attrs: Vec<crate::attribute::AttributeMessage>,
5892    },
5893    /// A chunked (and possibly filtered) dataset: the verbatim header `region`
5894    /// (datatype, dataspace, fill value, data layout, and filter pipeline kept as
5895    /// written). The chunk data is not captured here — [`read_copy_subtree`](WriteEngine::read_copy_subtree)
5896    /// enumerates and reads the chunks (it holds the source buffer), repointing the
5897    /// rebuilt index on write. See [`DatasetVerbatim`](ObjModel::DatasetVerbatim)
5898    /// for `dense_attrs`.
5899    DatasetChunked {
5900        region: Vec<u8>,
5901        dense_attrs: Vec<crate::attribute::AttributeMessage>,
5902    },
5903    /// A group: every non-link message verbatim, plus its hard-link children to
5904    /// copy and re-link by name. See
5905    /// [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
5906    Group {
5907        non_link_region: Vec<u8>,
5908        children: Vec<(String, u64)>,
5909        dense_attrs: Vec<crate::attribute::AttributeMessage>,
5910    },
5911}
5912
5913/// An object subtree fully read out of a source buffer and owning every byte it
5914/// will write, the read result of [`WriteEngine::read_copy_subtree`] and the
5915/// input to [`WriteEngine::write_copy_subtree`]. Unlike [`ObjModel`] (a single
5916/// object still referencing source addresses) it is recursive and self-contained:
5917/// a contiguous dataset owns its data bytes, and a group owns its children, so it
5918/// can be written into the destination without the source buffer still in hand —
5919/// which is what lets a cross-file copy read the source at staging time and apply
5920/// it at commit time.
5921enum CopyTree {
5922    /// A compact dataset: the header region is written verbatim (data is inline).
5923    /// `dense_attrs`, when non-empty, is re-emitted into a freshly built fractal
5924    /// heap appended just before the header, whose Attribute Info message is
5925    /// spliced into the region on write.
5926    DatasetVerbatim {
5927        region: Vec<u8>,
5928        dense_attrs: Vec<crate::attribute::AttributeMessage>,
5929    },
5930    /// A contiguous dataset: `data` is written first and its new address patched
5931    /// into the header `region` at `addr_off` before the header is written. See
5932    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
5933    DatasetContiguous {
5934        region: Vec<u8>,
5935        addr_off: usize,
5936        data: Vec<u8>,
5937        dense_attrs: Vec<crate::attribute::AttributeMessage>,
5938    },
5939    /// A chunked (and possibly filtered) dataset. The header `region` is written
5940    /// verbatim except its data-layout message, which is swapped for one naming the
5941    /// freshly rebuilt index; `chunk_bytes` (each chunk's already-compressed bytes,
5942    /// in dense row-major grid order, with sizes/masks in `meta`) and the source
5943    /// `pipeline_message` are carried unchanged, so the copy preserves the filter
5944    /// pipeline and chunk payloads byte-for-byte. The on-disk index *type* is
5945    /// reselected from `maxshape`/chunk count (single / fixed-array / extensible-
5946    /// array), so a B-tree-v1 or implicit source is reproduced with a v4 index. See
5947    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
5948    DatasetChunked {
5949        region: Vec<u8>,
5950        chunk_dims: Vec<u64>,
5951        element_size: usize,
5952        raw_size: u64,
5953        maxshape: Option<Vec<u64>>,
5954        pipeline_message: Option<Vec<u8>>,
5955        meta: Vec<ChunkMeta>,
5956        chunk_bytes: Vec<Vec<u8>>,
5957        dense_attrs: Vec<crate::attribute::AttributeMessage>,
5958    },
5959    /// A group: every non-link message verbatim, plus the (name, child) subtrees
5960    /// to write first and re-link by name. See
5961    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
5962    Group {
5963        non_link_region: Vec<u8>,
5964        children: Vec<(String, CopyTree)>,
5965        dense_attrs: Vec<crate::attribute::AttributeMessage>,
5966    },
5967}
5968
5969/// The validated, chunk-collapsed message region and existing link names of a
5970/// group header.
5971struct GroupInfo {
5972    region: Vec<u8>,
5973    link_names: Vec<String>,
5974}
5975
5976/// How a staged value overwrite (`write_dataset`) will be applied, decided by
5977/// [`WriteEngine::prepare_write`] during the all-or-nothing preflight.
5978enum WritePlan {
5979    /// A contiguous dataset whose new data is the same length as its existing,
5980    /// defined data block: overwrite the bytes straight in place at `data_addr`.
5981    /// No object header is rewritten and the superblock root is not flipped.
5982    InPlace { data_addr: usize, raw: Vec<u8> },
5983    /// A chunked dataset overwritten chunk-by-chunk in place: each `(addr, bytes)`
5984    /// pair is written straight over an existing chunk slot. Used when every new
5985    /// (re-encoded) chunk is the same byte length as the slot it replaces — an
5986    /// unfiltered chunked overwrite (chunk sizes are fixed by the unchanged shape)
5987    /// or a filtered one whose re-encoded chunks happen to match. Like
5988    /// [`InPlace`](WritePlan::InPlace) it touches no header and no chunk index, so
5989    /// the superblock root is not flipped.
5990    InPlaceChunks { writes: Vec<(usize, Vec<u8>)> },
5991    /// The dataset's header relocates: a contiguous resize, a compact rewrite, or
5992    /// a chunked rebuild. The parent group is rebuilt and its link patched. See
5993    /// [`MovingWrite`].
5994    Moving(MovingWrite),
5995}
5996
5997/// A value overwrite that relocates the dataset's object header — a contiguous
5998/// dataset whose data length changed (or had no data block) or a compact dataset
5999/// whose inline bytes are replaced. On apply the new data and a rewritten header
6000/// are written at end-of-file (or into reusable freed space), and the parent
6001/// group's link is repointed at the new header address.
6002enum MovingWrite {
6003    /// A contiguous dataset: write `raw` elsewhere, patch the data-layout address
6004    /// at `addr_off` in the verbatim header `region`, rewrite the header, and free
6005    /// `old_extent` (the prior data block, if any) after the commit lands.
6006    Contiguous {
6007        region: Vec<u8>,
6008        addr_off: usize,
6009        raw: Vec<u8>,
6010        old_extent: Option<(u64, u64)>,
6011    },
6012    /// A compact dataset: rebuild the header `region` with `raw` inline.
6013    Compact { region: Vec<u8>, raw: Vec<u8> },
6014    /// A chunked dataset whose new (re-encoded) chunks do not all fit their
6015    /// existing slots, so its whole storage is rebuilt and relocated. A fresh
6016    /// chunk-data blob and index are appended at end-of-file (via the verbatim
6017    /// layout path, carrying `chunk_bytes` and the source filter `pipeline_message`
6018    /// unchanged — no recompression and no filter-parameter reconstruction), the
6019    /// data-layout message in the verbatim header `region` is swapped for the new
6020    /// one (every other header message — datatype, dataspace, fill value, filter
6021    /// pipeline, and attributes, including a dense attribute heap referenced by an
6022    /// untouched Attribute Info message — is preserved verbatim), and the old
6023    /// chunk storage at `old_addr` is freed after the commit lands.
6024    Chunked {
6025        region: Vec<u8>,
6026        chunk_dims: Vec<u64>,
6027        element_size: usize,
6028        raw_size: u64,
6029        maxshape: Option<Vec<u64>>,
6030        pipeline_message: Option<Vec<u8>>,
6031        meta: Vec<ChunkMeta>,
6032        chunk_bytes: Vec<Vec<u8>>,
6033        old_addr: u64,
6034    },
6035    /// A relocating **append** to a chunked, unlimited, Extensible-Array-indexed
6036    /// dataset (`append_dataset`). The dataset's existing chunk *data* stays in
6037    /// place; only the newly-appended chunks and any rewritten trailing partial
6038    /// chunk (`new_chunk_bytes`, already compressed through the on-disk pipeline)
6039    /// are appended at end-of-file, a fresh Extensible Array is rebuilt over
6040    /// `kept_chunks ++ new_chunk_bytes`, the verbatim header `region`'s dataspace
6041    /// message is grown (`new_dataspace_body`) and its data-layout message
6042    /// repointed at the new index (every other message — datatype, filter
6043    /// pipeline, fill value, attributes — preserved verbatim), and the header is
6044    /// relocated. After the commit lands, only the old index structure at
6045    /// `old_addr`, the old header, and the relocated old trailing chunk
6046    /// (`old_tail_extent`) are freed — never the kept chunk data, which both the
6047    /// old and new index share during the commit.
6048    AppendedChunks {
6049        region: Vec<u8>,
6050        /// The grown dataspace message body (v2-serialized), current axis-0
6051        /// dimension increased, maximum dimensions (unlimited) preserved.
6052        new_dataspace_body: Vec<u8>,
6053        /// Rank-only spatial chunk dimensions, for the rebuilt v4 layout message.
6054        chunk_dims_u32: Vec<u32>,
6055        element_size: usize,
6056        /// Full (uncompressed) chunk byte size = product(spatial) * element_size.
6057        raw_size: u64,
6058        has_filters: bool,
6059        /// Existing complete chunks, in index order, carried by metadata alone —
6060        /// their base-relative addresses, on-disk stored sizes, and filter masks
6061        /// preserved exactly (a nonzero mask from a C/h5py-skipped filter is kept).
6062        kept_chunks: Vec<WrittenChunk>,
6063        /// The appended chunks in index order: the recompressed trailing partial
6064        /// chunk first (when present), then the remaining new full chunks.
6065        new_chunk_bytes: Vec<Vec<u8>>,
6066        /// The dataset header address, for old-index and old-header reclaim.
6067        old_addr: u64,
6068        /// The absolute `(addr, len)` of the old trailing partial chunk's data
6069        /// block when it was rewritten, freed after the commit lands. `None` when
6070        /// the append was chunk-aligned (no partial chunk to rewrite).
6071        old_tail_extent: Option<(u64, u64)>,
6072    },
6073    /// A compact dataset-attribute edit (`set_dataset_attr` / `remove_dataset_attr`).
6074    /// The verbatim header `region` already carries the fixed-size attribute change
6075    /// (applied by [`apply_group_attr_ops`] in the commit preflight); any
6076    /// variable-length attribute is placed and patched in [`WriteEngine::write_moving`]
6077    /// via `pending_vl_attrs`. The rewritten header is relocated and the parent link
6078    /// repointed, exactly like the other relocating writes — but the data-layout
6079    /// message is preserved verbatim, so the dataset's chunk data and index stay in
6080    /// place; only the old header is freed.
6081    AttrEdit {
6082        region: Vec<u8>,
6083        pending_vl_attrs: PendingVlAttrs,
6084    },
6085}
6086
6087/// A staged dataset reduced to the pieces the writer needs.
6088struct FlatDataset {
6089    name: String,
6090    dt: crate::datatype::Datatype,
6091    ds: Dataspace,
6092    raw: Vec<u8>,
6093    attrs: Vec<crate::attribute::AttributeMessage>,
6094    /// Chunked/filtered storage options. When [`ChunkOptions::is_chunked`] is
6095    /// false and `maxshape` is `None`, the dataset is written as contiguous,
6096    /// unfiltered storage; otherwise its chunk data and index are built by
6097    /// [`build_chunked_data_at_ext`] and appended at end-of-file.
6098    chunk_options: ChunkOptions,
6099    /// Maximum dimensions for an extensible dataset (an unlimited dimension is
6100    /// `u64::MAX`), mirrored into `ds.max_dimensions`. `None` for a fixed-shape
6101    /// dataset. A maxshape with an unlimited dimension selects the
6102    /// extensible-array chunk index; a finite maxshape stays fixed-array/single.
6103    maxshape: Option<Vec<u64>>,
6104    /// Variable-length attributes still carrying a placeholder heap address:
6105    /// (index into `attrs`, that attribute's global heap collections).
6106    /// Resolved in the apply loop right before this dataset's header is built.
6107    vl_attrs: Vec<(usize, Vec<Vec<u8>>)>,
6108    /// A staged variable-length-string dataset's element references (still
6109    /// carrying placeholder heap addresses in `raw`) and global heap
6110    /// collections. Resolved in the apply loop right before `raw` is appended.
6111    vl_string_staging: Option<VlStringStaging>,
6112    /// An object-reference dataset's per-element targets, still unresolved.
6113    /// Resolved (see [`WriteEngine::resolve_reference_target`]) and patched
6114    /// into `raw` in the apply loop, once every object this commit places has
6115    /// a known address. `None` for an ordinary dataset.
6116    reference_targets: Option<Vec<ObjectRefPatch>>,
6117    /// A user-defined fill value, encoded in the dataset's datatype, or `None`
6118    /// for the library default. Validated against the datatype element size in
6119    /// [`flatten_dataset`].
6120    fill: Option<Vec<u8>>,
6121}
6122
6123/// A borrow adapter that drives the shared Extensible-Array append engine
6124/// ([`crate::chunk_index_inplace`]) against the engine's *own* image and
6125/// superblock, so a session runs an immediate O(1) in-place append without
6126/// constructing a second writable handle (which would take a second exclusive
6127/// lock and keep a divergent view of the file). It borrows only those two
6128/// fields, leaving [`WriteEngine::located`] independently borrowable.
6129///
6130/// [`Store`] is the append engine's view of a file — an image *plus* the
6131/// superblock, which the image itself knows nothing about. Pairing them here is
6132/// all this adapter does; every primitive delegates, so the image's own
6133/// write-ordering discipline is what applies.
6134///
6135/// It carries the session's paged-file state too, so `append_raw` keeps a paged
6136/// file's pages homogeneous through exactly the rule the staged commit uses
6137/// ([`PagedEdit::begin`]). Before issue #198 there were two copies of that state —
6138/// one per engine — and the whole-file editor's copy was reachable only from the
6139/// commit path, so it had to refuse an in-place append to a paged file outright.
6140struct EditStore<'a> {
6141    image: &'a mut dyn FileImage,
6142    superblock: &'a mut Superblock,
6143    sb_sig_off: usize,
6144    /// The session's paged state when the file is paged, `None` otherwise. A
6145    /// borrow rather than a copy: padding recorded here has to reach the manager
6146    /// rewrite at the next commit or at close.
6147    paged: Option<&'a mut PagedEdit>,
6148}
6149
6150impl EditStore<'_> {
6151    /// Append `bytes` into a raw page, padding the tail page first when a paged
6152    /// file's tail holds metadata. A plain append on the common non-paged file.
6153    ///
6154    /// Raw is the only page type this adapter allocates: see
6155    /// [`Store::append_raw`](crate::chunk_index_inplace::Store::append_raw) for why
6156    /// an extensible-array index block belongs in a raw page here.
6157    fn append_into_raw_page(&mut self, bytes: &[u8]) -> Result<u64, Error> {
6158        if let Some(pg) = self.paged.as_deref_mut() {
6159            pg.begin(self.image, PageType::Raw)?;
6160        }
6161        self.image.append(bytes)
6162    }
6163}
6164
6165impl crate::source::Source for EditStore<'_> {
6166    fn len(&self) -> u64 {
6167        self.image.len()
6168    }
6169    fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), crate::error::FormatError> {
6170        self.image.read_at(offset, buf)
6171    }
6172    fn read_metadata_at(
6173        &self,
6174        offset: u64,
6175        len: usize,
6176    ) -> Result<Vec<u8>, crate::error::FormatError> {
6177        self.image.read_metadata_at(offset, len)
6178    }
6179}
6180
6181impl Store for EditStore<'_> {
6182    fn offset_size(&self) -> u8 {
6183        self.superblock.offset_size
6184    }
6185    fn length_size(&self) -> u8 {
6186        self.superblock.length_size
6187    }
6188    fn append_bytes(&mut self, bytes: &[u8]) -> Result<u64, Error> {
6189        self.image.append(bytes)
6190    }
6191    fn append_raw(&mut self, bytes: &[u8]) -> Result<u64, Error> {
6192        self.append_into_raw_page(bytes)
6193    }
6194    fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<(), Error> {
6195        self.image.write_at(offset, bytes)
6196    }
6197    fn patch_superblock_eof(&mut self) -> Result<(), Error> {
6198        // Advance only the recorded end-of-file and re-serialize the superblock in
6199        // place. Unlike `WriteEngine::commit`, this deliberately does NOT clear the
6200        // consistency flags and does NOT repoint the root group: base_address is 0
6201        // for every in-place-append-eligible file, so the normalized-absolute root
6202        // address serializes back to the same stored value.
6203        let eof = self.image.len();
6204        self.superblock.eof_address = eof;
6205        let bytes = self.superblock.serialize();
6206        self.write_at(self.sb_sig_off as u64, &bytes)
6207    }
6208    fn sync(&mut self) -> Result<(), Error> {
6209        self.image.sync_data()
6210    }
6211}
6212
6213/// Whether two object paths are equal or one is an ancestor of the other.
6214fn paths_overlap(a: &[String], b: &[String]) -> bool {
6215    a.starts_with(b) || b.starts_with(a)
6216}
6217
6218/// Re-tag a refusal from the shared append engine (`AppendUnsupported`) as the
6219/// fast-path [`Error::AppendInPlaceUnsupported`], so a caller can catch it and fall
6220/// back to the staged [`append_dataset`](WriteEngine::append_dataset) — which
6221/// handles the non-chunk-aligned filtered case, index-geometry limits, and
6222/// platform-width limits that the engine reports this way. Genuine I/O and format
6223/// errors pass through unchanged.
6224pub(crate) fn as_inplace_error(e: Error) -> Error {
6225    match e {
6226        Error::AppendUnsupported(m) => Error::AppendInPlaceUnsupported(m),
6227        other => other,
6228    }
6229}
6230
6231/// Validate a gathered append's bytes against a located dataset: the byte
6232/// length must be a whole number of elements, and the element datatype must
6233/// match the on-disk datatype (or, for a raw append, be raw-appendable).
6234/// Returns the appended element count (`0` = nothing to do). Shared by
6235/// [`WriteEngine::append_inplace_gathered`]'s path and the bounded backend's immediate
6236/// append so the acceptance rules stay identical.
6237pub(crate) fn validate_gathered_append(st: &LocatedState, b: &AppendBuilder) -> Result<u64, Error> {
6238    let raw = b.raw();
6239    if raw.len() % st.element_size != 0 {
6240        return Err(Error::AppendInPlaceUnsupported(
6241            "appended byte length is not a whole number of elements",
6242        ));
6243    }
6244    match b.elem_dt() {
6245        Some(expected) if *expected != st.datatype => {
6246            return Err(Error::AppendInPlaceUnsupported(
6247                "append datatype does not match the on-disk dataset (wrong element \
6248                 type or byte order)",
6249            ));
6250        }
6251        Some(_) => {}
6252        None => {
6253            if !datatype_is_raw_appendable(&st.datatype) {
6254                return Err(Error::AppendInPlaceUnsupported(
6255                    "append_raw onto this dataset's datatype (non-little-endian, \
6256                     variable-length, or reference) could misencode the bytes; use a \
6257                     typed append",
6258                ));
6259            }
6260        }
6261    }
6262    Ok((raw.len() / st.element_size) as u64)
6263}
6264
6265/// Locate the dataset at `oh_addr` in `file` and build its [`LocatedState`],
6266/// validating in-place append eligibility (rank-1 / unlimited / Extensible-Array
6267/// indexed, a nonzero chunk length, and a re-encodable filter pipeline). Mirrors
6268/// the append writer's `ensure_located`, reporting through
6269/// [`Error::AppendInPlaceUnsupported`].
6270pub(crate) fn locate_dataset_state<F: Store>(
6271    file: &F,
6272    oh_addr: u64,
6273) -> Result<LocatedState, Error> {
6274    let result = Located::locate_at(file, oh_addr, Error::AppendInPlaceUnsupported)?;
6275    if result.located.chunk_elems == 0 {
6276        return Err(Error::AppendInPlaceUnsupported(
6277            "in-place append requires a nonzero chunk length",
6278        ));
6279    }
6280    let (dt_off, dt_size) = result.spans.datatype;
6281    let dt_bytes = file
6282        .read_metadata_at(dt_off, dt_size)
6283        .map_err(|_| Error::AppendInPlaceUnsupported("dataset datatype could not be parsed"))?;
6284    let (datatype, _) = Datatype::parse(&dt_bytes)
6285        .map_err(|_| Error::AppendInPlaceUnsupported("dataset datatype could not be parsed"))?;
6286    let pipeline = match result.spans.filter {
6287        Some((fb, fsize)) => {
6288            let fp_bytes = file.read_metadata_at(fb, fsize).map_err(|_| {
6289                Error::AppendInPlaceUnsupported("dataset filter pipeline could not be parsed")
6290            })?;
6291            let parsed = FilterPipeline::parse(&fp_bytes).map_err(|_| {
6292                Error::AppendInPlaceUnsupported("dataset filter pipeline could not be parsed")
6293            })?;
6294            if !pipeline_reencodable(&parsed) {
6295                return Err(Error::AppendInPlaceUnsupported(
6296                    "dataset uses a filter this engine cannot re-encode",
6297                ));
6298            }
6299            Some(parsed)
6300        }
6301        None => None,
6302    };
6303    let element_size = result.located.elem_bytes;
6304    let spatial = vec![result.located.chunk_elems];
6305    Ok(LocatedState {
6306        loc: result.located,
6307        datatype,
6308        spatial,
6309        element_size,
6310        pipeline,
6311    })
6312}
6313
6314/// Split a path into non-empty components.
6315fn split_path(path: &str) -> PathKey {
6316    path.split('/')
6317        .filter(|s| !s.is_empty())
6318        .map(String::from)
6319        .collect()
6320}
6321
6322/// Ensure a node exists for every ancestor prefix of `path` (so each is rebuilt
6323/// and can re-wire its child link). Does not set `is_new`.
6324fn ensure_ancestors(nodes: &mut BTreeMap<PathKey, Node>, path: &[String]) {
6325    for len in 0..=path.len() {
6326        nodes.entry(path[..len].to_vec()).or_default();
6327    }
6328}
6329
6330/// Validate that every reclaim span `(addr, len)` is non-empty, ends at or
6331/// before `eof`, and that no two overlap; sorts `spans` by address as a side
6332/// effect. Returns `false` on any violation so the caller can decline to
6333/// reclaim the object rather than feed the free list an out-of-bounds or
6334/// overlapping (double-free) region. Touching spans are allowed — the free list
6335/// coalesces them.
6336fn spans_disjoint_in_bounds(spans: &mut [(u64, u64)], eof: u64) -> bool {
6337    for &(addr, len) in spans.iter() {
6338        match addr.checked_add(len) {
6339            Some(end) if len > 0 && end <= eof => {}
6340            _ => return false,
6341        }
6342    }
6343    spans.sort_unstable_by_key(|&(addr, _)| addr);
6344    spans.windows(2).all(|w| w[0].0 + w[0].1 <= w[1].0)
6345}
6346
6347/// Sanitize the accumulated free spans for a whole commit so the free list never
6348/// sees an out-of-bounds or overlapping (double-free) region: drop empty or
6349/// past-`eof` spans, sort by address, then drop any span overlapping one already
6350/// kept. Dropping only leaks (the bytes stay allocated); it never frees a live
6351/// region. With the last-hard-link guard in force nothing should be dropped for
6352/// a well-formed file — this is a backstop, not the primary defense.
6353fn retain_disjoint_in_bounds(spans: &mut Vec<(u64, u64, PageType)>, eof: u64) {
6354    spans.retain(|&(addr, len, _)| len > 0 && addr.checked_add(len).is_some_and(|e| e <= eof));
6355    spans.sort_unstable_by_key(|&(addr, _, _)| addr);
6356    let mut kept_end = 0u64;
6357    spans.retain(|&(addr, len, _)| {
6358        if addr >= kept_end {
6359            kept_end = addr + len;
6360            true
6361        } else {
6362            false // overlaps a span already kept; leak it rather than double-free
6363        }
6364    });
6365}
6366
6367/// Tag object-header chunk spans as file metadata. Every span
6368/// [`oh_chunk_spans`](EditSession::oh_chunk_spans) returns is part of an object
6369/// header, so the page type is the same for all of them.
6370fn meta_spans(spans: Vec<(u64, u64)>) -> impl Iterator<Item = (u64, u64, PageType)> {
6371    spans.into_iter().map(|(a, l)| (a, l, PageType::Meta))
6372}
6373
6374/// Validate a staged dataset and reduce it to a [`FlatDataset`]. Contiguous,
6375/// unfiltered datasets are emitted as such; chunked, filtered, or extensible
6376/// datasets carry their [`ChunkOptions`] and maxshape through to the commit,
6377/// where [`build_chunked_data_at_ext`] lays out their chunk data and index. An
6378/// empty (zero-element) shape is allowed for contiguous storage (mirroring the
6379/// whole-file writer, its data address is `HADDR_UNDEF` — see the apply loop),
6380/// but chunking one stays refused via the geometry validation below. A
6381/// `provenance` dataset has its SHA-256/creator/timestamp/source attributes
6382/// computed here from `raw`, exactly as the whole-file writer does. A
6383/// variable-length attribute's global heap collection is built here (it is
6384/// fully self-contained — no address of its own) but placed and patched later,
6385/// in the apply loop, once its final address is known; likewise a
6386/// variable-length-string dataset's staged references and collection
6387/// (`db.vl_string_staging`) are carried through unresolved. An object-reference
6388/// dataset's per-element targets (`db.reference_targets`) are likewise carried
6389/// through unresolved — resolving a path target requires knowing every other
6390/// object this commit places, which is only known well into the apply loop
6391/// (see [`WriteEngine::resolve_reference_target`]). Rejects any remaining
6392/// feature this engine cannot reproduce faithfully: dense attributes, a
6393/// chunked/extensible variable-length-string or object-reference dataset, or a
6394/// filter pipeline the build cannot construct.
6395fn flatten_dataset(db: DatasetBuilder) -> Result<FlatDataset, Error> {
6396    if db.name.is_empty() {
6397        return Err(Error::EditUnsupported("dataset path has an empty name"));
6398    }
6399    let dt = db
6400        .datatype
6401        .ok_or(Error::EditUnsupported("dataset has no datatype/data"))?;
6402    let shape = db
6403        .shape
6404        .ok_or(Error::EditUnsupported("dataset has no shape"))?;
6405    let is_empty = shape.contains(&0);
6406    let chunked = db.chunk_options.is_chunked() || db.maxshape.is_some();
6407    if is_empty && chunked {
6408        return Err(Error::EditUnsupported(
6409            "chunked or extensible empty (zero-element) datasets cannot be added in place yet",
6410        ));
6411    }
6412    // Variable-length string element references live in the global heap, whose
6413    // address is only known once the apply loop places the collection. For
6414    // chunked/filtered/resizable storage the references sit inside chunks
6415    // written before that address exists, so patching them in is impossible.
6416    //
6417    // The whole-file writer lifted the same restriction by placing such a
6418    // dataset's collections ahead of everything else (issue #109); this engine
6419    // appends into an existing layout, where there is no "ahead" to place them
6420    // in, so the equivalent fix is a separate piece of work.
6421    if db.vl_string_staging.is_some() && chunked {
6422        return Err(Error::EditUnsupported(
6423            "chunked or extensible variable-length-string datasets cannot be added in place yet",
6424        ));
6425    }
6426    // Object-reference elements are resolved (see `resolve_reference_target`)
6427    // and patched into `raw` right before it is appended; for chunked storage
6428    // that patch would need to reach inside already-built chunk data, which
6429    // this engine does not support (mirrors the variable-length-string
6430    // refusal above — untested and unneeded combination for v1).
6431    if db.reference_targets.is_some() && chunked {
6432        return Err(Error::EditUnsupported(
6433            "chunked or extensible object-reference datasets cannot be added in place yet",
6434        ));
6435    }
6436    let raw = if is_empty {
6437        db.data.unwrap_or_default()
6438    } else {
6439        db.data
6440            .ok_or(Error::EditUnsupported("dataset has no data"))?
6441    };
6442
6443    let elem = dt.type_size() as u64;
6444    if elem > 0 {
6445        // Multiply with checked arithmetic: an absurd shape whose element count
6446        // (or byte size) overflows `u64` is refused rather than panicking in a
6447        // debug build or silently wrapping in release (which could let a wrapped
6448        // product spuriously match `raw.len()`). For a zero-element shape this
6449        // expected length is always 0 (a `0` dimension makes every checked
6450        // multiplication `Some(0)` regardless of the other dimensions), so this
6451        // also catches data mistakenly supplied for a shape that holds nothing.
6452        let expected = shape
6453            .iter()
6454            .try_fold(1u64, |acc, &d| acc.checked_mul(d))
6455            .and_then(|n| n.checked_mul(elem));
6456        match expected {
6457            Some(expected) if raw.len() as u64 == expected => {}
6458            Some(_) => {
6459                return Err(Error::EditUnsupported(
6460                    "dataset data length does not match its shape",
6461                ));
6462            }
6463            None => {
6464                return Err(Error::EditUnsupported(
6465                    "dataset shape is too large to address on this platform",
6466                ));
6467            }
6468        }
6469    }
6470
6471    if chunked {
6472        // Refuse malformed chunk geometry up front (the same validation the
6473        // whole-file writer applies), so a bad request — chunk dimensions of the
6474        // wrong rank, a zero chunk dimension, an inconsistent maximum shape, or
6475        // chunking a scalar — never reaches and panics the chunk splitter, nor
6476        // yields a dataset the reader cannot decode.
6477        db.chunk_options
6478            .validate_geometry(&shape, db.maxshape.as_deref())
6479            .map_err(Error::EditUnsupported)?;
6480        // Deflate is compiled out unless the `deflate` feature is on, but
6481        // `build_pipeline` emits its descriptor regardless; catch a
6482        // disabled-feature request here so it is refused up front rather than
6483        // failing mid-apply when a chunk is compressed.
6484        #[cfg(not(feature = "deflate"))]
6485        if db.chunk_options.deflate_level.is_some() {
6486            return Err(Error::EditUnsupported(
6487                "deflate compression requires the `deflate` crate feature",
6488            ));
6489        }
6490        // Validate the requested filter pipeline now — before any file bytes are
6491        // written — so an unsupported filter, an incompatible datatype, or a
6492        // disabled compression feature is refused up front; the chunk data
6493        // itself is laid out in the commit's apply phase. Chunked/filtered
6494        // storage flows through the very builder the normal writer uses
6495        // ([`build_chunked_data_at_ext`] + [`build_chunked_dataset_oh`]), so the
6496        // resulting object header is byte-identical to a freshly written one.
6497        let chunk_dims = db.chunk_options.resolve_chunk_dims(&shape);
6498        let ctx = ChunkContext::from_datatype(&chunk_dims, &dt);
6499        db.chunk_options
6500            .build_pipeline(
6501                ctx.element_size,
6502                &chunk_dims,
6503                ctx.element_type,
6504                ctx.scale_offset_type,
6505            )
6506            .map_err(|_| {
6507                Error::EditUnsupported(
6508                    "this dataset's filter pipeline cannot be added in place \
6509                     (an unsupported filter, an incompatible datatype, or a \
6510                     compression feature that is not enabled)",
6511                )
6512            })?;
6513    }
6514
6515    // The link message body (whose length is independent of the address) must
6516    // fit the object-header message's u16 size field; a pathologically long
6517    // name would otherwise overflow it into silent corruption.
6518    if make_link(&db.name, 0).serialize(OFFSET_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
6519        return Err(Error::EditUnsupported(
6520            "dataset name is too long to encode as a link message",
6521        ));
6522    }
6523
6524    let ds = Dataspace {
6525        space_type: if shape.is_empty() {
6526            DataspaceType::Scalar
6527        } else {
6528            DataspaceType::Simple
6529        },
6530        #[expect(
6531            clippy::cast_possible_truncation,
6532            reason = "dataspace rank fits the 1-byte dimensionality field (HDF5 caps rank at 32)"
6533        )]
6534        rank: shape.len() as u8,
6535        dimensions: shape,
6536        // A chunked, extensible dataset records its maximum dimensions (an
6537        // unlimited dimension is `u64::MAX`); a fixed-shape dataset has none.
6538        max_dimensions: db.maxshape.clone(),
6539    };
6540    let mut attrs: Vec<crate::attribute::AttributeMessage> = Vec::with_capacity(db.attrs.len());
6541    for (n, v) in &db.attrs {
6542        attrs.push(build_attr_message(n, v));
6543    }
6544    // `build_attr_message` already writes a placeholder (heap address 0) for a
6545    // `VarLenAsciiArray` attribute; stage its self-contained global heap
6546    // collections here (no address of their own to resolve yet) and record which
6547    // `attrs` slot they patch once the apply loop places them.
6548    let mut vl_attrs: Vec<(usize, Vec<Vec<u8>>)> = Vec::new();
6549    for (i, (_, v)) in db.attrs.iter().enumerate() {
6550        if let AttrValue::VarLenAsciiArray(strings) = v {
6551            let str_refs: Vec<&str> = strings.iter().map(String::as_str).collect();
6552            vl_attrs.push((i, build_global_heap_collections(&str_refs)));
6553        }
6554    }
6555    #[cfg(feature = "provenance")]
6556    if let Some(ref prov) = db.provenance {
6557        let p = crate::provenance::Provenance {
6558            creator: prov.creator.clone(),
6559            timestamp: prov.timestamp.clone(),
6560            source: prov.source.clone(),
6561        };
6562        attrs.extend(p.build_attrs(&raw));
6563    }
6564    // The object-header message-size field is 2 bytes wide, so an oversized
6565    // attribute (most reachable via a `VarLenAsciiArray` with many/long
6566    // strings) would silently truncate and corrupt the header if written
6567    // as-is; refuse it instead, mirroring `apply_group_attr_ops`'s and
6568    // `encode_attr_message`'s equivalent checks for group/root attributes.
6569    for a in &attrs {
6570        if a.serialize(LENGTH_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
6571            return Err(Error::EditUnsupported(
6572                "dataset attribute is too large to encode in place",
6573            ));
6574        }
6575    }
6576    if attrs.len() > MAX_COMPACT_ATTRS {
6577        return Err(Error::EditUnsupported(
6578            "datasets with dense (many) attributes cannot be added in place yet",
6579        ));
6580    }
6581
6582    // A user-defined fill value is one element wide, so its byte length must
6583    // equal the datatype's element size (mirrors the whole-file writer's check).
6584    if let Some(fill) = &db.fill {
6585        let expected = elem.to_usize()?;
6586        if fill.len() != expected {
6587            return Err(Error::Format(FormatError::FillValueSizeMismatch {
6588                expected,
6589                actual: fill.len(),
6590            }));
6591        }
6592    }
6593
6594    Ok(FlatDataset {
6595        name: db.name,
6596        dt,
6597        ds,
6598        raw,
6599        attrs,
6600        chunk_options: db.chunk_options,
6601        maxshape: db.maxshape,
6602        vl_attrs,
6603        vl_string_staging: db.vl_string_staging,
6604        reference_targets: db.reference_targets,
6605        fill: db.fill,
6606    })
6607}
6608
6609/// A minimal Group Info message body (type 0x000A): version 0 with neither the
6610/// link-phase-change nor the estimated-entry fields stored. With both absent the
6611/// HDF5 C library fills `max_compact`/`min_dense` from its own defaults (8 and
6612/// 6). See [`ensure_group_info`] for why every group needs this message.
6613const GROUP_INFO_BODY: [u8; 2] = [0, 0];
6614
6615/// Frame one chunk-0 object-header message record: a 1-byte type, a 2-byte
6616/// little-endian body length, a 1-byte flags field (always 0 here), then the
6617/// body. This is the v2 message-record layout used throughout a group's chunk-0
6618/// message region. Callers pass bodies that fit the u16 length field: link
6619/// bodies are validated in [`flatten_dataset`], and the Link Info / Group Info
6620/// bodies are fixed and short.
6621/// Whether a chunked dataset with this data-layout version and chunk index type
6622/// can be enumerated chunk-by-chunk (and therefore overwritten or copied in
6623/// place). Mirrors the dispatch in
6624/// [`chunked_read::collect_chunks_for_layout_from_source`](crate::chunked_read):
6625/// version-3 B-tree v1 and the version-4 single / implicit / fixed-array /
6626/// extensible-array indexes have walkers; a version-2 B-tree (index type 5) or
6627/// any unknown index type does not.
6628fn chunk_index_enumerable(version: u8, chunk_index_type: Option<u8>) -> bool {
6629    matches!((version, chunk_index_type), (3, _) | (4, Some(1..=4)))
6630}
6631
6632/// Whether every filter in `pipeline` is one this crate can *apply* (re-encode a
6633/// chunk through) — not merely decode. A pipeline with any other filter cannot be
6634/// re-encoded for an in-place overwrite, so the caller refuses with a typed error
6635/// rather than letting [`compress_chunk`] surface a raw `UnsupportedFilter`.
6636pub(crate) fn pipeline_reencodable(pipeline: &FilterPipeline) -> bool {
6637    pipeline.filters.iter().all(|f| match f.filter_id {
6638        FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_SCALEOFFSET | FILTER_LZF => {
6639            true
6640        }
6641        #[cfg(feature = "zfp")]
6642        crate::filter_pipeline::FILTER_ZFP => true,
6643        _ => false,
6644    })
6645}
6646
6647/// Rebuild a header message `region`, replacing the single Data Layout message's
6648/// record with one carrying `new_layout_body` and leaving every other message
6649/// (datatype, dataspace, fill value, filter pipeline, attributes, attribute info)
6650/// byte-for-byte. The replacement may differ in length from the original — a
6651/// chunked rebuild can change the index type and thus the layout message size — so
6652/// the record is rebuilt via [`region_message`] rather than patched in place. The
6653/// chunked overwrite and copy paths use this to relocate a dataset's chunk storage
6654/// while preserving the rest of its header exactly.
6655fn replace_layout_message(region: &[u8], new_layout_body: &[u8]) -> Result<Vec<u8>, Error> {
6656    let mut out = Vec::with_capacity(region.len());
6657    let mut p = 0;
6658    let mut replaced = false;
6659    while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
6660        if msg_type == MessageType::DataLayout && !replaced {
6661            out.extend_from_slice(&region_message(MessageType::DataLayout, new_layout_body));
6662            replaced = true;
6663        } else {
6664            out.extend_from_slice(&region[p..body_end]);
6665        }
6666        p = body_end;
6667    }
6668    if !replaced {
6669        return Err(Error::EditUnsupported(
6670            "chunked dataset header has no data-layout message to relocate",
6671        ));
6672    }
6673    Ok(out)
6674}
6675
6676/// Rebuild a header message `region`, replacing the single Dataspace message's
6677/// record with one carrying `new_dataspace_body` (the grown current dimensions,
6678/// v2-serialized, maximum dimensions preserved) and leaving every other message
6679/// byte-for-byte. Used by the append path to grow a dataset's axis-0 dimension.
6680/// The replacement may differ in length from the original (a v1 on-disk
6681/// dataspace is normalized to v2 in the rebuilt header), so the record is rebuilt
6682/// via [`region_message`] rather than patched in place.
6683fn replace_dataspace_message(region: &[u8], new_dataspace_body: &[u8]) -> Result<Vec<u8>, Error> {
6684    let mut out = Vec::with_capacity(region.len());
6685    let mut p = 0;
6686    let mut replaced = false;
6687    while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
6688        if msg_type == MessageType::Dataspace && !replaced {
6689            out.extend_from_slice(&region_message(MessageType::Dataspace, new_dataspace_body));
6690            replaced = true;
6691        } else {
6692            out.extend_from_slice(&region[p..body_end]);
6693        }
6694        p = body_end;
6695    }
6696    if !replaced {
6697        return Err(Error::AppendUnsupported(
6698            "dataset header has no dataspace message to grow",
6699        ));
6700    }
6701    Ok(out)
6702}
6703
6704/// Whether a datatype's raw on-disk bytes can be appended verbatim from a caller
6705/// via [`AppendBuilder::append_raw`]. True only when every scalar leaf is safe to
6706/// write as flat little-endian bytes:
6707///
6708/// - numeric leaves (fixed-point, floating-point, time, bit field) must be
6709///   little-endian, or the caller's little-endian bytes would silently misencode
6710///   into a big-endian (or VAX) field;
6711/// - string and opaque leaves are byte arrays with no numeric byte order, so they
6712///   are order-agnostic and safe;
6713/// - aggregates (enumeration, array, compound) are appendable iff every leaf is;
6714/// - variable-length and reference leaves embed global-heap or object addresses
6715///   that a flat byte append cannot reproduce, so they are never raw-appendable.
6716///
6717/// A typed `append_*` bypasses this: it checks full datatype equality instead, so
6718/// it already refuses every non-little-endian and non-scalar dataset.
6719pub(crate) fn datatype_is_raw_appendable(dt: &Datatype) -> bool {
6720    match dt {
6721        Datatype::FixedPoint { byte_order, .. }
6722        | Datatype::FloatingPoint { byte_order, .. }
6723        | Datatype::Time { byte_order, .. }
6724        | Datatype::BitField { byte_order, .. } => *byte_order == DatatypeByteOrder::LittleEndian,
6725        Datatype::String { .. } | Datatype::Opaque { .. } => true,
6726        Datatype::Enumeration { base_type, .. } | Datatype::Array { base_type, .. } => {
6727            datatype_is_raw_appendable(base_type)
6728        }
6729        Datatype::Compound { members, .. } => members
6730            .iter()
6731            .all(|m| datatype_is_raw_appendable(&m.datatype)),
6732        Datatype::VariableLength { .. } | Datatype::Reference { .. } => false,
6733    }
6734}
6735
6736/// The datatype, dataspace, parsed chunked data layout, and verbatim filter-
6737/// pipeline message bytes (if any) of a chunked dataset header, parsed by
6738/// [`parse_chunked_header`].
6739struct ChunkedHeaderParts {
6740    dt: crate::datatype::Datatype,
6741    ds: Dataspace,
6742    layout: DataLayout,
6743    pipeline_message: Option<Vec<u8>>,
6744}
6745
6746/// Parse the datatype, dataspace, chunked data layout, and verbatim filter-
6747/// pipeline message bytes (if any) from a chunked dataset header `region`. Used by
6748/// the chunked copy path to derive chunk geometry and the on-disk filter pipeline.
6749/// Errors if any required message is missing or the layout is not chunked.
6750fn parse_chunked_header(region: &[u8]) -> Result<ChunkedHeaderParts, Error> {
6751    let mut datatype: Option<(usize, usize)> = None;
6752    let mut dataspace: Option<(usize, usize)> = None;
6753    let mut layout: Option<(usize, usize)> = None;
6754    let mut pipeline: Option<(usize, usize)> = None;
6755    let mut p = 0;
6756    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
6757        match msg_type {
6758            MessageType::Datatype => datatype = Some((body, body_end)),
6759            MessageType::Dataspace => dataspace = Some((body, body_end)),
6760            MessageType::DataLayout => layout = Some((body, body_end)),
6761            MessageType::FilterPipeline => pipeline = Some((body, body_end)),
6762            _ => {}
6763        }
6764        p = body_end;
6765    }
6766    let (dt_b, dt_e) = datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
6767    let (ds_b, ds_e) =
6768        dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
6769    let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
6770    let (dt, _) = crate::datatype::Datatype::parse(&region[dt_b..dt_e])
6771        .map_err(|_| Error::EditUnsupported("dataset header datatype could not be parsed"))?;
6772    let ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE)
6773        .map_err(|_| Error::EditUnsupported("dataset header dataspace could not be parsed"))?;
6774    let dl = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE)
6775        .map_err(|_| Error::EditUnsupported("dataset header data layout could not be parsed"))?;
6776    if !matches!(dl, DataLayout::Chunked { .. }) {
6777        return Err(Error::EditUnsupported("dataset is not chunked"));
6778    }
6779    let pipeline_message = pipeline.map(|(b, e)| region[b..e].to_vec());
6780    Ok(ChunkedHeaderParts {
6781        dt,
6782        ds,
6783        layout: dl,
6784        pipeline_message,
6785    })
6786}
6787
6788/// The chunk geometry a verbatim chunked rebuild needs, derived by
6789/// [`chunked_geometry`] from a chunked dataset's datatype, dataspace, and parsed
6790/// [`DataLayout::Chunked`].
6791struct ChunkedGeometry {
6792    /// Rank-only spatial chunk dimensions.
6793    spatial: Vec<u64>,
6794    /// Element size in bytes.
6795    element_size: usize,
6796    /// Full (uncompressed) chunk byte size, `product(spatial) * element_size`.
6797    raw_size: u64,
6798    /// The on-disk maximum dimensions when they differ from the current shape; an
6799    /// unlimited dimension selects the extensible-array index, a finite one the
6800    /// fixed-array index. `None` keeps the fixed-array / single-chunk index.
6801    maxshape: Option<Vec<u64>>,
6802}
6803
6804/// Derive the [`ChunkedGeometry`] for a chunked dataset from its datatype,
6805/// dataspace, and parsed [`DataLayout::Chunked`].
6806fn chunked_geometry(
6807    dt: &crate::datatype::Datatype,
6808    ds: &Dataspace,
6809    layout: &DataLayout,
6810) -> Result<ChunkedGeometry, Error> {
6811    let DataLayout::Chunked {
6812        chunk_dimensions, ..
6813    } = layout
6814    else {
6815        return Err(Error::EditUnsupported("dataset is not chunked"));
6816    };
6817    let rank = ds.dimensions.len();
6818    if chunk_dimensions.len() <= rank {
6819        return Err(Error::EditUnsupported(
6820            "chunked layout has malformed dimensions",
6821        ));
6822    }
6823    let spatial: Vec<u64> = chunk_dimensions[..rank]
6824        .iter()
6825        .map(|&c| u64::from(c))
6826        .collect();
6827    let element_size = dt.type_size() as usize;
6828    if element_size == 0 {
6829        return Err(Error::EditUnsupported(
6830            "chunked dataset has a zero element size",
6831        ));
6832    }
6833    let raw_size = spatial
6834        .iter()
6835        .copied()
6836        .product::<u64>()
6837        .saturating_mul(element_size as u64);
6838    let maxshape = ds
6839        .max_dimensions
6840        .as_ref()
6841        .filter(|ms| *ms != &ds.dimensions)
6842        .cloned();
6843    Ok(ChunkedGeometry {
6844        spatial,
6845        element_size,
6846        raw_size,
6847        maxshape,
6848    })
6849}
6850
6851/// Try to overwrite a chunked dataset's chunks in place. When the dataset's
6852/// on-disk chunks form a dense grid aligned with `new_bytes` (dense row-major
6853/// order), every slot is unmasked (`filter_mask == 0`), and every new chunk
6854/// **fits** the slot it replaces (`new_len <= slot`), return the in-place
6855/// `(address, bytes)` writes:
6856///
6857/// - When every new chunk is **exactly** its slot's size, only the chunk data is
6858///   written; the index is untouched (so any enumerable index type works, and a
6859///   crash can tear at most a chunk's value bytes, not the structure).
6860/// - When some new chunks are **smaller** (fit with slack), the chunk index
6861///   records each chunk's stored size, so the index is rebuilt in place to record
6862///   the new sizes (see [`try_rebuild_index_in_place`]). This is supported only
6863///   for a v4 fixed-array or extensible-array index occupying a single contiguous
6864///   on-disk region; any other case returns `None` to relocate.
6865///
6866/// Returns `None` — so the caller relocates the dataset instead — when the index
6867/// cannot be enumerated, the grid is sparse, a slot is masked, a new chunk does
6868/// not fit, the index cannot be rebuilt in place, or any write would be out of
6869/// bounds or overlap another.
6870fn try_inplace_chunk_writes<S: Source + ?Sized>(
6871    src: &S,
6872    layout: &DataLayout,
6873    ds: &Dataspace,
6874    spatial: &[u64],
6875    raw_size: u64,
6876    new_bytes: &[Vec<u8>],
6877) -> Option<Vec<(usize, Vec<u8>)>> {
6878    let infos = enumerate_chunks_from_source(src, layout, ds, OFFSET_SIZE, LENGTH_SIZE).ok()?;
6879    let grid = plan_dense_grid(infos, &ds.dimensions, spatial)?;
6880    if grid.grid_order.len() != new_bytes.len() {
6881        return None;
6882    }
6883    let mut writes = Vec::with_capacity(new_bytes.len() + 1);
6884    let mut spans: Vec<(u64, u64)> = Vec::with_capacity(new_bytes.len() + 1);
6885    let mut any_shrunk = false;
6886    for (ci, bytes) in grid.grid_order.iter().zip(new_bytes.iter()) {
6887        // A nonzero filter mask means the source left some filter unapplied for
6888        // this chunk; re-encoding always applies every filter (mask 0), so an
6889        // in-place overwrite would desync the index-recorded mask. Relocate.
6890        if ci.filter_mask != 0 {
6891            return None;
6892        }
6893        let new_len = bytes.len() as u64;
6894        let slot = u64::from(ci.chunk_size);
6895        // A chunk that no longer fits its slot must relocate.
6896        if new_len > slot {
6897            return None;
6898        }
6899        if new_len < slot {
6900            any_shrunk = true;
6901        }
6902        let start = usize::try_from(ci.address).ok()?;
6903        start
6904            .checked_add(bytes.len())
6905            .filter(|&e| e as u64 <= src.len())?;
6906        writes.push((start, bytes.clone()));
6907        spans.push((ci.address, new_len));
6908    }
6909
6910    // A shrinking overwrite changes the index-recorded chunk sizes, so the index
6911    // must be rebuilt in place to match; an equal-size one leaves it untouched.
6912    if any_shrunk {
6913        let (index_addr, index_bytes) =
6914            try_rebuild_index_in_place(src, layout, raw_size, &grid.grid_order, new_bytes)?;
6915        spans.push((index_addr as u64, index_bytes.len() as u64));
6916        writes.push((index_addr, index_bytes));
6917    }
6918
6919    // Refuse to perform overlapping in-place writes (a malformed source index, or
6920    // an index region that overlaps a chunk slot); relocate instead so two writes
6921    // never clobber each other.
6922    if !spans_disjoint_in_bounds(&mut spans, src.len()) {
6923        return None;
6924    }
6925    Some(writes)
6926}
6927
6928/// Rebuild a chunked dataset's index **in place** so it records the new
6929/// (smaller) per-chunk stored sizes after a fits-with-slack overwrite, returning
6930/// the `(address, bytes)` write that replaces it. The chunks keep their existing
6931/// addresses (only their stored bytes shrank), so the rebuilt index points at the
6932/// same slots with the new sizes.
6933///
6934/// Supported only for a v4 **fixed-array** or **extensible-array** index whose
6935/// on-disk structure is a single contiguous region starting at the index address
6936/// — the layout this crate's own writer produces. The element width derives from
6937/// the unchanged raw chunk size, so the rebuilt structure is byte-for-byte the
6938/// same length as the original; this is required to match exactly, which rejects a
6939/// scattered or differently-laid-out (e.g. C-written) index, leaving the caller
6940/// to relocate. Single-chunk (size in the layout message) and B-tree-v1 (no
6941/// writer) indexes are not rebuilt here.
6942///
6943/// Like any in-place value overwrite (the HDF5 `H5Dwrite` model) this is not
6944/// atomic: a crash mid-write can tear the index and leave the dataset needing a
6945/// rewrite. It is used only on the in-place path, whose linearization point is the
6946/// synced data write.
6947fn try_rebuild_index_in_place<S: Source + ?Sized>(
6948    src: &S,
6949    layout: &DataLayout,
6950    raw_size: u64,
6951    grid_order: &[crate::chunked_read::ChunkInfo],
6952    new_bytes: &[Vec<u8>],
6953) -> Option<(usize, Vec<u8>)> {
6954    let DataLayout::Chunked {
6955        btree_address: Some(index_addr),
6956        chunk_index_type,
6957        version,
6958        ..
6959    } = layout
6960    else {
6961        return None;
6962    };
6963    let written: Vec<crate::chunked_write::WrittenChunk> = grid_order
6964        .iter()
6965        .zip(new_bytes)
6966        .map(|(ci, b)| crate::chunked_write::WrittenChunk {
6967            address: ci.address,
6968            compressed_size: b.len() as u64,
6969            raw_size,
6970            filter_mask: 0,
6971        })
6972        .collect();
6973    let new_index = match (version, chunk_index_type) {
6974        (4, Some(3)) => crate::chunked_write::build_fixed_array_at(
6975            &written,
6976            OFFSET_SIZE,
6977            LENGTH_SIZE,
6978            true,
6979            *index_addr,
6980        ),
6981        (4, Some(4)) => crate::chunked_write::build_extensible_array_at(
6982            &written,
6983            OFFSET_SIZE,
6984            LENGTH_SIZE,
6985            true,
6986            *index_addr,
6987        )
6988        .ok()?,
6989        // Single-chunk records its size in the layout message (a header rewrite),
6990        // and a B-tree-v1 index has no writer; both relocate instead.
6991        _ => return None,
6992    };
6993
6994    // The on-disk index must be a single contiguous region starting at the index
6995    // address, and the rebuilt structure must be exactly the same length (true for
6996    // an index this crate wrote). A scattered or different on-disk layout fails
6997    // the check and the caller relocates.
6998    let mut spans =
6999        crate::chunked_read::chunk_index_spans_from_source(src, layout, OFFSET_SIZE, LENGTH_SIZE)
7000            .ok()?;
7001    if spans.is_empty() {
7002        return None;
7003    }
7004    spans.sort_unstable_by_key(|&(a, _)| a);
7005    if spans[0].0 != *index_addr {
7006        return None;
7007    }
7008    let mut end = *index_addr;
7009    for &(a, l) in &spans {
7010        if a != end {
7011            return None; // a gap means the index is not contiguous
7012        }
7013        end = a.checked_add(l)?;
7014    }
7015    if new_index.len() as u64 != end - *index_addr {
7016        return None;
7017    }
7018    let start = usize::try_from(*index_addr).ok()?;
7019    start
7020        .checked_add(new_index.len())
7021        .filter(|&e| e as u64 <= src.len())?;
7022    Some((start, new_index))
7023}
7024
7025/// A [`ChunkProvider`] over chunk bytes already held in memory, in dense
7026/// row-major grid order. Used by the editor's chunked copy and relocating
7027/// overwrite, which own each chunk's bytes (a [`CopyTree`] or [`MovingWrite`]
7028/// captured them) rather than streaming from a source file like repack.
7029struct SliceChunkProvider<'a> {
7030    chunks: &'a [Vec<u8>],
7031}
7032
7033impl ChunkProvider for SliceChunkProvider<'_> {
7034    fn chunk_bytes(&self, index: usize, out: &mut Vec<u8>) -> Result<(), FormatError> {
7035        let chunk = self.chunks.get(index).ok_or_else(|| {
7036            FormatError::ChunkedReadError("chunk index out of range for in-memory provider".into())
7037        })?;
7038        out.extend_from_slice(chunk);
7039        Ok(())
7040    }
7041}
7042
7043fn region_message(msg_type: MessageType, body: &[u8]) -> Vec<u8> {
7044    let mut m = Vec::with_capacity(4 + body.len());
7045    #[expect(
7046        clippy::cast_possible_truncation,
7047        reason = "message type ids are a small enum that fits the 1-byte v2 type field"
7048    )]
7049    m.push(msg_type.to_u16() as u8);
7050    #[expect(
7051        clippy::cast_possible_truncation,
7052        reason = "callers pass bodies that fit the 2-byte message-size field (see doc comment)"
7053    )]
7054    m.extend_from_slice(&(body.len() as u16).to_le_bytes());
7055    m.push(0); // message flags
7056    m.extend_from_slice(body);
7057    m
7058}
7059
7060/// The chunk-0 message region of a fresh, empty compact-link group: a LinkInfo
7061/// message advertising no dense storage, followed by a GroupInfo message.
7062/// Mirrors `build_group_oh`.
7063fn fresh_group_region() -> Vec<u8> {
7064    let mut li = Vec::with_capacity(18);
7065    li.push(0); // version
7066    li.push(0); // flags
7067    li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap addr = UNDEF
7068    li.extend_from_slice(&u64::MAX.to_le_bytes()); // btree name index addr = UNDEF
7069    let mut region = region_message(MessageType::LinkInfo, &li);
7070    region.extend_from_slice(&region_message(MessageType::GroupInfo, &GROUP_INFO_BODY));
7071    region
7072}
7073
7074/// Ensure a group's chunk-0 message `region` carries a Group Info message,
7075/// appending a minimal one when absent.
7076///
7077/// The HDF5 C library refuses to insert a link into a group whose object header
7078/// has a Link Info message but no Group Info message: on the new-format path
7079/// `H5G_obj_insert` reads the Group Info message unconditionally and fails with
7080/// "message type not found". Such a group round-trips for *reading* but cannot
7081/// be *modified* by the C library. Earlier hdf5-pure releases wrote groups that
7082/// way, so heal any such header whenever we rewrite one in place.
7083fn ensure_group_info(region: &mut Vec<u8>) -> Result<(), Error> {
7084    let mut p = 0;
7085    while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
7086        if msg_type == MessageType::GroupInfo {
7087            return Ok(());
7088        }
7089        p = body_end;
7090    }
7091    region.extend_from_slice(&region_message(MessageType::GroupInfo, &GROUP_INFO_BODY));
7092    Ok(())
7093}
7094
7095/// Encode a complete object-header Link message (4-byte record header + body)
7096/// for a hard link `name -> addr`. The caller must have validated that the body
7097/// fits the u16 size field (see [`flatten_dataset`]); group names are short.
7098fn encode_link_message(name: &str, addr: u64) -> Vec<u8> {
7099    let body = make_link(name, addr).serialize(OFFSET_SIZE);
7100    region_message(MessageType::Link, &body)
7101}
7102
7103/// Patch an existing hard Link message in a chunk-0 message `region`, retargeting
7104/// the link named `name` to `new_addr` (used to repoint a parent at a relocated
7105/// child group). The target address is the trailing `OFFSET_SIZE` bytes of the
7106/// link body for a hard link.
7107fn patch_link_target(region: &mut [u8], name: &str, new_addr: u64) -> Result<(), Error> {
7108    let mut p = 0;
7109    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7110        if msg_type == MessageType::Link {
7111            if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
7112                if link.name == name {
7113                    return match link.link_target {
7114                        LinkTarget::Hard { .. } => {
7115                            let ofs = body_end - OFFSET_SIZE as usize;
7116                            region[ofs..body_end].copy_from_slice(&new_addr.to_le_bytes());
7117                            Ok(())
7118                        }
7119                        _ => Err(Error::EditUnsupported(
7120                            "a group on the edited path is reached by a soft/external link",
7121                        )),
7122                    };
7123                }
7124            }
7125        }
7126        p = body_end;
7127    }
7128    Err(Error::EditUnsupported(
7129        "expected child link not found in parent group",
7130    ))
7131}
7132
7133/// Bytes a compact Data Layout message carries ahead of its inline data:
7134/// version(1) + class(1) + the 2-byte inline size.
7135const COMPACT_LAYOUT_PREAMBLE: usize = 4;
7136
7137/// Copy a chunk-0 message `region`, replacing the single (compact) Data Layout
7138/// message's inline data with `raw` and preserving every other message verbatim.
7139/// Used by `write_dataset` to overwrite a compact dataset's values. The message
7140/// header (type and flags) and version byte are kept; only the inline data — and
7141/// the message size and 2-byte inline-size fields — change. `raw` must fit both
7142/// the compact layout's own 2-byte size field (HDF5's 64 KiB compact-storage
7143/// limit) and, once the 4-byte layout preamble is added, the object header's
7144/// 2-byte message-size field — the tighter of the two, which an overwrite of an
7145/// existing compact dataset always satisfies.
7146fn rebuild_compact_layout_region(region: &[u8], raw: &[u8]) -> Result<Vec<u8>, Error> {
7147    // The bound is on the *message body* the layout becomes — version, class,
7148    // and the 2-byte inline size ahead of the data — not on `raw` alone, or the
7149    // last four lengths below the limit would truncate the size field written
7150    // for them.
7151    if raw.len() > OBJECT_HEADER_MESSAGE_MAX - COMPACT_LAYOUT_PREAMBLE {
7152        return Err(Error::EditUnsupported(
7153            "compact dataset data is too large to overwrite in place",
7154        ));
7155    }
7156    let mut out = Vec::with_capacity(region.len() + raw.len());
7157    let mut p = 0;
7158    let mut replaced = false;
7159    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7160        if msg_type == MessageType::DataLayout {
7161            if body_end - body < 2 || region[body + 1] != 0 {
7162                return Err(Error::EditUnsupported(
7163                    "compact-layout overwrite found a non-compact data layout",
7164                ));
7165            }
7166            // New compact layout body: version (kept), class=0, 2-byte inline
7167            // size, then the data.
7168            let mut layout = Vec::with_capacity(COMPACT_LAYOUT_PREAMBLE + raw.len());
7169            layout.push(region[body]); // version (3 or 4)
7170            layout.push(0); // class = compact
7171            #[expect(
7172                clippy::cast_possible_truncation,
7173                reason = "raw.len() bounded below the u16 inline-size field above"
7174            )]
7175            layout.extend_from_slice(&(raw.len() as u16).to_le_bytes());
7176            layout.extend_from_slice(raw);
7177            // Message record: type byte, 2-byte size (LE), flags byte (kept).
7178            out.push(region[p]);
7179            #[expect(
7180                clippy::cast_possible_truncation,
7181                reason = "the guard above bounds COMPACT_LAYOUT_PREAMBLE + raw.len(), this \
7182                          body's exact length, to the 2-byte message-size field"
7183            )]
7184            out.extend_from_slice(&(layout.len() as u16).to_le_bytes());
7185            out.push(region[p + 3]);
7186            out.extend_from_slice(&layout);
7187            replaced = true;
7188        } else {
7189            out.extend_from_slice(&region[p..body_end]);
7190        }
7191        p = body_end;
7192    }
7193    if p < region.len() {
7194        out.extend_from_slice(&region[p..]);
7195    }
7196    if !replaced {
7197        return Err(Error::EditUnsupported(
7198            "compact dataset header has no data-layout message",
7199        ));
7200    }
7201    Ok(out)
7202}
7203
7204/// Copy a chunk-0 message `region`, dropping the single Link message named
7205/// `name` and preserving every other message verbatim (used by `delete`). Errors
7206/// if no such link is present.
7207fn remove_link_from_region(region: &[u8], name: &str) -> Result<Vec<u8>, Error> {
7208    let mut out = Vec::with_capacity(region.len());
7209    let mut p = 0;
7210    let mut removed = false;
7211    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7212        let mut skip = false;
7213        if msg_type == MessageType::Link {
7214            if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
7215                if link.name == name {
7216                    skip = true;
7217                    removed = true;
7218                }
7219            }
7220        }
7221        if !skip {
7222            out.extend_from_slice(&region[p..body_end]);
7223        }
7224        p = body_end;
7225    }
7226    if p < region.len() {
7227        out.extend_from_slice(&region[p..]);
7228    }
7229    if !removed {
7230        return Err(Error::EditUnsupported(
7231            "link to delete not found in its parent group",
7232        ));
7233    }
7234    Ok(out)
7235}
7236
7237/// Apply compact attribute edits to a group message `region`, preserving every
7238/// non-attribute message verbatim. A fixed-size `Set`/`Remove` is resolved
7239/// into `region` directly; a variable-length `Set` (`VarLenAsciiArray`) is
7240/// instead collected into the returned `pending_vl_attrs` — its placeholder
7241/// heap address is only patched, and the message appended to the group's
7242/// header, by the apply loop once its global heap collection's real address
7243/// is known (see [`WriteEngine::place_vl_collection`]). A later op for the
7244/// same name (another `Set`, fixed-size or not, or a `Remove`) replaces or
7245/// cancels an earlier still-pending variable-length entry, keeping the net
7246/// effect the same regardless of op order within one commit. `region`'s
7247/// fixed-size portion is a complete compact-attribute header on return; dense
7248/// attribute storage and shared attribute messages are refused.
7249fn apply_group_attr_ops(region: &[u8], ops: &[AttrOp]) -> Result<(Vec<u8>, PendingVlAttrs), Error> {
7250    let mut out = region.to_vec();
7251    let mut pending_vl: PendingVlAttrs = Vec::new();
7252    let mut wrote_attr = false;
7253    for op in ops {
7254        match op {
7255            AttrOp::Set { name, value } => {
7256                wrote_attr = true;
7257                pending_vl.retain(|(msg, _)| &msg.name != name);
7258                if let AttrValue::VarLenAsciiArray(strings) = value {
7259                    // Nothing yet to remove from `region` if this name has
7260                    // never been set as a fixed-size attribute.
7261                    out = remove_attr_from_region(&out, name, false)?;
7262                    let msg = build_attr_message(name, value);
7263                    if msg.serialize(LENGTH_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
7264                        return Err(Error::EditUnsupported(
7265                            "attribute is too large to encode in place",
7266                        ));
7267                    }
7268                    let str_refs: Vec<&str> = strings.iter().map(String::as_str).collect();
7269                    pending_vl.push((msg, build_global_heap_collections(&str_refs)));
7270                } else {
7271                    out = set_attr_in_region(&out, name, value)?;
7272                }
7273            }
7274            AttrOp::Remove { name } => {
7275                let before = pending_vl.len();
7276                pending_vl.retain(|(msg, _)| &msg.name != name);
7277                if pending_vl.len() == before {
7278                    out = remove_attr_from_region(&out, name, true)?;
7279                }
7280            }
7281        }
7282    }
7283    if wrote_attr && compact_attr_count(&out)? + pending_vl.len() > MAX_COMPACT_ATTRS {
7284        return Err(Error::EditUnsupported(
7285            "attributes would exceed compact storage; dense attribute edits are not supported in place yet",
7286        ));
7287    }
7288    Ok((out, pending_vl))
7289}
7290
7291/// Whether an Attribute Info (0x0015) message body denotes *dense* (fractal-heap)
7292/// attribute storage — a *defined* heap address. The reference C library and h5py
7293/// emit an Attribute Info message with an *undefined* heap address even for
7294/// compact, inline attributes in the latest format (to carry creation-order
7295/// metadata), so its mere presence is not dense storage; only a defined heap
7296/// address is. An unparseable message is treated as dense (refused conservatively).
7297/// Mirrors the copy path's dense detection so the compact-attribute editors accept
7298/// the undefined-address message that nearly every real-world object carries.
7299fn attribute_info_is_dense(body: &[u8]) -> bool {
7300    match crate::attribute_info::AttributeInfoMessage::parse(body, OFFSET_SIZE) {
7301        Ok(ai) => ai.fractal_heap_address.is_some(),
7302        Err(_) => true,
7303    }
7304}
7305
7306/// Copy a message region, dropping all Attribute messages named `name` and then
7307/// appending a fresh compact Attribute message for `value`.
7308fn set_attr_in_region(region: &[u8], name: &str, value: &AttrValue) -> Result<Vec<u8>, Error> {
7309    let new_msg = encode_attr_message(name, value)?;
7310    let mut out = Vec::with_capacity(region.len() + new_msg.len());
7311    let mut p = 0;
7312    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7313        match msg_type {
7314            MessageType::AttributeInfo => {
7315                if attribute_info_is_dense(&region[body..body_end]) {
7316                    return Err(Error::EditUnsupported(
7317                        "a target object uses dense (fractal-heap) attribute storage (not supported in place yet)",
7318                    ));
7319                }
7320                // An undefined-heap Attribute Info message is creation-order
7321                // metadata, not dense storage; preserve it verbatim (fall through
7322                // to copy the message below).
7323            }
7324            MessageType::Attribute => {
7325                let attr_name = parse_compact_attr_name(region, p, body, body_end)?;
7326                if attr_name == name {
7327                    p = body_end;
7328                    continue;
7329                }
7330            }
7331            _ => {}
7332        }
7333        out.extend_from_slice(&region[p..body_end]);
7334        p = body_end;
7335    }
7336    out.extend_from_slice(&new_msg);
7337    if p < region.len() {
7338        out.extend_from_slice(&region[p..]);
7339    }
7340    Ok(out)
7341}
7342
7343/// Copy a message region, dropping all Attribute messages named `name`. When
7344/// `required` is true, an absent `name` is an [`Error::EditUnsupported`] (a
7345/// `Remove` of a nonexistent attribute); when false, it is not an error (a
7346/// `Set` of a fresh variable-length attribute may have no fixed-size message
7347/// to remove from the region yet).
7348fn remove_attr_from_region(region: &[u8], name: &str, required: bool) -> Result<Vec<u8>, Error> {
7349    let mut out = Vec::with_capacity(region.len());
7350    let mut p = 0;
7351    let mut removed = false;
7352    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7353        let mut skip = false;
7354        match msg_type {
7355            MessageType::AttributeInfo => {
7356                if attribute_info_is_dense(&region[body..body_end]) {
7357                    return Err(Error::EditUnsupported(
7358                        "a target object uses dense (fractal-heap) attribute storage (not supported in place yet)",
7359                    ));
7360                }
7361                // An undefined-heap Attribute Info message is creation-order
7362                // metadata, not dense storage; preserve it verbatim.
7363            }
7364            MessageType::Attribute => {
7365                let attr_name = parse_compact_attr_name(region, p, body, body_end)?;
7366                if attr_name == name {
7367                    skip = true;
7368                    removed = true;
7369                }
7370            }
7371            _ => {}
7372        }
7373        if !skip {
7374            out.extend_from_slice(&region[p..body_end]);
7375        }
7376        p = body_end;
7377    }
7378    if p < region.len() {
7379        out.extend_from_slice(&region[p..]);
7380    }
7381    if !removed && required {
7382        return Err(Error::EditUnsupported("attribute to remove was not found"));
7383    }
7384    Ok(out)
7385}
7386
7387fn compact_attr_count(region: &[u8]) -> Result<usize, Error> {
7388    let mut count = 0usize;
7389    let mut p = 0;
7390    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7391        if msg_type == MessageType::AttributeInfo
7392            && attribute_info_is_dense(&region[body..body_end])
7393        {
7394            return Err(Error::EditUnsupported(
7395                "a target object uses dense (fractal-heap) attribute storage (not supported in place yet)",
7396            ));
7397        }
7398        if msg_type == MessageType::Attribute {
7399            count += 1;
7400        }
7401        p = body_end;
7402    }
7403    Ok(count)
7404}
7405
7406fn parse_compact_attr_name(
7407    region: &[u8],
7408    msg_start: usize,
7409    body: usize,
7410    body_end: usize,
7411) -> Result<String, Error> {
7412    if region[msg_start + 3] != 0 {
7413        return Err(Error::EditUnsupported(
7414            "a target object has a shared attribute message (not editable in place yet)",
7415        ));
7416    }
7417    crate::attribute::AttributeMessage::parse(&region[body..body_end], LENGTH_SIZE)
7418        .map(|attr| attr.name)
7419        .map_err(|_| Error::EditUnsupported("a target object has an unreadable attribute message"))
7420}
7421
7422fn encode_attr_message(name: &str, value: &AttrValue) -> Result<Vec<u8>, Error> {
7423    // `apply_group_attr_ops`'s `Set` branch — this function's only caller —
7424    // handles `VarLenAsciiArray` itself (staging it into `pending_vl` instead
7425    // of calling `set_attr_in_region`/here), so this value is always
7426    // fixed-size by construction, not by a check made at this call site.
7427    debug_assert!(
7428        !matches!(value, AttrValue::VarLenAsciiArray(_)),
7429        "VarLenAsciiArray must be intercepted by apply_group_attr_ops before reaching encode_attr_message"
7430    );
7431    let body = build_attr_message(name, value).serialize(LENGTH_SIZE);
7432    if body.len() > OBJECT_HEADER_MESSAGE_MAX {
7433        return Err(Error::EditUnsupported(
7434            "group attribute is too large to encode in place",
7435        ));
7436    }
7437    Ok(region_message(MessageType::Attribute, &body))
7438}
7439
7440/// Whether `a` is a path prefix of (or equal to) `b`.
7441fn is_prefix(a: &[String], b: &[String]) -> bool {
7442    a.len() <= b.len() && b[..a.len()] == *a
7443}
7444
7445/// Parse the version-2 object-header message record at `p` within a chunk-0
7446/// message region, returning `(message type, body start, body end)`; the next
7447/// record begins at `body end`. Returns `Ok(None)` once fewer than 4 bytes
7448/// remain (a clean end of the region), and `Err` if a record's declared body
7449/// runs past the region. Centralizes the bounds check shared by every walker.
7450/// Rebuild a superblock-extension object header's message region (as collapsed by
7451/// [`WriteEngine::gather_oh_messages`]) with
7452/// its File Space Info message replaced by `info`, preserving every other message
7453/// verbatim. The persisting message is fixed-size, so the region length is stable.
7454/// Shared by the whole-file mirror commit and the bounded finalize so both write
7455/// the same extension bytes.
7456pub(crate) fn rewrite_extension_region_bytes(
7457    region: &[u8],
7458    info: &FileSpaceInfo,
7459) -> Result<Vec<u8>, Error> {
7460    let new_body = info.serialize();
7461    // The message body is the fixed-size File Space Info record (≤ 125 bytes),
7462    // so it always fits the u16 size field; `try_from` keeps this off the
7463    // 32-bit narrowing-cast ledger.
7464    let new_len = u16::try_from(new_body.len())
7465        .map_err(|_| Error::EditUnsupported("File Space Info message too large"))?;
7466    let mut out = Vec::with_capacity(region.len());
7467    let mut p = 0;
7468    let mut replaced = false;
7469    while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
7470        if msg_type == MessageType::FileSpaceInfo {
7471            out.push(region[p]); // message type byte
7472            out.extend_from_slice(&new_len.to_le_bytes());
7473            out.push(region[p + 3]); // preserve the message flags (0x14)
7474            out.extend_from_slice(&new_body);
7475            replaced = true;
7476        } else {
7477            out.extend_from_slice(&region[p..body_end]);
7478        }
7479        p = body_end;
7480    }
7481    if !replaced {
7482        // Persistence is armed only when the extension already carries a File
7483        // Space Info message, so this is unreachable; refuse rather than
7484        // silently restructure an extension we did not understand.
7485        return Err(Error::EditUnsupported(
7486            "a persisting file's superblock extension has no File Space Info message",
7487        ));
7488    }
7489    Ok(out)
7490}
7491
7492/// Parse and validate a version 2 object header's prefix, returning the absolute
7493/// `[start, end)` byte range of its chunk-0 message region.
7494///
7495/// `prefix` holds the bytes at `[addr, addr + prefix.len())` — up to
7496/// [`OH_PREFIX_MAX`], fewer when the header sits near the end of the image — and
7497/// `file_len` is the length of the image the header lives in, which bounds the
7498/// region. Rejects headers that are not OHDR v2 and headers that track message
7499/// creation order, whose 6-byte message records this engine does not emit.
7500fn oh_region_at(prefix: &[u8], addr: u64, file_len: u64) -> Result<(u64, u64), Error> {
7501    if prefix.len() < 6 || &prefix[..4] != b"OHDR" || prefix[4] != 2 {
7502        return Err(Error::EditUnsupported(
7503            "an object does not use a version 2 object header",
7504        ));
7505    }
7506    let flags = prefix[5];
7507    if flags & 0x04 != 0 {
7508        return Err(Error::EditUnsupported(
7509            "an object tracks message creation order (not supported in place yet)",
7510        ));
7511    }
7512    let mut pos = 6usize;
7513    if flags & 0x20 != 0 {
7514        pos += 16; // optional timestamps
7515    }
7516    if flags & 0x10 != 0 {
7517        pos += 4; // optional attribute phase-change thresholds
7518    }
7519    let size_width = match flags & 0x03 {
7520        0 => 1usize,
7521        1 => 2,
7522        2 => 4,
7523        _ => 8,
7524    };
7525    if prefix.len() < pos + size_width {
7526        return Err(Error::EditUnsupported("truncated object header"));
7527    }
7528    let chunk0_size = read_le(&prefix[pos..pos + size_width]) as u64;
7529    pos += size_width;
7530    let region_start = addr
7531        .checked_add(pos as u64)
7532        .ok_or(Error::EditUnsupported("truncated object header"))?;
7533    // The region is followed by a 4-byte checksum, which must also be present.
7534    let region_end = region_start
7535        .checked_add(chunk0_size)
7536        .filter(|e| e.checked_add(4).is_some_and(|end| end <= file_len))
7537        .ok_or(Error::EditUnsupported("truncated object header"))?;
7538    Ok((region_start, region_end))
7539}
7540
7541/// One chunk of a version 2 object header, read out of a file image.
7542///
7543/// The buffer stops at the end of the chunk's message region: the trailing
7544/// checksum is never walked, and it has already been confirmed present. `span`
7545/// covers the *whole* on-disk chunk including that checksum, so it can be handed
7546/// to the free list when the header is reclaimed.
7547struct OhChunk {
7548    /// Absolute file address and full on-disk length of the chunk.
7549    span: (u64, u64),
7550    /// The chunk's bytes, from `span.0` through the end of its message region.
7551    buf: Vec<u8>,
7552    /// Offset of the first message within [`buf`](Self::buf).
7553    messages_start: usize,
7554}
7555
7556impl OhChunk {
7557    /// The slice to walk messages in, and the offset to start at. The two are
7558    /// returned together because [`next_message`] must not read past the end of
7559    /// the message region into the checksum.
7560    fn message_region(&self) -> (&[u8], usize) {
7561        (&self.buf, self.messages_start)
7562    }
7563}
7564
7565/// Read chunk 0 of the version 2 object header at `addr` out of `src`.
7566fn read_oh_chunk0<S: Source + ?Sized>(src: &S, addr: u64) -> Result<OhChunk, Error> {
7567    let file_len = src.len();
7568    let window = file_len
7569        .saturating_sub(addr)
7570        .min(OH_PREFIX_MAX as u64)
7571        .to_usize()?;
7572    let prefix = src.read_metadata_at(addr, window)?;
7573    let (rs, re) = oh_region_at(&prefix, addr, file_len)?;
7574    // `re >= rs > addr`, so both differences are non-negative, and `oh_region_at`
7575    // has checked that the 4-byte checksum past `re` is present.
7576    let len = (re - addr).to_usize()?;
7577    Ok(OhChunk {
7578        span: (addr, len as u64 + 4),
7579        buf: src.read_metadata_at(addr, len)?,
7580        messages_start: (rs - addr).to_usize()?,
7581    })
7582}
7583
7584/// Read every chunk of the version 2 object header at `addr`, chunk 0 first,
7585/// following each `Continuation` message to its `OCHK` block.
7586///
7587/// This is the one traversal of a header's chunk chain: [`gather_oh_messages`]
7588/// collects the messages out of the result and
7589/// [`oh_chunk_spans`](WriteEngine::oh_chunk_spans) collects the extents, so the
7590/// two cannot disagree about what a header occupies.
7591fn read_oh_chunks<S: Source + ?Sized>(
7592    src: &S,
7593    addr: u64,
7594    base: u64,
7595) -> Result<Vec<OhChunk>, Error> {
7596    let mut chunks = vec![read_oh_chunk0(src, addr)?];
7597    let mut i = 0;
7598    while i < chunks.len() {
7599        if chunks.len() > MAX_OH_CHUNKS {
7600            return Err(Error::EditUnsupported(
7601                "object header has too many continuation chunks",
7602            ));
7603        }
7604        // Collect this chunk's continuations before extending the worklist, so the
7605        // borrow of `chunks[i]` ends first.
7606        let mut found = Vec::new();
7607        let (region, mut p) = chunks[i].message_region();
7608        while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7609            if msg_type == MessageType::ObjectHeaderContinuation {
7610                found.push(read_oh_continuation(src, region, body, body_end, base)?);
7611            }
7612            p = body_end;
7613        }
7614        i += 1;
7615        chunks.extend(found);
7616    }
7617    Ok(chunks)
7618}
7619
7620/// Read the `OCHK` continuation block a continuation message points at.
7621///
7622/// `region[body..body_end]` is the continuation message's body: the block's
7623/// base-relative address followed by its length.
7624fn read_oh_continuation<S: Source + ?Sized>(
7625    src: &S,
7626    region: &[u8],
7627    body: usize,
7628    body_end: usize,
7629    base: u64,
7630) -> Result<OhChunk, Error> {
7631    if body_end - body < (OFFSET_SIZE + LENGTH_SIZE) as usize {
7632        return Err(Error::EditUnsupported("malformed continuation message"));
7633    }
7634    let off = u64::from_le_bytes(region[body..body + 8].try_into().unwrap());
7635    let len = u64::from_le_bytes(region[body + 8..body + 16].try_into().unwrap());
7636    // The block address is stored relative to the base address; shift it to an
7637    // absolute file offset before reading.
7638    let off = off
7639        .checked_add(base)
7640        .ok_or(Error::EditUnsupported("continuation address overflow"))?;
7641    // An OCHK block is signature(4) + messages + checksum(4).
7642    let end = off
7643        .checked_add(len)
7644        .filter(|&e| e <= src.len() && len >= 8)
7645        .ok_or(Error::EditUnsupported("continuation block out of bounds"))?;
7646    let want = (end - off)
7647        .to_usize()
7648        .map_err(|_| Error::EditUnsupported("continuation length exceeds this platform"))?;
7649    let mut buf = src.read_metadata_at(off, want)?;
7650    if buf[..4] != *b"OCHK" {
7651        return Err(Error::EditUnsupported(
7652            "invalid continuation block signature",
7653        ));
7654    }
7655    // Trim the trailing checksum so the message walk stops at the last message.
7656    buf.truncate(want - 4);
7657    Ok(OhChunk {
7658        span: (off, len),
7659        buf,
7660        messages_start: 4,
7661    })
7662}
7663
7664pub(crate) fn next_message(
7665    region: &[u8],
7666    p: usize,
7667) -> Result<Option<(MessageType, usize, usize)>, Error> {
7668    if p + 4 > region.len() {
7669        return Ok(None);
7670    }
7671    let msg_type = MessageType::from_u16(region[p] as u16);
7672    let msg_size = u16::from_le_bytes([region[p + 1], region[p + 2]]) as usize;
7673    let body = p + 4;
7674    let body_end = body + msg_size;
7675    if body_end > region.len() {
7676        return Err(Error::EditUnsupported("malformed object header message"));
7677    }
7678    Ok(Some((msg_type, body, body_end)))
7679}
7680
7681/// Version-2 object-header message flag bit marking a message as *shared* (stored
7682/// once in the shared-message table and referenced by an object-header address or
7683/// fractal-heap id) rather than inline. Whatever the message type, that reference
7684/// points into the source file and is meaningless after a cross-file copy.
7685const MSG_FLAG_SHARED: u8 = 0x02;
7686
7687/// Refuse to copy an object whose header embeds a *source-file* absolute address
7688/// that a verbatim copy into another file cannot translate. An in-file copy keeps
7689/// these valid by sharing the source file's heaps and objects; a cross-file copy
7690/// cannot. Three things qualify:
7691///
7692/// - a **variable-length** datatype, whose element bytes are global-heap
7693///   references (collection address + index) into the source file's heap;
7694/// - a **reference** datatype (object or dataset-region), whose element bytes are
7695///   absolute object addresses in the source file;
7696/// - any **shared message** (the `MSG_FLAG_SHARED` bit set) — a committed datatype,
7697///   but also a shared dataspace, fill value, or filter-pipeline message — whose
7698///   body is a reference into the source file's shared-message storage.
7699///
7700/// The scan covers a copied object's whole message region (a dataset's or a
7701/// group's): it refuses any shared message outright, and inspects Datatype
7702/// messages (the element type) and Attribute messages (their own datatype),
7703/// recursing through compound members, array elements, and enumeration bases so a
7704/// nested variable-length or reference occurrence is caught too. It is applied
7705/// only on the cross-file path; the same-file [`copy`](crate::File::copy)
7706/// deliberately keeps these forms (their addresses stay valid in one file).
7707fn reject_foreign_addresses(region: &[u8]) -> Result<(), Error> {
7708    let mut p = 0;
7709    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7710        // A *shared* message stores, in place of its real body, a reference into
7711        // the source file's shared-message storage — an object-header address or a
7712        // fractal-heap (SOHM) id — which means nothing in another file. This
7713        // catches committed (shared) datatypes and shared attributes as well as a
7714        // shared dataspace, fill value, or filter-pipeline message, all of which
7715        // HDF5 may place in the shared-message table. Refuse any of them, whatever
7716        // the message type. The flags byte is the 4th of the record header (type,
7717        // size, flags); `next_message` returning `Some` guarantees
7718        // `p + 4 <= region.len()`.
7719        if region[p + 3] & MSG_FLAG_SHARED != 0 {
7720            return Err(Error::EditUnsupported(
7721                "a shared (committed/SOHM) object-header message cannot be copied to another file yet",
7722            ));
7723        }
7724        match msg_type {
7725            MessageType::Datatype => {
7726                let (dt, _) =
7727                    crate::datatype::Datatype::parse(&region[body..body_end]).map_err(|_| {
7728                        Error::EditUnsupported("a source datatype could not be parsed for copying")
7729                    })?;
7730                if datatype_copies_foreign_address(&dt) {
7731                    return Err(Error::EditUnsupported(
7732                        "variable-length or reference datasets cannot be copied to another file yet",
7733                    ));
7734                }
7735            }
7736            MessageType::Attribute => {
7737                let attr =
7738                    crate::attribute::AttributeMessage::parse(&region[body..body_end], LENGTH_SIZE)
7739                        .map_err(|_| {
7740                            Error::EditUnsupported(
7741                                "a source attribute could not be parsed for copying",
7742                            )
7743                        })?;
7744                if datatype_copies_foreign_address(&attr.datatype) {
7745                    return Err(Error::EditUnsupported(
7746                        "variable-length or reference attributes cannot be copied to another file yet",
7747                    ));
7748                }
7749            }
7750            _ => {}
7751        }
7752        p = body_end;
7753    }
7754    Ok(())
7755}
7756
7757/// Cross-file screen for a dense (fractal-heap) attribute set. The bytes parsed
7758/// out of the source heap can embed source-file absolute addresses just as inline
7759/// attribute messages can — variable-length (global-heap) or reference attribute
7760/// data — which would dangle in another file. [`reject_foreign_addresses`] screens
7761/// the verbatim object-header region but not heap-resident attribute bytes, so a
7762/// dense attribute set is screened here instead. Same-file copies skip this (their
7763/// addresses stay valid); the fresh heap built on write is same-file by
7764/// construction, so only the source datatypes matter.
7765fn reject_foreign_dense_attrs(attrs: &[crate::attribute::AttributeMessage]) -> Result<(), Error> {
7766    for attr in attrs {
7767        if datatype_copies_foreign_address(&attr.datatype) {
7768            return Err(Error::EditUnsupported(
7769                "variable-length or reference dense (fractal-heap) attributes cannot be copied to another file yet",
7770            ));
7771        }
7772    }
7773    Ok(())
7774}
7775
7776/// Whether `dt` stores, anywhere in its structure, a value that is a source-file
7777/// absolute address: a variable-length (global-heap) or reference datatype, or a
7778/// compound / array / enumeration built over one. See [`reject_foreign_addresses`].
7779fn datatype_copies_foreign_address(dt: &crate::datatype::Datatype) -> bool {
7780    use crate::datatype::Datatype;
7781    match dt {
7782        Datatype::VariableLength { .. } | Datatype::Reference { .. } => true,
7783        Datatype::Compound { members, .. } => members
7784            .iter()
7785            .any(|m| datatype_copies_foreign_address(&m.datatype)),
7786        Datatype::Array { base_type, .. } | Datatype::Enumeration { base_type, .. } => {
7787            datatype_copies_foreign_address(base_type)
7788        }
7789        _ => false,
7790    }
7791}
7792
7793/// Wrap a chunk-0 message region in a fresh single-chunk version 2 object header
7794/// (`OHDR` prefix + region + Jenkins checksum). Mirrors the encoding in
7795/// [`crate::object_header_writer::ObjectHeaderWriter::serialize`].
7796pub(crate) fn build_v2_object_header(region: &[u8]) -> Vec<u8> {
7797    let total = region.len();
7798    let (flags, width) = if total <= 255 {
7799        (0u8, 1usize)
7800    } else if total <= 65535 {
7801        (1u8, 2)
7802    } else {
7803        (2u8, 4)
7804    };
7805    let mut buf = Vec::with_capacity(8 + total + 4);
7806    buf.extend_from_slice(b"OHDR");
7807    buf.push(2); // version
7808    buf.push(flags);
7809    #[expect(
7810        clippy::cast_possible_truncation,
7811        reason = "width was selected just above to be the smallest field that holds total"
7812    )]
7813    match width {
7814        1 => buf.push(total as u8),
7815        2 => buf.extend_from_slice(&(total as u16).to_le_bytes()),
7816        _ => buf.extend_from_slice(&(total as u32).to_le_bytes()),
7817    }
7818    buf.extend_from_slice(region);
7819    let checksum = jenkins_lookup3(&buf);
7820    buf.extend_from_slice(&checksum.to_le_bytes());
7821    buf
7822}
7823
7824/// Read a little-endian unsigned integer of `bytes.len()` (≤ 8) bytes.
7825#[expect(
7826    clippy::cast_possible_truncation,
7827    reason = "callers parse in-file sizes/offsets bounded by the in-memory image; downstream \
7828              slicing is length-checked, so a malformed oversized field errors rather than reads OOB"
7829)]
7830fn read_le(bytes: &[u8]) -> usize {
7831    let mut v = 0u64;
7832    for (i, &b) in bytes.iter().enumerate() {
7833        v |= (b as u64) << (8 * i);
7834    }
7835    v as usize
7836}
7837
7838#[cfg(test)]
7839mod tests {
7840    use super::*;
7841
7842    /// Collect the message types present in a chunk-0 region, in order.
7843    fn region_types(region: &[u8]) -> Vec<MessageType> {
7844        let mut out = Vec::new();
7845        let mut p = 0;
7846        while let Some((mt, _, end)) = next_message(region, p).unwrap() {
7847            out.push(mt);
7848            p = end;
7849        }
7850        out
7851    }
7852
7853    /// Stopping an in-place append (`append_inplace`) at any phase boundary must
7854    /// leave the file readable as a consistent prefix — the old length until the
7855    /// phase-4 dimension commit, the new length after it — even though a
7856    /// partial-tail append repoints the visible trailing element in place. Mirrors
7857    /// `Dataset::append`'s crash-consistency harness, but driven through
7858    /// the in-place edit engine's own mirror (disk-before-mirror ordering) to prove the shared
7859    /// engine is crash-safe under both owners. Two starting layouts: the trailing
7860    /// element inline in the index block (chunk 4, n 6), and in a data block
7861    /// (chunk 2, n 9, slot 0).
7862    #[test]
7863    fn append_inplace_crash_consistency_partial_tail_prefix() {
7864        use crate::reader::File as PureFile;
7865        use crate::writer::FileBuilder;
7866        use tempfile::tempdir;
7867
7868        let build = |path: &std::path::Path, n: i32, chunk: u64| {
7869            let data: Vec<i32> = (0..n).collect();
7870            let mut b = FileBuilder::new();
7871            b.create_dataset("d")
7872                .with_i32_data(&data)
7873                .with_shape(&[n as u64])
7874                .with_maxshape(&[u64::MAX])
7875                .with_chunks(&[chunk]);
7876            b.write(path).unwrap();
7877        };
7878
7879        for (n, chunk, add) in [(6i32, 4u64, 5i32), (9, 2, 6)] {
7880            let dir = tempdir().unwrap();
7881            let base = dir.path().join("base.h5");
7882            build(&base, n, chunk);
7883
7884            for max_phase in 1u8..=4 {
7885                let p = dir.path().join(format!("crash_{n}_{chunk}_{max_phase}.h5"));
7886                std::fs::copy(&base, &p).unwrap();
7887                {
7888                    let mut s = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
7889                    s.append_inplace_i32_phased("d", &(n..n + add).collect::<Vec<_>>(), max_phase)
7890                        .unwrap();
7891                    // session dropped here, simulating a crash after `max_phase`
7892                }
7893                let expected_len = if max_phase == 4 { n + add } else { n };
7894                let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
7895                assert_eq!(
7896                    f.dataset("d").unwrap().read_i32().unwrap(),
7897                    (0..expected_len).collect::<Vec<_>>(),
7898                    "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk})"
7899                );
7900            }
7901        }
7902    }
7903
7904    /// Build a one-element-per-chunk unlimited `d` holding `0..n`, the shape the
7905    /// crash-consistency harnesses below grow.
7906    fn build_unit_chunked(path: &std::path::Path, n: i32) {
7907        use crate::writer::FileBuilder;
7908        let data: Vec<i32> = (0..n).collect();
7909        let mut b = FileBuilder::new();
7910        b.create_dataset("d")
7911            .with_i32_data(&data)
7912            .with_shape(&[n as u64])
7913            .with_maxshape(&[u64::MAX])
7914            .with_chunks(&[1]);
7915        b.write(path).unwrap();
7916    }
7917
7918    /// Stop an append after `max_phase` durability phases and hand back the
7919    /// resulting file. Dropping the engine inside is the simulated crash: no
7920    /// further phases run and no close barrier is written.
7921    fn append_stopped_at(
7922        base: &std::path::Path,
7923        out: &std::path::Path,
7924        values: std::ops::Range<i32>,
7925        max_phase: u8,
7926    ) {
7927        std::fs::copy(base, out).unwrap();
7928        let mut s = WriteEngine::open_with_locking(out, FileLocking::Enabled).unwrap();
7929        s.append_inplace_i32_phased("d", &values.collect::<Vec<_>>(), max_phase)
7930            .unwrap();
7931    }
7932
7933    /// Growing an Extensible-Array index across its inline -> direct-block ->
7934    /// super-block boundaries touches far more index structure than the
7935    /// partial-tail case above. Stopping at any phase boundary must still read
7936    /// back as a consistent prefix.
7937    ///
7938    /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
7939    /// owned path drives the same `apply_ea_append` engine.
7940    #[test]
7941    fn append_inplace_crash_consistency_across_ea_boundaries() {
7942        use crate::reader::File as PureFile;
7943        use tempfile::tempdir;
7944
7945        let dir = tempdir().unwrap();
7946        let base = dir.path().join("base.h5");
7947        let (n, target) = (50i32, 250i32);
7948        build_unit_chunked(&base, n);
7949
7950        for max_phase in 1u8..=4 {
7951            let p = dir.path().join(format!("crash_ea_{max_phase}.h5"));
7952            append_stopped_at(&base, &p, n..target, max_phase);
7953            let expected_len = if max_phase == 4 { target } else { n };
7954            let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
7955            assert_eq!(
7956                f.dataset("d").unwrap().read_i32().unwrap(),
7957                (0..expected_len).collect::<Vec<_>>(),
7958                "inconsistent view after crash at phase {max_phase}"
7959            );
7960        }
7961    }
7962
7963    /// The same guarantee for an append that crosses the paged-data-block
7964    /// boundary (~131,060 chunks), where phase 1 allocates a paged super block,
7965    /// paged data blocks, the per-page checksums, and the page-init bitmap. This
7966    /// is the most intricate in-place growth the engine performs, and truncating
7967    /// it partway is exactly what a power loss does.
7968    ///
7969    /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202).
7970    /// Opening a paged persisting file must seed each free section into the
7971    /// manager its *slot* names, not one derived from its size.
7972    ///
7973    /// The three managers a paged file uses mean different things — SUPER (slot 0)
7974    /// is metadata, DRAW (slot 2) is small raw, and the generic-large manager
7975    /// (slot 6) holds large-raw fragments — and a large-raw fragment is itself
7976    /// smaller than a page, so size alone cannot recover the distinction. Getting
7977    /// this wrong is invisible from the outside: the total free space is
7978    /// unchanged, the reference library still opens the file, and only a later
7979    /// allocation drawn from the wrong manager would mix a page. So assert the
7980    /// routing directly.
7981    #[test]
7982    fn paged_open_seeds_each_manager_by_slot() {
7983        use crate::writer::FileBuilder;
7984        use tempfile::tempdir;
7985
7986        let dir = tempdir().unwrap();
7987        let path = dir.path().join("paged_seed.h5");
7988        let mut b = FileBuilder::new();
7989        b.create_dataset("d")
7990            .with_i32_data(&(0..1000).collect::<Vec<i32>>())
7991            .with_shape(&[1000]);
7992        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
7993            .with_file_space_page_size(4096);
7994        b.write(&path).unwrap();
7995
7996        // Read the file's recorded free space *before* opening the session: the
7997        // session holds an exclusive OS lock, and on Windows those locks are
7998        // mandatory, so a concurrent `File::open` would fail outright.
7999        let on_disk: u64 = crate::reader::File::open(&path)
8000            .unwrap()
8001            .persisted_free_space()
8002            .iter()
8003            .map(|&(_, l)| l)
8004            .sum();
8005
8006        let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8007        let pg = s.paged.as_ref().expect("a paged file installs paged state");
8008        assert_eq!(pg.page_size, 4096);
8009
8010        // The from-scratch writer leaves a page tail free in both the metadata and
8011        // the raw pages, so both per-type managers are populated. If every slot
8012        // were funnelled into one list, one of these would be empty.
8013        assert!(
8014            !pg.meta.sections().is_empty(),
8015            "SUPER (slot 0) sections seed the metadata list"
8016        );
8017        assert!(
8018            !pg.raw_small.sections().is_empty(),
8019            "DRAW (slot 2) sections seed the small-raw list, not the metadata list"
8020        );
8021
8022        // Nothing is double-counted or dropped: the three lists partition exactly
8023        // the free space the file records, and no two sections overlap.
8024        let mut all = pg.all_sections();
8025        let flat: u64 = all.iter().map(|&(_, l)| l).sum();
8026        assert_eq!(
8027            flat, on_disk,
8028            "the split lists hold exactly the file's free space"
8029        );
8030        all.sort_by_key(|&(a, _)| a);
8031        let mut prev_end = 0u64;
8032        for (addr, len) in all {
8033            assert!(addr >= prev_end, "the per-type lists do not overlap");
8034            prev_end = addr + len;
8035        }
8036    }
8037
8038    /// Deleting a chunked dataset from a paged file must not record its freed
8039    /// chunk index in the *metadata* manager.
8040    ///
8041    /// Every writer in this crate emits a chunk index in the same run as the chunk
8042    /// data it indexes, so the index sits in a raw page. Recording it as metadata
8043    /// would advertise a metadata-sized hole inside a page that still holds another
8044    /// dataset's live chunk data, and the reference library placing metadata there
8045    /// would mix the page — the one thing a paged file forbids. Page homogeneity is
8046    /// preserved on disk either way, so the mis-filing is invisible to a signature
8047    /// scan of the file and to the C library; the manager a section lands in has to
8048    /// be checked directly.
8049    #[test]
8050    fn deleted_chunk_index_is_freed_into_a_raw_manager() {
8051        use crate::writer::FileBuilder;
8052        use tempfile::tempdir;
8053
8054        let dir = tempdir().unwrap();
8055        let path = dir.path().join("paged_chunk_index_free.h5");
8056        let page = 4096u64;
8057        let mut b = FileBuilder::new();
8058        for name in ["drop", "keep"] {
8059            b.create_dataset(name)
8060                .with_i32_data(&(0..200).collect::<Vec<i32>>())
8061                .with_shape(&[200])
8062                .with_chunks(&[50]);
8063        }
8064        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
8065            .with_file_space_page_size(page);
8066        b.write(&path).unwrap();
8067
8068        {
8069            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8070            s.delete("/drop");
8071            s.commit().unwrap();
8072        }
8073
8074        // Pages still occupied by the surviving dataset's chunk data. Read with the
8075        // session closed: its lock is mandatory on Windows.
8076        let live_raw_pages: Vec<u64> = {
8077            let f = crate::reader::File::open(&path).unwrap();
8078            let ds = f.dataset("keep").unwrap();
8079            let mut pages: Vec<u64> = ds
8080                .chunks()
8081                .unwrap()
8082                .iter()
8083                .filter(|c| c.storage_size > 0)
8084                .flat_map(|c| (c.address / page)..=((c.address + c.storage_size - 1) / page))
8085                .collect();
8086            pages.sort_unstable();
8087            pages.dedup();
8088            pages
8089        };
8090        assert!(!live_raw_pages.is_empty(), "expected live raw pages");
8091
8092        let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8093        let pg = s.paged.as_ref().expect("a paged file installs paged state");
8094        for (addr, len) in pg.meta.sections() {
8095            for p in (addr / page)..=((addr + len - 1) / page) {
8096                assert!(
8097                    !live_raw_pages.contains(&p),
8098                    "metadata free section ({addr}, {len}) sits in page {p}, which still \
8099                     holds live raw chunk data"
8100                );
8101            }
8102        }
8103        // The index really was reclaimed somewhere, so this is not vacuous.
8104        let reclaimed: u64 = pg.all_sections().iter().map(|&(_, l)| l).sum();
8105        assert!(reclaimed > 0, "the delete reclaimed nothing");
8106    }
8107
8108    /// A paged commit that fails partway must leave the session's free lists
8109    /// exactly as it found them.
8110    ///
8111    /// Everything the commit gathers to free is still *live* until the superblock
8112    /// repoint: the objects occupying those regions are reachable from the old
8113    /// root, which a failed commit never replaces. A session that recorded them as
8114    /// free anyway would hand them out on the next commit, and the file would lose
8115    /// data with no error anywhere — in a release build, where the free list's
8116    /// double-free `debug_assert` is compiled out, silently.
8117    ///
8118    /// The failure is induced by pointing the superblock extension at a byte range
8119    /// that is not an object header, which fails the extension rewrite immediately
8120    /// after the regions are gathered.
8121    #[test]
8122    fn failed_paged_commit_leaves_the_free_lists_untouched() {
8123        use crate::writer::FileBuilder;
8124        use tempfile::tempdir;
8125
8126        let dir = tempdir().unwrap();
8127        let path = dir.path().join("paged_failed_commit.h5");
8128        let mut b = FileBuilder::new();
8129        b.create_dataset("keep")
8130            .with_i32_data(&(0..200).collect::<Vec<i32>>())
8131            .with_shape(&[200]);
8132        b.create_dataset("drop")
8133            .with_i32_data(&(0..200).collect::<Vec<i32>>())
8134            .with_shape(&[200]);
8135        b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
8136            .with_file_space_page_size(4096);
8137        b.write(&path).unwrap();
8138
8139        let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8140        let before = s.space_accounting().reusable_free_space;
8141
8142        // Break the extension so the commit fails *after* it has gathered the
8143        // regions `drop` vacates and *before* the superblock repoint.
8144        let good_ext = s.superblock.superblock_extension_address;
8145        s.superblock.superblock_extension_address = Some(0);
8146        s.delete("/drop");
8147        assert!(
8148            s.commit().is_err(),
8149            "a commit with an unreadable extension must fail"
8150        );
8151
8152        assert_eq!(
8153            s.space_accounting().reusable_free_space,
8154            before,
8155            "a failed commit must not record still-live regions as free"
8156        );
8157
8158        // The session stays usable: repair the extension and commit for real. If
8159        // the failed commit had folded its regions in, this second commit would
8160        // double-free them (a debug assertion) and publish `keep`'s live extent.
8161        s.superblock.superblock_extension_address = good_ext;
8162        s.delete("/drop");
8163        s.commit()
8164            .expect("the session is usable after a failed commit");
8165
8166        // Release the session's exclusive OS lock before reading the file back.
8167        // Those locks are mandatory on Windows, so a `File::open` overlapping the
8168        // session fails outright there (advisory locks elsewhere would allow it).
8169        drop(s);
8170
8171        let f = crate::reader::File::open(&path).unwrap();
8172        let kept = f.dataset("keep").unwrap().read_i32().unwrap();
8173        assert_eq!(kept, (0..200).collect::<Vec<i32>>(), "keep survives intact");
8174        let freed: u64 = f.persisted_free_space().iter().map(|&(_, l)| l).sum();
8175        let live_end = f.file_size();
8176        assert!(
8177            freed < live_end,
8178            "the recorded free space cannot cover the whole file"
8179        );
8180    }
8181
8182    #[test]
8183    fn append_inplace_crash_consistency_paged_prefix() {
8184        use crate::reader::File as PureFile;
8185        use tempfile::tempdir;
8186
8187        let dir = tempdir().unwrap();
8188        let base = dir.path().join("base.h5");
8189        let (start, target) = (131_000i32, 132_000i32);
8190        build_unit_chunked(&base, start);
8191
8192        for max_phase in 1u8..=4 {
8193            let p = dir.path().join(format!("crash_paged_{max_phase}.h5"));
8194            append_stopped_at(&base, &p, start..target, max_phase);
8195            let expected_len = if max_phase == 4 { target } else { start };
8196            let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
8197            assert_eq!(
8198                f.dataset("d").unwrap().read_i32().unwrap(),
8199                (0..expected_len).collect::<Vec<_>>(),
8200                "inconsistent paged view after crash at phase {max_phase}"
8201            );
8202        }
8203    }
8204
8205    /// The consistent-prefix guarantee must hold for the *reference C library*,
8206    /// not only this crate's reader — and this crate's reader is the more lenient
8207    /// of the two, so it cannot stand in for it.
8208    ///
8209    /// The pure reader bounds chunk reads by `min(EA count, dimension)`, which
8210    /// makes it tolerate a phase-3 state where the element count has advanced
8211    /// past the dimension. The C library instead walks strictly by the dataspace
8212    /// dimension and re-validates block checksums, so a stale end-of-file, a
8213    /// half-grown index, or a mis-checksummed block could satisfy the reader here
8214    /// and still break C or h5py. That gap is the whole point of the test:
8215    /// crash-safety for the append path is an interop guarantee.
8216    ///
8217    /// Both starting layouts are covered — a partial trailing chunk and the
8218    /// EA-boundary growth above — since they exercise different index writes.
8219    ///
8220    /// Restores coverage lost with the deprecated `SwmrWriter` and `AppendWriter`
8221    /// (issue #202).
8222    #[test]
8223    // Reads back with the reference HDF5 C library (`hdf5-metno`), a
8224    // 64-bit-only dev-dependency; skip on 32-bit so the lib tests run there.
8225    #[cfg(not(target_pointer_width = "32"))]
8226    fn append_inplace_crash_consistency_c_library_reads_prefix() {
8227        use tempfile::tempdir;
8228
8229        // (initial length, chunk length, appended length): a partial trailing
8230        // chunk, a chunk-aligned start, and the EA-boundary crossing.
8231        for (n, chunk, add) in [(6i32, 4u64, 5i32), (8, 2, 6), (50, 1, 200)] {
8232            let dir = tempdir().unwrap();
8233            let base = dir.path().join("base.h5");
8234            {
8235                use crate::writer::FileBuilder;
8236                let mut b = FileBuilder::new();
8237                b.create_dataset("d")
8238                    .with_i32_data(&(0..n).collect::<Vec<i32>>())
8239                    .with_shape(&[n as u64])
8240                    .with_maxshape(&[u64::MAX])
8241                    .with_chunks(&[chunk]);
8242                b.write(&base).unwrap();
8243            }
8244
8245            for max_phase in 1u8..=4 {
8246                let p = dir
8247                    .path()
8248                    .join(format!("crash_c_{n}_{chunk}_{max_phase}.h5"));
8249                append_stopped_at(&base, &p, n..n + add, max_phase);
8250                let expected_len = if max_phase == 4 { n + add } else { n };
8251                let f = hdf5::File::open(&p).unwrap();
8252                assert_eq!(
8253                    f.dataset("d").unwrap().read_raw::<i32>().unwrap(),
8254                    (0..expected_len).collect::<Vec<_>>(),
8255                    "C library saw an inconsistent view after crash at phase {max_phase} \
8256                     (n={n}, chunk={chunk})"
8257                );
8258                f.close().unwrap();
8259            }
8260        }
8261    }
8262
8263    /// Crash recovery across the phase-3/phase-4 gap.
8264    ///
8265    /// A writer that crashes after publishing the Extensible-Array element count
8266    /// (phase 3) but before publishing the dataspace dimension (phase 4) leaves
8267    /// the on-disk count ahead of the committed dimension. A fresh writer must
8268    /// roll forward from the *committed dimension*, overwriting the uncommitted
8269    /// slots, rather than appending past them and leaving a gap.
8270    ///
8271    /// The crashed and recovering appends deliberately write different values at
8272    /// the overlapping positions, so a regression that seeds the chunk count from
8273    /// the stale EA header surfaces the crashed writer's values rather than
8274    /// merely producing plausible-looking data.
8275    ///
8276    /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
8277    /// surviving `recover_and_reappend_after_clean_phase4` covers only the clean
8278    /// case.
8279    #[test]
8280    // Reads back with the reference HDF5 C library (`hdf5-metno`), a
8281    // 64-bit-only dev-dependency; skip on 32-bit so the lib tests run there.
8282    #[cfg(not(target_pointer_width = "32"))]
8283    fn append_inplace_recover_and_reappend_after_phase3_crash() {
8284        use crate::reader::File as PureFile;
8285        use tempfile::tempdir;
8286
8287        let dir = tempdir().unwrap();
8288        let path = dir.path().join("phase3_recover.h5");
8289        let n = 50i32;
8290        build_unit_chunked(&path, n);
8291
8292        // Writer 1 crashes after phase 3: the element count advances but the
8293        // dimension stays at `n`. Its values are far from the correct
8294        // continuation, so a leak is unmistakable.
8295        {
8296            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8297            s.append_inplace_i32_phased("d", &(1000..1200).collect::<Vec<_>>(), 3)
8298                .unwrap();
8299        }
8300        let committed: Vec<i32> = (0..n).collect();
8301        let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
8302        assert_eq!(
8303            pf.dataset("d").unwrap().read_i32().unwrap(),
8304            committed,
8305            "phase-3 crash exposed uncommitted data to the pure reader"
8306        );
8307        {
8308            let f = hdf5::File::open(&path).unwrap();
8309            assert_eq!(
8310                f.dataset("d").unwrap().read_raw::<i32>().unwrap(),
8311                committed,
8312                "phase-3 crash exposed uncommitted data to the C library"
8313            );
8314            f.close().unwrap();
8315        }
8316
8317        // Writer 2 recovers: roll forward from the committed dimension,
8318        // overwriting the uncommitted slots with the real continuation.
8319        {
8320            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8321            s.append_inplace_i32_phased("d", &(n..150).collect::<Vec<_>>(), 4)
8322                .unwrap();
8323        }
8324
8325        let expected: Vec<i32> = (0..150).collect();
8326        let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
8327        assert_eq!(
8328            pf.dataset("d").unwrap().read_i32().unwrap(),
8329            expected,
8330            "recovery did not roll forward correctly (pure reader)"
8331        );
8332        let f = hdf5::File::open(&path).unwrap();
8333        assert_eq!(
8334            f.dataset("d").unwrap().read_raw::<i32>().unwrap(),
8335            expected,
8336            "recovery did not roll forward correctly (C library)"
8337        );
8338        f.close().unwrap();
8339    }
8340
8341    #[test]
8342    fn raw_appendable_recurses_into_aggregates() {
8343        use crate::datatype::{CompoundMember, DatatypeByteOrder};
8344
8345        let f64_with = |byte_order| Datatype::FloatingPoint {
8346            size: 8,
8347            byte_order,
8348            bit_offset: 0,
8349            bit_precision: 64,
8350            exponent_location: 52,
8351            exponent_size: 11,
8352            mantissa_location: 0,
8353            mantissa_size: 52,
8354            exponent_bias: 1023,
8355        };
8356        let le_f64 = f64_with(DatatypeByteOrder::LittleEndian);
8357        let be_f64 = f64_with(DatatypeByteOrder::BigEndian);
8358
8359        // Little-endian scalar: appendable. Big-endian scalar: not.
8360        assert!(datatype_is_raw_appendable(&le_f64));
8361        assert!(!datatype_is_raw_appendable(&be_f64));
8362
8363        // The confirmed bug: a compound / array whose leaf is big-endian must be
8364        // refused (it was wrongly accepted before recursion was added).
8365        let be_member = Datatype::Compound {
8366            size: 8,
8367            members: vec![CompoundMember {
8368                name: "x".into(),
8369                byte_offset: 0,
8370                datatype: be_f64.clone(),
8371            }],
8372        };
8373        assert!(!datatype_is_raw_appendable(&be_member));
8374        let le_member = Datatype::Compound {
8375            size: 8,
8376            members: vec![CompoundMember {
8377                name: "x".into(),
8378                byte_offset: 0,
8379                datatype: le_f64.clone(),
8380            }],
8381        };
8382        assert!(datatype_is_raw_appendable(&le_member));
8383        assert!(!datatype_is_raw_appendable(&Datatype::Array {
8384            base_type: Box::new(be_f64.clone()),
8385            dimensions: vec![4],
8386        }));
8387
8388        // Variable-length / reference leaves are never raw-appendable, even LE.
8389        assert!(!datatype_is_raw_appendable(&Datatype::VariableLength {
8390            is_string: false,
8391            padding: None,
8392            charset: None,
8393            base_type: Box::new(le_f64.clone()),
8394        }));
8395        assert!(!datatype_is_raw_appendable(&Datatype::Reference {
8396            size: 8,
8397            ref_type: crate::datatype::ReferenceType::Object,
8398        }));
8399    }
8400
8401    #[test]
8402    fn fresh_group_region_pairs_link_info_with_group_info() {
8403        // A new-style group must carry both a Link Info and a Group Info message
8404        // (the C library requires the pair before it will insert a link).
8405        let types = region_types(&fresh_group_region());
8406        assert_eq!(types, vec![MessageType::LinkInfo, MessageType::GroupInfo]);
8407    }
8408
8409    #[test]
8410    fn ensure_group_info_appends_when_missing() {
8411        // A region with a Link Info message but no Group Info message (how older
8412        // hdf5-pure releases wrote groups) gains exactly one Group Info message.
8413        let li_body = {
8414            let mut b = vec![0u8, 0];
8415            b.extend_from_slice(&u64::MAX.to_le_bytes());
8416            b.extend_from_slice(&u64::MAX.to_le_bytes());
8417            b
8418        };
8419        let mut region = region_message(MessageType::LinkInfo, &li_body);
8420        ensure_group_info(&mut region).unwrap();
8421        assert_eq!(
8422            region_types(&region),
8423            vec![MessageType::LinkInfo, MessageType::GroupInfo]
8424        );
8425
8426        // The appended message decodes as a minimal Group Info body.
8427        let mut p = 0;
8428        while let Some((mt, body, end)) = next_message(&region, p).unwrap() {
8429            if mt == MessageType::GroupInfo {
8430                assert_eq!(&region[body..end], &GROUP_INFO_BODY);
8431            }
8432            p = end;
8433        }
8434    }
8435
8436    #[test]
8437    fn ensure_group_info_is_idempotent() {
8438        // A region that already has a Group Info message is left untouched, so
8439        // re-editing a healed (or C-written) group does not duplicate it.
8440        let mut region = fresh_group_region();
8441        let before = region.clone();
8442        ensure_group_info(&mut region).unwrap();
8443        assert_eq!(region, before);
8444    }
8445
8446    #[test]
8447    fn reject_foreign_addresses_refuses_any_shared_message() {
8448        // A shared (SOHM) message of *any* type — here a Dataspace — stores a
8449        // source-file reference in place of its body, so a verbatim cross-file
8450        // copy must refuse it, not only shared datatypes/attributes. (A plain,
8451        // non-shared dataspace embeds no foreign address and is accepted.)
8452        let mut shared = region_message(MessageType::Dataspace, &[0u8; 8]);
8453        shared[3] = MSG_FLAG_SHARED; // set the message's shared flag
8454        let err = reject_foreign_addresses(&shared).unwrap_err();
8455        assert!(err.to_string().contains("shared"), "got: {err}");
8456
8457        let plain = region_message(MessageType::Dataspace, &[0u8; 8]);
8458        reject_foreign_addresses(&plain).unwrap();
8459    }
8460
8461    /// Build a compact data-layout message body: version, class=0, 2-byte inline
8462    /// size, then the data.
8463    fn compact_layout_body(version: u8, data: &[u8]) -> Vec<u8> {
8464        let mut b = vec![version, 0];
8465        b.extend_from_slice(&(data.len() as u16).to_le_bytes());
8466        b.extend_from_slice(data);
8467        b
8468    }
8469
8470    #[test]
8471    fn rebuild_compact_layout_replaces_inline_data_only() {
8472        // A region with a Dataspace message, a compact Data Layout, and a trailing
8473        // Attribute message: rewriting the inline data must replace exactly the
8474        // layout's bytes and leave every other message verbatim.
8475        let mut region = region_message(MessageType::Dataspace, &[0xAB; 8]);
8476        region.extend_from_slice(&region_message(
8477            MessageType::DataLayout,
8478            &compact_layout_body(3, &[1, 2, 3, 4]),
8479        ));
8480        region.extend_from_slice(&region_message(MessageType::Attribute, &[0xCD; 5]));
8481
8482        let out = rebuild_compact_layout_region(&region, &[9, 8, 7, 6]).unwrap();
8483
8484        // Same messages in the same order; only the layout's inline data changed.
8485        assert_eq!(
8486            region_types(&out),
8487            vec![
8488                MessageType::Dataspace,
8489                MessageType::DataLayout,
8490                MessageType::Attribute,
8491            ]
8492        );
8493        let mut p = 0;
8494        while let Some((mt, body, end)) = next_message(&out, p).unwrap() {
8495            match mt {
8496                MessageType::Dataspace => assert_eq!(&out[body..end], &[0xAB; 8]),
8497                MessageType::DataLayout => {
8498                    assert_eq!(out[body], 3, "version preserved");
8499                    assert_eq!(out[body + 1], 0, "still compact");
8500                    let size = u16::from_le_bytes([out[body + 2], out[body + 3]]) as usize;
8501                    assert_eq!(size, 4);
8502                    assert_eq!(&out[body + 4..body + 4 + size], &[9, 8, 7, 6]);
8503                }
8504                MessageType::Attribute => assert_eq!(&out[body..end], &[0xCD; 5]),
8505                other => panic!("unexpected message {other:?}"),
8506            }
8507            p = end;
8508        }
8509    }
8510
8511    #[test]
8512    fn rebuild_compact_layout_refuses_non_compact() {
8513        // A contiguous (class 1) data layout is not compact, so the rebuild refuses
8514        // rather than corrupt it.
8515        let mut region = region_message(MessageType::DataLayout, &{
8516            let mut b = vec![3u8, 1]; // version 3, class 1 (contiguous)
8517            b.extend_from_slice(&0u64.to_le_bytes());
8518            b.extend_from_slice(&0u64.to_le_bytes());
8519            b
8520        });
8521        region.extend_from_slice(&region_message(MessageType::Dataspace, &[0; 8]));
8522        let err = rebuild_compact_layout_region(&region, &[1, 2]).unwrap_err();
8523        assert!(err.to_string().contains("non-compact"), "got: {err}");
8524    }
8525
8526    #[test]
8527    fn a_refused_open_never_builds_the_image() {
8528        // The image is what may cost `O(file size)` — the mirror reads the whole
8529        // file — so every refusal has to come first. Asserting on the build
8530        // closure states that directly; measuring memory or elapsed time would
8531        // only correlate with it.
8532        use crate::writer::FileBuilder;
8533
8534        let dir = tempfile::tempdir().unwrap();
8535
8536        // One refusal from each family: the superblock's status flags (issue
8537        // #245), and an unsupported superblock version, which has always been
8538        // refused after the read this reorders.
8539        let flagged = dir.path().join("flagged.h5");
8540        let mut b = FileBuilder::new();
8541        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
8542        b.write(&flagged).unwrap();
8543        let ancient = dir.path().join("ancient.h5");
8544        std::fs::copy(&flagged, &ancient).unwrap();
8545        for (path, version, flags) in [(&flagged, 3, SWMR_WRITE_FLAGS), (&ancient, 9, 0)] {
8546            let mut data = std::fs::read(path).unwrap();
8547            let off = signature::find_signature(&data).unwrap();
8548            let mut sb = Superblock::parse(&data, off).unwrap();
8549            sb.version = version;
8550            sb.consistency_flags = flags;
8551            let bytes = sb.serialize();
8552            data[off..off + bytes.len()].copy_from_slice(&bytes);
8553            std::fs::write(path, &data).unwrap();
8554        }
8555
8556        for path in [&flagged, &ancient] {
8557            let built = std::cell::Cell::new(false);
8558            let err = match WriteEngine::open_imaged(path, Some(FileLocking::Enabled), |h, len| {
8559                built.set(true);
8560                Ok(Box::new(HandleImage::new(
8561                    h,
8562                    len,
8563                    MetadataCacheConfig::disabled(),
8564                )))
8565            }) {
8566                Err(e) => e,
8567                Ok(_) => panic!("{} must be refused", path.display()),
8568            };
8569            assert!(
8570                !built.get(),
8571                "{} was refused with {err:?}, but the image was built first",
8572                path.display()
8573            );
8574        }
8575    }
8576
8577    #[test]
8578    fn a_stale_consistency_flag_is_refused_then_cleared_by_a_commit() {
8579        // A v3 file a crashed SWMR writer left flagged is refused by the editor
8580        // (issue #245) rather than edited under a writer the file still records.
8581        // On a v2 file, where the check is gated off to match the C library, the
8582        // editor opens — and the commit clears the stale flag rather than
8583        // re-emitting it, so the file stays properly closed for the C library
8584        // (issue #73).
8585        use crate::writer::FileBuilder;
8586
8587        let dir = tempfile::tempdir().unwrap();
8588        let path = dir.path().join("stale_flag.h5");
8589
8590        let mut b = FileBuilder::new();
8591        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
8592        b.write(&path).unwrap();
8593
8594        // Simulate a crashed SWMR writer by stamping the on-disk write+SWMR flag
8595        // (0x05) into the superblock, recomputing its checksum.
8596        {
8597            let mut data = std::fs::read(&path).unwrap();
8598            let off = signature::find_signature(&data).unwrap();
8599            let mut sb = Superblock::parse(&data, off).unwrap();
8600            assert!(
8601                sb.version >= 2,
8602                "FileBuilder should emit a v2/v3 superblock"
8603            );
8604            sb.consistency_flags = 0x05;
8605            let bytes = sb.serialize();
8606            data[off..off + bytes.len()].copy_from_slice(&bytes);
8607            std::fs::write(&path, &data).unwrap();
8608            // Sanity: the stale flag is really set on disk now.
8609            assert_eq!(
8610                Superblock::parse(&data, off).unwrap().consistency_flags,
8611                0x05
8612            );
8613        }
8614
8615        // The editor refuses it while the flag stands.
8616        match WriteEngine::open_with_locking(&path, FileLocking::Enabled) {
8617            Err(Error::FileMarkedInUse(_)) => {}
8618            Err(e) => panic!("expected the flag refusal, got {e:?}"),
8619            Ok(_) => panic!("a flagged file must not be edited in place"),
8620        }
8621
8622        // The flag survives a *version-2* superblock, where the check is gated
8623        // off to match the C library — which is the one state that still carries
8624        // a stale flag into a commit, and so the one that keeps the healing below
8625        // load-bearing. (v2 and v3 superblocks share a byte layout, so restamping
8626        // the version is the whole difference.) A crashed C writer leaves plain
8627        // write access, without the SWMR bit.
8628        {
8629            let mut data = std::fs::read(&path).unwrap();
8630            let off = signature::find_signature(&data).unwrap();
8631            let mut sb = Superblock::parse(&data, off).unwrap();
8632            sb.version = 2;
8633            sb.consistency_flags = crate::file_lock::WRITE_ACCESS;
8634            let bytes = sb.serialize();
8635            data[off..off + bytes.len()].copy_from_slice(&bytes);
8636            std::fs::write(&path, &data).unwrap();
8637        }
8638
8639        // A clean edit-and-commit cycle heals it.
8640        {
8641            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
8642                .expect("the gate skips a v2 superblock, so this opens");
8643            let mut b = DatasetBuilder::new("e");
8644            b.with_i32_data(&[4, 5]);
8645            s.stage_created_dataset("e", b);
8646            s.commit().unwrap();
8647        }
8648
8649        let data = std::fs::read(&path).unwrap();
8650        let off = signature::find_signature(&data).unwrap();
8651        assert_eq!(
8652            Superblock::parse(&data, off).unwrap().consistency_flags,
8653            0,
8654            "commit must clear the stale consistency flag"
8655        );
8656    }
8657
8658    #[test]
8659    fn add_vlen_string_dataset_with_null_elements_via_edit_session() {
8660        // Regression test for a silent-corruption bug (issue #105): a
8661        // VL-string dataset added via the in-place edit engine used to commit `Ok(())`
8662        // without ever writing its global heap collection or patching its
8663        // placeholder references, so the dataset failed to read back. A null
8664        // element (no heap object at all, distinct from an empty string) must
8665        // stay untouched by the patch — only heap-backed elements'
8666        // placeholder addresses are resolved; exercising both keeps the mask
8667        // itself, not just the common all-`Bytes` case, under test.
8668        use crate::type_builders::VlStringElement;
8669        use crate::writer::FileBuilder;
8670
8671        let dir = tempfile::tempdir().unwrap();
8672        let path = dir.path().join("vlen_null.h5");
8673
8674        let mut b = FileBuilder::new();
8675        b.create_dataset("seed").with_i32_data(&[0]);
8676        b.write(&path).unwrap();
8677
8678        let datatype =
8679            crate::type_builders::make_vlen_string_type(crate::datatype::CharacterSet::Utf8);
8680        let elements = vec![
8681            VlStringElement::Bytes(b"alpha".to_vec()),
8682            VlStringElement::Null,
8683            VlStringElement::Bytes(b"gamma".to_vec()),
8684        ];
8685
8686        {
8687            let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8688            let mut b = DatasetBuilder::new("labels");
8689            b.with_vlen_string_elements(datatype, &elements).unwrap();
8690            s.stage_created_dataset("labels", b);
8691            s.commit().unwrap();
8692        }
8693
8694        let file = crate::reader::File::open(&path).unwrap();
8695        let ds = file.dataset("labels").unwrap();
8696        assert_eq!(
8697            ds.read_string().unwrap(),
8698            vec!["alpha".to_string(), String::new(), "gamma".to_string()]
8699        );
8700    }
8701
8702    #[test]
8703    fn edit_session_root_group_base_address_overflow_is_rejected() {
8704        // The edit-path sibling of issue #137. A userblock file has a nonzero base
8705        // address that `WriteEngine::open` adds to the stored root-group address.
8706        // A crafted address of HADDR_UNDEF must be rejected rather than overflow
8707        // (panicking in debug, wrapping in release).
8708        use crate::writer::FileBuilder;
8709
8710        let dir = tempfile::tempdir().unwrap();
8711        let path = dir.path().join("edit_root_overflow.h5");
8712
8713        const UB: u64 = 512;
8714        let mut b = FileBuilder::new();
8715        b.with_userblock(UB);
8716        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
8717        b.write(&path).unwrap();
8718
8719        // Rewrite the stored (base-relative) root-group address to HADDR_UNDEF,
8720        // recomputing the superblock checksum via `serialize`. The base address
8721        // still equals the superblock offset, so the file stays editable and the
8722        // editor reaches the `root_group_address + base` normalization.
8723        let mut data = std::fs::read(&path).unwrap();
8724        let off = signature::find_signature(&data).unwrap();
8725        let mut sb = Superblock::parse(&data, off).unwrap();
8726        assert_eq!(sb.base_address, UB, "userblock file must have base == UB");
8727        sb.root_group_address = u64::MAX;
8728        let bytes = sb.serialize();
8729        data[off..off + bytes.len()].copy_from_slice(&bytes);
8730        std::fs::write(&path, &data).unwrap();
8731
8732        let err = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
8733            .err()
8734            .expect("open must fail");
8735        match err {
8736            Error::Format(FormatError::OffsetOverflow { offset, length }) => {
8737                assert_eq!(offset, u64::MAX);
8738                assert_eq!(length, UB);
8739            }
8740            other => panic!("expected root-group address overflow, got {other:?}"),
8741        }
8742    }
8743
8744    use tempfile::tempdir;
8745
8746    // -----------------------------------------------------------------------
8747    // Bounded sessions: the same engine over a `HandleImage`, which holds no
8748    // whole-file mirror. These came across from the standalone bounded engine
8749    // deleted in issue #198; what they cover is unchanged, but they now exercise
8750    // the shared code the mirror sessions use.
8751    // -----------------------------------------------------------------------
8752
8753    /// Build a rank-1 unlimited chunked i32 dataset `d` seeded with `0..n`.
8754    fn build_appendable(path: &Path, n: i32, chunk: u64) {
8755        let data: Vec<i32> = (0..n).collect();
8756        let mut b = crate::writer::FileBuilder::new();
8757        b.create_dataset("d")
8758            .with_i32_data(&data)
8759            .with_shape(&[n as u64])
8760            .with_maxshape(&[u64::MAX])
8761            .with_chunks(&[chunk]);
8762        b.write(path).unwrap();
8763    }
8764
8765    fn open_bounded_session(path: &Path) -> WriteEngine {
8766        WriteEngine::open_rw_with_strategy(
8767            path,
8768            crate::source::MetadataCacheConfig::disabled(),
8769            FileLocking::Enabled,
8770            MemoryStrategy::Bounded,
8771        )
8772        .unwrap()
8773    }
8774
8775    fn dataset_addr(engine: &WriteEngine) -> u64 {
8776        crate::group_v2::resolve_path_any_from_source(&engine.image(), engine.superblock(), "d")
8777            .unwrap()
8778    }
8779
8780    /// Crash consistency on a bounded session: stop the append after only the
8781    /// first `max_phase` durability phases (simulating a crash at that boundary)
8782    /// and assert the reopened file reads either the old length (phases 1-3) or
8783    /// the new one (phase 4), never a torn view. Layouts cover a partial trailing
8784    /// chunk (relocated tail) and a chunk-aligned start.
8785    #[test]
8786    fn bounded_append_crash_consistency_partial_tail_prefix() {
8787        let dir = tempdir().unwrap();
8788        for (case, (n, chunk, add)) in [(0usize, (6i32, 4u64, 5i32)), (1, (8, 2, 6))] {
8789            let base = dir.path().join(std::format!("base_{case}.h5"));
8790            build_appendable(&base, n, chunk);
8791            for max_phase in 1u8..=4 {
8792                let p = dir.path().join(std::format!("crash_{case}_{max_phase}.h5"));
8793                std::fs::copy(&base, &p).unwrap();
8794                {
8795                    let mut engine = open_bounded_session(&p);
8796                    let addr = dataset_addr(&engine);
8797                    let mut b = AppendBuilder::new();
8798                    b.append_i32(&(n..n + add).collect::<Vec<_>>());
8799                    engine
8800                        .append_inplace_gathered(AppendTarget::Header(addr), &b, max_phase)
8801                        .unwrap();
8802                    // Dropping the engine simulates the crash: no further phases,
8803                    // no close barrier.
8804                }
8805                let expected_len = if max_phase == 4 { n + add } else { n };
8806                let got = crate::File::open(&p)
8807                    .unwrap()
8808                    .dataset("d")
8809                    .unwrap()
8810                    .read_i32()
8811                    .unwrap();
8812                assert_eq!(
8813                    got,
8814                    (0..expected_len).collect::<Vec<_>>(),
8815                    "case {case} phase {max_phase}"
8816                );
8817            }
8818        }
8819    }
8820
8821    /// The batching loop only honors `max_phase < 4` on its first batch, and a
8822    /// full multi-batch append leaves every batch fully committed: after a large
8823    /// append the file reads the complete sequence.
8824    #[test]
8825    fn bounded_multi_batch_append_commits_every_batch() {
8826        let dir = tempdir().unwrap();
8827        let p = dir.path().join("multibatch.h5");
8828        build_appendable(&p, 5, 512);
8829        let total = 700_000i32;
8830        {
8831            let mut engine = open_bounded_session(&p);
8832            let addr = dataset_addr(&engine);
8833            let mut b = AppendBuilder::new();
8834            b.append_i32(&(5..total).collect::<Vec<_>>());
8835            engine
8836                .append_inplace_gathered(AppendTarget::Header(addr), &b, 4)
8837                .unwrap();
8838        }
8839        let got = crate::File::open(&p)
8840            .unwrap()
8841            .dataset("d")
8842            .unwrap()
8843            .read_i32()
8844            .unwrap();
8845        assert_eq!(got.len(), total as usize);
8846        assert!(got.iter().enumerate().all(|(i, &v)| v == i as i32));
8847    }
8848
8849    /// A bounded session batches; a mirror session does not. The distinction is
8850    /// a deliberate trade — bounded peak memory against whole-call crash
8851    /// atomicity — so it is asserted rather than left to the batching code's
8852    /// arithmetic.
8853    #[test]
8854    fn only_a_bounded_session_batches_a_large_append() {
8855        let dir = tempdir().unwrap();
8856        let p = dir.path().join("batching.h5");
8857        build_appendable(&p, 8, 4);
8858
8859        let bounded_batch = {
8860            let mut engine = open_bounded_session(&p);
8861            engine
8862                .append_geometry(AppendTarget::Path("d"))
8863                .unwrap()
8864                .full_batch_elems
8865        };
8866        let mirror_batch = {
8867            let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
8868            engine
8869                .append_geometry(AppendTarget::Path("d"))
8870                .unwrap()
8871                .full_batch_elems
8872        };
8873
8874        assert_eq!(
8875            mirror_batch,
8876            u64::MAX,
8877            "a mirror session must take the whole append as one crash-atomic batch"
8878        );
8879        assert!(
8880            bounded_batch < u64::MAX,
8881            "a bounded session must cap a batch, got {bounded_batch}"
8882        );
8883        assert_eq!(
8884            bounded_batch % 4,
8885            0,
8886            "a batch must be a whole number of chunks"
8887        );
8888    }
8889
8890    /// A persisting file appended through a bounded session and dropped WITHOUT
8891    /// `finalize_persist` (the true-crash case) still reads back every durable
8892    /// append. Dropping the engine releases the exclusive lock, so the reopen is
8893    /// portable (no leaked lock). The finalize-at-close path is covered by the
8894    /// `tests/bounded_append.rs` integration tests.
8895    #[test]
8896    fn bounded_persist_append_without_finalize_is_readable() {
8897        let dir = tempdir().unwrap();
8898        let p = dir.path().join("persist_crash.h5");
8899        let mut b = crate::writer::FileBuilder::new();
8900        b.with_file_space_strategy(crate::FileSpaceStrategy::FsmAggr, true, 1);
8901        b.create_dataset("d")
8902            .with_i32_data(&(0..6).collect::<Vec<i32>>())
8903            .with_shape(&[6])
8904            .with_maxshape(&[u64::MAX])
8905            .with_chunks(&[4]);
8906        b.write(&p).unwrap();
8907        {
8908            let mut engine = open_bounded_session(&p);
8909            assert!(engine.persist.is_some(), "persist state is armed at open");
8910            let addr = dataset_addr(&engine);
8911            let mut ab = AppendBuilder::new();
8912            ab.append_i32(&(6..20).collect::<Vec<_>>());
8913            engine
8914                .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
8915                .unwrap();
8916            // Drop without finalizing: models a true crash and releases the lock.
8917        }
8918        let got = crate::File::open(&p)
8919            .unwrap()
8920            .dataset("d")
8921            .unwrap()
8922            .read_i32()
8923            .unwrap();
8924        assert_eq!(got, (0..20).collect::<Vec<_>>());
8925    }
8926
8927    /// A bounded session grows a PAGED persisting file and is killed before
8928    /// finalize (models a crash), leaving the file non-page-aligned. Reopening it
8929    /// must not panic, and the next append must re-align the crashed tail page
8930    /// before writing raw data (so no page mixes metadata and raw); a clean close
8931    /// then re-page-aligns the file and every row reads back.
8932    #[test]
8933    fn bounded_paged_reopen_after_crash_realigns_and_stays_readable() {
8934        let dir = tempdir().unwrap();
8935        let p = dir.path().join("paged_crash.h5");
8936        let mut b = crate::writer::FileBuilder::new();
8937        b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
8938            .with_file_space_page_size(4096);
8939        b.create_dataset("d")
8940            .with_i32_data(&(0..64).collect::<Vec<i32>>())
8941            .with_shape(&[64])
8942            .with_maxshape(&[u64::MAX])
8943            .with_chunks(&[64]);
8944        b.write(&p).unwrap();
8945
8946        // Grow enough to force extensible-array index growth, so the last write of
8947        // the session is metadata and the tail page is a partial metadata page.
8948        {
8949            let mut engine = open_bounded_session(&p);
8950            let addr = dataset_addr(&engine);
8951            let mut ab = AppendBuilder::new();
8952            ab.append_i32(&(64..2000).collect::<Vec<_>>());
8953            engine
8954                .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
8955                .unwrap();
8956            // Drop without finalize: models a crash and releases the OS lock.
8957        }
8958        assert_ne!(
8959            std::fs::metadata(&p).unwrap().len() % 4096,
8960            0,
8961            "a crashed (un-finalized) paged session leaves the file non-page-aligned"
8962        );
8963
8964        // Reopen must not panic on the non-aligned file; the next append re-aligns
8965        // the crashed tail page, and finalize re-page-aligns the whole file.
8966        {
8967            let mut engine = open_bounded_session(&p);
8968            let addr = dataset_addr(&engine);
8969            let mut ab = AppendBuilder::new();
8970            ab.append_i32(&(2000..2500).collect::<Vec<_>>());
8971            engine
8972                .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
8973                .unwrap();
8974            engine.finalize_persist().unwrap();
8975            engine.sync().unwrap();
8976        }
8977        assert_eq!(
8978            std::fs::metadata(&p).unwrap().len() % 4096,
8979            0,
8980            "reopen + append + finalize re-aligns the paged file"
8981        );
8982        let got = crate::File::open(&p)
8983            .unwrap()
8984            .dataset("d")
8985            .unwrap()
8986            .read_i32()
8987            .unwrap();
8988        assert_eq!(got, (0..2500).collect::<Vec<_>>());
8989    }
8990
8991    /// A staged commit on a bounded session must stay bounded: it may read the
8992    /// metadata it edits, but never the file's bulk. Measured rather than
8993    /// asserted from the design — the engine is shared with the mirror sessions
8994    /// now, and a single slice-taking read added anywhere on the commit path
8995    /// would silently make a bounded open cost as much as a mirrored one.
8996    #[test]
8997    fn a_bounded_commit_reads_far_less_than_the_file() {
8998        use std::sync::Arc;
8999        use std::sync::atomic::{AtomicU64, Ordering};
9000
9001        let dir = tempdir().unwrap();
9002        let p = dir.path().join("bulk.h5");
9003        // ~8 MiB of chunked data, so "reads the whole file" and "reads only the
9004        // metadata" differ by orders of magnitude rather than by a margin.
9005        let rows = 2_000_000i32;
9006        build_appendable(&p, rows, 8192);
9007        let file_len = std::fs::metadata(&p).unwrap().len();
9008        assert!(file_len > 4 << 20, "file is only {file_len} bytes");
9009
9010        let read_bytes = Arc::new(AtomicU64::new(0));
9011        {
9012            let mut engine =
9013                WriteEngine::open_bounded_counting(&p, Arc::clone(&read_bytes)).unwrap();
9014            engine.create_group("g");
9015            engine.commit().unwrap();
9016        }
9017        let read = read_bytes.load(Ordering::Relaxed);
9018
9019        assert!(
9020            read > 0,
9021            "the commit read nothing, so the test proves nothing"
9022        );
9023        // Measured at 310 bytes here. The bound is loose enough to survive a
9024        // changed header layout and still orders of magnitude below the file.
9025        assert!(
9026            read < 64 << 10,
9027            "a bounded commit read {read} bytes of a {file_len}-byte file"
9028        );
9029    }
9030
9031    /// An in-place append leaves a partially-filled **raw** page, so the next
9032    /// commit's metadata must pad it rather than pack into it.
9033    ///
9034    /// This is what keeps [`PagedEdit::begin`] reachable from [`EditStore`] now
9035    /// that an append allocates raw pages only: the append's job is to record that
9036    /// the tail page turned raw, and the commit's job is to act on it. It is also
9037    /// the interleaving that a single session-level page tracker makes possible —
9038    /// with a tracker per engine, the commit path could not see what the append
9039    /// path had done, which is why the whole-file editor refused an in-place append
9040    /// to a paged file at all (issue #198).
9041    #[test]
9042    fn a_commit_after_an_append_pads_the_raw_page_the_append_left() {
9043        const PAGE: u64 = 4096;
9044        let dir = tempdir().unwrap();
9045        let p = dir.path().join("paged_interleave.h5");
9046        let mut b = crate::writer::FileBuilder::new();
9047        b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
9048            .with_file_space_page_size(PAGE);
9049        b.create_dataset("d")
9050            .with_i32_data(&(0..64).collect::<Vec<i32>>())
9051            .with_shape(&[64])
9052            .with_maxshape(&[u64::MAX])
9053            .with_chunks(&[64]);
9054        b.write(&p).unwrap();
9055
9056        let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
9057        let mut ab = AppendBuilder::new();
9058        ab.append_i32(&(64..2000).collect::<Vec<_>>());
9059        engine
9060            .append_inplace_gathered(AppendTarget::Path("d"), &ab, 4)
9061            .unwrap();
9062
9063        assert_eq!(
9064            engine.paged.as_ref().unwrap().last,
9065            Some(PageType::Raw),
9066            "the append must record that the tail page now holds raw data"
9067        );
9068        assert_ne!(
9069            engine.image.len() % PAGE,
9070            0,
9071            "the append must leave a partially-filled page for the commit to pad"
9072        );
9073
9074        engine.create_group("g");
9075        engine.commit().unwrap();
9076
9077        // The commit padded the raw tail before laying down metadata, and folded
9078        // that padding into the small-raw manager (`meta_pad`/`raw_pad` are cleared
9079        // into the free lists as part of the paged tail).
9080        let pg = engine.paged.as_ref().expect("the file is paged");
9081        let raw_free = pg.raw_small.sections();
9082        assert!(
9083            !raw_free.is_empty(),
9084            "the commit packed metadata into the raw page the append left open"
9085        );
9086        for (addr, len) in raw_free {
9087            assert_eq!(
9088                (addr + len) % PAGE,
9089                0,
9090                "padding {addr}+{len} does not reach a page boundary"
9091            );
9092        }
9093
9094        drop(engine);
9095        assert_eq!(
9096            crate::File::open(&p)
9097                .unwrap()
9098                .dataset("d")
9099                .unwrap()
9100                .read_i32()
9101                .unwrap(),
9102            (0..2000).collect::<Vec<_>>()
9103        );
9104    }
9105
9106    /// An in-place append to a paged file must allocate **only raw pages** — the
9107    /// chunk data and the extensible-array blocks indexing it alike.
9108    ///
9109    /// The reclaim path (`chunked_storage_spans`) reports both halves of a chunked
9110    /// dataset as raw free space, because that is where this crate places them. An
9111    /// append that put its index blocks in a metadata page instead would make the
9112    /// reclaim advertise metadata-page bytes for raw reuse, mixing the page a paged
9113    /// file exists to keep homogeneous. Measured here rather than through the
9114    /// reference C library, which reads a mixed-page file without complaint: an
9115    /// interop test proves interop and says nothing about segregation.
9116    #[test]
9117    fn an_inplace_append_to_a_paged_file_allocates_only_raw_pages() {
9118        const PAGE: u64 = 4096;
9119        let dir = tempdir().unwrap();
9120        let p = dir.path().join("paged_raw.h5");
9121        let mut b = crate::writer::FileBuilder::new();
9122        b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
9123            .with_file_space_page_size(PAGE);
9124        b.create_dataset("d")
9125            .with_i32_data(&(0..64).collect::<Vec<i32>>())
9126            .with_shape(&[64])
9127            .with_maxshape(&[u64::MAX])
9128            .with_chunks(&[64]);
9129        b.write(&p).unwrap();
9130
9131        let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
9132        let before = engine.image().len();
9133        // Two appends, each large enough to grow the extensible-array index, so the
9134        // run allocates index blocks as well as chunk data.
9135        for range in [64..2000, 2000..4000] {
9136            let mut ab = AppendBuilder::new();
9137            ab.append_i32(&range.collect::<Vec<_>>());
9138            engine
9139                .append_inplace_gathered(AppendTarget::Path("d"), &ab, 4)
9140                .unwrap();
9141        }
9142
9143        let pg = engine.paged.as_ref().expect("the file is paged");
9144        assert_eq!(
9145            pg.last,
9146            Some(PageType::Raw),
9147            "the append left the tail page holding something other than raw data"
9148        );
9149        assert!(
9150            pg.meta_pad.is_empty() && pg.raw_pad.is_empty(),
9151            "an in-place append switched page type: meta_pad={:?} raw_pad={:?}",
9152            pg.meta_pad,
9153            pg.raw_pad
9154        );
9155
9156        // Not vacuous: the append really did allocate index structure above the
9157        // pre-append end-of-file, which is what would have opened a metadata page.
9158        let addr = crate::group_v2::resolve_path_any_from_source(
9159            &engine.image(),
9160            engine.superblock(),
9161            "d",
9162        )
9163        .unwrap();
9164        let spans = engine
9165            .chunked_storage_spans(addr.to_usize().unwrap())
9166            .expect("a chunked dataset has reclaimable spans");
9167        let fresh = spans.iter().filter(|&&(a, _, _)| a >= before).count();
9168        assert!(
9169            fresh > 0,
9170            "the append allocated nothing above {before}, so the assertion above proves nothing"
9171        );
9172        assert!(
9173            spans.iter().all(|&(_, _, ty)| ty == PageType::Raw),
9174            "the reclaim tags every chunked span raw; a metadata tag here would need \
9175             the placement rule above to change with it"
9176        );
9177    }
9178}