Skip to main content

hdf5_pure/
edit.rs

1//! In-place editing of an existing HDF5 file (issue #32, Group C).
2//!
3//! [`EditSession`] 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 ([`EditSession::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 ([`EditSession::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//! [`EditSession::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 ([`EditSession::write_dataset`], 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`](EditSession::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//!   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 and root
107//!   attribute edits (`set_group_attr`) may likewise be fixed-size or
108//!   variable-length, under the same compact-storage limit; dense
109//!   (fractal-heap) attribute storage is not supported.
110//! - A new group's parent must already exist or be created in the same session
111//!   (each level created explicitly); intermediate groups are not auto-created.
112//!
113//! # Free-space reuse (issue #21)
114//!
115//! Each commit vacates space: the object headers it rewrites are superseded, and
116//! a deletion abandons its target's blocks. Those regions are recorded in a
117//! session-local free list and reused by later commits in the same session —
118//! a new object is written into a fitting freed region instead of growing the
119//! file, and when freed space forms a run reaching end-of-file the file is
120//! physically truncated. The reuse is crash-safe: it only ever overwrites space
121//! freed by an *earlier*, already-durable commit (never space the current commit
122//! is mid-way through freeing), and truncation happens only after the superblock
123//! recording the smaller end-of-file is itself durable.
124//!
125//! Reclaim is best-effort and conservative. Contiguous and chunked datasets
126//! (chunk index plus chunk data) and whole group subtrees are reclaimed; a
127//! deleted object whose blocks cannot be enumerated exhaustively —
128//! variable-length global-heap storage, dense attribute/link heaps, a
129//! non–version-2 header, a version 2 B-tree chunk index — is left as dead bytes
130//! rather than risk freeing a region that is still in use; under-reclaiming only
131//! wastes space, while over-reclaiming would corrupt.
132//!
133//! Whether the free list outlives the session depends on how the file was
134//! created. For the default (non-persisting) file it is **not** persisted: it is
135//! forgotten on close, so reuse and shrinkage apply to churn within a session,
136//! and a single delete-then-close shrinks the file only when the freed bytes
137//! reach end-of-file. A file created with
138//! `H5Pset_file_space_strategy(persist = true)` instead **persists** its free
139//! space: `open` seeds the list from the on-disk free-space managers (the
140//! `FSHD`/`FSSE` blocks the superblock-extension File Space Info message points
141//! at), and each commit rewrites those managers, so freed regions survive
142//! close/reopen and are reused across sessions — by this crate and the reference
143//! C library alike. A persisting commit *retains* freed space (recording it on
144//! disk) rather than truncating it; the blocks holding the managers are appended
145//! past all live data and the superblock is repointed last, so a crash before the
146//! repoint leaves the prior file wholly intact. Whole-file compaction that
147//! reclaims every hole at once is still the separate repack path.
148
149use std::collections::{BTreeMap, HashMap, HashSet};
150use std::fs;
151use std::io::{Read, Seek, SeekFrom, Write};
152use std::path::Path;
153
154use crate::checksum::jenkins_lookup3;
155use crate::chunked_read::{enumerate_chunks_buffered, plan_dense_grid};
156use crate::chunked_write::{
157    ChunkMeta, ChunkOptions, ChunkProvider, build_chunked_data_at_ext, emit_chunked_data_verbatim,
158    plan_chunked_data_verbatim, split_into_chunks,
159};
160use crate::data_layout::DataLayout;
161use crate::dataspace::{Dataspace, DataspaceType};
162use crate::error::{Error, FormatError};
163use crate::file_lock::{self, FileLocking};
164use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy};
165use crate::file_writer::{
166    LENGTH_SIZE, OFFSET_SIZE, build_chunked_dataset_oh, build_dataset_oh, make_link,
167};
168use crate::filter_pipeline::{
169    FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_SCALEOFFSET, FILTER_SHUFFLE, FilterPipeline,
170};
171use crate::filters::{ChunkContext, compress_chunk};
172use crate::free_space::FreeList;
173use crate::free_space_manager::{self, FreeSection, FsmHeader, fshd_len, serialize_file_fsm};
174use crate::group_v2::resolve_group_entries;
175use crate::link_message::{LinkMessage, LinkTarget};
176use crate::message_type::MessageType;
177use crate::object_header::ObjectHeader;
178use crate::signature;
179use crate::superblock::Superblock;
180use crate::type_builders::{
181    AttrValue, DatasetBuilder, ObjectRefTarget, VlStringStaging, build_attr_message,
182    build_global_heap_collection, patch_vl_refs, patch_vl_refs_masked,
183};
184
185/// An undefined on-disk address (all bits set), HDF5's "no address" sentinel.
186const UNDEF: u64 = u64::MAX;
187
188/// Maximum number of compact attributes; beyond this HDF5 switches a dataset to
189/// dense (fractal-heap) attribute storage, which this engine does not emit.
190/// Mirrors `DENSE_ATTR_THRESHOLD` in `file_writer`.
191const MAX_COMPACT_ATTRS: usize = 8;
192
193/// Recursion-depth cap for object copy, guarding against a stack overflow on a
194/// pathological or cyclic hard-link graph (HDF5 hard links can form cycles).
195/// Far deeper than any real group hierarchy.
196const MAX_COPY_DEPTH: u32 = 1000;
197
198/// Upper bound on the number of object headers walked when counting hard links
199/// across the file (issue #77 / reclaim safety). Far beyond any real file; a
200/// graph larger than this aborts the count, and the commit then leaves deleted
201/// objects unreclaimed (a safe leak) rather than risk an unbounded walk.
202const MAX_LINK_GRAPH_NODES: u32 = 1 << 24;
203
204/// Maximum number of object-header chunks to follow when gathering a header that
205/// spans continuation blocks, guarding against a cyclic continuation chain.
206/// Matches the reader's continuation-depth cap.
207const MAX_OH_CHUNKS: usize = 256;
208
209/// A path identified by its components (no leading/trailing empties); the root
210/// group is the empty vector.
211type PathKey = Vec<String>;
212
213/// Variable-length group/root attributes staged by [`apply_group_attr_ops`],
214/// each an (attribute message still carrying a placeholder heap address, its
215/// global heap collection bytes) pair, resolved in the apply loop.
216type PendingVlAttrs = Vec<(crate::attribute::AttributeMessage, Vec<u8>)>;
217
218/// An open HDF5 file being edited in place.
219///
220/// Mirror the file in memory and keep a writable handle; every mutation is
221/// applied to both so the on-disk file stays consistent. Stage additions with
222/// [`create_dataset`](Self::create_dataset) / [`create_group`](Self::create_group),
223/// value overwrites with [`write_dataset`](Self::write_dataset), and group
224/// attribute edits with [`set_group_attr`](Self::set_group_attr) /
225/// [`remove_group_attr`](Self::remove_group_attr), then apply them with
226/// [`commit`](Self::commit).
227///
228/// # Example
229///
230/// ```no_run
231/// use hdf5_pure::{AttrValue, EditSession};
232///
233/// let mut session = EditSession::open("existing.h5")?;
234/// session.create_group("run2");
235/// session.set_group_attr("run2", "kind", AttrValue::AsciiString("trial".into()));
236/// session
237///     .create_dataset("run2/signal")
238///     .with_f64_data(&[1.0, 2.0, 3.0]);
239/// session.commit()?;
240/// # Ok::<(), hdf5_pure::Error>(())
241/// ```
242pub struct EditSession {
243    handle: fs::File,
244    /// In-memory mirror of the file, kept byte-for-byte in sync with `handle`.
245    data: Vec<u8>,
246    /// Absolute offset of the superblock signature in the file.
247    sb_sig_off: usize,
248    /// Parsed superblock. On-disk addresses are stored relative to `base_address`;
249    /// the in-memory `root_group_address` is normalized to an absolute file offset
250    /// on open and converted back to a base-relative address when serialized on
251    /// commit. `base_address` equals the superblock's file location (`sb_sig_off`):
252    /// 0 for a plain file, the userblock size for one with a userblock.
253    superblock: Superblock,
254    /// Datasets staged by `create_dataset`, as (parent group path, builder).
255    pending_datasets: Vec<(PathKey, DatasetBuilder)>,
256    /// Value overwrites staged by `write_dataset`, as (full dataset path,
257    /// builder). Each replaces an existing dataset's values in place; the new
258    /// datatype and shape must match the on-disk ones byte-exactly (this is a
259    /// value overwrite, not a reshape/retype). Applied on the next `commit`.
260    pending_writes: Vec<(PathKey, DatasetBuilder)>,
261    /// New groups staged by `create_group`, as full paths.
262    pending_groups: Vec<PathKey>,
263    /// Group attribute edits staged as (group path, operation). The path may be
264    /// a group created in this same session.
265    pending_group_attrs: Vec<(PathKey, GroupAttrOp)>,
266    /// Links staged for removal by `delete`, as full paths.
267    pending_deletes: Vec<PathKey>,
268    /// Object copies staged by `copy`, as (source path, destination full path).
269    pending_copies: Vec<(PathKey, PathKey)>,
270    /// Cross-file object copies staged by `copy_from`, as (destination full path,
271    /// the source subtree already read out of the other file). The subtree is read
272    /// — and foreign-address-screened — eagerly in `copy_from` (the source file is
273    /// borrowed only for that call), then linked in at the next `commit`.
274    pending_cross_copies: Vec<(PathKey, CopyTree)>,
275    /// Session-local free-space tracker (issue #21). Holds regions vacated by
276    /// prior commits in this session — superseded object headers and the blocks
277    /// of deleted objects — so later commits reuse them instead of growing the
278    /// file, and so a freed run reaching end-of-file can be truncated away. It
279    /// starts empty on `open` for a non-persisting file: holes already present
280    /// from earlier sessions or other tools are not tracked. When the file
281    /// persists its free space (`persist` is `Some`), `open` instead seeds it
282    /// from the on-disk free-space managers, so reuse spans sessions.
283    free: FreeList,
284    /// Free-space persistence read from the file's superblock extension on
285    /// `open` (the file-creation `H5Pset_file_space_strategy(persist = true)`
286    /// setting). `None` for the default non-persisting file; when `Some`, every
287    /// [`commit`](Self::commit) rewrites the on-disk free-space managers so the
288    /// free list survives close/reopen.
289    persist: Option<PersistState>,
290}
291
292/// State for a file that persists its free space on disk. Carries the file's
293/// fixed file-space parameters and the extents of the free-space-manager blocks
294/// (and superblock extension) the *current* on-disk file uses, so the next
295/// persisting commit can reclaim them when it writes fresh ones.
296struct PersistState {
297    strategy: FileSpaceStrategy,
298    threshold: u64,
299    page_size: u64,
300    /// `(addr, len)` of the on-disk superblock-extension header and every
301    /// free-space-manager `FSHD`/`FSSE` block currently in use. Superseded — and
302    /// therefore freed — by the next persisting commit.
303    old_blocks: Vec<(u64, u64)>,
304}
305
306impl EditSession {
307    /// Open an existing HDF5 file for in-place editing.
308    ///
309    /// Reads the file into memory and retains a read/write handle. Takes an
310    /// exclusive OS advisory lock so the file cannot be opened concurrently by
311    /// another writer or reader; the lock is released automatically when the
312    /// session is dropped or the process exits (including on a crash). Fails with
313    /// [`Error::FileLocked`] if the file is already locked, or
314    /// [`Error::EditUnsupported`] if the file is not a supported target (see the
315    /// [module docs](self) for the exact requirements). To control or disable
316    /// locking, use [`open_with_locking`](Self::open_with_locking) or set
317    /// `HDF5_USE_FILE_LOCKING=FALSE`.
318    pub fn open<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
319        Self::open_with_locking(path, FileLocking::Enabled)
320    }
321
322    /// Open an existing HDF5 file for in-place editing, choosing the file-locking
323    /// policy explicitly. See [`open`](Self::open) and [`FileLocking`].
324    pub fn open_with_locking<P: AsRef<Path>>(path: P, locking: FileLocking) -> Result<Self, Error> {
325        let path = path.as_ref();
326        let mut handle = fs::OpenOptions::new()
327            .read(true)
328            .write(true)
329            .open(path)
330            .map_err(Error::Io)?;
331        // Acquire the exclusive lock before reading or mutating; the retained
332        // `handle` holds it for the session's life.
333        file_lock::acquire_exclusive(&handle, locking, path)?;
334        let mut data = Vec::new();
335        handle.read_to_end(&mut data).map_err(Error::Io)?;
336
337        let sb_sig_off = signature::find_signature(&data)?;
338        let mut superblock = Superblock::parse(&data, sb_sig_off)?;
339
340        if superblock.version > 3 {
341            return Err(Error::EditUnsupported("unsupported superblock version"));
342        }
343        if superblock.offset_size != OFFSET_SIZE || superblock.length_size != LENGTH_SIZE {
344            return Err(Error::EditUnsupported(
345                "only 8-byte offsets and lengths are supported for in-place editing",
346            ));
347        }
348        // A userblock shifts the whole HDF5 image forward by `base_address`: the
349        // superblock sits at the base address and every stored address is relative
350        // to it (the end-of-file address is the sole absolute field). The editor
351        // supports this by reading at `stored + base` and writing back
352        // `file_offset - base`. Only the canonical layout — superblock located
353        // exactly at the base address (e.g. a MATLAB v7.3 `.mat` file's 512-byte
354        // userblock) — is accepted; a base address that disagrees with the
355        // superblock's location is a relocated or malformed file we will not rewrite.
356        if superblock.base_address != sb_sig_off as u64 {
357            return Err(Error::EditUnsupported(
358                "a file whose superblock is not located at its base address is not editable in place",
359            ));
360        }
361        // Normalize the root group address to an absolute file offset, exactly as
362        // the reader does (`reader::parse_superblock`), so `resolve_path_any` and
363        // the link-graph walk index `self.data` correctly. It is converted back to a
364        // stored (base-relative) address only when the superblock is serialized on
365        // commit.
366        superblock.root_group_address += superblock.base_address;
367
368        let mut session = Self {
369            handle,
370            data,
371            sb_sig_off,
372            superblock,
373            pending_datasets: Vec::new(),
374            pending_writes: Vec::new(),
375            pending_groups: Vec::new(),
376            pending_group_attrs: Vec::new(),
377            pending_deletes: Vec::new(),
378            pending_copies: Vec::new(),
379            pending_cross_copies: Vec::new(),
380            free: FreeList::new(),
381            persist: None,
382        };
383        // If the file persists its free space, seed the free list from the
384        // on-disk managers and arm persistence for future commits. Best-effort:
385        // an unreadable or non-persisting extension simply leaves the session in
386        // the default, non-persisting mode.
387        session.load_persisted_free_space();
388        Ok(session)
389    }
390
391    /// Read the superblock-extension File Space Info message; if it requests
392    /// persistence, seed [`self.free`](Self::free) from the on-disk free-space
393    /// managers and record the manager/extension block extents for reclamation on
394    /// the next commit. Silent on any malformed or absent metadata — persistence
395    /// is then simply off for this session.
396    fn load_persisted_free_space(&mut self) {
397        if self.superblock.version < 2 {
398            return; // no superblock extension exists before v2
399        }
400        // Free-space reuse and persistence are not yet base-address aware: the
401        // persisted section addresses (and the extension/manager block walk below)
402        // are read as absolute, so on a userblock file they would seed `self.free`
403        // with wrong regions that `alloc_or_append` could later hand out into live
404        // data. Leave persistence off for such a file — the on-disk managers stay
405        // untouched and valid, this session simply appends rather than reusing.
406        if self.superblock.base_address != 0 {
407            return;
408        }
409        let Some(ext_rel) = self.superblock.superblock_extension_address else {
410            return;
411        };
412        if ext_rel == UNDEF {
413            return;
414        }
415        let Ok(ext_addr) = usize::try_from(ext_rel) else {
416            return;
417        };
418        let Some(info) = self.extension_fsinfo(ext_addr) else {
419            return;
420        };
421        if !info.persist {
422            return;
423        }
424        let os = self.superblock.offset_size;
425
426        // Seed the free list with every persisted section (addresses are stored
427        // relative to the base address, which this editor requires to be 0).
428        // Defensive against a malformed or corrupt manager: skip a section that is
429        // empty, runs past end-of-file, or overlaps one already taken. A
430        // well-formed file (this crate's or the C library's) has none of these;
431        // tolerating them keeps a bad file from seeding a bogus or double-counted
432        // free region that a later commit would hand out into live data.
433        if let Ok(mut sections) =
434            free_space_manager::read_persisted_sections(&self.data, &info.manager_addrs, 0, os)
435        {
436            let file_len = self.data.len() as u64;
437            sections.sort_by_key(|s| s.addr);
438            let mut prev_end = 0u64;
439            for s in sections {
440                let Some(end) = s.addr.checked_add(s.size) else {
441                    continue;
442                };
443                if s.size == 0 || end > file_len || s.addr < prev_end {
444                    continue;
445                }
446                prev_end = end;
447                self.free.free(s.addr, s.size);
448            }
449        }
450
451        // Record the byte extents of the blocks the live file uses so the next
452        // persisting commit frees them when it writes replacements: the
453        // extension header, and each defined manager's FSHD + FSSE.
454        let mut old_blocks = Vec::new();
455        if let Ok(spans) = self.oh_chunk_spans(ext_addr) {
456            old_blocks.extend(spans);
457        }
458        for &m in &info.manager_addrs {
459            if m == UNDEF {
460                continue;
461            }
462            let Ok(m_us) = usize::try_from(m) else {
463                continue;
464            };
465            let Some(slice) = self.data.get(m_us..) else {
466                continue;
467            };
468            if let Ok(h) = FsmHeader::parse(slice, os) {
469                // `FsmHeader::parse` succeeding guarantees the header's own bytes
470                // are present, so the FSHD extent is in-bounds; validate the
471                // section-info extent before recording it, so a malformed
472                // `fsse_used` can't later free a region running past end-of-file.
473                old_blocks.push((m, fshd_len(os)));
474                if h.fsse_addr != UNDEF
475                    && h.fsse_addr
476                        .checked_add(h.fsse_used)
477                        .is_some_and(|end| end <= self.data.len() as u64)
478                {
479                    old_blocks.push((h.fsse_addr, h.fsse_used));
480                }
481            }
482        }
483
484        self.persist = Some(PersistState {
485            strategy: info.strategy,
486            threshold: info.threshold,
487            page_size: info.page_size,
488            old_blocks,
489        });
490    }
491
492    /// Parse the File Space Info message out of the superblock-extension object
493    /// header at `ext_addr`, if present and readable.
494    fn extension_fsinfo(&self, ext_addr: usize) -> Option<FileSpaceInfo> {
495        let os = self.superblock.offset_size;
496        let ls = self.superblock.length_size;
497        let base = self.superblock.base_address;
498        let oh = ObjectHeader::parse_with_base(&self.data, ext_addr, os, ls, base).ok()?;
499        let msg = oh
500            .messages
501            .iter()
502            .find(|m| m.msg_type == MessageType::FileSpaceInfo)?;
503        FileSpaceInfo::parse(&msg.data, os, ls).ok()
504    }
505
506    /// Stage a new dataset, added on the next [`commit`](Self::commit). The
507    /// argument is the full path of the dataset; everything before the last
508    /// component names the parent group, which must exist (or be created in this
509    /// session). Returns the [`DatasetBuilder`] — the same builder used by
510    /// [`FileBuilder`](crate::FileBuilder) — to configure data, shape, and
511    /// attributes.
512    ///
513    /// The dataset may be contiguous or chunked, and chunked datasets may be
514    /// filtered (`with_deflate`, `with_shuffle`, `with_fletcher32`,
515    /// `with_scale_offset`, `with_zfp`) and/or extensible (`with_maxshape`). An
516    /// empty (zero-element) contiguous dataset is supported (chunking one is
517    /// not), a provenance dataset (`with_provenance`) is supported, and a
518    /// contiguous dataset may carry variable-length attributes, a
519    /// variable-length-string payload (`with_vlen_strings`), or path-resolved
520    /// object-reference elements (`with_path_references`; chunking any of
521    /// these is not supported — see the [module docs](self) for the
522    /// path-resolution rule and what still stays unsupported (dense
523    /// attributes)).
524    pub fn create_dataset(&mut self, path: &str) -> &mut DatasetBuilder {
525        let mut comps = split_path(path);
526        let leaf = comps.pop().unwrap_or_default();
527        self.pending_datasets
528            .push((comps, DatasetBuilder::new(&leaf)));
529        &mut self.pending_datasets.last_mut().unwrap().1
530    }
531
532    /// Stage an in-place overwrite of an **existing** dataset's values (the HDF5
533    /// `H5Dwrite` whole-dataset write), applied on the next
534    /// [`commit`](Self::commit). `path` is the full path of a dataset that must
535    /// already exist; the returned [`DatasetBuilder`] — the same builder used by
536    /// [`create_dataset`](Self::create_dataset) — supplies the replacement data.
537    ///
538    /// This is a *value* overwrite, not a reshape or retype: the new data's
539    /// datatype and shape must match the on-disk dataset's exactly (byte-for-byte
540    /// after serialization, so endianness and compound layout must agree), or
541    /// `commit` reports [`Error::EditUnsupported`]. Contiguous, compact, and
542    /// chunked (including filtered) datasets are all supported; the dataset's
543    /// existing chunk geometry, filter pipeline, and chunk index are taken from the
544    /// on-disk header (a builder that itself requests chunking/filtering is refused
545    /// as "not a value overwrite"). A chunk index this engine cannot enumerate (a
546    /// version-2 B-tree) is refused. Partial / sub-region writes are out of scope —
547    /// the whole dataset is replaced.
548    ///
549    /// When the new data is the same length as the existing contiguous data block
550    /// (the common case), the bytes are written straight into that block: no
551    /// object header is rewritten and the superblock root is not flipped, so the
552    /// commit's linearization point is the synced data write itself. A chunked
553    /// dataset is handled the same way when every (re-encoded) chunk is the same
554    /// byte length as the slot it replaces — an unfiltered overwrite (chunk sizes
555    /// are fixed by the unchanged shape) or a filtered one whose re-encoded chunks
556    /// match — so it too writes straight into the existing chunk slots. When the
557    /// length differs (a resized contiguous block, or a filtered chunk that no
558    /// longer fits), the dataset's storage is rebuilt at end-of-file (or in
559    /// reusable freed space), the old extent is freed, the data-layout message is
560    /// repointed, the object header is rewritten, and the parent group's link is
561    /// patched — exactly like an addition relocates the path up to the root. A
562    /// relocating overwrite moves the object header, so it is refused unless the
563    /// dataset has a single hard link.
564    pub fn write_dataset(&mut self, path: &str) -> &mut DatasetBuilder {
565        let comps = split_path(path);
566        let leaf = comps.last().cloned().unwrap_or_default();
567        self.pending_writes
568            .push((comps, DatasetBuilder::new(&leaf)));
569        &mut self.pending_writes.last_mut().unwrap().1
570    }
571
572    /// Stage a new (empty) group at `path`, created on the next
573    /// [`commit`](Self::commit). The parent must already exist or be created in
574    /// the same session; populate the group with datasets via
575    /// [`create_dataset`](Self::create_dataset) using a path under it.
576    pub fn create_group(&mut self, path: &str) {
577        self.pending_groups.push(split_path(path));
578    }
579
580    /// Stage an attribute add or replacement on a group, applied on the next
581    /// [`commit`](Self::commit).
582    ///
583    /// `path` names the group to edit; `""` or `"/"` names the root group. The
584    /// group may already exist or may be created earlier in the same session
585    /// with [`create_group`](Self::create_group). Attributes — fixed-size or
586    /// variable-length (`AttrValue::VarLenAsciiArray`) — are stored compactly in
587    /// the rebuilt group header; an edit that would exceed the compact-attribute
588    /// limit, or a group using dense (fractal-heap) attribute storage, is
589    /// refused before any file bytes are changed.
590    pub fn set_group_attr(&mut self, path: &str, name: &str, value: AttrValue) -> &mut Self {
591        self.pending_group_attrs.push((
592            split_path(path),
593            GroupAttrOp::Set {
594                name: name.to_string(),
595                value,
596            },
597        ));
598        self
599    }
600
601    /// Stage removal of a compact attribute from a group, applied on the next
602    /// [`commit`](Self::commit).
603    ///
604    /// `path` names the group to edit; `""` or `"/"` names the root group. The
605    /// named attribute must exist in the committed group state after any earlier
606    /// staged attribute operations for the same group have been applied.
607    pub fn remove_group_attr(&mut self, path: &str, name: &str) -> &mut Self {
608        self.pending_group_attrs.push((
609            split_path(path),
610            GroupAttrOp::Remove {
611                name: name.to_string(),
612            },
613        ));
614        self
615    }
616
617    /// Stage removal of the link at `path` (the HDF5 `H5Ldelete`), applied on the
618    /// next [`commit`](Self::commit). The link's object — and, for a group, its
619    /// whole subtree — becomes unreachable. The bytes it occupied are returned to
620    /// this session's free list (issue #21): a later commit reuses them for new
621    /// objects instead of growing the file, and if a freed run reaches
622    /// end-of-file the file is truncated. Contiguous and chunked datasets (their
623    /// chunk index and chunk data blocks) and whole group subtrees are all
624    /// reclaimed. Reclaim is best-effort — an object whose blocks this engine
625    /// cannot enumerate exhaustively (variable-length global-heap storage, dense
626    /// attribute/link heaps, a version 2 B-tree chunk index) is left as dead
627    /// bytes rather than risk freeing a region that is still in use. Freed space is
628    /// reused within the open session; for a file created with
629    /// `H5Pset_file_space_strategy(persist = true)` it is also recorded on disk so
630    /// it survives reopen (see the [module docs](self)), otherwise it is forgotten
631    /// on close. After reuse, an object reference to a deleted object may resolve
632    /// to an unrelated object (deleting a referenced object is undefined in HDF5).
633    ///
634    /// The path must exist. A deletion may not overlap another staged change in
635    /// the same commit (e.g. delete `/a` while adding `/a/b`); split such
636    /// edits into separate commits. The link's parent group must itself be
637    /// editable in place (compact links, single-chunk header); the target being
638    /// removed has no such restriction.
639    pub fn delete(&mut self, path: &str) {
640        self.pending_deletes.push(split_path(path));
641    }
642
643    /// Stage a deep copy of the object at `src` to a new link at `dst` (the HDF5
644    /// `H5Ocopy`), applied on the next [`commit`](Self::commit). The source — a
645    /// dataset or a whole group subtree — is duplicated: fresh copies of every
646    /// object's data and header are written, internal links and the contiguous
647    /// data address are repointed to the copies, and a link named by `dst`'s last
648    /// component is added to `dst`'s parent group. The original is untouched.
649    ///
650    /// The copy reflects the file's on-disk state at commit time. `src` must
651    /// exist and `dst` must not (and may not lie inside `src`). A chunked (and
652    /// filtered) dataset is copied with its chunk payloads and filter pipeline
653    /// preserved byte-for-byte (the index is rebuilt at the new location, so a
654    /// source using a B-tree-v1 or implicit index is reproduced with an equivalent
655    /// v4 index). The source subtree must otherwise be copyable in place: compact
656    /// links and attributes, single-chunk headers, and a chunk index this engine
657    /// can enumerate (a version-2 B-tree, or a sparse/unallocated chunk grid, is
658    /// refused) — otherwise `commit` reports [`Error::EditUnsupported`].
659    pub fn copy(&mut self, src: &str, dst: &str) {
660        self.pending_copies.push((split_path(src), split_path(dst)));
661    }
662
663    /// Stage a deep copy of the object at `src` in another open file `source` to a
664    /// new link at `dst` in this file — a *cross-file* HDF5 `H5Ocopy` — applied on
665    /// the next [`commit`](Self::commit). Like [`copy`](Self::copy) but the source
666    /// lives in a separate, independently-opened [`File`](crate::File) reader
667    /// rather than the file being edited.
668    ///
669    /// The source — a dataset or a whole group subtree — is duplicated faithfully:
670    /// fresh, byte-identical copies of every object's header and data are appended
671    /// to this file, internal links repointed, and a link named by `dst`'s last
672    /// component added to `dst`'s parent group (which must already exist or be
673    /// created earlier in this session). Both files are left otherwise untouched;
674    /// the destination only changes on `commit`.
675    ///
676    /// Unlike the same-file [`copy`](Self::copy), the source is read **eagerly**
677    /// here (the `source` borrow need not outlive the call), so this returns
678    /// `Result`: the source subtree is resolved, validated, and read out before
679    /// returning, and only an already-validated copy is queued for `commit`.
680    ///
681    /// # Errors
682    ///
683    /// Returns [`Error::EditUnsupported`] if the copy cannot be reproduced exactly
684    /// in another file. Because the copy is byte-for-byte verbatim, anything that
685    /// embeds a *source-file* absolute address is refused (it would dangle here):
686    /// **variable-length** or **reference** datasets and attributes (including a
687    /// chunked dataset whose elements are variable-length or references, whose
688    /// chunk payloads embed such addresses), and any **shared header message** (a
689    /// committed datatype, or an SOHM-shared dataspace, fill value, or filter
690    /// pipeline). As with [`copy`](Self::copy) a chunked/filtered source is copied
691    /// with its chunk payloads and pipeline preserved (index rebuilt at the new
692    /// location); the source must use compact links and attributes, single-chunk
693    /// version-2 headers, and a chunk index this engine can enumerate (a
694    /// version-2 B-tree, or a sparse chunk grid, is refused). The
695    /// `source` must be a buffered file ([`File::open`](crate::File::open) or
696    /// [`File::from_bytes`](crate::File::from_bytes), not
697    /// [`open_streaming`](crate::File::open_streaming)) using 8-byte offsets and no
698    /// userblock, and `src` must exist in it and not be the root group.
699    pub fn copy_from(
700        &mut self,
701        source: &crate::reader::File,
702        src: &str,
703        dst: &str,
704    ) -> Result<(), Error> {
705        // The source bytes must be addressable: a streaming file is refused.
706        let src_data = source.in_memory_image().ok_or(Error::EditUnsupported(
707            "cross-file copy requires a buffered source file (File::open or File::from_bytes), not a streaming one",
708        ))?;
709        let src_sb = source.superblock();
710        if src_sb.offset_size != OFFSET_SIZE || src_sb.length_size != LENGTH_SIZE {
711            return Err(Error::EditUnsupported(
712                "cross-file copy requires the source file to use 8-byte offsets and lengths",
713            ));
714        }
715        if source.base_address() != 0 {
716            return Err(Error::EditUnsupported(
717                "cross-file copy requires the source file to have no userblock (base address 0)",
718            ));
719        }
720
721        let src = split_path(src);
722        if src.is_empty() {
723            return Err(Error::EditUnsupported("cannot copy the root group"));
724        }
725        let dst = split_path(dst);
726        if dst.is_empty() {
727            return Err(Error::EditUnsupported("copy destination path is empty"));
728        }
729
730        let src_addr = crate::group_v2::resolve_path_any(src_data, src_sb, &src.join("/"))
731            .map_err(|_| Error::EditUnsupported("copy source does not exist in the source file"))?;
732        let src_addr = usize::try_from(src_addr)
733            .map_err(|_| Error::EditUnsupported("source address exceeds this platform"))?;
734        // Read (and foreign-address-screen) the whole subtree now, while `source`
735        // is borrowed; the owned tree carries every byte the commit will write. The
736        // source is gated to base 0 above, so its stored addresses are absolute.
737        let tree = Self::read_copy_subtree(src_data, src_addr, 0, true, 0)?;
738        self.pending_cross_copies.push((dst, tree));
739        Ok(())
740    }
741
742    /// Apply all staged additions and deletions to the file in place and flush.
743    ///
744    /// Appends each new dataset (its data — a contiguous blob, or the chunk data
745    /// and index for a chunked/filtered dataset — plus its object header) and
746    /// each new group, then appends rewritten object headers for every touched
747    /// group and its ancestors up to the root (omitting any deleted links), then
748    /// repoints the superblock at the new root. On success the staged set is
749    /// cleared and the session can be reused. On any [`Error::EditUnsupported`]
750    /// the file on disk is left untouched: the checks that raise it — including
751    /// each dataset's filter-pipeline and chunk-geometry validation — all run
752    /// before the first byte is written. Should a later step fail mid-apply (an
753    /// I/O error, or a residual build error), the superblock — repointed last —
754    /// still names the prior root, so the file stays valid and the appended bytes
755    /// are unreferenced slack.
756    pub fn commit(&mut self) -> Result<(), Error> {
757        if self.pending_datasets.is_empty()
758            && self.pending_writes.is_empty()
759            && self.pending_groups.is_empty()
760            && self.pending_group_attrs.is_empty()
761            && self.pending_deletes.is_empty()
762            && self.pending_copies.is_empty()
763            && self.pending_cross_copies.is_empty()
764        {
765            return Ok(());
766        }
767
768        // On a file with a userblock, stored addresses are relative to this base
769        // and the editor converts at every disk boundary (read `stored + base`,
770        // write `file_offset - base`). Userblock support covers value overwrites,
771        // additions of contiguous and chunked/filtered datasets, in-place and
772        // relocating overwrites of every layout (chunked, contiguous, compact) with
773        // reclaim, object deletion (with base-aware subtree reclaim), object copy
774        // (in-file, and cross-file into a userblock destination), group creation,
775        // and compact group attributes. Cross-file copy still requires a base-0
776        // *source* (see [`copy_from`](Self::copy_from)).
777        let base = self.superblock.base_address;
778
779        // --- Preflight value overwrites (`write_dataset`) before any write, under
780        // the same all-or-nothing contract as additions. Each is resolved,
781        // validated (datatype and shape must match the on-disk dataset exactly),
782        // and classified: a same-length contiguous overwrite is applied straight
783        // in place (no header rewrite, no superblock flip), while a resize or
784        // compact rewrite relocates the header and is staged against its parent
785        // group so the commit below rebuilds it and patches the link. ---
786        let writes = std::mem::take(&mut self.pending_writes);
787        let mut inplace_writes: Vec<(usize, Vec<u8>)> = Vec::new();
788        let mut moving_writes: Vec<(PathKey, String, MovingWrite)> = Vec::new();
789        let mut write_targets: Vec<PathKey> = Vec::new();
790        // The file-wide hard-link count, computed lazily the first time a write
791        // relocates a header: such a write moves the dataset's object header and
792        // patches only the one parent link that names it, so a dataset reachable
793        // through more than one hard link would have its other links left pointing
794        // at the stale header. Refuse that rather than silently diverge the aliases
795        // (a same-length in-place overwrite is unaffected — it rewrites the shared
796        // data block, which every link sees).
797        let mut incoming_links: Option<Option<HashMap<u64, u32>>> = None;
798        for (full, db) in writes {
799            if full.is_empty() {
800                return Err(Error::EditUnsupported("cannot overwrite the root group"));
801            }
802            // A path named twice in one commit would write it twice (and double-
803            // free a resized extent); require separate commits.
804            if write_targets.contains(&full) {
805                return Err(Error::EditUnsupported(
806                    "the same dataset is overwritten twice in one commit; use separate commits",
807                ));
808            }
809            let path_str = full.join("/");
810            let addr = crate::group_v2::resolve_path_any(&self.data, &self.superblock, &path_str)
811                .map_err(|_| {
812                Error::EditUnsupported("nothing to overwrite at the given path")
813            })?;
814            let addr = usize::try_from(addr)
815                .map_err(|_| Error::EditUnsupported("dataset address exceeds this platform"))?;
816            let fd = flatten_dataset(db)?;
817            match Self::prepare_write(&self.data, addr, &fd, base)? {
818                WritePlan::InPlace { data_addr, raw } => inplace_writes.push((data_addr, raw)),
819                WritePlan::InPlaceChunks { writes } => inplace_writes.extend(writes),
820                WritePlan::Moving(mw) => {
821                    // A relocating overwrite rewrites the dataset's header and data
822                    // address. Every variant is base-aware on a userblock file: the
823                    // chunked one rebuilds the chunk blob with stored addresses and
824                    // reclaims the old storage base-relative, the contiguous one
825                    // stores the relocated data address base-relative (and frees the
826                    // old extent at its absolute offset), and the compact one carries
827                    // its data inline. The parent link to the rewritten header is
828                    // patched base-relative below.
829                    //
830                    // A relocating overwrite is safe only when this is the
831                    // dataset's sole hard link. Compute the link graph once.
832                    let counts = incoming_links
833                        .get_or_insert_with(|| self.count_incoming_hard_links())
834                        .as_ref();
835                    match counts.and_then(|c| c.get(&(addr as u64))) {
836                        Some(&1) => {}
837                        _ => {
838                            return Err(Error::EditUnsupported(
839                                "overwriting a dataset that resizes or relocates its header is \
840                                 only supported when it has a single hard link",
841                            ));
842                        }
843                    }
844                    let leaf = full.last().unwrap().clone();
845                    let parent = full[..full.len() - 1].to_vec();
846                    moving_writes.push((parent, leaf, mw));
847                }
848            }
849            write_targets.push(full);
850        }
851
852        // Fast path: when the only staged edits are same-length in-place
853        // overwrites, apply them straight to their data blocks and return without
854        // rebuilding any header or flipping the superblock root. The commit's
855        // linearization point is the synced data write — there is no tree to
856        // repoint, so each overwrite stands alone. (A persisting file takes the
857        // same path: no free-space change occurs.)
858        //
859        // Because this path never rewrites the superblock, it deliberately leaves
860        // it untouched — including a pre-existing stale consistency flag (e.g. one
861        // left by a crashed SWMR writer). A lone same-length value overwrite does
862        // not introduce any inconsistency, so it does not clear one either; an edit
863        // that takes the full path below (any header/root change) clears the flag
864        // as usual.
865        if moving_writes.is_empty()
866            && self.pending_datasets.is_empty()
867            && self.pending_groups.is_empty()
868            && self.pending_group_attrs.is_empty()
869            && self.pending_deletes.is_empty()
870            && self.pending_copies.is_empty()
871            && self.pending_cross_copies.is_empty()
872        {
873            for (data_addr, raw) in &inplace_writes {
874                self.write_at(*data_addr, raw)?;
875            }
876            self.handle.sync_all().map_err(Error::Io)?;
877            return Ok(());
878        }
879
880        // --- Plan: build the tree of "dirty" groups (root plus every group on a
881        // path to an addition or deletion), validating every target before any
882        // write. `add_targets` records the full paths created this commit, used
883        // to reject a deletion that overlaps an addition. ---
884        let mut nodes: BTreeMap<PathKey, Node> = BTreeMap::new();
885        nodes.entry(PathKey::new()).or_default(); // root is always dirty
886        let mut add_targets: Vec<PathKey> = Vec::new();
887        let mut attr_targets: Vec<PathKey> = Vec::new();
888
889        // Mark explicitly-created new groups, ensuring their ancestor chain.
890        for path in std::mem::take(&mut self.pending_groups) {
891            if path.is_empty() {
892                return Err(Error::EditUnsupported("cannot create the root group"));
893            }
894            ensure_ancestors(&mut nodes, &path);
895            nodes.entry(path.clone()).or_default().is_new = true;
896            add_targets.push(path);
897        }
898
899        // Attach datasets to their parent group nodes, ensuring ancestor chains.
900        for (parent, db) in std::mem::take(&mut self.pending_datasets) {
901            let mut full = parent.clone();
902            full.push(db.name.clone());
903            add_targets.push(full);
904            ensure_ancestors(&mut nodes, &parent);
905            nodes.entry(parent).or_default().datasets.push(db);
906        }
907
908        // Attach relocating value overwrites (resized contiguous or compact) to
909        // their parent group nodes: the new header is written below and the
910        // parent's existing link patched to it, like an existing child group.
911        for (parent, leaf, mw) in moving_writes {
912            ensure_ancestors(&mut nodes, &parent);
913            nodes.entry(parent).or_default().writes.push((leaf, mw));
914        }
915
916        // Stage group attribute edits against their target groups. A target may
917        // be a newly-created group from this same commit, but not a copied
918        // destination or a dataset being added in the same commit.
919        for (path, op) in std::mem::take(&mut self.pending_group_attrs) {
920            ensure_ancestors(&mut nodes, &path);
921            nodes.entry(path.clone()).or_default().attr_ops.push(op);
922            attr_targets.push(path);
923        }
924
925        // Stage copies: validate the source subtree is copyable (read-only),
926        // then treat the destination like an addition to its parent group.
927        for (src, dst) in std::mem::take(&mut self.pending_copies) {
928            if src.is_empty() {
929                return Err(Error::EditUnsupported("cannot copy the root group"));
930            }
931            if dst.is_empty() {
932                return Err(Error::EditUnsupported("copy destination path is empty"));
933            }
934            if is_prefix(&src, &dst) {
935                return Err(Error::EditUnsupported(
936                    "cannot copy an object into itself or its own subtree",
937                ));
938            }
939            let src_str = src.join("/");
940            let src_addr =
941                crate::group_v2::resolve_path_any(&self.data, &self.superblock, &src_str)
942                    .map_err(|_| Error::EditUnsupported("copy source does not exist"))?;
943            let src_addr = usize::try_from(src_addr)
944                .map_err(|_| Error::EditUnsupported("source address exceeds this platform"))?;
945            // Read the source subtree from this file's own mirror (`cross_file`
946            // false: same address space, so verbatim addresses stay valid). On a
947            // userblock file the stored addresses are base-relative, so pass this
948            // session's base for the read to absolutize them.
949            let tree = Self::read_copy_subtree(&self.data, src_addr, 0, false, base)?;
950            add_targets.push(dst.clone());
951            let leaf = dst.last().unwrap().clone();
952            let parent = dst[..dst.len() - 1].to_vec();
953            ensure_ancestors(&mut nodes, &parent);
954            nodes.entry(parent).or_default().copies.push((leaf, tree));
955        }
956
957        // Stage cross-file copies: their subtrees were already read out of the
958        // source file (with foreign-address screening) when `copy_from` was
959        // called, so here they are simply linked into the destination parent like
960        // any other addition.
961        for (dst, tree) in std::mem::take(&mut self.pending_cross_copies) {
962            if dst.is_empty() {
963                return Err(Error::EditUnsupported("copy destination path is empty"));
964            }
965            add_targets.push(dst.clone());
966            let leaf = dst.last().unwrap().clone();
967            let parent = dst[..dst.len() - 1].to_vec();
968            ensure_ancestors(&mut nodes, &parent);
969            nodes.entry(parent).or_default().copies.push((leaf, tree));
970        }
971
972        // Stage deletions: each must exist, must not overlap any other staged
973        // change, and is recorded against its parent group (which becomes dirty).
974        // `deleted_addrs` keeps each removed object's header address so its owned
975        // blocks can be reclaimed after the commit lands (issue #21).
976        let delete_targets = std::mem::take(&mut self.pending_deletes);
977        let mut deleted_addrs: Vec<usize> = Vec::new();
978        for (i, d) in delete_targets.iter().enumerate() {
979            if d.is_empty() {
980                return Err(Error::EditUnsupported("cannot delete the root group"));
981            }
982            let path_str = d.join("/");
983            let del_addr =
984                crate::group_v2::resolve_path_any(&self.data, &self.superblock, &path_str)
985                    .map_err(|_| Error::EditUnsupported("nothing to delete at the given path"))?;
986            if let Ok(a) = usize::try_from(del_addr) {
987                deleted_addrs.push(a);
988            }
989            for t in &add_targets {
990                if is_prefix(d, t) || is_prefix(t, d) {
991                    return Err(Error::EditUnsupported(
992                        "a deletion overlaps an addition in the same commit; use separate commits",
993                    ));
994                }
995            }
996            for t in &attr_targets {
997                if is_prefix(d, t) {
998                    return Err(Error::EditUnsupported(
999                        "a deletion overlaps a group-attribute edit in the same commit; use separate commits",
1000                    ));
1001                }
1002            }
1003            for t in &write_targets {
1004                if is_prefix(d, t) {
1005                    return Err(Error::EditUnsupported(
1006                        "a deletion overlaps a value overwrite in the same commit; use separate commits",
1007                    ));
1008                }
1009            }
1010            for (j, d2) in delete_targets.iter().enumerate() {
1011                if i != j && is_prefix(d, d2) {
1012                    return Err(Error::EditUnsupported(
1013                        "overlapping deletions in one commit; delete the common parent only",
1014                    ));
1015                }
1016            }
1017            let parent = d[..d.len() - 1].to_vec();
1018            ensure_ancestors(&mut nodes, &parent);
1019            nodes
1020                .entry(parent)
1021                .or_default()
1022                .deletes
1023                .push(d.last().unwrap().clone());
1024        }
1025
1026        // Resolve / validate each node's base object-header region up front.
1027        // Every existing dirty group is rewritten to a freshly-appended header,
1028        // so its old header becomes dead bytes once the superblock is repointed;
1029        // `superseded_addrs` records those old headers for reclamation (#21).
1030        let keys: Vec<PathKey> = nodes.keys().cloned().collect();
1031        let mut superseded_addrs: Vec<usize> = Vec::new();
1032        for key in &keys {
1033            let is_new = nodes[key].is_new;
1034            if is_new {
1035                nodes.get_mut(key).unwrap().base_region = fresh_group_region();
1036            } else {
1037                let path_str = key.join("/");
1038                let addr =
1039                    crate::group_v2::resolve_path_any(&self.data, &self.superblock, &path_str)
1040                        .map_err(|_| {
1041                            Error::EditUnsupported(
1042                                "a target group does not exist; create it first in this session",
1043                            )
1044                        })?;
1045                let addr = usize::try_from(addr)
1046                    .map_err(|_| Error::EditUnsupported("group address exceeds this platform"))?;
1047                let info = self.inspect_group(addr)?;
1048                superseded_addrs.push(addr);
1049                let node = nodes.get_mut(key).unwrap();
1050                node.base_region = info.region;
1051                node.existing_links = info.link_names;
1052            }
1053        }
1054
1055        // Apply and validate group attribute edits before any writes. This keeps
1056        // unsupported attribute edits under the same all-or-nothing preflight
1057        // contract as unsupported dataset additions. A variable-length attribute
1058        // is not fully resolved here — its global heap collection is built (it
1059        // is self-contained, no address needed yet) but placed and patched into
1060        // `base_region` only in the apply loop below, once its address is known.
1061        for key in &keys {
1062            let node = nodes.get_mut(key).unwrap();
1063            let ops = std::mem::take(&mut node.attr_ops);
1064            if !ops.is_empty() {
1065                let region = std::mem::take(&mut node.base_region);
1066                let (region, pending_vl_attrs) = apply_group_attr_ops(&region, &ops)?;
1067                node.base_region = region;
1068                node.pending_vl_attrs = pending_vl_attrs;
1069            }
1070        }
1071
1072        // Map each node to its direct child group nodes (for link wiring).
1073        let mut children: BTreeMap<PathKey, Vec<PathKey>> = BTreeMap::new();
1074        for key in &keys {
1075            if !key.is_empty() {
1076                let parent = key[..key.len() - 1].to_vec();
1077                children.entry(parent).or_default().push(key.clone());
1078            }
1079        }
1080
1081        // Validate names: no addition may collide with an existing link or with
1082        // another addition under the same parent.
1083        for key in &keys {
1084            let node = &nodes[key];
1085            let mut adding: Vec<&str> = Vec::new();
1086            for db in &node.datasets {
1087                adding.push(&db.name);
1088            }
1089            for child in children.get(key).into_iter().flatten() {
1090                if nodes[child].is_new {
1091                    adding.push(child.last().unwrap());
1092                }
1093            }
1094            for (leaf, _) in &node.copies {
1095                adding.push(leaf);
1096            }
1097            for (i, name) in adding.iter().enumerate() {
1098                if node.existing_links.iter().any(|n| n == name) || adding[..i].contains(name) {
1099                    return Err(Error::EditUnsupported(
1100                        "a link with this name already exists in the target group",
1101                    ));
1102                }
1103            }
1104        }
1105
1106        // Flatten datasets (more guards) before any write, so a rejected one
1107        // leaves the commit unapplied.
1108        let mut flat: BTreeMap<PathKey, Vec<FlatDataset>> = BTreeMap::new();
1109        for key in &keys {
1110            let dbs = std::mem::take(&mut nodes.get_mut(key).unwrap().datasets);
1111            let mut v = Vec::with_capacity(dbs.len());
1112            for db in dbs {
1113                v.push(flatten_dataset(db)?);
1114            }
1115            flat.insert(key.clone(), v);
1116        }
1117
1118        // Prove every object-reference target resolves before any write (see
1119        // `preflight_reference_targets`'s doc comment): otherwise a reference
1120        // resolution failure discovered mid-apply-loop would leave every
1121        // earlier-processed group's real writes (headers, data, copied
1122        // subtrees) orphaned in the file despite `commit()` returning `Err`.
1123        Self::preflight_reference_targets(
1124            &keys,
1125            &flat,
1126            &nodes,
1127            &add_targets,
1128            &write_targets,
1129            &delete_targets,
1130            &self.data,
1131            &self.superblock,
1132        )?;
1133
1134        // Gather the regions this commit will vacate, read from the current
1135        // on-disk layout before any byte moves: every deleted object's owned
1136        // blocks plus every superseded group header. These are not added to the
1137        // free list until after the superblock repoint (they remain live until
1138        // then), so the appends below never reuse them. Enumeration is
1139        // best-effort — `collect_free_spans` simply omits anything it cannot
1140        // account for exhaustively, so the worst case is unreclaimed dead bytes,
1141        // never a freed-but-live region.
1142        let mut to_free: Vec<(u64, u64)> = Vec::new();
1143
1144        // An object's storage is reclaimed only when the link being removed is
1145        // its LAST hard link: HDF5 objects can have several hard links, and one
1146        // reachable through a surviving link is still live (freeing it would
1147        // corrupt the survivor). Count every hard link in the pre-commit file
1148        // and reclaim a deleted object only when its count is exactly 1.
1149        // `deleted_addrs` is de-duplicated first so two delete paths that are
1150        // hard links to the same object are not visited (and freed) twice. If
1151        // the link graph cannot be walked in full, no deleted object is
1152        // reclaimed (a safe leak), but superseded headers — always dead once the
1153        // root is repointed — still are.
1154        deleted_addrs.sort_unstable();
1155        deleted_addrs.dedup();
1156        if !deleted_addrs.is_empty() {
1157            if let Some(incoming) = self.count_incoming_hard_links() {
1158                for &a in &deleted_addrs {
1159                    self.collect_free_spans(a, 0, &incoming, &mut to_free);
1160                }
1161            }
1162        }
1163        // A superseded group header is dead once the root is repointed. Its chunk
1164        // spans are enumerated base-aware (`oh_chunk_spans` shifts continuation
1165        // addresses by the userblock base and returns absolute file offsets), as is
1166        // the delete path (`collect_free_spans`), so all of this reclamation works
1167        // on userblock files too.
1168        for &a in &superseded_addrs {
1169            if let Ok(spans) = self.oh_chunk_spans(a) {
1170                to_free.extend(spans);
1171            }
1172        }
1173
1174        // A relocating overwrite (`write_dataset` resize, or any compact rewrite)
1175        // vacates the dataset's old object header, and a resized contiguous one
1176        // also vacates its old data block: both become dead once the parent's
1177        // relinked header lands. `superseded_addrs` covers only the rebuilt group
1178        // headers, not the relocated dataset's own header, so record that here too.
1179        // The pre-commit dataset-header address is resolved from the live file; its
1180        // chunks and old data extent are freed only after the superblock repoint.
1181        // The single-hard-link guard in the write preflight makes freeing the old
1182        // header safe (no surviving link still points at it).
1183        for key in &keys {
1184            for (leaf, mw) in &nodes[key].writes {
1185                match mw {
1186                    MovingWrite::Contiguous {
1187                        old_extent: Some(extent),
1188                        ..
1189                    } => to_free.push(*extent),
1190                    // A relocated chunked dataset vacates its old chunk index and
1191                    // chunk data blocks. `chunked_storage_spans` returns `None` for
1192                    // anything it cannot enumerate exhaustively (leaving dead bytes
1193                    // rather than freeing a region still in use); the old header
1194                    // chunks are freed generically below.
1195                    MovingWrite::Chunked { old_addr, .. } => {
1196                        if let Ok(a) = usize::try_from(*old_addr) {
1197                            if let Some(spans) = self.chunked_storage_spans(a) {
1198                                to_free.extend(spans);
1199                            }
1200                        }
1201                    }
1202                    _ => {}
1203                }
1204                // The relocated dataset's old header chunks are dead too.
1205                let mut full = key.clone();
1206                full.push(leaf.clone());
1207                let path_str = full.join("/");
1208                if let Ok(addr) =
1209                    crate::group_v2::resolve_path_any(&self.data, &self.superblock, &path_str)
1210                {
1211                    if let Ok(a) = usize::try_from(addr) {
1212                        if let Ok(spans) = self.oh_chunk_spans(a) {
1213                            to_free.extend(spans);
1214                        }
1215                    }
1216                }
1217            }
1218        }
1219
1220        // Defense in depth: never hand the free list an out-of-bounds or
1221        // overlapping span. The last-link guard plus the per-object checks
1222        // should already make the accumulated spans disjoint; this enforces it
1223        // as a whole-commit invariant against the pre-commit end-of-file. Any
1224        // dropped span (which should not occur for a well-formed file) only
1225        // leaks, never corrupts.
1226        retain_disjoint_in_bounds(&mut to_free, self.data.len() as u64);
1227
1228        // --- Apply: process deepest groups first so each parent sees its
1229        // children's new addresses, then repoint the superblock last.
1230        // `path_addr` accumulates every group's and dataset's address as it is
1231        // placed — read by `resolve_reference_target` to resolve a same-commit
1232        // object-reference target (see the dataset-placement loop below for the
1233        // group/dataset key convention: a group's own path, or a dataset's
1234        // full parent+name path). ---
1235        let mut path_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
1236        let mut by_depth = keys.clone();
1237        by_depth.sort_by_key(|k| std::cmp::Reverse(k.len())); // deepest first
1238        for key in &by_depth {
1239            let (mut region, deletes, copies, writes, pending_vl_attrs) = {
1240                let node = nodes.get_mut(key).unwrap();
1241                (
1242                    std::mem::take(&mut node.base_region),
1243                    std::mem::take(&mut node.deletes),
1244                    std::mem::take(&mut node.copies),
1245                    std::mem::take(&mut node.writes),
1246                    std::mem::take(&mut node.pending_vl_attrs),
1247                )
1248            };
1249
1250            // Remove deleted links first (verbatim-preserving the rest).
1251            for name in &deletes {
1252                region = remove_link_from_region(&region, name)?;
1253            }
1254
1255            // Write each staged source subtree and link its root into this group.
1256            // `write_copy_subtree` returns an absolute header address; the parent
1257            // link stores it relative to the userblock base.
1258            for (leaf, tree) in copies {
1259                let root = self.write_copy_subtree(&tree)?;
1260                region.extend_from_slice(&encode_link_message(&leaf, root - base));
1261            }
1262
1263            // Datasets directly under this group. Appended addresses are absolute
1264            // file offsets; the contiguous data-layout address and the parent link
1265            // target are stored relative to the base address (`- base`). Placed
1266            // non-reference datasets first (recording each into `path_addr`), then
1267            // reference datasets — a reference to a *non-reference* sibling added
1268            // in the same group's batch resolves regardless of `pending_datasets`
1269            // call order (`Vec::sort_by_key` is stable, so within each of the two
1270            // groups the original order is preserved). Two reference datasets that
1271            // target each other in the same batch are still call-order-dependent —
1272            // whichever is placed first resolves the other, and the reverse
1273            // direction is safely refused as "still writing" (never corrupted),
1274            // caught up front by `preflight_reference_targets`.
1275            let mut group_datasets: Vec<FlatDataset> =
1276                flat.remove(key).into_iter().flatten().collect();
1277            group_datasets.sort_by_key(|fd| fd.reference_targets.is_some());
1278            for mut fd in group_datasets {
1279                // Place each variable-length attribute's global heap collection
1280                // and patch its placeholder heap address. Unlike VL-string
1281                // *data* (`vl_string_staging`, refused when chunked below), a
1282                // chunked/extensible dataset can carry a VL *attribute* just
1283                // fine — attributes live in the object header, not inside a
1284                // chunk, so patching them here before either apply branch runs
1285                // covers both.
1286                for (idx, collection_bytes) in std::mem::take(&mut fd.vl_attrs) {
1287                    let addr = self.place_vl_collection(&collection_bytes)?;
1288                    patch_vl_refs(&mut fd.attrs[idx].raw_data, addr);
1289                }
1290                // Resolve an object-reference dataset's per-element targets now
1291                // that every earlier-placed object in this commit is in
1292                // `path_addr` (chunked datasets never carry these —
1293                // `flatten_dataset` refuses that combination).
1294                if let Some(targets) = fd.reference_targets.take() {
1295                    let mut patched = Vec::with_capacity(targets.len() * 8);
1296                    for target in &targets {
1297                        let addr = Self::resolve_reference_target(
1298                            target,
1299                            &path_addr,
1300                            &nodes,
1301                            &add_targets,
1302                            &write_targets,
1303                            &delete_targets,
1304                            &self.data,
1305                            &self.superblock,
1306                        )?;
1307                        patched.extend_from_slice(&addr.to_le_bytes());
1308                    }
1309                    fd.raw = patched;
1310                }
1311                let oh = if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
1312                    self.build_chunked_dataset(&fd)?
1313                } else {
1314                    // A staged variable-length-string dataset's element
1315                    // references still carry a placeholder heap address; place
1316                    // its collection and patch them before `raw` is appended
1317                    // (chunked datasets never carry staging — refused above).
1318                    if let Some(staging) = fd.vl_string_staging.take() {
1319                        if !staging.collection_bytes.is_empty() {
1320                            let addr = self.place_vl_collection(&staging.collection_bytes)?;
1321                            patch_vl_refs_masked(&mut fd.raw, &staging.patch_mask, addr);
1322                        }
1323                    }
1324                    // A zero-element dataset has no data block to allocate; its
1325                    // layout address is the undefined-address sentinel (never
1326                    // base-relative — see `build_dataset_oh`'s empty-data callers
1327                    // in the whole-file writer), matching every reader's and the
1328                    // reference C library's convention for "no storage allocated".
1329                    let data_addr = if fd.raw.is_empty() {
1330                        u64::MAX
1331                    } else {
1332                        self.alloc_or_append(&fd.raw)? - base
1333                    };
1334                    build_dataset_oh(
1335                        &fd.dt,
1336                        &fd.ds,
1337                        data_addr,
1338                        fd.raw.len() as u64,
1339                        &fd.attrs,
1340                        None,
1341                    )
1342                };
1343                let oh_addr = self.alloc_or_append(&oh)?;
1344                region.extend_from_slice(&encode_link_message(&fd.name, oh_addr - base));
1345                let mut full = key.clone();
1346                full.push(fd.name.clone());
1347                path_addr.insert(full, oh_addr);
1348            }
1349
1350            // Relocating value overwrites under this group: write the new data and
1351            // rewritten header, then patch this group's existing link to it. The
1352            // link target is stored relative to the base address (`- base`); on a
1353            // userblock file only the chunked variant reaches here (contiguous and
1354            // compact resizes are refused in the write preflight).
1355            for (leaf, mw) in &writes {
1356                let new_oh = self.write_moving(mw)?;
1357                patch_link_target(&mut region, leaf, new_oh - base)?;
1358            }
1359
1360            // Wire links to dirty child groups (new → add a link; existing →
1361            // patch the existing link to the child's new address). Link targets are
1362            // stored relative to the base address.
1363            for child in children.get(key).into_iter().flatten() {
1364                let child_name = child.last().unwrap();
1365                let child_addr = path_addr[child] - base;
1366                if nodes[child].is_new {
1367                    region.extend_from_slice(&encode_link_message(child_name, child_addr));
1368                } else {
1369                    patch_link_target(&mut region, child_name, child_addr)?;
1370                }
1371            }
1372
1373            // Variable-length group/root attributes staged by
1374            // `apply_group_attr_ops`: place each collection and patch its
1375            // attribute message's placeholder heap address, then append the
1376            // resolved message to this group's header region.
1377            for (mut msg, collection_bytes) in pending_vl_attrs {
1378                let addr = self.place_vl_collection(&collection_bytes)?;
1379                patch_vl_refs(&mut msg.raw_data, addr);
1380                region.extend_from_slice(&region_message(
1381                    MessageType::Attribute,
1382                    &msg.serialize(LENGTH_SIZE),
1383                ));
1384            }
1385
1386            let oh = build_v2_object_header(&region);
1387            let addr = self.alloc_or_append(&oh)?;
1388            path_addr.insert(key.clone(), addr);
1389        }
1390
1391        // Same-length in-place overwrites (`write_dataset`) write straight into
1392        // their existing, already-referenced data blocks. Those blocks are
1393        // reachable from both the old and the new root (the dataset's header is
1394        // unchanged), so the write is independent of the superblock flip; it is
1395        // ordered before the barrier sync below so the new bytes are durable
1396        // alongside everything else this commit appended.
1397        for (data_addr, raw) in &inplace_writes {
1398            self.write_at(*data_addr, raw)?;
1399        }
1400
1401        // Repoint the superblock at the new root last: this is the commit's
1402        // linearization point. Until it lands, the file on disk still points at
1403        // the old root (the appended objects are merely unreferenced trailing
1404        // bytes), so a failure here leaves a valid file.
1405        //
1406        // That ordering is only crash-safe if the appended objects are durable
1407        // before the root pointer is flipped; otherwise a power loss could
1408        // persist the flip ahead of the data it references, leaving the root
1409        // pointing at bytes that never reached disk. `flush` on a plain `File`
1410        // does not force a write-back, so sync the appended bytes to disk first
1411        // (the barrier), then flip the pointer, then sync the flip.
1412        let new_root = path_addr[&PathKey::new()];
1413
1414        // A persisting file keeps its freed space recorded on disk rather than
1415        // truncating it away, so its commit takes a different, append-only tail.
1416        if self.persist.is_some() {
1417            return self.commit_persisting(new_root, to_free);
1418        }
1419
1420        // The new tree is fully written, so the regions this commit vacated are
1421        // now dead: hand them to the session free list. If the resulting free
1422        // space forms a run reaching end-of-file, the file can be physically
1423        // truncated to where that run starts; otherwise the end-of-file is
1424        // unchanged. `take_trailing` removes the trimmed run so it is not also
1425        // counted as reusable interior space.
1426        for (a, l) in to_free.drain(..) {
1427            self.free.free(a, l);
1428        }
1429        let cur_eof = self.data.len() as u64;
1430        let trunc_to = self.free.take_trailing(cur_eof);
1431        let new_eof = trunc_to.unwrap_or(cur_eof);
1432
1433        self.handle.sync_all().map_err(Error::Io)?;
1434        // The root address is stored relative to the base address; the end-of-file
1435        // address is absolute. After writing the relative root to disk, keep the
1436        // in-memory `root_group_address` absolute (the open-time convention).
1437        if self.superblock.version >= 2 {
1438            // Build the new superblock off a clone and adopt it only once the
1439            // write succeeds, so a failed write does not desync the in-memory
1440            // state. The v2/v3 superblock carries its own checksum.
1441            let mut new_sb = self.superblock.clone();
1442            new_sb.root_group_address = new_root - base;
1443            new_sb.eof_address = new_eof;
1444            // Clear any write/SWMR consistency flag rather than re-emitting one
1445            // the source file carried (e.g. left set by a crashed SWMR writer):
1446            // this clean commit leaves the file properly closed for the C library
1447            // (issue #73). serialize() recomputes the v2/v3 checksum.
1448            new_sb.consistency_flags = 0;
1449            let sb_bytes = new_sb.serialize();
1450            self.write_at(self.sb_sig_off, &sb_bytes)?;
1451            self.handle.sync_all().map_err(Error::Io)?;
1452            new_sb.root_group_address = new_root;
1453            self.superblock = new_sb;
1454        } else {
1455            self.repoint_v0v1_root(new_root - base, new_eof)?;
1456            self.handle.sync_all().map_err(Error::Io)?;
1457            self.superblock.root_group_address = new_root;
1458            self.superblock.eof_address = new_eof;
1459        }
1460
1461        // Physically shrink the file only after the superblock — now carrying the
1462        // smaller end-of-file — is durable. A crash between the two leaves a file
1463        // whose superblock end-of-file is correct and whose trailing bytes are
1464        // mere unreferenced slack, which the next open ignores; the reverse order
1465        // could advertise an end-of-file past the actual file length.
1466        if let Some(cut) = trunc_to {
1467            self.handle.set_len(cut).map_err(Error::Io)?;
1468            #[expect(
1469                clippy::cast_possible_truncation,
1470                reason = "cut is a shrink target <= the current file length, which equals \
1471                          self.data.len() (a usize)"
1472            )]
1473            self.data.truncate(cut as usize);
1474            self.handle.sync_all().map_err(Error::Io)?;
1475        }
1476        Ok(())
1477    }
1478
1479    /// Commit tail for a file that persists its free space (issue #21). Unlike
1480    /// the non-persisting path, freed space is *retained* and recorded on disk —
1481    /// matching the reference library's persistent free-space strategy — so a
1482    /// later reopen (by this crate or the C library) recovers it.
1483    ///
1484    /// The post-commit free list (this commit's vacated regions plus the now-dead
1485    /// old free-space-manager and extension blocks) is serialized into a fresh
1486    /// `FSHD`/`FSSE` pair and a rewritten superblock-extension File Space Info
1487    /// message, all appended at the current end-of-file. Nothing live or
1488    /// still-referenced is overwritten: the new blocks sit strictly past the old
1489    /// ones, and the superblock — repointed last — is the linearization point. A
1490    /// crash before it leaves the prior file (root, extension, and managers)
1491    /// wholly intact.
1492    fn commit_persisting(&mut self, new_root: u64, to_free: Vec<(u64, u64)>) -> Result<(), Error> {
1493        let os = self.superblock.offset_size;
1494        let (strategy, threshold, page_size, old_blocks) = {
1495            // Copy what we need so no borrow of `self.persist` is held across the
1496            // `&mut self` writes below; the old state stays in place so a failure
1497            // leaves the session reusable.
1498            let ps = self
1499                .persist
1500                .as_ref()
1501                .expect("commit_persisting is only called when persistence is armed");
1502            (
1503                ps.strategy,
1504                ps.threshold,
1505                ps.page_size,
1506                ps.old_blocks.clone(),
1507            )
1508        };
1509
1510        // The free list the new managers will record: this commit's vacated
1511        // regions plus the superseded FSM/extension blocks (dead once we
1512        // repoint), coalesced. Built in a temp so `self.free` and the on-disk old
1513        // blocks stay untouched until after the superblock repoint.
1514        let mut post = self.free.clone();
1515        for &(a, l) in &to_free {
1516            post.free(a, l);
1517        }
1518        for &(a, l) in &old_blocks {
1519            post.free(a, l);
1520        }
1521        let sections: Vec<FreeSection> = post
1522            .sections()
1523            .into_iter()
1524            .map(|(addr, size)| FreeSection { addr, size })
1525            .collect();
1526
1527        let old_ext_rel = self
1528            .superblock
1529            .superblock_extension_address
1530            .filter(|&a| a != UNDEF)
1531            .ok_or(Error::EditUnsupported(
1532                "a persisting file has no superblock extension to update",
1533            ))?;
1534        let old_ext_addr = usize::try_from(old_ext_rel)
1535            .map_err(|_| Error::EditUnsupported("extension address exceeds this platform"))?;
1536
1537        // The persist File Space Info message is fixed-size, so the rewritten
1538        // extension's length is independent of the addresses it will carry: size
1539        // it with a placeholder to place the FSM blocks that follow it.
1540        let placeholder =
1541            FileSpaceInfo::persistent_single_manager(strategy, threshold, page_size, 0, 0);
1542        let ext_len =
1543            build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)
1544                .len() as u64;
1545
1546        let ext_addr = self.data.len() as u64;
1547        let fshd_addr = ext_addr + ext_len;
1548
1549        // Build the real extension and the FSM blocks. With no free space to
1550        // record we still refresh the extension (persist on, managers undefined).
1551        let (ext_oh, fsm_blocks, final_eof) = if sections.is_empty() {
1552            let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
1553            let ext_oh =
1554                build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?);
1555            let final_eof = ext_addr + ext_oh.len() as u64;
1556            (ext_oh, None, final_eof)
1557        } else {
1558            let fsse_addr = fshd_addr + fshd_len(os);
1559            // `eoa_pre_fsm` is the end-of-allocation before the free-space-manager
1560            // section blocks (`FSHD`/`FSSE`) were allocated: a consumer may shrink
1561            // back to here and rebuild them. It points at the FSHD, not the
1562            // extension — the extension sits below it and persists, so shrinking
1563            // leaves the superblock and its extension pointer valid (only the
1564            // manager blocks, which are rewritten every commit, are discarded).
1565            // This matches the C library's convention of keeping the superblock
1566            // extension stable across closes, and is the value `H5Fget_freespace`
1567            // accounts for correctly (verified in the crosscheck).
1568            let eoa_pre_fsm = fshd_addr;
1569            let info = FileSpaceInfo::persistent_single_manager(
1570                strategy,
1571                threshold,
1572                page_size,
1573                fshd_addr,
1574                eoa_pre_fsm,
1575            );
1576            let ext_oh =
1577                build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?);
1578            debug_assert_eq!(
1579                ext_oh.len() as u64,
1580                ext_len,
1581                "extension length must be stable across the placeholder and real messages"
1582            );
1583            let (fshd, fsse) = serialize_file_fsm(&sections, fshd_addr, fsse_addr, os);
1584            let final_eof = fsse_addr + fsse.len() as u64;
1585            (ext_oh, Some((fshd, fsse)), final_eof)
1586        };
1587
1588        // Append the extension, then the FSM blocks, at end-of-file. They are
1589        // unreferenced until the superblock repoint, so a crash here is harmless.
1590        let written_ext = self.append(&ext_oh)?;
1591        debug_assert_eq!(written_ext, ext_addr);
1592        let mut new_old_blocks = vec![(ext_addr, ext_oh.len() as u64)];
1593        if let Some((fshd, fsse)) = fsm_blocks {
1594            let wf = self.append(&fshd)?;
1595            debug_assert_eq!(wf, fshd_addr);
1596            new_old_blocks.push((fshd_addr, fshd.len() as u64));
1597            let ws = self.append(&fsse)?;
1598            new_old_blocks.push((ws, fsse.len() as u64));
1599        }
1600
1601        // Barrier, then repoint the superblock (root, eof, and the new extension)
1602        // — the linearization point — and sync it.
1603        self.handle.sync_all().map_err(Error::Io)?;
1604        let mut new_sb = self.superblock.clone();
1605        new_sb.root_group_address = new_root;
1606        new_sb.eof_address = final_eof;
1607        new_sb.superblock_extension_address = Some(ext_addr);
1608        // Clear any leftover write/SWMR consistency flag on a clean commit (see
1609        // the non-persisting path above and issue #73).
1610        new_sb.consistency_flags = 0;
1611        let sb_bytes = new_sb.serialize();
1612        self.write_at(self.sb_sig_off, &sb_bytes)?;
1613        self.handle.sync_all().map_err(Error::Io)?;
1614        self.superblock = new_sb;
1615
1616        // The repoint is durable: the prior free list plus this commit's vacated
1617        // regions are now genuinely free, and the freshly written blocks become
1618        // the ones a future commit will supersede.
1619        self.free = post;
1620        self.persist = Some(PersistState {
1621            strategy,
1622            threshold,
1623            page_size,
1624            old_blocks: new_old_blocks,
1625        });
1626        Ok(())
1627    }
1628
1629    /// Rebuild the superblock-extension object header's message region with its
1630    /// File Space Info message replaced by `info` (every other message preserved
1631    /// verbatim), ready to wrap with [`build_v2_object_header`]. The persisting
1632    /// message is fixed-size, so this never changes the region's length.
1633    fn rewrite_extension_region(
1634        &self,
1635        ext_addr: usize,
1636        info: &FileSpaceInfo,
1637    ) -> Result<Vec<u8>, Error> {
1638        let region = Self::gather_oh_messages(&self.data, ext_addr, self.superblock.base_address)?;
1639        let new_body = info.serialize();
1640        // The message body is the fixed-size File Space Info record (≤ 125 bytes),
1641        // so it always fits the u16 size field; `try_from` keeps this off the
1642        // 32-bit narrowing-cast ledger.
1643        let new_len = u16::try_from(new_body.len())
1644            .map_err(|_| Error::EditUnsupported("File Space Info message too large"))?;
1645        let mut out = Vec::with_capacity(region.len());
1646        let mut p = 0;
1647        let mut replaced = false;
1648        while let Some((msg_type, _body, body_end)) = next_message(&region, p)? {
1649            if msg_type == MessageType::FileSpaceInfo {
1650                out.push(region[p]); // message type byte
1651                out.extend_from_slice(&new_len.to_le_bytes());
1652                out.push(region[p + 3]); // preserve the message flags (0x14)
1653                out.extend_from_slice(&new_body);
1654                replaced = true;
1655            } else {
1656                out.extend_from_slice(&region[p..body_end]);
1657            }
1658            p = body_end;
1659        }
1660        if !replaced {
1661            // Persistence is armed only when the extension already carries a File
1662            // Space Info message, so this is unreachable; refuse rather than
1663            // silently restructure an extension we did not understand.
1664            return Err(Error::EditUnsupported(
1665                "a persisting file's superblock extension has no File Space Info message",
1666            ));
1667        }
1668        Ok(out)
1669    }
1670
1671    /// Repoint a version 0/1 superblock at the rebuilt (now v2) root group and
1672    /// update its end-of-file field, patching the raw bytes in place — these
1673    /// superblocks carry no checksum. The root symbol-table entry is switched to
1674    /// cache type 0 (its scratch-pad B-tree / local-heap addresses, which
1675    /// describe the old symbol-table group, no longer apply). The
1676    /// object-header-address write is done last so it is the linearization point.
1677    fn repoint_v0v1_root(&mut self, new_root: u64, new_eof: u64) -> Result<(), Error> {
1678        let os = self.superblock.offset_size as usize;
1679        // Field layout after the fixed prefix: base / free-space / EOF / driver
1680        // addresses, then the root symbol-table entry (link-name offset, object
1681        // header address, cache type(4), reserved(4), scratch(16)). The prefix is
1682        // 24 bytes for v0 and 28 for v1 (the latter adds indexed-storage-K).
1683        let var_start = if self.superblock.version == 0 { 24 } else { 28 };
1684        let base = self.sb_sig_off + var_start;
1685        let eof_off = base + 2 * os;
1686        let ste = base + 4 * os;
1687        let oh_addr_off = ste + os;
1688        let cache_off = ste + 2 * os;
1689        self.write_at(eof_off, &new_eof.to_le_bytes()[..os])?;
1690        self.write_at(cache_off, &[0u8; 4])?; // cache type = none
1691        self.write_at(cache_off + 8, &[0u8; 16])?; // clear scratch-pad
1692        self.write_at(oh_addr_off, &new_root.to_le_bytes()[..os])?;
1693        Ok(())
1694    }
1695
1696    /// Parse and validate the prefix of a single-chunk version 2 object header at
1697    /// `addr`, returning the `[start, end)` byte range of its message region.
1698    /// Rejects headers that are not OHDR v2 or that track message creation order
1699    /// (whose 6-byte message records this engine does not emit).
1700    fn oh_region(d: &[u8], addr: usize) -> Result<(usize, usize), Error> {
1701        if d.len() < addr + 6 || &d[addr..addr + 4] != b"OHDR" || d[addr + 4] != 2 {
1702            return Err(Error::EditUnsupported(
1703                "an object does not use a version 2 object header",
1704            ));
1705        }
1706        let flags = d[addr + 5];
1707        if flags & 0x04 != 0 {
1708            return Err(Error::EditUnsupported(
1709                "an object tracks message creation order (not supported in place yet)",
1710            ));
1711        }
1712        let mut pos = addr + 6;
1713        if flags & 0x20 != 0 {
1714            pos += 16; // optional timestamps
1715        }
1716        if flags & 0x10 != 0 {
1717            pos += 4; // optional attribute phase-change thresholds
1718        }
1719        let size_width = match flags & 0x03 {
1720            0 => 1usize,
1721            1 => 2,
1722            2 => 4,
1723            _ => 8,
1724        };
1725        if d.len() < pos + size_width {
1726            return Err(Error::EditUnsupported("truncated object header"));
1727        }
1728        let chunk0_size = read_le(&d[pos..pos + size_width]);
1729        pos += size_width;
1730        let region_start = pos;
1731        let region_end = region_start
1732            .checked_add(chunk0_size)
1733            .filter(|&e| e + 4 <= d.len())
1734            .ok_or(Error::EditUnsupported("truncated object header"))?;
1735        Ok((region_start, region_end))
1736    }
1737
1738    /// Collect every message of the object header at `addr` into one contiguous
1739    /// region, following continuation blocks across chunks and dropping the
1740    /// `Continuation` messages themselves. Re-emitting the result through
1741    /// [`build_v2_object_header`] collapses a multi-chunk header (as the
1742    /// reference C library often writes) into a single chunk, which is how this
1743    /// editor rebuilds headers. The chunk-0 prefix is validated by
1744    /// [`oh_region`]; each continuation block must be a well-formed `OCHK` block
1745    /// within the file.
1746    fn gather_oh_messages(d: &[u8], addr: usize, base: u64) -> Result<Vec<u8>, Error> {
1747        let (rs, re) = Self::oh_region(d, addr)?;
1748        let mut out = Vec::new();
1749        // Worklist of (message-region start, end) per chunk, chunk 0 first.
1750        let mut chunks: Vec<(usize, usize)> = vec![(rs, re)];
1751        let mut i = 0;
1752        while i < chunks.len() {
1753            if chunks.len() > MAX_OH_CHUNKS {
1754                return Err(Error::EditUnsupported(
1755                    "object header has too many continuation chunks",
1756                ));
1757            }
1758            let (cs, ce) = chunks[i];
1759            i += 1;
1760            let region = &d[..ce];
1761            let mut p = cs;
1762            while let Some((msg_type, body, body_end)) = next_message(region, p)? {
1763                if msg_type == MessageType::ObjectHeaderContinuation {
1764                    // Body: block offset (offset_size) + block length (length_size).
1765                    if body_end - body < (OFFSET_SIZE + LENGTH_SIZE) as usize {
1766                        return Err(Error::EditUnsupported("malformed continuation message"));
1767                    }
1768                    let off = u64::from_le_bytes(d[body..body + 8].try_into().unwrap());
1769                    let len = u64::from_le_bytes(d[body + 8..body + 16].try_into().unwrap());
1770                    // The continuation block address is stored relative to the base
1771                    // address; convert to an absolute file offset to index `d`.
1772                    let off = off
1773                        .checked_add(base)
1774                        .ok_or(Error::EditUnsupported("continuation address overflow"))?;
1775                    let off = usize::try_from(off).map_err(|_| {
1776                        Error::EditUnsupported("continuation address exceeds this platform")
1777                    })?;
1778                    let len = usize::try_from(len).map_err(|_| {
1779                        Error::EditUnsupported("continuation length exceeds this platform")
1780                    })?;
1781                    // An OCHK block is signature(4) + messages + checksum(4).
1782                    let blk_end = off
1783                        .checked_add(len)
1784                        .filter(|&e| e <= d.len() && len >= 8)
1785                        .ok_or(Error::EditUnsupported("continuation block out of bounds"))?;
1786                    if &d[off..off + 4] != b"OCHK" {
1787                        return Err(Error::EditUnsupported(
1788                            "invalid continuation block signature",
1789                        ));
1790                    }
1791                    chunks.push((off + 4, blk_end - 4));
1792                } else {
1793                    out.extend_from_slice(&region[p..body_end]);
1794                }
1795                p = body_end;
1796            }
1797        }
1798        Ok(out)
1799    }
1800
1801    /// Reconstruct a version-1 (symbol-table) group as a fresh v2 compact-link
1802    /// message region: a LinkInfo message, one Link message per existing child,
1803    /// and the group's existing attributes (re-wrapped as v2 messages). The
1804    /// symbol-table message and other non-link/non-attribute messages
1805    /// (modification time, comment, …) are dropped — editing a v0/v1 group
1806    /// converts it to the latest format. Refuses an attribute it cannot
1807    /// reproduce (shared, or larger than a v2 message can hold).
1808    fn reconstruct_v1_group(&self, addr: usize) -> Result<GroupInfo, Error> {
1809        let os = self.superblock.offset_size;
1810        let ls = self.superblock.length_size;
1811        let base = self.superblock.base_address;
1812        let oh = ObjectHeader::parse_with_base(&self.data, addr, os, ls, base)?;
1813        if oh
1814            .messages
1815            .iter()
1816            .any(|m| m.msg_type == MessageType::DataLayout)
1817        {
1818            return Err(Error::EditUnsupported(
1819                "a target path names a dataset, not a group",
1820            ));
1821        }
1822        let entries = resolve_group_entries(&self.data, &oh, os, ls, base)?;
1823
1824        let mut region = fresh_group_region();
1825        let mut link_names = Vec::with_capacity(entries.len());
1826        for e in &entries {
1827            // Group-entry addresses are already stored relative to the base address,
1828            // matching how `encode_link_message` stores link targets — so they are
1829            // re-emitted verbatim, no base conversion needed.
1830            region.extend_from_slice(&encode_link_message(&e.name, e.object_header_address));
1831            link_names.push(e.name.clone());
1832        }
1833        for m in &oh.messages {
1834            if m.msg_type == MessageType::Attribute {
1835                if m.flags != 0 {
1836                    return Err(Error::EditUnsupported(
1837                        "a v0/v1 group has a shared attribute message (not convertible in place yet)",
1838                    ));
1839                }
1840                if m.data.len() > u16::MAX as usize {
1841                    return Err(Error::EditUnsupported(
1842                        "a v0/v1 group attribute is too large to convert in place",
1843                    ));
1844                }
1845                // Re-wrap the attribute message body (it is self-describing) in a
1846                // v2 message record.
1847                #[expect(
1848                    clippy::cast_possible_truncation,
1849                    reason = "message type ids are a small enum that fits the 1-byte v2 type field"
1850                )]
1851                region.push(MessageType::Attribute.to_u16() as u8);
1852                #[expect(
1853                    clippy::cast_possible_truncation,
1854                    reason = "attribute body length fits the 2-byte message-size field (oversized \
1855                              bodies are rejected above)"
1856                )]
1857                region.extend_from_slice(&(m.data.len() as u16).to_le_bytes());
1858                region.push(0); // message flags
1859                region.extend_from_slice(&m.data);
1860            }
1861        }
1862        Ok(GroupInfo { region, link_names })
1863    }
1864
1865    /// Parse and validate a group's object header, returning its message region
1866    /// — the bytes to copy when rewriting the header — and the names of its
1867    /// existing links. A version 2 header is rebuilt from its own message bytes
1868    /// (collapsing continuation chunks, preserving every message); a version 1
1869    /// symbol-table group is converted to v2 via [`reconstruct_v1_group`].
1870    fn inspect_group(&self, addr: usize) -> Result<GroupInfo, Error> {
1871        if self.data.len() < addr + 4 || self.data[addr..addr + 4] != *b"OHDR" {
1872            return self.reconstruct_v1_group(addr);
1873        }
1874        let mut region = Self::gather_oh_messages(&self.data, addr, self.superblock.base_address)?;
1875        let mut p = 0;
1876        let mut has_link_info = false;
1877        let mut link_names = Vec::new();
1878        while let Some((msg_type, body, body_end)) = next_message(&region, p)? {
1879            match msg_type {
1880                MessageType::LinkInfo => {
1881                    has_link_info = true;
1882                    // LinkInfo: version(1) flags(1) [max_creation_index(8) if
1883                    // flags&0x01] fractal_heap_addr(8) … — dense storage has a
1884                    // defined fractal-heap address. Bound the read by this
1885                    // message's own body, not just the region, so a short or
1886                    // malformed LinkInfo can't make us read the next message.
1887                    let mut q = body + 2;
1888                    if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
1889                        q += 8;
1890                    }
1891                    if q + 8 <= body_end {
1892                        let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
1893                        if heap_addr != u64::MAX {
1894                            return Err(Error::EditUnsupported(
1895                                "a target group uses dense (fractal-heap) link storage (not supported in place yet)",
1896                            ));
1897                        }
1898                    }
1899                }
1900                MessageType::Link => {
1901                    if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
1902                        link_names.push(link.name);
1903                    }
1904                }
1905                MessageType::DataLayout => {
1906                    return Err(Error::EditUnsupported(
1907                        "a target path names a dataset, not a group",
1908                    ));
1909                }
1910                _ => {}
1911            }
1912            p = body_end;
1913        }
1914        if !has_link_info {
1915            return Err(Error::EditUnsupported(
1916                "a target group's object header has no link-info message",
1917            ));
1918        }
1919        // Heal headers written by older hdf5-pure releases that omitted the
1920        // Group Info message, so the rewritten group stays writable by the C
1921        // library.
1922        ensure_group_info(&mut region)?;
1923        Ok(GroupInfo { region, link_names })
1924    }
1925
1926    /// Preflight a staged value overwrite (`write_dataset`): resolve the dataset
1927    /// at `addr`, validate that the staged `fd` matches it byte-exactly in
1928    /// datatype and shape, and classify how the bytes will be applied. No file
1929    /// bytes are written here — this is part of the all-or-nothing preflight, so a
1930    /// rejected write leaves the commit unapplied.
1931    ///
1932    /// Contiguous, compact, and chunked (including filtered) datasets are all
1933    /// supported; the chunk geometry, filter pipeline, and chunk index come from
1934    /// the on-disk header (a staged builder that itself requests chunking/filters/an
1935    /// extensible shape is refused as "not a value overwrite", and a chunk index
1936    /// this engine cannot enumerate — a version-2 B-tree — is refused too). A
1937    /// datatype or shape that differs from the on-disk dataset's is likewise
1938    /// refused — this is a value overwrite, not a reshape or retype.
1939    fn prepare_write(
1940        d: &[u8],
1941        addr: usize,
1942        fd: &FlatDataset,
1943        base: u64,
1944    ) -> Result<WritePlan, Error> {
1945        // A value overwrite never introduces chunking, filters, or an extensible
1946        // shape: those would change the storage layout, not just the bytes.
1947        if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
1948            return Err(Error::EditUnsupported(
1949                "write_dataset overwrites values only; it cannot make a dataset \
1950                 chunked, filtered, or extensible",
1951            ));
1952        }
1953
1954        // `write_dataset` overwrites element bytes only; it does not touch the
1955        // object header's attribute messages. Attributes staged on the returned
1956        // builder would otherwise be silently dropped (the in-place path rewrites
1957        // only the data block, and the moving path reuses the verbatim on-disk
1958        // header), so refuse rather than degrade — set them in a separate edit.
1959        if !fd.attrs.is_empty() {
1960            return Err(Error::EditUnsupported(
1961                "write_dataset overwrites values only; it cannot set attributes \
1962                 (set them with a separate edit)",
1963            ));
1964        }
1965
1966        // `with_vlen_strings` stages placeholder element references that only the
1967        // add path's apply loop knows how to resolve (place the global heap
1968        // collection, then patch the placeholders once its address is known,
1969        // before the data block itself is written). `prepare_write` runs during
1970        // preflight, before any bytes are written and without `&mut self`
1971        // access to place a heap collection, and its result can be flushed by
1972        // the same-length fast path with no apply loop at all — so refuse
1973        // rather than write unpatched (heap address 0) placeholders as if they
1974        // were final.
1975        if fd.vl_string_staging.is_some() {
1976            return Err(Error::EditUnsupported(
1977                "write_dataset cannot overwrite a variable-length-string dataset's \
1978                 data in place yet",
1979            ));
1980        }
1981
1982        let region = Self::gather_oh_messages(d, addr, base)?;
1983
1984        // Locate the datatype, dataspace, and data-layout messages, and detect a
1985        // filter pipeline (filtered storage is always chunked, never contiguous).
1986        let mut datatype: Option<(usize, usize)> = None;
1987        let mut dataspace: Option<(usize, usize)> = None;
1988        let mut layout: Option<(usize, usize)> = None;
1989        let mut filter: Option<(usize, usize)> = None;
1990        let mut has_link = false;
1991        let mut p = 0;
1992        while let Some((msg_type, body, body_end)) = next_message(&region, p)? {
1993            match msg_type {
1994                MessageType::Datatype => datatype = Some((body, body_end)),
1995                MessageType::Dataspace => dataspace = Some((body, body_end)),
1996                MessageType::DataLayout => layout = Some((body, body_end)),
1997                MessageType::FilterPipeline => filter = Some((body, body_end)),
1998                MessageType::Link | MessageType::LinkInfo | MessageType::SymbolTable => {
1999                    has_link = true;
2000                }
2001                _ => {}
2002            }
2003            p = body_end;
2004        }
2005
2006        if has_link {
2007            return Err(Error::EditUnsupported(
2008                "write_dataset target is a group, not a dataset",
2009            ));
2010        }
2011        let (dt_b, dt_e) =
2012            datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
2013        let (ds_b, ds_e) =
2014            dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
2015        let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
2016
2017        // Compare datatype and shape structurally against the staged data. A
2018        // value overwrite must keep both exactly: the datatype (including its
2019        // class, size, endianness, and any compound/array/enumeration layout) so
2020        // the bytes are interpreted the same, and the *current* dimensions so the
2021        // byte count is unchanged. Parsing both sides and comparing the decoded
2022        // values — rather than the raw message bytes — tolerates the harmless
2023        // encoding differences between this crate's writer and the reference C
2024        // library (e.g. the C library records a maximum-dimensions array equal to
2025        // the current dimensions, which this crate omits) while still refusing any
2026        // real retype or reshape.
2027        let (disk_dt, _) = crate::datatype::Datatype::parse(&region[dt_b..dt_e])
2028            .map_err(|_| Error::EditUnsupported("dataset header datatype could not be parsed"))?;
2029        if disk_dt != fd.dt {
2030            return Err(Error::EditUnsupported(
2031                "write_dataset datatype does not match the on-disk dataset (overwrite, not retype)",
2032            ));
2033        }
2034        let disk_ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE)
2035            .map_err(|_| Error::EditUnsupported("dataset header dataspace could not be parsed"))?;
2036        if disk_ds.space_type != fd.ds.space_type
2037            || disk_ds.rank != fd.ds.rank
2038            || disk_ds.dimensions != fd.ds.dimensions
2039        {
2040            return Err(Error::EditUnsupported(
2041                "write_dataset shape does not match the on-disk dataset (overwrite, not reshape)",
2042            ));
2043        }
2044
2045        // Classify the layout. Version 3/4 compact (class 0), contiguous (class
2046        // 1), and chunked (class 2) are supported; an old-version layout or a
2047        // virtual layout (class 3) is refused.
2048        if le - lb < 2 {
2049            return Err(Error::EditUnsupported("malformed data-layout message"));
2050        }
2051        let version = region[lb];
2052        if version != 3 && version != 4 {
2053            return Err(Error::EditUnsupported(
2054                "an unsupported data-layout version cannot be overwritten in place yet",
2055            ));
2056        }
2057        match region[lb + 1] {
2058            // Compact: the data is inline in the header. Rebuild the header with
2059            // the new inline bytes (relocating it), patching the parent link.
2060            0 => Ok(WritePlan::Moving(MovingWrite::Compact {
2061                region,
2062                raw: fd.raw.clone(),
2063            })),
2064            1 => {
2065                if le - lb < 18 {
2066                    return Err(Error::EditUnsupported("malformed contiguous data layout"));
2067                }
2068                let addr_off = lb + 2;
2069                let data_addr =
2070                    u64::from_le_bytes(region[addr_off..addr_off + 8].try_into().unwrap());
2071                let data_size = u64::from_le_bytes(region[lb + 10..lb + 18].try_into().unwrap());
2072
2073                // Same length and a defined, in-bounds data block: overwrite the
2074                // bytes straight in place. No header rewrite, no relink. The stored
2075                // address is base-relative; the in-place write targets the absolute
2076                // file offset `data_addr + base`.
2077                if data_addr != UNDEF && data_size == fd.raw.len() as u64 {
2078                    if let Some(start) = data_addr
2079                        .checked_add(base)
2080                        .and_then(|a| usize::try_from(a).ok())
2081                    {
2082                        if start
2083                            .checked_add(fd.raw.len())
2084                            .is_some_and(|e| e <= d.len())
2085                        {
2086                            return Ok(WritePlan::InPlace {
2087                                data_addr: start,
2088                                raw: fd.raw.clone(),
2089                            });
2090                        }
2091                    }
2092                }
2093
2094                // Length differs or the block was undefined/out of bounds: the new
2095                // data goes elsewhere and the old extent (if any) is freed. The
2096                // freed extent is recorded as an absolute file offset (`+ base`) to
2097                // match the session free list.
2098                let old_extent = if data_addr != UNDEF && data_size > 0 {
2099                    Some((data_addr + base, data_size))
2100                } else {
2101                    None
2102                };
2103                Ok(WritePlan::Moving(MovingWrite::Contiguous {
2104                    region,
2105                    addr_off,
2106                    raw: fd.raw.clone(),
2107                    old_extent,
2108                }))
2109            }
2110            // Chunked: overwrite each chunk in place when every new (re-encoded)
2111            // chunk is the same byte length as its slot, else rebuild and relocate
2112            // the whole chunk storage. The chunk geometry, filter pipeline, and
2113            // index type all come from the existing on-disk header (the staged
2114            // builder carries none — chunked/filtered/extensible builders are
2115            // refused at the top of this function as "not a value overwrite").
2116            2 => {
2117                // Chunked overwrite (in-place or relocating). On a userblock file
2118                // every stored chunk-index and chunk address is relative to `base`:
2119                // the in-place path below walks the index on a base-relative view of
2120                // the file and shifts the resulting write offsets back by `base`,
2121                // and the relocating path rebuilds the chunk blob with stored
2122                // addresses (see `write_chunked_relocatable`).
2123                let dl =
2124                    DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).map_err(|_| {
2125                        Error::EditUnsupported("dataset header data layout could not be parsed")
2126                    })?;
2127                let DataLayout::Chunked {
2128                    version: lversion,
2129                    chunk_index_type,
2130                    ..
2131                } = dl
2132                else {
2133                    return Err(Error::EditUnsupported("dataset is not chunked"));
2134                };
2135                if !chunk_index_enumerable(lversion, chunk_index_type) {
2136                    return Err(Error::EditUnsupported(
2137                        "a chunked dataset with a version-2 B-tree or unknown chunk index \
2138                         cannot be overwritten in place yet",
2139                    ));
2140                }
2141
2142                let ChunkedGeometry {
2143                    spatial,
2144                    element_size,
2145                    raw_size,
2146                    maxshape,
2147                } = chunked_geometry(&fd.dt, &disk_ds, &dl)?;
2148
2149                // Split the new value into full-size chunk buffers in dense
2150                // row-major grid order (edge overhang zero-filled, matching how
2151                // unfiltered chunks are stored), then re-encode through the on-disk
2152                // pipeline when the dataset is filtered.
2153                let split = split_into_chunks(&fd.raw, &disk_ds.dimensions, &spatial, element_size);
2154                let pipeline_message: Option<Vec<u8>> =
2155                    filter.map(|(fb, fe)| region[fb..fe].to_vec());
2156
2157                let new_chunk_bytes: Vec<Vec<u8>> = if let Some(pm) = &pipeline_message {
2158                    let pipeline = FilterPipeline::parse(pm).map_err(|_| {
2159                        Error::EditUnsupported("dataset filter pipeline could not be parsed")
2160                    })?;
2161                    if !pipeline_reencodable(&pipeline) {
2162                        return Err(Error::EditUnsupported(
2163                            "a chunked dataset using a filter this engine cannot re-encode \
2164                             cannot be overwritten in place yet",
2165                        ));
2166                    }
2167                    let ctx = ChunkContext::from_datatype(&spatial, &fd.dt);
2168                    let mut encoded = Vec::with_capacity(split.len());
2169                    for (_, buf) in &split {
2170                        encoded.push(compress_chunk(buf, &pipeline, ctx)?);
2171                    }
2172                    encoded
2173                } else {
2174                    split.into_iter().map(|(_, buf)| buf).collect()
2175                };
2176
2177                // Fast path: overwrite each chunk straight in its slot when every
2178                // new chunk fits. No header rewrite and no superblock flip — the
2179                // chunk (and index) blocks are reachable from both roots. The index
2180                // is left untouched when chunks keep their size and rebuilt in place
2181                // when they shrink. The index walk runs on a base-relative view of
2182                // the file (so the layout's stored addresses index correctly), and
2183                // the returned write offsets are shifted back to absolute file
2184                // offsets by adding `base` (a no-op on a base-0 file).
2185                let base_off = usize::try_from(base).map_err(|_| {
2186                    Error::EditUnsupported("userblock base address exceeds this platform")
2187                })?;
2188                if let Some(writes) = try_inplace_chunk_writes(
2189                    &d[base_off..],
2190                    &dl,
2191                    &disk_ds,
2192                    &spatial,
2193                    raw_size,
2194                    &new_chunk_bytes,
2195                ) {
2196                    let writes = writes
2197                        .into_iter()
2198                        .map(|(off, b)| (off + base_off, b))
2199                        .collect();
2200                    return Ok(WritePlan::InPlaceChunks { writes });
2201                }
2202
2203                // Otherwise relocate: rebuild a fresh chunk blob + index at
2204                // end-of-file (carrying the re-encoded chunk bytes and the source
2205                // pipeline verbatim), swap the data-layout message in the verbatim
2206                // header, and free the old chunk storage after the commit lands.
2207                let meta = new_chunk_bytes
2208                    .iter()
2209                    .map(|c| ChunkMeta {
2210                        compressed_size: c.len() as u64,
2211                        filter_mask: 0,
2212                    })
2213                    .collect();
2214                Ok(WritePlan::Moving(MovingWrite::Chunked {
2215                    region,
2216                    chunk_dims: spatial,
2217                    element_size,
2218                    raw_size,
2219                    maxshape,
2220                    pipeline_message,
2221                    meta,
2222                    chunk_bytes: new_chunk_bytes,
2223                    old_addr: addr as u64,
2224                }))
2225            }
2226            _ => Err(Error::EditUnsupported(
2227                "an unsupported data-layout class cannot be overwritten in place yet",
2228            )),
2229        }
2230    }
2231
2232    /// Parse the object header at `addr` into a copyable model, validating that
2233    /// every message can be reproduced faithfully (verbatim message bytes, with
2234    /// only the contiguous data address and child link targets repointed).
2235    /// Dense (fractal-heap) attribute storage is read out of the source heap into
2236    /// a parsed attribute set carried on the model (`dense_attrs`) and re-emitted
2237    /// into a fresh heap on write, provided it fits the single-direct-block layout
2238    /// the emitter can build; an oversized set is refused. Rejects multi-chunk
2239    /// headers, dense or soft/external links, chunked/old-version data layouts, and
2240    /// headers that are neither a dataset nor a group.
2241    fn read_object(d: &[u8], addr: usize, base: u64) -> Result<ObjModel, Error> {
2242        let region = Self::gather_oh_messages(d, addr, base)?;
2243
2244        // First pass: detect whether attributes are stored densely (a defined
2245        // fractal-heap address in the Attribute Info message). A dense object is
2246        // copied by reading its attributes out of the source heap and rebuilding
2247        // a fresh heap on write, so its Attribute Info message and any inline
2248        // Attribute messages are dropped from the verbatim region — the rebuilt
2249        // region carries neither, and `dense_attrs` carries the parsed set.
2250        let mut dense = false;
2251        let mut p = 0;
2252        while let Some((msg_type, body, body_end)) = next_message(&region, p)? {
2253            if msg_type == MessageType::AttributeInfo {
2254                // An Attribute Info message does not by itself mean dense
2255                // storage: the reference C library and h5py emit one (with an
2256                // *undefined* fractal-heap address) even for compact, inline
2257                // attributes in the latest format, to carry attribute
2258                // creation-order metadata. Only a *defined* heap address is real
2259                // dense (fractal-heap) storage. A message that cannot be parsed
2260                // is refused conservatively.
2261                let ai = crate::attribute_info::AttributeInfoMessage::parse(
2262                    &region[body..body_end],
2263                    OFFSET_SIZE,
2264                )
2265                .map_err(|_| {
2266                    Error::EditUnsupported(
2267                        "a source attribute-info message could not be parsed for copying",
2268                    )
2269                })?;
2270                if ai.fractal_heap_address.is_some() {
2271                    dense = true;
2272                }
2273            }
2274            p = body_end;
2275        }
2276
2277        // If dense, read the attribute set out of the source fractal heap now (so
2278        // the source buffer need not outlive the read) and validate it can be
2279        // re-emitted into a fresh heap on write. `extract_attributes_full` reads
2280        // both compact and dense attributes; a dense object carries no inline
2281        // Attribute messages, so it returns exactly the heap-resident set.
2282        let dense_attrs = if dense {
2283            let header =
2284                ObjectHeader::parse_with_base(d, addr, OFFSET_SIZE, LENGTH_SIZE, base).map_err(|_| {
2285                    Error::EditUnsupported(
2286                        "a source object header with dense attributes could not be parsed for copying",
2287                    )
2288                })?;
2289            let attrs = crate::attribute::extract_attributes_full(
2290                d,
2291                &header,
2292                OFFSET_SIZE,
2293                LENGTH_SIZE,
2294            )
2295            .map_err(|_| {
2296                Error::EditUnsupported(
2297                    "a source object's dense (fractal-heap) attributes could not be read for copying",
2298                )
2299            })?;
2300            if !crate::file_writer::dense_attrs_fit(&attrs) {
2301                return Err(Error::EditUnsupported(
2302                    "an object's dense (fractal-heap) attribute set is too large to reproduce (would need fractal-heap indirect blocks)",
2303                ));
2304            }
2305            attrs
2306        } else {
2307            Vec::new()
2308        };
2309
2310        let mut layout: Option<(usize, usize)> = None; // (body offset in kept, size)
2311        let mut has_link_info = false;
2312        let mut children: Vec<(String, u64)> = Vec::new();
2313        // The rebuilt chunk-0 region: every message kept verbatim except hard
2314        // Link messages (carried as `children`) and, when dense, the Attribute
2315        // Info message and inline Attribute messages (carried as `dense_attrs`).
2316        let mut kept: Vec<u8> = Vec::new();
2317
2318        let mut p = 0;
2319        while let Some((msg_type, body, body_end)) = next_message(&region, p)? {
2320            let mut keep = true;
2321            match msg_type {
2322                MessageType::AttributeInfo => {
2323                    // Already parsed in the first pass; drop the dense Attribute
2324                    // Info message so the rebuilt header references the fresh heap
2325                    // (spliced in on write) rather than the source one. A compact
2326                    // (undefined-heap) Attribute Info message is kept verbatim.
2327                    if dense {
2328                        keep = false;
2329                    }
2330                }
2331                MessageType::Attribute => {
2332                    // A dense object should carry no inline Attribute messages,
2333                    // but drop any defensively so the rebuilt header's only
2334                    // attribute storage is the fresh heap.
2335                    if dense {
2336                        keep = false;
2337                    }
2338                }
2339                MessageType::LinkInfo => {
2340                    has_link_info = true;
2341                    let mut q = body + 2;
2342                    if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
2343                        q += 8;
2344                    }
2345                    if q + 8 <= body_end {
2346                        let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
2347                        if heap_addr != u64::MAX {
2348                            return Err(Error::EditUnsupported(
2349                                "a group uses dense (fractal-heap) link storage (not supported in place yet)",
2350                            ));
2351                        }
2352                    }
2353                }
2354                MessageType::Link => {
2355                    keep = false;
2356                    match LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
2357                        Ok(LinkMessage {
2358                            name,
2359                            link_target:
2360                                LinkTarget::Hard {
2361                                    object_header_address,
2362                                },
2363                            ..
2364                        }) => children.push((name, object_header_address)),
2365                        _ => {
2366                            return Err(Error::EditUnsupported(
2367                                "a group contains a soft/external link (not copyable in place yet)",
2368                            ));
2369                        }
2370                    }
2371                }
2372                MessageType::DataLayout => {
2373                    // Record the layout body offset within the *kept* region so a
2374                    // contiguous dataset's data-address field can be repointed
2375                    // even after earlier messages were dropped.
2376                    layout = Some((kept.len() + (body - p), body_end - body));
2377                }
2378                _ => {}
2379            }
2380            if keep {
2381                kept.extend_from_slice(&region[p..body_end]);
2382            }
2383            p = body_end;
2384        }
2385
2386        if let Some((lbody, lsize)) = layout {
2387            let version = kept[lbody];
2388            if !(version == 3 || version == 4) || lsize < 2 {
2389                return Err(Error::EditUnsupported(
2390                    "an unsupported data-layout version cannot be copied in place yet",
2391                ));
2392            }
2393            let class = kept[lbody + 1];
2394            match class {
2395                0 => Ok(ObjModel::DatasetVerbatim {
2396                    region: kept,
2397                    dense_attrs,
2398                }),
2399                1 => {
2400                    if lbody + 18 > kept.len() {
2401                        return Err(Error::EditUnsupported("malformed contiguous data layout"));
2402                    }
2403                    let data_addr =
2404                        u64::from_le_bytes(kept[lbody + 2..lbody + 10].try_into().unwrap());
2405                    let data_size =
2406                        u64::from_le_bytes(kept[lbody + 10..lbody + 18].try_into().unwrap());
2407                    Ok(ObjModel::DatasetContiguous {
2408                        region: kept,
2409                        addr_off: lbody + 2,
2410                        data_addr,
2411                        data_size,
2412                        dense_attrs,
2413                    })
2414                }
2415                // Chunked: the verbatim header carries the data-layout and filter-
2416                // pipeline messages; `read_copy_subtree` (which holds the source
2417                // buffer) enumerates and captures the chunk bytes and rebuilds the
2418                // index on write.
2419                2 => Ok(ObjModel::DatasetChunked {
2420                    region: kept,
2421                    dense_attrs,
2422                }),
2423                _ => Err(Error::EditUnsupported(
2424                    "an unsupported data-layout class cannot be copied in place yet",
2425                )),
2426            }
2427        } else if has_link_info {
2428            // A copied group must carry a Group Info message so the copy stays
2429            // writable by the C library, even when the source omitted it.
2430            ensure_group_info(&mut kept)?;
2431            Ok(ObjModel::Group {
2432                non_link_region: kept,
2433                children,
2434                dense_attrs,
2435            })
2436        } else {
2437            Err(Error::EditUnsupported(
2438                "an object is neither a contiguous/compact dataset nor a group",
2439            ))
2440        }
2441    }
2442
2443    /// Read the object at `addr` in the source buffer `d` — and, for a group, its
2444    /// whole subtree — into an owned [`CopyTree`], the read half of an object copy.
2445    /// No bytes are written; this both validates that the subtree is copyable and
2446    /// captures the bytes the write half ([`write_copy_subtree`](Self::write_copy_subtree))
2447    /// later appends, so the source buffer need not outlive the read.
2448    ///
2449    /// `d` is the buffer the source object lives in: this session's own mirror for
2450    /// an in-file [`copy`](Self::copy), or another file's image for a cross-file
2451    /// [`copy_from`](Self::copy_from). `base` is that buffer's userblock base (the
2452    /// session's own base for an in-file copy, always 0 for a cross-file copy, whose
2453    /// source is gated to base 0): the stored, base-relative addresses read out of
2454    /// the source headers are shifted by it to index `d`. When `cross_file` is set,
2455    /// every copied object header is additionally screened by
2456    /// [`reject_foreign_addresses`] — verbatim bytes that embed a *source-file*
2457    /// absolute address (variable-length or reference data, a committed datatype)
2458    /// would dangle in another file and are refused, whereas an in-file copy keeps
2459    /// them valid by sharing the source file's heaps and objects.
2460    fn read_copy_subtree(
2461        d: &[u8],
2462        addr: usize,
2463        depth: u32,
2464        cross_file: bool,
2465        base: u64,
2466    ) -> Result<CopyTree, Error> {
2467        if depth >= MAX_COPY_DEPTH {
2468            return Err(Error::EditUnsupported(
2469                "copy source nests too deeply (possible hard-link cycle)",
2470            ));
2471        }
2472        // `base` is the userblock base of the buffer `d`: this session's own base
2473        // for an in-file copy, and always 0 for a cross-file copy (the source is
2474        // gated to base 0 in `copy_from`). `addr` is an absolute offset into `d`;
2475        // the stored (base-relative) addresses `read_object` returns for contiguous
2476        // data, chunk storage, and child links are converted to absolute offsets by
2477        // adding `base` before `d` is indexed or a child is descended into.
2478        let base_off = usize::try_from(base)
2479            .map_err(|_| Error::EditUnsupported("userblock base address exceeds this platform"))?;
2480        match Self::read_object(d, addr, base)? {
2481            ObjModel::DatasetVerbatim {
2482                region,
2483                dense_attrs,
2484            } => {
2485                if cross_file {
2486                    reject_foreign_addresses(&region)?;
2487                    reject_foreign_dense_attrs(&dense_attrs)?;
2488                }
2489                Ok(CopyTree::DatasetVerbatim {
2490                    region,
2491                    dense_attrs,
2492                })
2493            }
2494            ObjModel::DatasetContiguous {
2495                region,
2496                addr_off,
2497                data_addr,
2498                data_size,
2499                dense_attrs,
2500            } => {
2501                if cross_file {
2502                    reject_foreign_addresses(&region)?;
2503                    reject_foreign_dense_attrs(&dense_attrs)?;
2504                }
2505                // The stored data address is base-relative; shift it to an absolute
2506                // offset into `d` before slicing out the data block.
2507                let start = data_addr
2508                    .checked_add(base)
2509                    .and_then(|a| usize::try_from(a).ok())
2510                    .ok_or(Error::EditUnsupported("data address exceeds this platform"))?;
2511                let len = usize::try_from(data_size)
2512                    .map_err(|_| Error::EditUnsupported("data size exceeds this platform"))?;
2513                let end = start
2514                    .checked_add(len)
2515                    .filter(|&e| e <= d.len())
2516                    .ok_or(Error::EditUnsupported("dataset data is out of bounds"))?;
2517                Ok(CopyTree::DatasetContiguous {
2518                    region,
2519                    addr_off,
2520                    data: d[start..end].to_vec(),
2521                    dense_attrs,
2522                })
2523            }
2524            ObjModel::DatasetChunked {
2525                region,
2526                dense_attrs,
2527            } => {
2528                // Screen the verbatim header on the cross-file path. This refuses a
2529                // variable-length or reference datatype (whose chunk payload embeds
2530                // source-file global-heap / object addresses that would dangle in
2531                // another file) and any shared message — exactly the forms repack
2532                // also refuses for a cross-file verbatim chunk copy. An in-file copy
2533                // keeps them valid by sharing the source file's heaps.
2534                if cross_file {
2535                    reject_foreign_addresses(&region)?;
2536                    reject_foreign_dense_attrs(&dense_attrs)?;
2537                }
2538                let ChunkedHeaderParts {
2539                    dt,
2540                    ds,
2541                    layout,
2542                    pipeline_message,
2543                } = parse_chunked_header(&region)?;
2544                let DataLayout::Chunked {
2545                    version: lversion,
2546                    chunk_index_type,
2547                    ..
2548                } = layout
2549                else {
2550                    return Err(Error::EditUnsupported("dataset is not chunked"));
2551                };
2552                if !chunk_index_enumerable(lversion, chunk_index_type) {
2553                    return Err(Error::EditUnsupported(
2554                        "a chunked dataset with a version-2 B-tree or unknown chunk index \
2555                         cannot be copied in place yet",
2556                    ));
2557                }
2558                let ChunkedGeometry {
2559                    spatial: chunk_dims,
2560                    element_size,
2561                    raw_size,
2562                    maxshape,
2563                } = chunked_geometry(&dt, &ds, &layout)?;
2564
2565                // The layout's chunk-index address and every chunk address it leads
2566                // to are stored base-relative, so enumerate and read on a
2567                // base-relative view of the source buffer (a no-op slice on a base-0
2568                // file). The returned addresses are then offsets into `dview`.
2569                let dview = &d[base_off..];
2570
2571                // Enumerate the source chunks and map them onto a dense grid; a
2572                // sparse (holed/unallocated) dataset cannot be reproduced by the
2573                // verbatim layout path, which needs every grid slot filled.
2574                let infos =
2575                    enumerate_chunks_buffered(dview, &layout, &ds, OFFSET_SIZE, LENGTH_SIZE)?;
2576                let grid = plan_dense_grid(infos, &ds.dimensions, &chunk_dims).ok_or(
2577                    Error::EditUnsupported(
2578                        "a chunked dataset with unallocated (sparse) chunks cannot be copied in place yet",
2579                    ),
2580                )?;
2581                if grid.grid_order.is_empty() {
2582                    return Err(Error::EditUnsupported(
2583                        "an empty chunked dataset cannot be copied in place yet",
2584                    ));
2585                }
2586
2587                // Capture each chunk's already-compressed bytes (no decode) into an
2588                // owned buffer, in dense row-major grid order, so the copy can be
2589                // written after the source buffer is gone (cross-file copy reads at
2590                // staging time). Sizes and masks are carried verbatim.
2591                let mut meta = Vec::with_capacity(grid.grid_order.len());
2592                let mut chunk_bytes = Vec::with_capacity(grid.grid_order.len());
2593                for ci in &grid.grid_order {
2594                    let start = usize::try_from(ci.address).map_err(|_| {
2595                        Error::EditUnsupported("chunk address exceeds this platform")
2596                    })?;
2597                    let len = ci.chunk_size as usize;
2598                    let end = start
2599                        .checked_add(len)
2600                        .filter(|&e| e <= dview.len())
2601                        .ok_or(Error::EditUnsupported("chunk data is out of bounds"))?;
2602                    chunk_bytes.push(dview[start..end].to_vec());
2603                    meta.push(ChunkMeta {
2604                        compressed_size: ci.chunk_size as u64,
2605                        filter_mask: ci.filter_mask,
2606                    });
2607                }
2608
2609                Ok(CopyTree::DatasetChunked {
2610                    region,
2611                    chunk_dims,
2612                    element_size,
2613                    raw_size,
2614                    maxshape,
2615                    pipeline_message,
2616                    meta,
2617                    chunk_bytes,
2618                    dense_attrs,
2619                })
2620            }
2621            ObjModel::Group {
2622                non_link_region,
2623                children,
2624                dense_attrs,
2625            } => {
2626                if cross_file {
2627                    reject_foreign_addresses(&non_link_region)?;
2628                    reject_foreign_dense_attrs(&dense_attrs)?;
2629                }
2630                let mut kids = Vec::with_capacity(children.len());
2631                for (name, child) in children {
2632                    // Child link targets are stored base-relative; re-absolutize
2633                    // before descending so `addr` stays an absolute offset into `d`.
2634                    let child = child
2635                        .checked_add(base)
2636                        .and_then(|a| usize::try_from(a).ok())
2637                        .ok_or(Error::EditUnsupported(
2638                            "child address exceeds this platform",
2639                        ))?;
2640                    kids.push((
2641                        name,
2642                        Self::read_copy_subtree(d, child, depth + 1, cross_file, base)?,
2643                    ));
2644                }
2645                Ok(CopyTree::Group {
2646                    non_link_region,
2647                    children: kids,
2648                    dense_attrs,
2649                })
2650            }
2651        }
2652    }
2653
2654    /// Append the fresh copies described by `node` (data blobs and headers) into
2655    /// this session at end-of-file or into reusable freed regions, returning the
2656    /// new object-header address of the copied root. The write half of an object
2657    /// copy; children are written before their parent group so each parent links
2658    /// its children's new addresses, and a contiguous dataset's data-address field
2659    /// is repointed at the freshly-written copy. Every address the copy writes into
2660    /// a header (a contiguous data block, a child link) is stored relative to the
2661    /// userblock base (`- base`, a no-op on a base-0 file); the chunked storage and
2662    /// dense attribute heaps are laid out base-relative by their own builders.
2663    fn write_copy_subtree(&mut self, node: &CopyTree) -> Result<u64, Error> {
2664        let base = self.superblock.base_address;
2665        match node {
2666            CopyTree::DatasetVerbatim {
2667                region,
2668                dense_attrs,
2669            } => {
2670                let mut region = region.clone();
2671                self.append_dense_attrs(&mut region, dense_attrs)?;
2672                let oh = build_v2_object_header(&region);
2673                self.alloc_or_append(&oh)
2674            }
2675            CopyTree::DatasetContiguous {
2676                region,
2677                addr_off,
2678                data,
2679                dense_attrs,
2680            } => {
2681                let new_data_addr = self.alloc_or_append(data)?;
2682                let mut region = region.clone();
2683                // `alloc_or_append` returns an absolute offset; the data-layout
2684                // address field stores it relative to the userblock base.
2685                region[*addr_off..*addr_off + 8]
2686                    .copy_from_slice(&(new_data_addr - base).to_le_bytes());
2687                // Append the dense heap *after* the data so the heap's base
2688                // equals end-of-file (see `append_dense_attrs`).
2689                self.append_dense_attrs(&mut region, dense_attrs)?;
2690                let oh = build_v2_object_header(&region);
2691                self.alloc_or_append(&oh)
2692            }
2693            CopyTree::DatasetChunked {
2694                region,
2695                chunk_dims,
2696                element_size,
2697                raw_size,
2698                maxshape,
2699                pipeline_message,
2700                meta,
2701                chunk_bytes,
2702                dense_attrs,
2703            } => self.write_chunked_relocatable(
2704                region,
2705                chunk_dims,
2706                *element_size,
2707                *raw_size,
2708                maxshape.as_deref(),
2709                pipeline_message.as_deref(),
2710                meta,
2711                chunk_bytes,
2712                dense_attrs,
2713            ),
2714            CopyTree::Group {
2715                non_link_region,
2716                children,
2717                dense_attrs,
2718            } => {
2719                let mut region = non_link_region.clone();
2720                for (name, child) in children {
2721                    let new_child = self.write_copy_subtree(child)?;
2722                    // The link target is stored relative to the userblock base.
2723                    region.extend_from_slice(&encode_link_message(name, new_child - base));
2724                }
2725                // Append the dense heap after the children's headers/data so its
2726                // base equals end-of-file (see `append_dense_attrs`).
2727                self.append_dense_attrs(&mut region, dense_attrs)?;
2728                let oh = build_v2_object_header(&region);
2729                self.alloc_or_append(&oh)
2730            }
2731        }
2732    }
2733
2734    /// Write a chunked dataset's storage at end-of-file and return its new
2735    /// object-header address — the shared write half of a chunked copy
2736    /// ([`CopyTree::DatasetChunked`]) and a relocating chunked overwrite
2737    /// ([`MovingWrite::Chunked`]).
2738    ///
2739    /// A fresh chunk-data blob and index are laid out relocatably at the current
2740    /// end-of-file via [`plan_chunked_data_verbatim`] / [`emit_chunked_data_verbatim`],
2741    /// pulling each chunk's already-compressed bytes from `chunk_bytes` (in dense
2742    /// row-major grid order) and carrying `meta`'s sizes and filter masks and the
2743    /// source `pipeline_message` verbatim — no recompression, no filter-parameter
2744    /// reconstruction. The blob is *appended* (not placed via [`alloc_or_append`])
2745    /// because its embedded addresses assume `base == end-of-file`, exactly like
2746    /// [`build_chunked_dataset`](Self::build_chunked_dataset). The verbatim header
2747    /// `region`'s data-layout message is then swapped for the one the planner
2748    /// produced (every other message preserved), any dense attribute heap is
2749    /// appended after the blob, and the header is written into reusable freed space
2750    /// or at end-of-file.
2751    #[expect(
2752        clippy::too_many_arguments,
2753        reason = "the chunked rebuild needs the full geometry, \
2754        pipeline, and chunk payloads; bundling them into a struct would only move the list"
2755    )]
2756    fn write_chunked_relocatable(
2757        &mut self,
2758        region: &[u8],
2759        chunk_dims: &[u64],
2760        element_size: usize,
2761        raw_size: u64,
2762        maxshape: Option<&[u64]>,
2763        pipeline_message: Option<&[u8]>,
2764        meta: &[ChunkMeta],
2765        chunk_bytes: &[Vec<u8>],
2766        dense_attrs: &[crate::attribute::AttributeMessage],
2767    ) -> Result<u64, Error> {
2768        let eof = self.data.len() as u64;
2769        // Build with the *stored* (base-relative) address the blob will occupy, so
2770        // its embedded addresses resolve to its real file offset once the reader adds
2771        // the userblock base back (see `build_chunked_dataset`). On a base-0 file this
2772        // equals `eof`.
2773        let stored_base = eof - self.superblock.base_address;
2774        let layout = plan_chunked_data_verbatim(
2775            meta,
2776            chunk_dims,
2777            element_size,
2778            raw_size,
2779            pipeline_message,
2780            stored_base,
2781            maxshape,
2782        )?;
2783        let mut buf = Vec::with_capacity(usize::try_from(layout.plan.total_len).unwrap_or(0));
2784        emit_chunked_data_verbatim(
2785            &mut buf,
2786            &layout.plan,
2787            &SliceChunkProvider {
2788                chunks: chunk_bytes,
2789            },
2790        )?;
2791        let written = self.append(&buf)?;
2792        debug_assert_eq!(written, eof, "chunk blob must land at end-of-file",);
2793        // Swap the data-layout message for the rebuilt one; keep every other header
2794        // message (datatype, dataspace, fill value, filter pipeline, attributes)
2795        // verbatim. A dense attribute heap, if any, is appended after the blob so
2796        // its base equals end-of-file (see `append_dense_attrs`).
2797        let mut new_region = replace_layout_message(region, &layout.layout_message)?;
2798        self.append_dense_attrs(&mut new_region, dense_attrs)?;
2799        let oh = build_v2_object_header(&new_region);
2800        self.alloc_or_append(&oh)
2801    }
2802
2803    /// When `attrs` is non-empty, build a fresh dense (fractal-heap) attribute
2804    /// blob for it, append it at end-of-file, and splice the matching Attribute
2805    /// Info message onto `region`. A no-op for an empty set.
2806    ///
2807    /// The blob produced by [`file_writer::build_dense_attrs`] is fully
2808    /// relocatable: every address it embeds is `base + fixed offset`, so passing
2809    /// the current end-of-file as the base makes those addresses land exactly
2810    /// where the bytes are written. Like [`build_chunked_dataset`](Self::build_chunked_dataset)
2811    /// the blob is therefore *appended* (never placed into an interior freed
2812    /// region), and the caller must append it before any later append in the same
2813    /// node so `base == end-of-file` still holds. The freshly built heap is
2814    /// always same-file, so it never aliases the source heap even for an in-file
2815    /// copy. The caller has already validated [`file_writer::dense_attrs_fit`].
2816    fn append_dense_attrs(
2817        &mut self,
2818        region: &mut Vec<u8>,
2819        attrs: &[crate::attribute::AttributeMessage],
2820    ) -> Result<(), Error> {
2821        if attrs.is_empty() {
2822            return Ok(());
2823        }
2824        let eof = self.data.len() as u64;
2825        // Build with the *stored* (base-relative) address the blob will occupy, so
2826        // every address it embeds resolves to its real file offset once the reader
2827        // adds the userblock base back (see `build_chunked_dataset`). On a base-0
2828        // file this equals `eof`.
2829        let stored_base = eof - self.superblock.base_address;
2830        let blob = crate::file_writer::build_dense_attrs(attrs, stored_base);
2831        let written = self.append(&blob.blob)?;
2832        debug_assert_eq!(
2833            written, eof,
2834            "dense attribute blob must land at end-of-file",
2835        );
2836        region.extend_from_slice(&region_message(
2837            MessageType::AttributeInfo,
2838            &blob.attr_info_message,
2839        ));
2840        Ok(())
2841    }
2842
2843    /// Apply a relocating value overwrite (`write_dataset` resize / compact
2844    /// rewrite): write the new data and a rewritten object header at end-of-file
2845    /// (or into reusable freed space) and return the new header address. The
2846    /// caller patches the parent group's link to this address. The old data
2847    /// extent (for a resized contiguous dataset) is freed separately, after the
2848    /// commit's superblock repoint, so it is never reused mid-commit.
2849    fn write_moving(&mut self, mw: &MovingWrite) -> Result<u64, Error> {
2850        let base = self.superblock.base_address;
2851        match mw {
2852            MovingWrite::Contiguous {
2853                region,
2854                addr_off,
2855                raw,
2856                ..
2857            } => {
2858                let new_data_addr = self.alloc_or_append(raw)?;
2859                let mut region = region.clone();
2860                // `alloc_or_append` returns an absolute file offset; the contiguous
2861                // data-layout field stores it relative to the userblock base (`-
2862                // base`, a no-op on a base-0 file).
2863                region[*addr_off..*addr_off + 8]
2864                    .copy_from_slice(&(new_data_addr - base).to_le_bytes());
2865                // The data size field follows the 8-byte address in the contiguous
2866                // layout body; keep it in sync with the new length.
2867                let size_off = *addr_off + 8;
2868                region[size_off..size_off + 8].copy_from_slice(&(raw.len() as u64).to_le_bytes());
2869                let oh = build_v2_object_header(&region);
2870                self.alloc_or_append(&oh)
2871            }
2872            MovingWrite::Compact { region, raw } => {
2873                let region = rebuild_compact_layout_region(region, raw)?;
2874                let oh = build_v2_object_header(&region);
2875                self.alloc_or_append(&oh)
2876            }
2877            MovingWrite::Chunked {
2878                region,
2879                chunk_dims,
2880                element_size,
2881                raw_size,
2882                maxshape,
2883                pipeline_message,
2884                meta,
2885                chunk_bytes,
2886                ..
2887            } => self.write_chunked_relocatable(
2888                region,
2889                chunk_dims,
2890                *element_size,
2891                *raw_size,
2892                maxshape.as_deref(),
2893                pipeline_message.as_deref(),
2894                meta,
2895                chunk_bytes,
2896                &[],
2897            ),
2898        }
2899    }
2900
2901    /// Append `bytes` at end-of-file, updating both the mirror and the file.
2902    /// Returns the absolute address the bytes were written at.
2903    fn append(&mut self, bytes: &[u8]) -> Result<u64, Error> {
2904        // Write to disk before updating the in-memory mirror, so a failed write
2905        // never leaves the mirror ahead of the file on disk.
2906        let addr = self.data.len() as u64;
2907        self.handle.seek(SeekFrom::Start(addr)).map_err(Error::Io)?;
2908        self.handle.write_all(bytes).map_err(Error::Io)?;
2909        self.data.extend_from_slice(bytes);
2910        Ok(addr)
2911    }
2912
2913    /// Overwrite bytes in place at `offset`, updating both the mirror and the
2914    /// file. The caller guarantees the range already exists.
2915    fn write_at(&mut self, offset: usize, bytes: &[u8]) -> Result<(), Error> {
2916        // Write to disk before updating the in-memory mirror (see `append`).
2917        self.handle
2918            .seek(SeekFrom::Start(offset as u64))
2919            .map_err(Error::Io)?;
2920        self.handle.write_all(bytes).map_err(Error::Io)?;
2921        self.data[offset..offset + bytes.len()].copy_from_slice(bytes);
2922        Ok(())
2923    }
2924
2925    /// Place `bytes` either in a reusable free region left by a prior commit
2926    /// (overwriting it in place) or, failing that, by appending at end-of-file.
2927    /// Returns the address written to.
2928    ///
2929    /// Reuse only ever draws from [`self.free`](Self::free), which holds regions
2930    /// vacated by *earlier* commits in this session — never space the current
2931    /// commit is about to free — so the bytes it overwrites are already
2932    /// unreachable from the on-disk root and a mid-commit crash cannot corrupt
2933    /// the live tree (the superblock still points at the prior, intact root).
2934    fn alloc_or_append(&mut self, bytes: &[u8]) -> Result<u64, Error> {
2935        if let Some(addr) = self.free.alloc(bytes.len() as u64) {
2936            self.write_at(
2937                usize::try_from(addr).map_err(|_| {
2938                    Error::EditUnsupported("free-region address exceeds this platform")
2939                })?,
2940                bytes,
2941            )?;
2942            Ok(addr)
2943        } else {
2944            self.append(bytes)
2945        }
2946    }
2947
2948    /// Place an already-built, self-contained global heap collection (from
2949    /// [`build_global_heap_collection`] or a [`VlStringStaging::collection_bytes`])
2950    /// and return the base-relative address a variable-length reference into it
2951    /// should be patched to. A `GCOL` blob embeds no addresses of its own, so it
2952    /// can be appended (or dropped into reused free space) at any point in the
2953    /// apply loop, unlike a group or dataset header, which must be built last so
2954    /// it can name its children's real addresses.
2955    fn place_vl_collection(&mut self, collection_bytes: &[u8]) -> Result<u64, Error> {
2956        let addr = self.alloc_or_append(collection_bytes)?;
2957        Ok(addr - self.superblock.base_address)
2958    }
2959
2960    /// Resolve one object-reference element's target to the base-relative
2961    /// address that should be stored on disk. [`ObjectRefTarget::Raw`] is
2962    /// written back verbatim (a null or undefined reference is a sentinel, not
2963    /// a real address, so it needs no base adjustment — mirrors the whole-file
2964    /// writer). [`ObjectRefTarget::Path`] resolves, in order:
2965    ///
2966    /// 1. Against `path_addr` — every group and dataset this commit has
2967    ///    already placed (a sibling dataset placed earlier in the same
2968    ///    group's batch — see the apply loop's non-reference-first ordering —
2969    ///    or a descendant subtree fully processed earlier in the deepest-first
2970    ///    walk).
2971    /// 2. Against the pre-commit on-disk file
2972    ///    ([`resolve_path_any`](crate::group_v2::resolve_path_any)), but only
2973    ///    when the path is untouched by this commit, so its pre-commit
2974    ///    address is guaranteed to still be valid post-commit. "Touched"
2975    ///    means: a dirty group (`nodes`, new or merely rewritten because an
2976    ///    addition lives under it — its own address changes either way); a
2977    ///    path this commit adds, or that lies under a subtree this commit
2978    ///    copies in (`add_targets`, checked by prefix so a copy's interior is
2979    ///    covered even though only its root is enumerated there); or a
2980    ///    `write_dataset` target (`write_targets`) — conservatively refused
2981    ///    even for a same-length overwrite that does not actually relocate,
2982    ///    since resolving that distinction here is not worth the complexity.
2983    /// 3. If the path resolves nowhere at all (neither this commit nor the
2984    ///    pre-commit file has ever heard of it), as an undefined reference
2985    ///    (`HADDR_UNDEF`) — mirroring [`ObjectRefTarget::Path`]'s existing
2986    ///    whole-file-writer resolution convention for the same builder type.
2987    ///
2988    /// A path that step 1 misses but step 2 identifies as commit-touched is
2989    /// refused with a clear [`Error::EditUnsupported`] rather than resolved to
2990    /// a stale or wrong address — the one case this engine cannot resolve
2991    /// without the whole-file writer's two-pass dummy/real-address scheme.
2992    /// "Touched" also covers a path this same commit deletes (`pending_deletes`):
2993    /// without that check the deleted object's pre-commit address would still
2994    /// resolve via step 2, and the reference would end up pointing at storage
2995    /// this same commit is about to reclaim and hand out to something else.
2996    fn resolve_reference_target(
2997        target: &ObjectRefTarget,
2998        path_addr: &BTreeMap<PathKey, u64>,
2999        nodes: &BTreeMap<PathKey, Node>,
3000        add_targets: &[PathKey],
3001        write_targets: &[PathKey],
3002        pending_deletes: &[PathKey],
3003        data: &[u8],
3004        superblock: &Superblock,
3005    ) -> Result<u64, Error> {
3006        let path = match target {
3007            ObjectRefTarget::Raw(addr) => return Ok(*addr),
3008            ObjectRefTarget::Path(path) => path,
3009        };
3010        let base = superblock.base_address;
3011        let key = split_path(path);
3012        if let Some(&addr) = path_addr.get(&key) {
3013            return Ok(addr - base);
3014        }
3015        if nodes.contains_key(&key)
3016            || add_targets.iter().any(|t| is_prefix(t, &key))
3017            || write_targets.contains(&key)
3018            || pending_deletes.contains(&key)
3019        {
3020            return Err(Error::EditUnsupported(
3021                "an object-reference dataset targets a path this commit is still writing; \
3022                 use separate commits",
3023            ));
3024        }
3025        match crate::group_v2::resolve_path_any(data, superblock, path) {
3026            Ok(addr) => Ok(addr - base),
3027            Err(_) => Ok(UNDEF),
3028        }
3029    }
3030
3031    /// Prove, before any byte of this commit is written, that every
3032    /// object-reference target across every staged dataset will resolve
3033    /// successfully — either against a pre-existing untouched object or
3034    /// against something this same commit places. [`resolve_reference_target`]
3035    /// classifies a target purely from *whether* a `PathKey` has been placed
3036    /// yet (`path_addr.get`), never from the address *value*, so replaying the
3037    /// apply loop's placement order here with placeholder addresses (`0`)
3038    /// standing in for "already placed" reproduces the exact same verdict the
3039    /// apply loop's own calls will reach later, without writing anything. If
3040    /// this preflight pass returns `Ok`, none of the apply loop's own
3041    /// `resolve_reference_target` calls can fail, so a reference-resolution
3042    /// error can no longer leave earlier-processed groups' real writes
3043    /// orphaned in the file (the failure surfaces here instead, before the
3044    /// apply loop's first `alloc_or_append`/`write_at`).
3045    fn preflight_reference_targets(
3046        keys: &[PathKey],
3047        flat: &BTreeMap<PathKey, Vec<FlatDataset>>,
3048        nodes: &BTreeMap<PathKey, Node>,
3049        add_targets: &[PathKey],
3050        write_targets: &[PathKey],
3051        pending_deletes: &[PathKey],
3052        data: &[u8],
3053        superblock: &Superblock,
3054    ) -> Result<(), Error> {
3055        let mut by_depth = keys.to_vec();
3056        by_depth.sort_by_key(|k| std::cmp::Reverse(k.len()));
3057        let mut sim_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
3058        for key in &by_depth {
3059            if let Some(datasets) = flat.get(key) {
3060                // Mirrors the apply loop's `group_datasets.sort_by_key(|fd|
3061                // fd.reference_targets.is_some())`: non-reference datasets are
3062                // placed (and so become resolvable) before any reference
3063                // dataset in the same group.
3064                let mut ordered: Vec<&FlatDataset> = datasets.iter().collect();
3065                ordered.sort_by_key(|fd| fd.reference_targets.is_some());
3066                for fd in ordered {
3067                    if let Some(targets) = &fd.reference_targets {
3068                        for target in targets {
3069                            Self::resolve_reference_target(
3070                                target,
3071                                &sim_addr,
3072                                nodes,
3073                                add_targets,
3074                                write_targets,
3075                                pending_deletes,
3076                                data,
3077                                superblock,
3078                            )?;
3079                        }
3080                    }
3081                    let mut full = key.clone();
3082                    full.push(fd.name.clone());
3083                    sim_addr.insert(full, 0);
3084                }
3085            }
3086            sim_addr.insert(key.clone(), 0);
3087        }
3088        Ok(())
3089    }
3090
3091    /// Lay out a chunked / filtered / extensible dataset and return its object
3092    /// header bytes (which the caller links into the parent group).
3093    ///
3094    /// The chunk data and index (B-tree v1 / fixed-array / extensible-array, with
3095    /// any filter pipeline applied) are produced as one relocatable blob by
3096    /// [`build_chunked_data_at_ext`], whose internal layout — and therefore total
3097    /// size — is independent of the base address it is given. The blob is
3098    /// appended at end-of-file, so passing the current end-of-file as the base
3099    /// makes every absolute address it embeds (chunk addresses, index-structure
3100    /// addresses, the addresses in the data-layout message) land exactly where
3101    /// the bytes are written. The header is then built with
3102    /// [`build_chunked_dataset_oh`] — the same function the whole-file writer
3103    /// uses — so the header is byte-identical to one written fresh.
3104    ///
3105    /// Unlike the contiguous path the blob is always *appended* rather than
3106    /// placed via [`alloc_or_append`]: reusing an interior freed region would
3107    /// require knowing the blob's size before building it at that region's
3108    /// address, and appending keeps the address known up front. Freed space is
3109    /// still reused for the object header and for every other object in the
3110    /// commit.
3111    fn build_chunked_dataset(&mut self, fd: &FlatDataset) -> Result<Vec<u8>, Error> {
3112        let eof = self.data.len() as u64;
3113        // The blob embeds *stored* (base-relative) addresses, so the planner base is
3114        // the stored address the blob will occupy: its end-of-file offset minus the
3115        // userblock base. The reader recovers each as `stored + base_address`, which
3116        // resolves back to the blob's real file offset. On a base-0 file this is just
3117        // `eof`.
3118        let stored_base = eof - self.superblock.base_address;
3119        let chunk_dims = fd.chunk_options.resolve_chunk_dims(&fd.ds.dimensions);
3120        let ctx = ChunkContext::from_datatype(&chunk_dims, &fd.dt);
3121        let result = build_chunked_data_at_ext(
3122            &fd.raw,
3123            &fd.ds.dimensions,
3124            ctx,
3125            &fd.chunk_options,
3126            stored_base,
3127            fd.maxshape.as_deref(),
3128        )?;
3129        // `append` writes at the current end-of-file, which equals `eof`: the blob
3130        // lands exactly where its embedded (stored) addresses expect once the reader
3131        // adds the base back.
3132        let written = self.append(&result.data_bytes)?;
3133        debug_assert_eq!(written, eof, "chunk blob must land at end-of-file",);
3134        Ok(build_chunked_dataset_oh(
3135            &fd.dt,
3136            &fd.ds,
3137            &result.layout_message,
3138            result.pipeline_message.as_deref(),
3139            &fd.attrs,
3140            None,
3141        ))
3142    }
3143
3144    /// On-disk byte spans `(addr, len)` of every chunk of the version 2 object
3145    /// header at `addr`: chunk 0 (signature, prefix, messages, checksum) plus
3146    /// each continuation (`OCHK`) block. Used to reclaim a header's storage when
3147    /// its object is deleted. An error (propagated from [`oh_region`] or a
3148    /// malformed continuation) means the header is not a plain v2 header this
3149    /// engine can fully account for, and the caller leaves it as dead bytes
3150    /// rather than guess its extent.
3151    fn oh_chunk_spans(&self, addr: usize) -> Result<Vec<(u64, u64)>, Error> {
3152        let (rs, re) = Self::oh_region(&self.data, addr)?;
3153        let d = &self.data;
3154        // A continuation message records the OCHK block's address relative to the
3155        // userblock base, so it is shifted to an absolute file offset before
3156        // indexing the file or recording the span (a no-op on a base-0 file). Chunk
3157        // 0 sits at the absolute header address itself, which is already absolute.
3158        let base = self.superblock.base_address;
3159        // Chunk 0 spans from the header start through its trailing checksum;
3160        // `oh_region` guarantees `re + 4 <= d.len()`.
3161        let mut spans: Vec<(u64, u64)> = vec![(addr as u64, (re + 4 - addr) as u64)];
3162        // Walk continuation messages exactly as `gather_oh_messages` does, but
3163        // record each OCHK block's extent instead of collecting its messages.
3164        let mut chunks: Vec<(usize, usize)> = vec![(rs, re)];
3165        let mut i = 0;
3166        while i < chunks.len() {
3167            if chunks.len() > MAX_OH_CHUNKS {
3168                return Err(Error::EditUnsupported(
3169                    "object header has too many continuation chunks",
3170                ));
3171            }
3172            let (cs, ce) = chunks[i];
3173            i += 1;
3174            let region = &d[..ce];
3175            let mut p = cs;
3176            while let Some((msg_type, body, body_end)) = next_message(region, p)? {
3177                if msg_type == MessageType::ObjectHeaderContinuation {
3178                    if body_end - body < (OFFSET_SIZE + LENGTH_SIZE) as usize {
3179                        return Err(Error::EditUnsupported("malformed continuation message"));
3180                    }
3181                    let off = u64::from_le_bytes(d[body..body + 8].try_into().unwrap());
3182                    let len = u64::from_le_bytes(d[body + 8..body + 16].try_into().unwrap());
3183                    let off_abs = off
3184                        .checked_add(base)
3185                        .ok_or(Error::EditUnsupported("continuation address overflow"))?;
3186                    let off_us = usize::try_from(off_abs).map_err(|_| {
3187                        Error::EditUnsupported("continuation address exceeds this platform")
3188                    })?;
3189                    let len_us = usize::try_from(len).map_err(|_| {
3190                        Error::EditUnsupported("continuation length exceeds this platform")
3191                    })?;
3192                    let blk_end = off_us
3193                        .checked_add(len_us)
3194                        .filter(|&e| e <= d.len() && len_us >= 8)
3195                        .ok_or(Error::EditUnsupported("continuation block out of bounds"))?;
3196                    if d[off_us..off_us + 4] != *b"OCHK" {
3197                        return Err(Error::EditUnsupported(
3198                            "invalid continuation block signature",
3199                        ));
3200                    }
3201                    spans.push((off_abs, len));
3202                    chunks.push((off_us + 4, blk_end - 4));
3203                }
3204                p = body_end;
3205            }
3206        }
3207        Ok(spans)
3208    }
3209
3210    /// Count, for every object-header address reachable from the root, how many
3211    /// hard links in the *pre-commit* file point to it. The result drives the
3212    /// last-hard-link reclaim guard in [`collect_free_spans`](Self::collect_free_spans):
3213    /// an object is freed only when its count is 1.
3214    ///
3215    /// Walks the whole link graph from the root, following hard links through
3216    /// groups of any on-disk format (v0/v1 symbol-table, v2 compact, v2 dense)
3217    /// via [`resolve_group_entries`], tallying each hard-link edge. Datasets and
3218    /// other leaves contribute no edges. Returns `None` — so the caller reclaims
3219    /// nothing for the deletions, a safe leak — if the graph cannot be walked in
3220    /// full: an unparseable header, a group whose links cannot be enumerated, or
3221    /// more than [`MAX_LINK_GRAPH_NODES`] objects. Cycles are handled by visiting
3222    /// each object once. Base-aware: stored child addresses are shifted by the
3223    /// userblock base, so the returned keys are absolute file offsets.
3224    fn count_incoming_hard_links(&self) -> Option<HashMap<u64, u32>> {
3225        let os = self.superblock.offset_size;
3226        let ls = self.superblock.length_size;
3227        let base = self.superblock.base_address;
3228        let mut counts: HashMap<u64, u32> = HashMap::new();
3229        let mut visited: HashSet<u64> = HashSet::new();
3230        let mut stack: Vec<u64> = vec![self.superblock.root_group_address];
3231        let mut budget = MAX_LINK_GRAPH_NODES;
3232        while let Some(addr) = stack.pop() {
3233            if !visited.insert(addr) {
3234                continue; // already expanded (also breaks hard-link cycles)
3235            }
3236            if budget == 0 {
3237                return None; // graph larger than we will walk; leak conservatively
3238            }
3239            budget -= 1;
3240            let off = usize::try_from(addr).ok()?;
3241            let header = ObjectHeader::parse_with_base(&self.data, off, os, ls, base).ok()?;
3242            // Datasets and other leaves are not groups and own no links.
3243            let is_group = header.messages.iter().any(|m| {
3244                matches!(
3245                    m.msg_type,
3246                    MessageType::SymbolTable | MessageType::Link | MessageType::LinkInfo
3247                )
3248            });
3249            if !is_group {
3250                continue;
3251            }
3252            // A group we cannot enumerate fully would undercount incoming links
3253            // and risk over-reclaim; bail to the safe-leak fallback instead.
3254            let entries = resolve_group_entries(&self.data, &header, os, ls, base).ok()?;
3255            for e in entries {
3256                let child = e.object_header_address.checked_add(base)?;
3257                *counts.entry(child).or_insert(0) += 1;
3258                stack.push(child);
3259            }
3260        }
3261        Some(counts)
3262    }
3263
3264    /// Best-effort enumeration of every on-disk block owned by the object at
3265    /// `addr` (and, for a group, its whole subtree), accumulating `(addr, len)`
3266    /// spans into `out` for reclamation after a delete.
3267    ///
3268    /// Contiguous datasets (header + data block), chunked datasets (header +
3269    /// chunk index + chunk data, via [`chunked_storage_spans`](Self::chunked_storage_spans)),
3270    /// and whole group subtrees are reclaimed. Deliberately conservative: any
3271    /// object whose layout it cannot fully account for — a non-v2 header, an
3272    /// unsupported or only-partially-enumerable chunk index, a group holding a
3273    /// soft/external link, dense attribute storage — contributes nothing and is
3274    /// not descended into, so `out` never names a region that might still be in
3275    /// use. Bounded by [`MAX_COPY_DEPTH`] against a hard-link cycle.
3276    /// Variable-length data in global-heap collections is never reclaimed here (a
3277    /// collection can be shared between objects), so it is simply left behind.
3278    ///
3279    /// `incoming` is the file-wide hard-link count per object-header address
3280    /// (from [`count_incoming_hard_links`](Self::count_incoming_hard_links)). An
3281    /// object is reclaimed — and, for a group, descended into — only when its
3282    /// count is exactly 1, i.e. the link being removed is its last: an object
3283    /// still reachable through another hard link is live and is left untouched
3284    /// (so is everything below a surviving group), which is what keeps deleting
3285    /// one of several hard links from corrupting the survivor.
3286    fn collect_free_spans(
3287        &self,
3288        addr: usize,
3289        depth: u32,
3290        incoming: &HashMap<u64, u32>,
3291        out: &mut Vec<(u64, u64)>,
3292    ) {
3293        // `addr` is an absolute file offset (the caller resolves it from the live
3294        // file, and the group recursion below re-absolutizes each child). `incoming`
3295        // is keyed by absolute offset, and `oh_chunk_spans`/`chunked_storage_spans`
3296        // both take an absolute address and return absolute spans, so the whole
3297        // walk works in absolute file offsets. The one shift this method must apply
3298        // itself is on the *stored* (base-relative) addresses `read_object` returns
3299        // for a contiguous data block and a group's child links: each is converted
3300        // to an absolute offset by adding `base` (a no-op on a base-0 file) before
3301        // it is bounds-checked, recorded, or descended into.
3302        let base = self.superblock.base_address;
3303        if depth >= MAX_COPY_DEPTH {
3304            return;
3305        }
3306        // Reclaim only when this delete removes the object's last hard link. A
3307        // count other than 1 (it has surviving links, or the graph walk could
3308        // not account for it) means the object — and a group's whole subtree —
3309        // stays live and must not be freed.
3310        if incoming.get(&(addr as u64)) != Some(&1) {
3311            return;
3312        }
3313        // The header's own chunks. If they cannot be mapped, account for nothing.
3314        let spans = match self.oh_chunk_spans(addr) {
3315            Ok(s) => s,
3316            Err(_) => return,
3317        };
3318        match Self::read_object(&self.data, addr, self.superblock.base_address) {
3319            Ok(ObjModel::DatasetVerbatim { .. }) => out.extend(spans),
3320            Ok(ObjModel::DatasetContiguous {
3321                data_addr,
3322                data_size,
3323                ..
3324            }) => {
3325                out.extend(spans);
3326                // A defined, in-bounds contiguous data block is owned outright;
3327                // an empty dataset stores the undefined address and owns none. The
3328                // stored address is base-relative, so shift it to an absolute file
3329                // offset before bounds-checking and recording it.
3330                if data_addr != u64::MAX && data_size > 0 {
3331                    if let (Some(abs), Ok(len)) =
3332                        (data_addr.checked_add(base), usize::try_from(data_size))
3333                    {
3334                        if let Ok(start) = usize::try_from(abs) {
3335                            if start.checked_add(len).is_some_and(|e| e <= self.data.len()) {
3336                                out.push((abs, data_size));
3337                            }
3338                        }
3339                    }
3340                }
3341            }
3342            Ok(ObjModel::Group { children, .. }) => {
3343                out.extend(spans);
3344                // Child link targets are stored base-relative; re-absolutize each
3345                // before descending so the recursion keeps working in absolute
3346                // offsets (matching `incoming`'s keys and `oh_chunk_spans`).
3347                for (_, child) in children {
3348                    if let Some(c) = child
3349                        .checked_add(base)
3350                        .and_then(|a| usize::try_from(a).ok())
3351                    {
3352                        self.collect_free_spans(c, depth + 1, incoming, out);
3353                    }
3354                }
3355            }
3356            // A chunked dataset: reclaim its chunk index and chunk data blocks
3357            // alongside its header. `chunked_storage_spans` returns `None` for
3358            // anything it cannot account for exhaustively (an index type with no
3359            // walker, an undefined index address, or spans that fail the
3360            // bounds/overlap check), leaving the whole dataset as dead bytes
3361            // rather than freeing a region that might still be in use.
3362            Ok(ObjModel::DatasetChunked { .. }) => {
3363                if let Some(storage) = self.chunked_storage_spans(addr) {
3364                    out.extend(spans);
3365                    out.extend(storage);
3366                }
3367            }
3368            // A truly unsupported object (one `read_object` cannot model): leave
3369            // its bytes in place rather than guess its extent.
3370            Err(_) => {}
3371        }
3372    }
3373
3374    /// Best-effort enumeration of every on-disk block a *chunked* dataset at
3375    /// `addr` owns: its chunk index structure (B-tree v1 nodes, or fixed- /
3376    /// extensible-array header, index, super, and data blocks) plus every
3377    /// allocated chunk data block. The object-header chunks are freed by the
3378    /// caller ([`collect_free_spans`](Self::collect_free_spans)); this returns
3379    /// only the storage the data-layout message points at.
3380    ///
3381    /// Returns `None` — contribute nothing, leave the object as dead bytes —
3382    /// whenever the dataset cannot be enumerated *exhaustively* and safely: a
3383    /// header that does not parse or is not a chunked dataset, a chunk index
3384    /// with no walker (a version 2 B-tree, index type 5), an undefined index
3385    /// address (an empty, never-written dataset), or any resulting span that
3386    /// falls outside the file image or overlaps another. This upholds the
3387    /// editor's invariant that reclaimed space is never a region still in use:
3388    /// under-reclaiming only wastes space, while over-reclaiming would corrupt.
3389    ///
3390    /// Chunk data addresses and sizes come from the same index walkers the
3391    /// reader uses, so they match the bytes the writer laid down exactly. The
3392    /// per-layout enumeration lives in
3393    /// [`chunked_read::collect_chunked_storage_spans`](crate::chunked_read::collect_chunked_storage_spans);
3394    /// this method only locates the layout and dataspace messages and validates
3395    /// the result. Variable-length data in global-heap collections is still
3396    /// never reclaimed (a collection can be shared between objects); see the
3397    /// [module docs](self).
3398    fn chunked_storage_spans(&self, addr: usize) -> Option<Vec<(u64, u64)>> {
3399        // Locate the data-layout and dataspace messages in the object header.
3400        let region =
3401            Self::gather_oh_messages(&self.data, addr, self.superblock.base_address).ok()?;
3402        let mut layout_msg: Option<(usize, usize)> = None;
3403        let mut dataspace_msg: Option<(usize, usize)> = None;
3404        let mut p = 0;
3405        loop {
3406            match next_message(&region, p) {
3407                Ok(Some((msg_type, body, body_end))) => {
3408                    match msg_type {
3409                        MessageType::DataLayout => layout_msg = Some((body, body_end)),
3410                        MessageType::Dataspace => dataspace_msg = Some((body, body_end)),
3411                        _ => {}
3412                    }
3413                    p = body_end;
3414                }
3415                Ok(None) => break,
3416                Err(_) => return None,
3417            }
3418        }
3419        let (lb, le) = layout_msg?;
3420        let (db, de) = dataspace_msg?;
3421
3422        let layout = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
3423        if !matches!(layout, DataLayout::Chunked { .. }) {
3424            return None;
3425        }
3426        let dataspace = Dataspace::parse(&region[db..de], LENGTH_SIZE).ok()?;
3427
3428        // Delegate the per-index-type enumeration to the chunked reader (the
3429        // single owner of chunk-storage layout knowledge), then validate: every
3430        // span must lie inside the current file image and be pairwise disjoint,
3431        // or the free list would later hand out live bytes (and a debug build
3432        // would panic on the double-free). On any error or violation, leave the
3433        // whole dataset unreclaimed rather than free a region still in use.
3434        //
3435        // The layout's stored addresses are relative to the userblock base, so the
3436        // enumeration runs on a base-relative view of the file and each returned
3437        // span address is shifted back to an absolute file offset by adding `base`
3438        // (a no-op on a base-0 file). The free list and the bounds check below both
3439        // work in absolute file offsets.
3440        let base = self.superblock.base_address;
3441        let base_off = usize::try_from(base).ok()?;
3442        let mut spans = crate::chunked_read::collect_chunked_storage_spans(
3443            &self.data[base_off..],
3444            &layout,
3445            &dataspace,
3446            OFFSET_SIZE,
3447            LENGTH_SIZE,
3448        )
3449        .ok()?;
3450        for (addr, _) in &mut spans {
3451            *addr = addr.checked_add(base)?;
3452        }
3453        if !spans_disjoint_in_bounds(&mut spans, self.data.len() as u64) {
3454            return None;
3455        }
3456        Some(spans)
3457    }
3458}
3459
3460/// A dirty group in the edit plan: its base object-header message region and the
3461/// additions targeting it.
3462#[derive(Default)]
3463struct Node {
3464    is_new: bool,
3465    datasets: Vec<DatasetBuilder>,
3466    /// Compact group-attribute operations to apply to this group.
3467    attr_ops: Vec<GroupAttrOp>,
3468    /// Names of links to remove from this group (from `delete`).
3469    deletes: Vec<String>,
3470    /// Copies to add to this group: (new link name, the source subtree read out
3471    /// for writing). Built at staging time from either this file (an in-file
3472    /// [`copy`](EditSession::copy)) or another open file (a cross-file
3473    /// [`copy_from`](EditSession::copy_from)).
3474    copies: Vec<(String, CopyTree)>,
3475    /// Value overwrites whose dataset header relocates (a resize or compact
3476    /// rewrite by `write_dataset`), as (child link name, the relocation plan). On
3477    /// apply, the new data and header are written and this group's existing link
3478    /// to the moved header is patched to its new address — exactly like an
3479    /// existing child group's link.
3480    writes: Vec<(String, MovingWrite)>,
3481    base_region: Vec<u8>,
3482    existing_links: Vec<String>,
3483    /// Variable-length group/root attributes staged by [`apply_group_attr_ops`],
3484    /// each still carrying a placeholder heap address: (the attribute message,
3485    /// its global heap collection bytes). Resolved in the apply loop right
3486    /// before this node's header is built — [`EditSession::place_vl_collection`]
3487    /// appends the collection, then the patched message is appended to
3488    /// `base_region`.
3489    pending_vl_attrs: PendingVlAttrs,
3490}
3491
3492/// A staged compact attribute edit for a group.
3493enum GroupAttrOp {
3494    Set { name: String, value: AttrValue },
3495    Remove { name: String },
3496}
3497
3498/// A source object parsed for copying. Headers are reproduced from their
3499/// verbatim message bytes; only the contiguous data address and child link
3500/// targets are repointed to the freshly-written copies.
3501enum ObjModel {
3502    /// A compact dataset (data inline in the header): copy the region verbatim.
3503    /// `dense_attrs` is empty unless the source stored its attributes densely, in
3504    /// which case the Attribute Info message and inline Attribute messages have
3505    /// been stripped from `region` and the parsed set is carried here to be
3506    /// re-emitted into a fresh fractal heap on write.
3507    DatasetVerbatim {
3508        region: Vec<u8>,
3509        dense_attrs: Vec<crate::attribute::AttributeMessage>,
3510    },
3511    /// A contiguous dataset: copy the region, repointing the data address at
3512    /// `addr_off` (region-relative) to a fresh copy of `[data_addr, +data_size)`.
3513    /// See [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
3514    DatasetContiguous {
3515        region: Vec<u8>,
3516        addr_off: usize,
3517        data_addr: u64,
3518        data_size: u64,
3519        dense_attrs: Vec<crate::attribute::AttributeMessage>,
3520    },
3521    /// A chunked (and possibly filtered) dataset: the verbatim header `region`
3522    /// (datatype, dataspace, fill value, data layout, and filter pipeline kept as
3523    /// written). The chunk data is not captured here — [`read_copy_subtree`](EditSession::read_copy_subtree)
3524    /// enumerates and reads the chunks (it holds the source buffer), repointing the
3525    /// rebuilt index on write. See [`DatasetVerbatim`](ObjModel::DatasetVerbatim)
3526    /// for `dense_attrs`.
3527    DatasetChunked {
3528        region: Vec<u8>,
3529        dense_attrs: Vec<crate::attribute::AttributeMessage>,
3530    },
3531    /// A group: every non-link message verbatim, plus its hard-link children to
3532    /// copy and re-link by name. See
3533    /// [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
3534    Group {
3535        non_link_region: Vec<u8>,
3536        children: Vec<(String, u64)>,
3537        dense_attrs: Vec<crate::attribute::AttributeMessage>,
3538    },
3539}
3540
3541/// An object subtree fully read out of a source buffer and owning every byte it
3542/// will write, the read result of [`EditSession::read_copy_subtree`] and the
3543/// input to [`EditSession::write_copy_subtree`]. Unlike [`ObjModel`] (a single
3544/// object still referencing source addresses) it is recursive and self-contained:
3545/// a contiguous dataset owns its data bytes, and a group owns its children, so it
3546/// can be written into the destination without the source buffer still in hand —
3547/// which is what lets a cross-file copy read the source at staging time and apply
3548/// it at commit time.
3549enum CopyTree {
3550    /// A compact dataset: the header region is written verbatim (data is inline).
3551    /// `dense_attrs`, when non-empty, is re-emitted into a freshly built fractal
3552    /// heap appended just before the header, whose Attribute Info message is
3553    /// spliced into the region on write.
3554    DatasetVerbatim {
3555        region: Vec<u8>,
3556        dense_attrs: Vec<crate::attribute::AttributeMessage>,
3557    },
3558    /// A contiguous dataset: `data` is written first and its new address patched
3559    /// into the header `region` at `addr_off` before the header is written. See
3560    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
3561    DatasetContiguous {
3562        region: Vec<u8>,
3563        addr_off: usize,
3564        data: Vec<u8>,
3565        dense_attrs: Vec<crate::attribute::AttributeMessage>,
3566    },
3567    /// A chunked (and possibly filtered) dataset. The header `region` is written
3568    /// verbatim except its data-layout message, which is swapped for one naming the
3569    /// freshly rebuilt index; `chunk_bytes` (each chunk's already-compressed bytes,
3570    /// in dense row-major grid order, with sizes/masks in `meta`) and the source
3571    /// `pipeline_message` are carried unchanged, so the copy preserves the filter
3572    /// pipeline and chunk payloads byte-for-byte. The on-disk index *type* is
3573    /// reselected from `maxshape`/chunk count (single / fixed-array / extensible-
3574    /// array), so a B-tree-v1 or implicit source is reproduced with a v4 index. See
3575    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
3576    DatasetChunked {
3577        region: Vec<u8>,
3578        chunk_dims: Vec<u64>,
3579        element_size: usize,
3580        raw_size: u64,
3581        maxshape: Option<Vec<u64>>,
3582        pipeline_message: Option<Vec<u8>>,
3583        meta: Vec<ChunkMeta>,
3584        chunk_bytes: Vec<Vec<u8>>,
3585        dense_attrs: Vec<crate::attribute::AttributeMessage>,
3586    },
3587    /// A group: every non-link message verbatim, plus the (name, child) subtrees
3588    /// to write first and re-link by name. See
3589    /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
3590    Group {
3591        non_link_region: Vec<u8>,
3592        children: Vec<(String, CopyTree)>,
3593        dense_attrs: Vec<crate::attribute::AttributeMessage>,
3594    },
3595}
3596
3597/// The validated, chunk-collapsed message region and existing link names of a
3598/// group header.
3599struct GroupInfo {
3600    region: Vec<u8>,
3601    link_names: Vec<String>,
3602}
3603
3604/// How a staged value overwrite (`write_dataset`) will be applied, decided by
3605/// [`EditSession::prepare_write`] during the all-or-nothing preflight.
3606enum WritePlan {
3607    /// A contiguous dataset whose new data is the same length as its existing,
3608    /// defined data block: overwrite the bytes straight in place at `data_addr`.
3609    /// No object header is rewritten and the superblock root is not flipped.
3610    InPlace { data_addr: usize, raw: Vec<u8> },
3611    /// A chunked dataset overwritten chunk-by-chunk in place: each `(addr, bytes)`
3612    /// pair is written straight over an existing chunk slot. Used when every new
3613    /// (re-encoded) chunk is the same byte length as the slot it replaces — an
3614    /// unfiltered chunked overwrite (chunk sizes are fixed by the unchanged shape)
3615    /// or a filtered one whose re-encoded chunks happen to match. Like
3616    /// [`InPlace`](WritePlan::InPlace) it touches no header and no chunk index, so
3617    /// the superblock root is not flipped.
3618    InPlaceChunks { writes: Vec<(usize, Vec<u8>)> },
3619    /// The dataset's header relocates: a contiguous resize, a compact rewrite, or
3620    /// a chunked rebuild. The parent group is rebuilt and its link patched. See
3621    /// [`MovingWrite`].
3622    Moving(MovingWrite),
3623}
3624
3625/// A value overwrite that relocates the dataset's object header — a contiguous
3626/// dataset whose data length changed (or had no data block) or a compact dataset
3627/// whose inline bytes are replaced. On apply the new data and a rewritten header
3628/// are written at end-of-file (or into reusable freed space), and the parent
3629/// group's link is repointed at the new header address.
3630enum MovingWrite {
3631    /// A contiguous dataset: write `raw` elsewhere, patch the data-layout address
3632    /// at `addr_off` in the verbatim header `region`, rewrite the header, and free
3633    /// `old_extent` (the prior data block, if any) after the commit lands.
3634    Contiguous {
3635        region: Vec<u8>,
3636        addr_off: usize,
3637        raw: Vec<u8>,
3638        old_extent: Option<(u64, u64)>,
3639    },
3640    /// A compact dataset: rebuild the header `region` with `raw` inline.
3641    Compact { region: Vec<u8>, raw: Vec<u8> },
3642    /// A chunked dataset whose new (re-encoded) chunks do not all fit their
3643    /// existing slots, so its whole storage is rebuilt and relocated. A fresh
3644    /// chunk-data blob and index are appended at end-of-file (via the verbatim
3645    /// layout path, carrying `chunk_bytes` and the source filter `pipeline_message`
3646    /// unchanged — no recompression and no filter-parameter reconstruction), the
3647    /// data-layout message in the verbatim header `region` is swapped for the new
3648    /// one (every other header message — datatype, dataspace, fill value, filter
3649    /// pipeline, and attributes, including a dense attribute heap referenced by an
3650    /// untouched Attribute Info message — is preserved verbatim), and the old
3651    /// chunk storage at `old_addr` is freed after the commit lands.
3652    Chunked {
3653        region: Vec<u8>,
3654        chunk_dims: Vec<u64>,
3655        element_size: usize,
3656        raw_size: u64,
3657        maxshape: Option<Vec<u64>>,
3658        pipeline_message: Option<Vec<u8>>,
3659        meta: Vec<ChunkMeta>,
3660        chunk_bytes: Vec<Vec<u8>>,
3661        old_addr: u64,
3662    },
3663}
3664
3665/// A staged dataset reduced to the pieces the writer needs.
3666struct FlatDataset {
3667    name: String,
3668    dt: crate::datatype::Datatype,
3669    ds: Dataspace,
3670    raw: Vec<u8>,
3671    attrs: Vec<crate::attribute::AttributeMessage>,
3672    /// Chunked/filtered storage options. When [`ChunkOptions::is_chunked`] is
3673    /// false and `maxshape` is `None`, the dataset is written as contiguous,
3674    /// unfiltered storage; otherwise its chunk data and index are built by
3675    /// [`build_chunked_data_at_ext`] and appended at end-of-file.
3676    chunk_options: ChunkOptions,
3677    /// Maximum dimensions for an extensible dataset (an unlimited dimension is
3678    /// `u64::MAX`), mirrored into `ds.max_dimensions`. `None` for a fixed-shape
3679    /// dataset. A maxshape with an unlimited dimension selects the
3680    /// extensible-array chunk index; a finite maxshape stays fixed-array/single.
3681    maxshape: Option<Vec<u64>>,
3682    /// Variable-length attributes still carrying a placeholder heap address:
3683    /// (index into `attrs`, that attribute's global heap collection bytes).
3684    /// Resolved in the apply loop right before this dataset's header is built.
3685    vl_attrs: Vec<(usize, Vec<u8>)>,
3686    /// A staged variable-length-string dataset's element references (still
3687    /// carrying placeholder heap addresses in `raw`) and global heap collection.
3688    /// Resolved in the apply loop right before `raw` is appended.
3689    vl_string_staging: Option<VlStringStaging>,
3690    /// An object-reference dataset's per-element targets, still unresolved.
3691    /// Resolved (see [`EditSession::resolve_reference_target`]) and patched
3692    /// into `raw` in the apply loop, once every object this commit places has
3693    /// a known address. `None` for an ordinary dataset.
3694    reference_targets: Option<Vec<ObjectRefTarget>>,
3695}
3696
3697/// Split a path into non-empty components.
3698fn split_path(path: &str) -> PathKey {
3699    path.split('/')
3700        .filter(|s| !s.is_empty())
3701        .map(String::from)
3702        .collect()
3703}
3704
3705/// Ensure a node exists for every ancestor prefix of `path` (so each is rebuilt
3706/// and can re-wire its child link). Does not set `is_new`.
3707fn ensure_ancestors(nodes: &mut BTreeMap<PathKey, Node>, path: &[String]) {
3708    for len in 0..=path.len() {
3709        nodes.entry(path[..len].to_vec()).or_default();
3710    }
3711}
3712
3713/// Validate that every reclaim span `(addr, len)` is non-empty, ends at or
3714/// before `eof`, and that no two overlap; sorts `spans` by address as a side
3715/// effect. Returns `false` on any violation so the caller can decline to
3716/// reclaim the object rather than feed the free list an out-of-bounds or
3717/// overlapping (double-free) region. Touching spans are allowed — the free list
3718/// coalesces them.
3719fn spans_disjoint_in_bounds(spans: &mut [(u64, u64)], eof: u64) -> bool {
3720    for &(addr, len) in spans.iter() {
3721        match addr.checked_add(len) {
3722            Some(end) if len > 0 && end <= eof => {}
3723            _ => return false,
3724        }
3725    }
3726    spans.sort_unstable_by_key(|&(addr, _)| addr);
3727    spans.windows(2).all(|w| w[0].0 + w[0].1 <= w[1].0)
3728}
3729
3730/// Sanitize the accumulated free spans for a whole commit so the free list never
3731/// sees an out-of-bounds or overlapping (double-free) region: drop empty or
3732/// past-`eof` spans, sort by address, then drop any span overlapping one already
3733/// kept. Dropping only leaks (the bytes stay allocated); it never frees a live
3734/// region. With the last-hard-link guard in force nothing should be dropped for
3735/// a well-formed file — this is a backstop, not the primary defense.
3736fn retain_disjoint_in_bounds(spans: &mut Vec<(u64, u64)>, eof: u64) {
3737    spans.retain(|&(addr, len)| len > 0 && addr.checked_add(len).is_some_and(|e| e <= eof));
3738    spans.sort_unstable_by_key(|&(addr, _)| addr);
3739    let mut kept_end = 0u64;
3740    spans.retain(|&(addr, len)| {
3741        if addr >= kept_end {
3742            kept_end = addr + len;
3743            true
3744        } else {
3745            false // overlaps a span already kept; leak it rather than double-free
3746        }
3747    });
3748}
3749
3750/// Validate a staged dataset and reduce it to a [`FlatDataset`]. Contiguous,
3751/// unfiltered datasets are emitted as such; chunked, filtered, or extensible
3752/// datasets carry their [`ChunkOptions`] and maxshape through to the commit,
3753/// where [`build_chunked_data_at_ext`] lays out their chunk data and index. An
3754/// empty (zero-element) shape is allowed for contiguous storage (mirroring the
3755/// whole-file writer, its data address is `HADDR_UNDEF` — see the apply loop),
3756/// but chunking one stays refused via the geometry validation below. A
3757/// `provenance` dataset has its SHA-256/creator/timestamp/source attributes
3758/// computed here from `raw`, exactly as the whole-file writer does. A
3759/// variable-length attribute's global heap collection is built here (it is
3760/// fully self-contained — no address of its own) but placed and patched later,
3761/// in the apply loop, once its final address is known; likewise a
3762/// variable-length-string dataset's staged references and collection
3763/// (`db.vl_string_staging`) are carried through unresolved. An object-reference
3764/// dataset's per-element targets (`db.reference_targets`) are likewise carried
3765/// through unresolved — resolving a path target requires knowing every other
3766/// object this commit places, which is only known well into the apply loop
3767/// (see [`EditSession::resolve_reference_target`]). Rejects any remaining
3768/// feature this engine cannot reproduce faithfully: dense attributes, a
3769/// chunked/extensible variable-length-string or object-reference dataset, or a
3770/// filter pipeline the build cannot construct.
3771fn flatten_dataset(db: DatasetBuilder) -> Result<FlatDataset, Error> {
3772    if db.name.is_empty() {
3773        return Err(Error::EditUnsupported("dataset path has an empty name"));
3774    }
3775    let dt = db
3776        .datatype
3777        .ok_or(Error::EditUnsupported("dataset has no datatype/data"))?;
3778    let shape = db
3779        .shape
3780        .ok_or(Error::EditUnsupported("dataset has no shape"))?;
3781    let is_empty = shape.contains(&0);
3782    let chunked = db.chunk_options.is_chunked() || db.maxshape.is_some();
3783    if is_empty && chunked {
3784        return Err(Error::EditUnsupported(
3785            "chunked or extensible empty (zero-element) datasets cannot be added in place yet",
3786        ));
3787    }
3788    // Variable-length string element references live in the global heap, whose
3789    // address is only known once the apply loop places the collection. For
3790    // chunked/filtered/resizable storage the references sit inside chunks
3791    // written before that address exists, so patching them in is impossible —
3792    // mirrors the whole-file writer's `ChunkedVlenStringUnsupported` refusal.
3793    if db.vl_string_staging.is_some() && chunked {
3794        return Err(Error::EditUnsupported(
3795            "chunked or extensible variable-length-string datasets cannot be added in place yet",
3796        ));
3797    }
3798    // Object-reference elements are resolved (see `resolve_reference_target`)
3799    // and patched into `raw` right before it is appended; for chunked storage
3800    // that patch would need to reach inside already-built chunk data, which
3801    // this engine does not support (mirrors the variable-length-string
3802    // refusal above — untested and unneeded combination for v1).
3803    if db.reference_targets.is_some() && chunked {
3804        return Err(Error::EditUnsupported(
3805            "chunked or extensible object-reference datasets cannot be added in place yet",
3806        ));
3807    }
3808    let raw = if is_empty {
3809        db.data.unwrap_or_default()
3810    } else {
3811        db.data
3812            .ok_or(Error::EditUnsupported("dataset has no data"))?
3813    };
3814
3815    let elem = dt.type_size() as u64;
3816    if elem > 0 {
3817        // Multiply with checked arithmetic: an absurd shape whose element count
3818        // (or byte size) overflows `u64` is refused rather than panicking in a
3819        // debug build or silently wrapping in release (which could let a wrapped
3820        // product spuriously match `raw.len()`). For a zero-element shape this
3821        // expected length is always 0 (a `0` dimension makes every checked
3822        // multiplication `Some(0)` regardless of the other dimensions), so this
3823        // also catches data mistakenly supplied for a shape that holds nothing.
3824        let expected = shape
3825            .iter()
3826            .try_fold(1u64, |acc, &d| acc.checked_mul(d))
3827            .and_then(|n| n.checked_mul(elem));
3828        match expected {
3829            Some(expected) if raw.len() as u64 == expected => {}
3830            Some(_) => {
3831                return Err(Error::EditUnsupported(
3832                    "dataset data length does not match its shape",
3833                ));
3834            }
3835            None => {
3836                return Err(Error::EditUnsupported(
3837                    "dataset shape is too large to address on this platform",
3838                ));
3839            }
3840        }
3841    }
3842
3843    if chunked {
3844        // Refuse malformed chunk geometry up front (the same validation the
3845        // whole-file writer applies), so a bad request — chunk dimensions of the
3846        // wrong rank, a zero chunk dimension, an inconsistent maximum shape, or
3847        // chunking a scalar — never reaches and panics the chunk splitter, nor
3848        // yields a dataset the reader cannot decode.
3849        db.chunk_options
3850            .validate_geometry(&shape, db.maxshape.as_deref())
3851            .map_err(Error::EditUnsupported)?;
3852        // Deflate is compiled out unless the `deflate` feature is on, but
3853        // `build_pipeline` emits its descriptor regardless; catch a
3854        // disabled-feature request here so it is refused up front rather than
3855        // failing mid-apply when a chunk is compressed.
3856        #[cfg(not(feature = "deflate"))]
3857        if db.chunk_options.deflate_level.is_some() {
3858            return Err(Error::EditUnsupported(
3859                "deflate compression requires the `deflate` crate feature",
3860            ));
3861        }
3862        // Validate the requested filter pipeline now — before any file bytes are
3863        // written — so an unsupported filter, an incompatible datatype, or a
3864        // disabled compression feature is refused up front; the chunk data
3865        // itself is laid out in the commit's apply phase. Chunked/filtered
3866        // storage flows through the very builder the normal writer uses
3867        // ([`build_chunked_data_at_ext`] + [`build_chunked_dataset_oh`]), so the
3868        // resulting object header is byte-identical to a freshly written one.
3869        let chunk_dims = db.chunk_options.resolve_chunk_dims(&shape);
3870        let ctx = ChunkContext::from_datatype(&chunk_dims, &dt);
3871        db.chunk_options
3872            .build_pipeline(
3873                ctx.element_size,
3874                &chunk_dims,
3875                ctx.element_type,
3876                ctx.scale_offset_type,
3877            )
3878            .map_err(|_| {
3879                Error::EditUnsupported(
3880                    "this dataset's filter pipeline cannot be added in place \
3881                     (an unsupported filter, an incompatible datatype, or a \
3882                     compression feature that is not enabled)",
3883                )
3884            })?;
3885    }
3886
3887    // The link message body (whose length is independent of the address) must
3888    // fit the object-header message's u16 size field; a pathologically long
3889    // name would otherwise overflow it into silent corruption.
3890    if make_link(&db.name, 0).serialize(OFFSET_SIZE).len() > u16::MAX as usize {
3891        return Err(Error::EditUnsupported(
3892            "dataset name is too long to encode as a link message",
3893        ));
3894    }
3895
3896    let ds = Dataspace {
3897        space_type: if shape.is_empty() {
3898            DataspaceType::Scalar
3899        } else {
3900            DataspaceType::Simple
3901        },
3902        #[expect(
3903            clippy::cast_possible_truncation,
3904            reason = "dataspace rank fits the 1-byte dimensionality field (HDF5 caps rank at 32)"
3905        )]
3906        rank: shape.len() as u8,
3907        dimensions: shape,
3908        // A chunked, extensible dataset records its maximum dimensions (an
3909        // unlimited dimension is `u64::MAX`); a fixed-shape dataset has none.
3910        max_dimensions: db.maxshape.clone(),
3911    };
3912    let mut attrs: Vec<crate::attribute::AttributeMessage> = Vec::with_capacity(db.attrs.len());
3913    for (n, v) in &db.attrs {
3914        attrs.push(build_attr_message(n, v));
3915    }
3916    // `build_attr_message` already writes a placeholder (heap address 0) for a
3917    // `VarLenAsciiArray` attribute; stage its self-contained global heap
3918    // collection here (no address of its own to resolve yet) and record which
3919    // `attrs` slot it patches once the apply loop places it.
3920    let vl_attrs: Vec<(usize, Vec<u8>)> = db
3921        .attrs
3922        .iter()
3923        .enumerate()
3924        .filter_map(|(i, (_, v))| match v {
3925            AttrValue::VarLenAsciiArray(strings) => {
3926                let str_refs: Vec<&str> = strings.iter().map(String::as_str).collect();
3927                Some((i, build_global_heap_collection(&str_refs)))
3928            }
3929            _ => None,
3930        })
3931        .collect();
3932    #[cfg(feature = "provenance")]
3933    if let Some(ref prov) = db.provenance {
3934        let p = crate::provenance::Provenance {
3935            creator: prov.creator.clone(),
3936            timestamp: prov.timestamp.clone(),
3937            source: prov.source.clone(),
3938        };
3939        attrs.extend(p.build_attrs(&raw));
3940    }
3941    // The object-header message-size field is 2 bytes wide, so an oversized
3942    // attribute (most reachable via a `VarLenAsciiArray` with many/long
3943    // strings) would silently truncate and corrupt the header if written
3944    // as-is; refuse it instead, mirroring `apply_group_attr_ops`'s and
3945    // `encode_attr_message`'s equivalent checks for group/root attributes.
3946    for a in &attrs {
3947        if a.serialize(LENGTH_SIZE).len() > u16::MAX as usize {
3948            return Err(Error::EditUnsupported(
3949                "dataset attribute is too large to encode in place",
3950            ));
3951        }
3952    }
3953    if attrs.len() > MAX_COMPACT_ATTRS {
3954        return Err(Error::EditUnsupported(
3955            "datasets with dense (many) attributes cannot be added in place yet",
3956        ));
3957    }
3958
3959    Ok(FlatDataset {
3960        name: db.name,
3961        dt,
3962        ds,
3963        raw,
3964        attrs,
3965        chunk_options: db.chunk_options,
3966        maxshape: db.maxshape,
3967        vl_attrs,
3968        vl_string_staging: db.vl_string_staging,
3969        reference_targets: db.reference_targets,
3970    })
3971}
3972
3973/// A minimal Group Info message body (type 0x000A): version 0 with neither the
3974/// link-phase-change nor the estimated-entry fields stored. With both absent the
3975/// HDF5 C library fills `max_compact`/`min_dense` from its own defaults (8 and
3976/// 6). See [`ensure_group_info`] for why every group needs this message.
3977const GROUP_INFO_BODY: [u8; 2] = [0, 0];
3978
3979/// Frame one chunk-0 object-header message record: a 1-byte type, a 2-byte
3980/// little-endian body length, a 1-byte flags field (always 0 here), then the
3981/// body. This is the v2 message-record layout used throughout a group's chunk-0
3982/// message region. Callers pass bodies that fit the u16 length field: link
3983/// bodies are validated in [`flatten_dataset`], and the Link Info / Group Info
3984/// bodies are fixed and short.
3985/// Whether a chunked dataset with this data-layout version and chunk index type
3986/// can be enumerated chunk-by-chunk (and therefore overwritten or copied in
3987/// place). Mirrors the dispatch in
3988/// [`chunked_read::collect_chunks_for_layout_from_source`](crate::chunked_read):
3989/// version-3 B-tree v1 and the version-4 single / implicit / fixed-array /
3990/// extensible-array indexes have walkers; a version-2 B-tree (index type 5) or
3991/// any unknown index type does not.
3992fn chunk_index_enumerable(version: u8, chunk_index_type: Option<u8>) -> bool {
3993    matches!((version, chunk_index_type), (3, _) | (4, Some(1..=4)))
3994}
3995
3996/// Whether every filter in `pipeline` is one this crate can *apply* (re-encode a
3997/// chunk through) — not merely decode. A pipeline with any other filter cannot be
3998/// re-encoded for an in-place overwrite, so the caller refuses with a typed error
3999/// rather than letting [`compress_chunk`] surface a raw `UnsupportedFilter`.
4000fn pipeline_reencodable(pipeline: &FilterPipeline) -> bool {
4001    pipeline.filters.iter().all(|f| match f.filter_id {
4002        FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_SCALEOFFSET => true,
4003        #[cfg(feature = "zfp")]
4004        crate::filter_pipeline::FILTER_ZFP => true,
4005        _ => false,
4006    })
4007}
4008
4009/// Rebuild a header message `region`, replacing the single Data Layout message's
4010/// record with one carrying `new_layout_body` and leaving every other message
4011/// (datatype, dataspace, fill value, filter pipeline, attributes, attribute info)
4012/// byte-for-byte. The replacement may differ in length from the original — a
4013/// chunked rebuild can change the index type and thus the layout message size — so
4014/// the record is rebuilt via [`region_message`] rather than patched in place. The
4015/// chunked overwrite and copy paths use this to relocate a dataset's chunk storage
4016/// while preserving the rest of its header exactly.
4017fn replace_layout_message(region: &[u8], new_layout_body: &[u8]) -> Result<Vec<u8>, Error> {
4018    let mut out = Vec::with_capacity(region.len());
4019    let mut p = 0;
4020    let mut replaced = false;
4021    while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
4022        if msg_type == MessageType::DataLayout && !replaced {
4023            out.extend_from_slice(&region_message(MessageType::DataLayout, new_layout_body));
4024            replaced = true;
4025        } else {
4026            out.extend_from_slice(&region[p..body_end]);
4027        }
4028        p = body_end;
4029    }
4030    if !replaced {
4031        return Err(Error::EditUnsupported(
4032            "chunked dataset header has no data-layout message to relocate",
4033        ));
4034    }
4035    Ok(out)
4036}
4037
4038/// The datatype, dataspace, parsed chunked data layout, and verbatim filter-
4039/// pipeline message bytes (if any) of a chunked dataset header, parsed by
4040/// [`parse_chunked_header`].
4041struct ChunkedHeaderParts {
4042    dt: crate::datatype::Datatype,
4043    ds: Dataspace,
4044    layout: DataLayout,
4045    pipeline_message: Option<Vec<u8>>,
4046}
4047
4048/// Parse the datatype, dataspace, chunked data layout, and verbatim filter-
4049/// pipeline message bytes (if any) from a chunked dataset header `region`. Used by
4050/// the chunked copy path to derive chunk geometry and the on-disk filter pipeline.
4051/// Errors if any required message is missing or the layout is not chunked.
4052fn parse_chunked_header(region: &[u8]) -> Result<ChunkedHeaderParts, Error> {
4053    let mut datatype: Option<(usize, usize)> = None;
4054    let mut dataspace: Option<(usize, usize)> = None;
4055    let mut layout: Option<(usize, usize)> = None;
4056    let mut pipeline: Option<(usize, usize)> = None;
4057    let mut p = 0;
4058    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
4059        match msg_type {
4060            MessageType::Datatype => datatype = Some((body, body_end)),
4061            MessageType::Dataspace => dataspace = Some((body, body_end)),
4062            MessageType::DataLayout => layout = Some((body, body_end)),
4063            MessageType::FilterPipeline => pipeline = Some((body, body_end)),
4064            _ => {}
4065        }
4066        p = body_end;
4067    }
4068    let (dt_b, dt_e) = datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
4069    let (ds_b, ds_e) =
4070        dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
4071    let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
4072    let (dt, _) = crate::datatype::Datatype::parse(&region[dt_b..dt_e])
4073        .map_err(|_| Error::EditUnsupported("dataset header datatype could not be parsed"))?;
4074    let ds = Dataspace::parse(&region[ds_b..ds_e], LENGTH_SIZE)
4075        .map_err(|_| Error::EditUnsupported("dataset header dataspace could not be parsed"))?;
4076    let dl = DataLayout::parse(&region[lb..le], OFFSET_SIZE, LENGTH_SIZE)
4077        .map_err(|_| Error::EditUnsupported("dataset header data layout could not be parsed"))?;
4078    if !matches!(dl, DataLayout::Chunked { .. }) {
4079        return Err(Error::EditUnsupported("dataset is not chunked"));
4080    }
4081    let pipeline_message = pipeline.map(|(b, e)| region[b..e].to_vec());
4082    Ok(ChunkedHeaderParts {
4083        dt,
4084        ds,
4085        layout: dl,
4086        pipeline_message,
4087    })
4088}
4089
4090/// The chunk geometry a verbatim chunked rebuild needs, derived by
4091/// [`chunked_geometry`] from a chunked dataset's datatype, dataspace, and parsed
4092/// [`DataLayout::Chunked`].
4093struct ChunkedGeometry {
4094    /// Rank-only spatial chunk dimensions.
4095    spatial: Vec<u64>,
4096    /// Element size in bytes.
4097    element_size: usize,
4098    /// Full (uncompressed) chunk byte size, `product(spatial) * element_size`.
4099    raw_size: u64,
4100    /// The on-disk maximum dimensions when they differ from the current shape; an
4101    /// unlimited dimension selects the extensible-array index, a finite one the
4102    /// fixed-array index. `None` keeps the fixed-array / single-chunk index.
4103    maxshape: Option<Vec<u64>>,
4104}
4105
4106/// Derive the [`ChunkedGeometry`] for a chunked dataset from its datatype,
4107/// dataspace, and parsed [`DataLayout::Chunked`].
4108fn chunked_geometry(
4109    dt: &crate::datatype::Datatype,
4110    ds: &Dataspace,
4111    layout: &DataLayout,
4112) -> Result<ChunkedGeometry, Error> {
4113    let DataLayout::Chunked {
4114        chunk_dimensions, ..
4115    } = layout
4116    else {
4117        return Err(Error::EditUnsupported("dataset is not chunked"));
4118    };
4119    let rank = ds.dimensions.len();
4120    if chunk_dimensions.len() <= rank {
4121        return Err(Error::EditUnsupported(
4122            "chunked layout has malformed dimensions",
4123        ));
4124    }
4125    let spatial: Vec<u64> = chunk_dimensions[..rank]
4126        .iter()
4127        .map(|&c| u64::from(c))
4128        .collect();
4129    let element_size = dt.type_size() as usize;
4130    if element_size == 0 {
4131        return Err(Error::EditUnsupported(
4132            "chunked dataset has a zero element size",
4133        ));
4134    }
4135    let raw_size = spatial
4136        .iter()
4137        .copied()
4138        .product::<u64>()
4139        .saturating_mul(element_size as u64);
4140    let maxshape = ds
4141        .max_dimensions
4142        .as_ref()
4143        .filter(|ms| *ms != &ds.dimensions)
4144        .cloned();
4145    Ok(ChunkedGeometry {
4146        spatial,
4147        element_size,
4148        raw_size,
4149        maxshape,
4150    })
4151}
4152
4153/// Try to overwrite a chunked dataset's chunks in place. When the dataset's
4154/// on-disk chunks form a dense grid aligned with `new_bytes` (dense row-major
4155/// order), every slot is unmasked (`filter_mask == 0`), and every new chunk
4156/// **fits** the slot it replaces (`new_len <= slot`), return the in-place
4157/// `(address, bytes)` writes:
4158///
4159/// - When every new chunk is **exactly** its slot's size, only the chunk data is
4160///   written; the index is untouched (so any enumerable index type works, and a
4161///   crash can tear at most a chunk's value bytes, not the structure).
4162/// - When some new chunks are **smaller** (fit with slack), the chunk index
4163///   records each chunk's stored size, so the index is rebuilt in place to record
4164///   the new sizes (see [`try_rebuild_index_in_place`]). This is supported only
4165///   for a v4 fixed-array or extensible-array index occupying a single contiguous
4166///   on-disk region; any other case returns `None` to relocate.
4167///
4168/// Returns `None` — so the caller relocates the dataset instead — when the index
4169/// cannot be enumerated, the grid is sparse, a slot is masked, a new chunk does
4170/// not fit, the index cannot be rebuilt in place, or any write would be out of
4171/// bounds or overlap another.
4172fn try_inplace_chunk_writes(
4173    d: &[u8],
4174    layout: &DataLayout,
4175    ds: &Dataspace,
4176    spatial: &[u64],
4177    raw_size: u64,
4178    new_bytes: &[Vec<u8>],
4179) -> Option<Vec<(usize, Vec<u8>)>> {
4180    let infos = enumerate_chunks_buffered(d, layout, ds, OFFSET_SIZE, LENGTH_SIZE).ok()?;
4181    let grid = plan_dense_grid(infos, &ds.dimensions, spatial)?;
4182    if grid.grid_order.len() != new_bytes.len() {
4183        return None;
4184    }
4185    let mut writes = Vec::with_capacity(new_bytes.len() + 1);
4186    let mut spans: Vec<(u64, u64)> = Vec::with_capacity(new_bytes.len() + 1);
4187    let mut any_shrunk = false;
4188    for (ci, bytes) in grid.grid_order.iter().zip(new_bytes.iter()) {
4189        // A nonzero filter mask means the source left some filter unapplied for
4190        // this chunk; re-encoding always applies every filter (mask 0), so an
4191        // in-place overwrite would desync the index-recorded mask. Relocate.
4192        if ci.filter_mask != 0 {
4193            return None;
4194        }
4195        let new_len = bytes.len() as u64;
4196        let slot = u64::from(ci.chunk_size);
4197        // A chunk that no longer fits its slot must relocate.
4198        if new_len > slot {
4199            return None;
4200        }
4201        if new_len < slot {
4202            any_shrunk = true;
4203        }
4204        let start = usize::try_from(ci.address).ok()?;
4205        start.checked_add(bytes.len()).filter(|&e| e <= d.len())?;
4206        writes.push((start, bytes.clone()));
4207        spans.push((ci.address, new_len));
4208    }
4209
4210    // A shrinking overwrite changes the index-recorded chunk sizes, so the index
4211    // must be rebuilt in place to match; an equal-size one leaves it untouched.
4212    if any_shrunk {
4213        let (index_addr, index_bytes) =
4214            try_rebuild_index_in_place(d, layout, raw_size, &grid.grid_order, new_bytes)?;
4215        spans.push((index_addr as u64, index_bytes.len() as u64));
4216        writes.push((index_addr, index_bytes));
4217    }
4218
4219    // Refuse to perform overlapping in-place writes (a malformed source index, or
4220    // an index region that overlaps a chunk slot); relocate instead so two writes
4221    // never clobber each other.
4222    if !spans_disjoint_in_bounds(&mut spans, d.len() as u64) {
4223        return None;
4224    }
4225    Some(writes)
4226}
4227
4228/// Rebuild a chunked dataset's index **in place** so it records the new
4229/// (smaller) per-chunk stored sizes after a fits-with-slack overwrite, returning
4230/// the `(address, bytes)` write that replaces it. The chunks keep their existing
4231/// addresses (only their stored bytes shrank), so the rebuilt index points at the
4232/// same slots with the new sizes.
4233///
4234/// Supported only for a v4 **fixed-array** or **extensible-array** index whose
4235/// on-disk structure is a single contiguous region starting at the index address
4236/// — the layout this crate's own writer produces. The element width derives from
4237/// the unchanged raw chunk size, so the rebuilt structure is byte-for-byte the
4238/// same length as the original; this is required to match exactly, which rejects a
4239/// scattered or differently-laid-out (e.g. C-written) index, leaving the caller
4240/// to relocate. Single-chunk (size in the layout message) and B-tree-v1 (no
4241/// writer) indexes are not rebuilt here.
4242///
4243/// Like any in-place value overwrite (the HDF5 `H5Dwrite` model) this is not
4244/// atomic: a crash mid-write can tear the index and leave the dataset needing a
4245/// rewrite. It is used only on the in-place path, whose linearization point is the
4246/// synced data write.
4247fn try_rebuild_index_in_place(
4248    d: &[u8],
4249    layout: &DataLayout,
4250    raw_size: u64,
4251    grid_order: &[crate::chunked_read::ChunkInfo],
4252    new_bytes: &[Vec<u8>],
4253) -> Option<(usize, Vec<u8>)> {
4254    let DataLayout::Chunked {
4255        btree_address: Some(index_addr),
4256        chunk_index_type,
4257        version,
4258        ..
4259    } = layout
4260    else {
4261        return None;
4262    };
4263    let written: Vec<crate::chunked_write::WrittenChunk> = grid_order
4264        .iter()
4265        .zip(new_bytes)
4266        .map(|(ci, b)| crate::chunked_write::WrittenChunk {
4267            address: ci.address,
4268            compressed_size: b.len() as u64,
4269            raw_size,
4270            filter_mask: 0,
4271        })
4272        .collect();
4273    let new_index = match (version, chunk_index_type) {
4274        (4, Some(3)) => crate::chunked_write::build_fixed_array_at(
4275            &written,
4276            OFFSET_SIZE,
4277            LENGTH_SIZE,
4278            true,
4279            *index_addr,
4280        ),
4281        (4, Some(4)) => crate::chunked_write::build_extensible_array_at(
4282            &written,
4283            OFFSET_SIZE,
4284            LENGTH_SIZE,
4285            true,
4286            *index_addr,
4287        )
4288        .ok()?,
4289        // Single-chunk records its size in the layout message (a header rewrite),
4290        // and a B-tree-v1 index has no writer; both relocate instead.
4291        _ => return None,
4292    };
4293
4294    // The on-disk index must be a single contiguous region starting at the index
4295    // address, and the rebuilt structure must be exactly the same length (true for
4296    // an index this crate wrote). A scattered or different on-disk layout fails
4297    // the check and the caller relocates.
4298    let mut spans =
4299        crate::chunked_read::chunk_index_spans_buffered(d, layout, OFFSET_SIZE, LENGTH_SIZE)
4300            .ok()?;
4301    if spans.is_empty() {
4302        return None;
4303    }
4304    spans.sort_unstable_by_key(|&(a, _)| a);
4305    if spans[0].0 != *index_addr {
4306        return None;
4307    }
4308    let mut end = *index_addr;
4309    for &(a, l) in &spans {
4310        if a != end {
4311            return None; // a gap means the index is not contiguous
4312        }
4313        end = a.checked_add(l)?;
4314    }
4315    if new_index.len() as u64 != end - *index_addr {
4316        return None;
4317    }
4318    let start = usize::try_from(*index_addr).ok()?;
4319    start
4320        .checked_add(new_index.len())
4321        .filter(|&e| e <= d.len())?;
4322    Some((start, new_index))
4323}
4324
4325/// A [`ChunkProvider`] over chunk bytes already held in memory, in dense
4326/// row-major grid order. Used by the editor's chunked copy and relocating
4327/// overwrite, which own each chunk's bytes (a [`CopyTree`] or [`MovingWrite`]
4328/// captured them) rather than streaming from a source file like repack.
4329struct SliceChunkProvider<'a> {
4330    chunks: &'a [Vec<u8>],
4331}
4332
4333impl ChunkProvider for SliceChunkProvider<'_> {
4334    fn chunk_bytes(&self, index: usize) -> Result<Vec<u8>, FormatError> {
4335        self.chunks.get(index).cloned().ok_or_else(|| {
4336            FormatError::ChunkedReadError("chunk index out of range for in-memory provider".into())
4337        })
4338    }
4339}
4340
4341fn region_message(msg_type: MessageType, body: &[u8]) -> Vec<u8> {
4342    let mut m = Vec::with_capacity(4 + body.len());
4343    #[expect(
4344        clippy::cast_possible_truncation,
4345        reason = "message type ids are a small enum that fits the 1-byte v2 type field"
4346    )]
4347    m.push(msg_type.to_u16() as u8);
4348    #[expect(
4349        clippy::cast_possible_truncation,
4350        reason = "callers pass bodies that fit the 2-byte message-size field (see doc comment)"
4351    )]
4352    m.extend_from_slice(&(body.len() as u16).to_le_bytes());
4353    m.push(0); // message flags
4354    m.extend_from_slice(body);
4355    m
4356}
4357
4358/// The chunk-0 message region of a fresh, empty compact-link group: a LinkInfo
4359/// message advertising no dense storage, followed by a GroupInfo message.
4360/// Mirrors `build_group_oh`.
4361fn fresh_group_region() -> Vec<u8> {
4362    let mut li = Vec::with_capacity(18);
4363    li.push(0); // version
4364    li.push(0); // flags
4365    li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap addr = UNDEF
4366    li.extend_from_slice(&u64::MAX.to_le_bytes()); // btree name index addr = UNDEF
4367    let mut region = region_message(MessageType::LinkInfo, &li);
4368    region.extend_from_slice(&region_message(MessageType::GroupInfo, &GROUP_INFO_BODY));
4369    region
4370}
4371
4372/// Ensure a group's chunk-0 message `region` carries a Group Info message,
4373/// appending a minimal one when absent.
4374///
4375/// The HDF5 C library refuses to insert a link into a group whose object header
4376/// has a Link Info message but no Group Info message: on the new-format path
4377/// `H5G_obj_insert` reads the Group Info message unconditionally and fails with
4378/// "message type not found". Such a group round-trips for *reading* but cannot
4379/// be *modified* by the C library. Earlier hdf5-pure releases wrote groups that
4380/// way, so heal any such header whenever we rewrite one in place.
4381fn ensure_group_info(region: &mut Vec<u8>) -> Result<(), Error> {
4382    let mut p = 0;
4383    while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
4384        if msg_type == MessageType::GroupInfo {
4385            return Ok(());
4386        }
4387        p = body_end;
4388    }
4389    region.extend_from_slice(&region_message(MessageType::GroupInfo, &GROUP_INFO_BODY));
4390    Ok(())
4391}
4392
4393/// Encode a complete object-header Link message (4-byte record header + body)
4394/// for a hard link `name -> addr`. The caller must have validated that the body
4395/// fits the u16 size field (see [`flatten_dataset`]); group names are short.
4396fn encode_link_message(name: &str, addr: u64) -> Vec<u8> {
4397    let body = make_link(name, addr).serialize(OFFSET_SIZE);
4398    region_message(MessageType::Link, &body)
4399}
4400
4401/// Patch an existing hard Link message in a chunk-0 message `region`, retargeting
4402/// the link named `name` to `new_addr` (used to repoint a parent at a relocated
4403/// child group). The target address is the trailing `OFFSET_SIZE` bytes of the
4404/// link body for a hard link.
4405fn patch_link_target(region: &mut [u8], name: &str, new_addr: u64) -> Result<(), Error> {
4406    let mut p = 0;
4407    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
4408        if msg_type == MessageType::Link {
4409            if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
4410                if link.name == name {
4411                    return match link.link_target {
4412                        LinkTarget::Hard { .. } => {
4413                            let ofs = body_end - OFFSET_SIZE as usize;
4414                            region[ofs..body_end].copy_from_slice(&new_addr.to_le_bytes());
4415                            Ok(())
4416                        }
4417                        _ => Err(Error::EditUnsupported(
4418                            "a group on the edited path is reached by a soft/external link",
4419                        )),
4420                    };
4421                }
4422            }
4423        }
4424        p = body_end;
4425    }
4426    Err(Error::EditUnsupported(
4427        "expected child link not found in parent group",
4428    ))
4429}
4430
4431/// Copy a chunk-0 message `region`, replacing the single (compact) Data Layout
4432/// message's inline data with `raw` and preserving every other message verbatim.
4433/// Used by `write_dataset` to overwrite a compact dataset's values. The message
4434/// header (type and flags) and version byte are kept; only the inline data — and
4435/// the message size and 2-byte inline-size fields — change. `raw` must fit the
4436/// compact layout's 2-byte size field (HDF5's 64 KiB compact-storage limit),
4437/// which an overwrite of an existing compact dataset always satisfies.
4438fn rebuild_compact_layout_region(region: &[u8], raw: &[u8]) -> Result<Vec<u8>, Error> {
4439    if raw.len() > u16::MAX as usize {
4440        return Err(Error::EditUnsupported(
4441            "compact dataset data is too large to overwrite in place",
4442        ));
4443    }
4444    let mut out = Vec::with_capacity(region.len() + raw.len());
4445    let mut p = 0;
4446    let mut replaced = false;
4447    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
4448        if msg_type == MessageType::DataLayout {
4449            if body_end - body < 2 || region[body + 1] != 0 {
4450                return Err(Error::EditUnsupported(
4451                    "compact-layout overwrite found a non-compact data layout",
4452                ));
4453            }
4454            // New compact layout body: version (kept), class=0, 2-byte inline
4455            // size, then the data.
4456            let mut layout = Vec::with_capacity(4 + raw.len());
4457            layout.push(region[body]); // version (3 or 4)
4458            layout.push(0); // class = compact
4459            #[expect(
4460                clippy::cast_possible_truncation,
4461                reason = "raw.len() bounded to u16::MAX above"
4462            )]
4463            layout.extend_from_slice(&(raw.len() as u16).to_le_bytes());
4464            layout.extend_from_slice(raw);
4465            // Message record: type byte, 2-byte size (LE), flags byte (kept).
4466            out.push(region[p]);
4467            #[expect(
4468                clippy::cast_possible_truncation,
4469                reason = "layout body length is 4 + raw.len() <= u16::MAX + 4, and an OH \
4470                          message size that overflows u16 is itself malformed"
4471            )]
4472            out.extend_from_slice(&(layout.len() as u16).to_le_bytes());
4473            out.push(region[p + 3]);
4474            out.extend_from_slice(&layout);
4475            replaced = true;
4476        } else {
4477            out.extend_from_slice(&region[p..body_end]);
4478        }
4479        p = body_end;
4480    }
4481    if p < region.len() {
4482        out.extend_from_slice(&region[p..]);
4483    }
4484    if !replaced {
4485        return Err(Error::EditUnsupported(
4486            "compact dataset header has no data-layout message",
4487        ));
4488    }
4489    Ok(out)
4490}
4491
4492/// Copy a chunk-0 message `region`, dropping the single Link message named
4493/// `name` and preserving every other message verbatim (used by `delete`). Errors
4494/// if no such link is present.
4495fn remove_link_from_region(region: &[u8], name: &str) -> Result<Vec<u8>, Error> {
4496    let mut out = Vec::with_capacity(region.len());
4497    let mut p = 0;
4498    let mut removed = false;
4499    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
4500        let mut skip = false;
4501        if msg_type == MessageType::Link {
4502            if let Ok(link) = LinkMessage::parse(&region[body..body_end], OFFSET_SIZE) {
4503                if link.name == name {
4504                    skip = true;
4505                    removed = true;
4506                }
4507            }
4508        }
4509        if !skip {
4510            out.extend_from_slice(&region[p..body_end]);
4511        }
4512        p = body_end;
4513    }
4514    if p < region.len() {
4515        out.extend_from_slice(&region[p..]);
4516    }
4517    if !removed {
4518        return Err(Error::EditUnsupported(
4519            "link to delete not found in its parent group",
4520        ));
4521    }
4522    Ok(out)
4523}
4524
4525/// Apply compact attribute edits to a group message `region`, preserving every
4526/// non-attribute message verbatim. A fixed-size `Set`/`Remove` is resolved
4527/// into `region` directly; a variable-length `Set` (`VarLenAsciiArray`) is
4528/// instead collected into the returned `pending_vl_attrs` — its placeholder
4529/// heap address is only patched, and the message appended to the group's
4530/// header, by the apply loop once its global heap collection's real address
4531/// is known (see [`EditSession::place_vl_collection`]). A later op for the
4532/// same name (another `Set`, fixed-size or not, or a `Remove`) replaces or
4533/// cancels an earlier still-pending variable-length entry, keeping the net
4534/// effect the same regardless of op order within one commit. `region`'s
4535/// fixed-size portion is a complete compact-attribute header on return; dense
4536/// attribute storage and shared attribute messages are refused.
4537fn apply_group_attr_ops(
4538    region: &[u8],
4539    ops: &[GroupAttrOp],
4540) -> Result<(Vec<u8>, PendingVlAttrs), Error> {
4541    let mut out = region.to_vec();
4542    let mut pending_vl: PendingVlAttrs = Vec::new();
4543    let mut wrote_attr = false;
4544    for op in ops {
4545        match op {
4546            GroupAttrOp::Set { name, value } => {
4547                wrote_attr = true;
4548                pending_vl.retain(|(msg, _)| &msg.name != name);
4549                if let AttrValue::VarLenAsciiArray(strings) = value {
4550                    // Nothing yet to remove from `region` if this name has
4551                    // never been set as a fixed-size attribute.
4552                    out = remove_attr_from_region(&out, name, false)?;
4553                    let msg = build_attr_message(name, value);
4554                    if msg.serialize(LENGTH_SIZE).len() > u16::MAX as usize {
4555                        return Err(Error::EditUnsupported(
4556                            "group attribute is too large to encode in place",
4557                        ));
4558                    }
4559                    let str_refs: Vec<&str> = strings.iter().map(String::as_str).collect();
4560                    pending_vl.push((msg, build_global_heap_collection(&str_refs)));
4561                } else {
4562                    out = set_attr_in_region(&out, name, value)?;
4563                }
4564            }
4565            GroupAttrOp::Remove { name } => {
4566                let before = pending_vl.len();
4567                pending_vl.retain(|(msg, _)| &msg.name != name);
4568                if pending_vl.len() == before {
4569                    out = remove_attr_from_region(&out, name, true)?;
4570                }
4571            }
4572        }
4573    }
4574    if wrote_attr && compact_attr_count(&out)? + pending_vl.len() > MAX_COMPACT_ATTRS {
4575        return Err(Error::EditUnsupported(
4576            "group attributes would exceed compact storage; dense attribute edits are not supported in place yet",
4577        ));
4578    }
4579    Ok((out, pending_vl))
4580}
4581
4582/// Copy a message region, dropping all Attribute messages named `name` and then
4583/// appending a fresh compact Attribute message for `value`.
4584fn set_attr_in_region(region: &[u8], name: &str, value: &AttrValue) -> Result<Vec<u8>, Error> {
4585    let new_msg = encode_attr_message(name, value)?;
4586    let mut out = Vec::with_capacity(region.len() + new_msg.len());
4587    let mut p = 0;
4588    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
4589        match msg_type {
4590            MessageType::AttributeInfo => {
4591                return Err(Error::EditUnsupported(
4592                    "a target group uses dense (fractal-heap) attribute storage (not supported in place yet)",
4593                ));
4594            }
4595            MessageType::Attribute => {
4596                let attr_name = parse_compact_attr_name(region, p, body, body_end)?;
4597                if attr_name == name {
4598                    p = body_end;
4599                    continue;
4600                }
4601            }
4602            _ => {}
4603        }
4604        out.extend_from_slice(&region[p..body_end]);
4605        p = body_end;
4606    }
4607    out.extend_from_slice(&new_msg);
4608    if p < region.len() {
4609        out.extend_from_slice(&region[p..]);
4610    }
4611    Ok(out)
4612}
4613
4614/// Copy a message region, dropping all Attribute messages named `name`. When
4615/// `required` is true, an absent `name` is an [`Error::EditUnsupported`] (a
4616/// `Remove` of a nonexistent attribute); when false, it is not an error (a
4617/// `Set` of a fresh variable-length attribute may have no fixed-size message
4618/// to remove from the region yet).
4619fn remove_attr_from_region(region: &[u8], name: &str, required: bool) -> Result<Vec<u8>, Error> {
4620    let mut out = Vec::with_capacity(region.len());
4621    let mut p = 0;
4622    let mut removed = false;
4623    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
4624        let mut skip = false;
4625        match msg_type {
4626            MessageType::AttributeInfo => {
4627                return Err(Error::EditUnsupported(
4628                    "a target group uses dense (fractal-heap) attribute storage (not supported in place yet)",
4629                ));
4630            }
4631            MessageType::Attribute => {
4632                let attr_name = parse_compact_attr_name(region, p, body, body_end)?;
4633                if attr_name == name {
4634                    skip = true;
4635                    removed = true;
4636                }
4637            }
4638            _ => {}
4639        }
4640        if !skip {
4641            out.extend_from_slice(&region[p..body_end]);
4642        }
4643        p = body_end;
4644    }
4645    if p < region.len() {
4646        out.extend_from_slice(&region[p..]);
4647    }
4648    if !removed && required {
4649        return Err(Error::EditUnsupported(
4650            "group attribute to remove was not found",
4651        ));
4652    }
4653    Ok(out)
4654}
4655
4656fn compact_attr_count(region: &[u8]) -> Result<usize, Error> {
4657    let mut count = 0usize;
4658    let mut p = 0;
4659    while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
4660        if msg_type == MessageType::AttributeInfo {
4661            return Err(Error::EditUnsupported(
4662                "a target group uses dense (fractal-heap) attribute storage (not supported in place yet)",
4663            ));
4664        }
4665        if msg_type == MessageType::Attribute {
4666            count += 1;
4667        }
4668        p = body_end;
4669    }
4670    Ok(count)
4671}
4672
4673fn parse_compact_attr_name(
4674    region: &[u8],
4675    msg_start: usize,
4676    body: usize,
4677    body_end: usize,
4678) -> Result<String, Error> {
4679    if region[msg_start + 3] != 0 {
4680        return Err(Error::EditUnsupported(
4681            "a target group has a shared attribute message (not editable in place yet)",
4682        ));
4683    }
4684    crate::attribute::AttributeMessage::parse(&region[body..body_end], LENGTH_SIZE)
4685        .map(|attr| attr.name)
4686        .map_err(|_| Error::EditUnsupported("a target group has an unreadable attribute message"))
4687}
4688
4689fn encode_attr_message(name: &str, value: &AttrValue) -> Result<Vec<u8>, Error> {
4690    // `apply_group_attr_ops`'s `Set` branch — this function's only caller —
4691    // handles `VarLenAsciiArray` itself (staging it into `pending_vl` instead
4692    // of calling `set_attr_in_region`/here), so this value is always
4693    // fixed-size by construction, not by a check made at this call site.
4694    debug_assert!(
4695        !matches!(value, AttrValue::VarLenAsciiArray(_)),
4696        "VarLenAsciiArray must be intercepted by apply_group_attr_ops before reaching encode_attr_message"
4697    );
4698    let body = build_attr_message(name, value).serialize(LENGTH_SIZE);
4699    if body.len() > u16::MAX as usize {
4700        return Err(Error::EditUnsupported(
4701            "group attribute is too large to encode in place",
4702        ));
4703    }
4704    Ok(region_message(MessageType::Attribute, &body))
4705}
4706
4707/// Whether `a` is a path prefix of (or equal to) `b`.
4708fn is_prefix(a: &[String], b: &[String]) -> bool {
4709    a.len() <= b.len() && b[..a.len()] == *a
4710}
4711
4712/// Parse the version-2 object-header message record at `p` within a chunk-0
4713/// message region, returning `(message type, body start, body end)`; the next
4714/// record begins at `body end`. Returns `Ok(None)` once fewer than 4 bytes
4715/// remain (a clean end of the region), and `Err` if a record's declared body
4716/// runs past the region. Centralizes the bounds check shared by every walker.
4717fn next_message(region: &[u8], p: usize) -> Result<Option<(MessageType, usize, usize)>, Error> {
4718    if p + 4 > region.len() {
4719        return Ok(None);
4720    }
4721    let msg_type = MessageType::from_u16(region[p] as u16);
4722    let msg_size = u16::from_le_bytes([region[p + 1], region[p + 2]]) as usize;
4723    let body = p + 4;
4724    let body_end = body + msg_size;
4725    if body_end > region.len() {
4726        return Err(Error::EditUnsupported("malformed object header message"));
4727    }
4728    Ok(Some((msg_type, body, body_end)))
4729}
4730
4731/// Version-2 object-header message flag bit marking a message as *shared* (stored
4732/// once in the shared-message table and referenced by an object-header address or
4733/// fractal-heap id) rather than inline. Whatever the message type, that reference
4734/// points into the source file and is meaningless after a cross-file copy.
4735const MSG_FLAG_SHARED: u8 = 0x02;
4736
4737/// Refuse to copy an object whose header embeds a *source-file* absolute address
4738/// that a verbatim copy into another file cannot translate. An in-file copy keeps
4739/// these valid by sharing the source file's heaps and objects; a cross-file copy
4740/// cannot. Three things qualify:
4741///
4742/// - a **variable-length** datatype, whose element bytes are global-heap
4743///   references (collection address + index) into the source file's heap;
4744/// - a **reference** datatype (object or dataset-region), whose element bytes are
4745///   absolute object addresses in the source file;
4746/// - any **shared message** (the `MSG_FLAG_SHARED` bit set) — a committed datatype,
4747///   but also a shared dataspace, fill value, or filter-pipeline message — whose
4748///   body is a reference into the source file's shared-message storage.
4749///
4750/// The scan covers a copied object's whole message region (a dataset's or a
4751/// group's): it refuses any shared message outright, and inspects Datatype
4752/// messages (the element type) and Attribute messages (their own datatype),
4753/// recursing through compound members, array elements, and enumeration bases so a
4754/// nested variable-length or reference occurrence is caught too. It is applied
4755/// only on the cross-file path; the same-file [`copy`](EditSession::copy)
4756/// deliberately keeps these forms (their addresses stay valid in one file).
4757fn reject_foreign_addresses(region: &[u8]) -> Result<(), Error> {
4758    let mut p = 0;
4759    while let Some((msg_type, body, body_end)) = next_message(region, p)? {
4760        // A *shared* message stores, in place of its real body, a reference into
4761        // the source file's shared-message storage — an object-header address or a
4762        // fractal-heap (SOHM) id — which means nothing in another file. This
4763        // catches committed (shared) datatypes and shared attributes as well as a
4764        // shared dataspace, fill value, or filter-pipeline message, all of which
4765        // HDF5 may place in the shared-message table. Refuse any of them, whatever
4766        // the message type. The flags byte is the 4th of the record header (type,
4767        // size, flags); `next_message` returning `Some` guarantees
4768        // `p + 4 <= region.len()`.
4769        if region[p + 3] & MSG_FLAG_SHARED != 0 {
4770            return Err(Error::EditUnsupported(
4771                "a shared (committed/SOHM) object-header message cannot be copied to another file yet",
4772            ));
4773        }
4774        match msg_type {
4775            MessageType::Datatype => {
4776                let (dt, _) =
4777                    crate::datatype::Datatype::parse(&region[body..body_end]).map_err(|_| {
4778                        Error::EditUnsupported("a source datatype could not be parsed for copying")
4779                    })?;
4780                if datatype_copies_foreign_address(&dt) {
4781                    return Err(Error::EditUnsupported(
4782                        "variable-length or reference datasets cannot be copied to another file yet",
4783                    ));
4784                }
4785            }
4786            MessageType::Attribute => {
4787                let attr =
4788                    crate::attribute::AttributeMessage::parse(&region[body..body_end], LENGTH_SIZE)
4789                        .map_err(|_| {
4790                            Error::EditUnsupported(
4791                                "a source attribute could not be parsed for copying",
4792                            )
4793                        })?;
4794                if datatype_copies_foreign_address(&attr.datatype) {
4795                    return Err(Error::EditUnsupported(
4796                        "variable-length or reference attributes cannot be copied to another file yet",
4797                    ));
4798                }
4799            }
4800            _ => {}
4801        }
4802        p = body_end;
4803    }
4804    Ok(())
4805}
4806
4807/// Cross-file screen for a dense (fractal-heap) attribute set. The bytes parsed
4808/// out of the source heap can embed source-file absolute addresses just as inline
4809/// attribute messages can — variable-length (global-heap) or reference attribute
4810/// data — which would dangle in another file. [`reject_foreign_addresses`] screens
4811/// the verbatim object-header region but not heap-resident attribute bytes, so a
4812/// dense attribute set is screened here instead. Same-file copies skip this (their
4813/// addresses stay valid); the fresh heap built on write is same-file by
4814/// construction, so only the source datatypes matter.
4815fn reject_foreign_dense_attrs(attrs: &[crate::attribute::AttributeMessage]) -> Result<(), Error> {
4816    for attr in attrs {
4817        if datatype_copies_foreign_address(&attr.datatype) {
4818            return Err(Error::EditUnsupported(
4819                "variable-length or reference dense (fractal-heap) attributes cannot be copied to another file yet",
4820            ));
4821        }
4822    }
4823    Ok(())
4824}
4825
4826/// Whether `dt` stores, anywhere in its structure, a value that is a source-file
4827/// absolute address: a variable-length (global-heap) or reference datatype, or a
4828/// compound / array / enumeration built over one. See [`reject_foreign_addresses`].
4829fn datatype_copies_foreign_address(dt: &crate::datatype::Datatype) -> bool {
4830    use crate::datatype::Datatype;
4831    match dt {
4832        Datatype::VariableLength { .. } | Datatype::Reference { .. } => true,
4833        Datatype::Compound { members, .. } => members
4834            .iter()
4835            .any(|m| datatype_copies_foreign_address(&m.datatype)),
4836        Datatype::Array { base_type, .. } | Datatype::Enumeration { base_type, .. } => {
4837            datatype_copies_foreign_address(base_type)
4838        }
4839        _ => false,
4840    }
4841}
4842
4843/// Wrap a chunk-0 message region in a fresh single-chunk version 2 object header
4844/// (`OHDR` prefix + region + Jenkins checksum). Mirrors the encoding in
4845/// [`crate::object_header_writer::ObjectHeaderWriter::serialize`].
4846fn build_v2_object_header(region: &[u8]) -> Vec<u8> {
4847    let total = region.len();
4848    let (flags, width) = if total <= 255 {
4849        (0u8, 1usize)
4850    } else if total <= 65535 {
4851        (1u8, 2)
4852    } else {
4853        (2u8, 4)
4854    };
4855    let mut buf = Vec::with_capacity(8 + total + 4);
4856    buf.extend_from_slice(b"OHDR");
4857    buf.push(2); // version
4858    buf.push(flags);
4859    #[expect(
4860        clippy::cast_possible_truncation,
4861        reason = "width was selected just above to be the smallest field that holds total"
4862    )]
4863    match width {
4864        1 => buf.push(total as u8),
4865        2 => buf.extend_from_slice(&(total as u16).to_le_bytes()),
4866        _ => buf.extend_from_slice(&(total as u32).to_le_bytes()),
4867    }
4868    buf.extend_from_slice(region);
4869    let checksum = jenkins_lookup3(&buf);
4870    buf.extend_from_slice(&checksum.to_le_bytes());
4871    buf
4872}
4873
4874/// Read a little-endian unsigned integer of `bytes.len()` (≤ 8) bytes.
4875#[expect(
4876    clippy::cast_possible_truncation,
4877    reason = "callers parse in-file sizes/offsets bounded by the in-memory image; downstream \
4878              slicing is length-checked, so a malformed oversized field errors rather than reads OOB"
4879)]
4880fn read_le(bytes: &[u8]) -> usize {
4881    let mut v = 0u64;
4882    for (i, &b) in bytes.iter().enumerate() {
4883        v |= (b as u64) << (8 * i);
4884    }
4885    v as usize
4886}
4887
4888#[cfg(test)]
4889mod tests {
4890    use super::*;
4891
4892    /// Collect the message types present in a chunk-0 region, in order.
4893    fn region_types(region: &[u8]) -> Vec<MessageType> {
4894        let mut out = Vec::new();
4895        let mut p = 0;
4896        while let Some((mt, _, end)) = next_message(region, p).unwrap() {
4897            out.push(mt);
4898            p = end;
4899        }
4900        out
4901    }
4902
4903    #[test]
4904    fn fresh_group_region_pairs_link_info_with_group_info() {
4905        // A new-style group must carry both a Link Info and a Group Info message
4906        // (the C library requires the pair before it will insert a link).
4907        let types = region_types(&fresh_group_region());
4908        assert_eq!(types, vec![MessageType::LinkInfo, MessageType::GroupInfo]);
4909    }
4910
4911    #[test]
4912    fn ensure_group_info_appends_when_missing() {
4913        // A region with a Link Info message but no Group Info message (how older
4914        // hdf5-pure releases wrote groups) gains exactly one Group Info message.
4915        let li_body = {
4916            let mut b = vec![0u8, 0];
4917            b.extend_from_slice(&u64::MAX.to_le_bytes());
4918            b.extend_from_slice(&u64::MAX.to_le_bytes());
4919            b
4920        };
4921        let mut region = region_message(MessageType::LinkInfo, &li_body);
4922        ensure_group_info(&mut region).unwrap();
4923        assert_eq!(
4924            region_types(&region),
4925            vec![MessageType::LinkInfo, MessageType::GroupInfo]
4926        );
4927
4928        // The appended message decodes as a minimal Group Info body.
4929        let mut p = 0;
4930        while let Some((mt, body, end)) = next_message(&region, p).unwrap() {
4931            if mt == MessageType::GroupInfo {
4932                assert_eq!(&region[body..end], &GROUP_INFO_BODY);
4933            }
4934            p = end;
4935        }
4936    }
4937
4938    #[test]
4939    fn ensure_group_info_is_idempotent() {
4940        // A region that already has a Group Info message is left untouched, so
4941        // re-editing a healed (or C-written) group does not duplicate it.
4942        let mut region = fresh_group_region();
4943        let before = region.clone();
4944        ensure_group_info(&mut region).unwrap();
4945        assert_eq!(region, before);
4946    }
4947
4948    #[test]
4949    fn reject_foreign_addresses_refuses_any_shared_message() {
4950        // A shared (SOHM) message of *any* type — here a Dataspace — stores a
4951        // source-file reference in place of its body, so a verbatim cross-file
4952        // copy must refuse it, not only shared datatypes/attributes. (A plain,
4953        // non-shared dataspace embeds no foreign address and is accepted.)
4954        let mut shared = region_message(MessageType::Dataspace, &[0u8; 8]);
4955        shared[3] = MSG_FLAG_SHARED; // set the message's shared flag
4956        let err = reject_foreign_addresses(&shared).unwrap_err();
4957        assert!(err.to_string().contains("shared"), "got: {err}");
4958
4959        let plain = region_message(MessageType::Dataspace, &[0u8; 8]);
4960        reject_foreign_addresses(&plain).unwrap();
4961    }
4962
4963    /// Build a compact data-layout message body: version, class=0, 2-byte inline
4964    /// size, then the data.
4965    fn compact_layout_body(version: u8, data: &[u8]) -> Vec<u8> {
4966        let mut b = vec![version, 0];
4967        b.extend_from_slice(&(data.len() as u16).to_le_bytes());
4968        b.extend_from_slice(data);
4969        b
4970    }
4971
4972    #[test]
4973    fn rebuild_compact_layout_replaces_inline_data_only() {
4974        // A region with a Dataspace message, a compact Data Layout, and a trailing
4975        // Attribute message: rewriting the inline data must replace exactly the
4976        // layout's bytes and leave every other message verbatim.
4977        let mut region = region_message(MessageType::Dataspace, &[0xAB; 8]);
4978        region.extend_from_slice(&region_message(
4979            MessageType::DataLayout,
4980            &compact_layout_body(3, &[1, 2, 3, 4]),
4981        ));
4982        region.extend_from_slice(&region_message(MessageType::Attribute, &[0xCD; 5]));
4983
4984        let out = rebuild_compact_layout_region(&region, &[9, 8, 7, 6]).unwrap();
4985
4986        // Same messages in the same order; only the layout's inline data changed.
4987        assert_eq!(
4988            region_types(&out),
4989            vec![
4990                MessageType::Dataspace,
4991                MessageType::DataLayout,
4992                MessageType::Attribute,
4993            ]
4994        );
4995        let mut p = 0;
4996        while let Some((mt, body, end)) = next_message(&out, p).unwrap() {
4997            match mt {
4998                MessageType::Dataspace => assert_eq!(&out[body..end], &[0xAB; 8]),
4999                MessageType::DataLayout => {
5000                    assert_eq!(out[body], 3, "version preserved");
5001                    assert_eq!(out[body + 1], 0, "still compact");
5002                    let size = u16::from_le_bytes([out[body + 2], out[body + 3]]) as usize;
5003                    assert_eq!(size, 4);
5004                    assert_eq!(&out[body + 4..body + 4 + size], &[9, 8, 7, 6]);
5005                }
5006                MessageType::Attribute => assert_eq!(&out[body..end], &[0xCD; 5]),
5007                other => panic!("unexpected message {other:?}"),
5008            }
5009            p = end;
5010        }
5011    }
5012
5013    #[test]
5014    fn rebuild_compact_layout_refuses_non_compact() {
5015        // A contiguous (class 1) data layout is not compact, so the rebuild refuses
5016        // rather than corrupt it.
5017        let mut region = region_message(MessageType::DataLayout, &{
5018            let mut b = vec![3u8, 1]; // version 3, class 1 (contiguous)
5019            b.extend_from_slice(&0u64.to_le_bytes());
5020            b.extend_from_slice(&0u64.to_le_bytes());
5021            b
5022        });
5023        region.extend_from_slice(&region_message(MessageType::Dataspace, &[0; 8]));
5024        let err = rebuild_compact_layout_region(&region, &[1, 2]).unwrap_err();
5025        assert!(err.to_string().contains("non-compact"), "got: {err}");
5026    }
5027
5028    #[test]
5029    fn commit_clears_a_stale_consistency_flag() {
5030        // A clean in-place edit must leave the file properly closed for the C
5031        // library: the write/SWMR consistency flag a crashed SWMR writer left
5032        // behind is cleared rather than re-emitted (issue #73).
5033        use crate::writer::FileBuilder;
5034
5035        let dir = tempfile::tempdir().unwrap();
5036        let path = dir.path().join("stale_flag.h5");
5037
5038        let mut b = FileBuilder::new();
5039        b.create_dataset("d").with_i32_data(&[1, 2, 3]);
5040        b.write(&path).unwrap();
5041
5042        // Simulate a crashed SWMR writer by stamping the on-disk write+SWMR flag
5043        // (0x05) into the superblock, recomputing its checksum.
5044        {
5045            let mut data = std::fs::read(&path).unwrap();
5046            let off = signature::find_signature(&data).unwrap();
5047            let mut sb = Superblock::parse(&data, off).unwrap();
5048            assert!(
5049                sb.version >= 2,
5050                "FileBuilder should emit a v2/v3 superblock"
5051            );
5052            sb.consistency_flags = 0x05;
5053            let bytes = sb.serialize();
5054            data[off..off + bytes.len()].copy_from_slice(&bytes);
5055            std::fs::write(&path, &data).unwrap();
5056            // Sanity: the stale flag is really set on disk now.
5057            assert_eq!(
5058                Superblock::parse(&data, off).unwrap().consistency_flags,
5059                0x05
5060            );
5061        }
5062
5063        // A clean edit-and-commit cycle heals it.
5064        {
5065            let mut s = EditSession::open(&path).unwrap();
5066            s.create_dataset("e").with_i32_data(&[4, 5]);
5067            s.commit().unwrap();
5068        }
5069
5070        let data = std::fs::read(&path).unwrap();
5071        let off = signature::find_signature(&data).unwrap();
5072        assert_eq!(
5073            Superblock::parse(&data, off).unwrap().consistency_flags,
5074            0,
5075            "commit must clear the stale consistency flag"
5076        );
5077    }
5078
5079    #[test]
5080    fn add_vlen_string_dataset_with_null_elements_via_edit_session() {
5081        // Regression test for a silent-corruption bug (issue #105): a
5082        // VL-string dataset added via `EditSession` used to commit `Ok(())`
5083        // without ever writing its global heap collection or patching its
5084        // placeholder references, so the dataset failed to read back. A null
5085        // element (no heap object at all, distinct from an empty string) must
5086        // stay untouched by the patch — only `patch_mask`-flagged elements'
5087        // placeholder addresses are resolved; exercising both keeps the mask
5088        // itself, not just the common all-`Bytes` case, under test.
5089        use crate::type_builders::VlStringElement;
5090        use crate::writer::FileBuilder;
5091
5092        let dir = tempfile::tempdir().unwrap();
5093        let path = dir.path().join("vlen_null.h5");
5094
5095        let mut b = FileBuilder::new();
5096        b.create_dataset("seed").with_i32_data(&[0]);
5097        b.write(&path).unwrap();
5098
5099        let datatype =
5100            crate::type_builders::make_vlen_string_type(crate::datatype::CharacterSet::Utf8);
5101        let elements = vec![
5102            VlStringElement::Bytes(b"alpha".to_vec()),
5103            VlStringElement::Null,
5104            VlStringElement::Bytes(b"gamma".to_vec()),
5105        ];
5106
5107        {
5108            let mut s = EditSession::open(&path).unwrap();
5109            s.create_dataset("labels")
5110                .with_vlen_string_elements(datatype, &elements)
5111                .unwrap();
5112            s.commit().unwrap();
5113        }
5114
5115        let file = crate::reader::File::open(&path).unwrap();
5116        let ds = file.dataset("labels").unwrap();
5117        assert_eq!(
5118            ds.read_string().unwrap(),
5119            vec!["alpha".to_string(), String::new(), "gamma".to_string()]
5120        );
5121    }
5122}