hdf5_pure/edit.rs
1//! In-place editing of an existing HDF5 file (issue #32, Group C).
2//!
3//! The in-place edit engine opens an existing file and adds objects, overwrites dataset
4//! values, or edits compact group attributes **in place**:
5//! new data and object headers are written at the end of the file, and the
6//! object headers of the touched groups (and their ancestors up to the root)
7//! are rewritten — also appended — so the superblock ends up pointing at the
8//! new root header. Nothing already in the file is moved, so the cost is
9//! proportional to what you add, not to the file size — unlike the
10//! read-everything-then-rebuild path through [`FileBuilder`](crate::FileBuilder).
11//!
12//! Both new datasets, new (sub)groups, and group attribute edits are supported,
13//! at any existing group path. Adding into a nested group `/a/b` rewrites `b`'s
14//! header (with the new link), then `a`'s header (repointing its link to `b`'s
15//! new location), then the root's — "relocation up the tree". This is always
16//! safe for *additions* because no surviving object is relocated except the
17//! groups on the path being edited, and those are reachable only through links
18//! this same commit rewrites (the root through the superblock); absolute
19//! object-reference addresses to other objects stay valid.
20//!
21//! Deletion ([`Group::delete`](crate::Group::delete), the HDF5 `H5Ldelete`) is the mirror image:
22//! the parent group's header is rebuilt without the removed link, relocated up
23//! the tree the same way, and the unlinked object (and its subtree) is freed —
24//! its blocks are returned to a session-local free list (see below).
25//! Object copy ([`File::copy`](crate::File::copy), the HDF5 `H5Ocopy`) deep-copies
26//! a source subtree — appending fresh copies of every object, repointing internal
27//! links and the contiguous data address — and links the copy in like an
28//! addition; the headers are reproduced from their verbatim message bytes, so
29//! datatypes, dataspaces, and attributes stay byte-exact. A chunked (and filtered)
30//! dataset is copied with its chunk payloads and filter pipeline preserved
31//! byte-for-byte, its index rebuilt at the new location. The same machinery,
32//! [`File::copy_from`](crate::File::copy_from), copies an object **across two open files** — the
33//! source being a separate [`File`](crate::File) reader rather than the file being
34//! edited. Because the copy is byte-for-byte, the cross-file path refuses anything
35//! that embeds a source-file absolute address (variable-length or reference data,
36//! a committed datatype), which an in-file copy keeps valid by sharing the source
37//! file's heaps and objects.
38//!
39//! Value overwrite ([`Dataset::write`](crate::Dataset::write), the HDF5 `H5Dwrite`) replaces
40//! an **existing** dataset's values. The replacement's datatype and shape must
41//! match the on-disk dataset (an overwrite, not a reshape or retype); contiguous,
42//! compact, and chunked (including filtered) datasets are all supported, the chunk
43//! geometry and filter pipeline taken from the on-disk header. A same-length
44//! contiguous overwrite is the cheapest edit there is — the new bytes go straight
45//! into the existing data block, so no header is rewritten and the superblock root
46//! is not flipped, and the synced data write is the commit's linearization point.
47//! A chunked overwrite takes the same in-place path when every (re-encoded) chunk
48//! still fits its slot — always for unfiltered storage (chunk sizes are fixed by
49//! the unchanged shape), and for filtered storage when the re-encoded chunks match.
50//! When a length differs (a resized contiguous block, a filtered chunk that no
51//! longer fits, or a compact dataset) the dataset's storage is rebuilt and its
52//! header relocated like an addition: the new data and a rewritten header are
53//! appended, the data-layout message is repointed, the old storage is freed, and
54//! the parent group's link is patched. A relocating overwrite of a dataset
55//! reachable through more than one hard link is refused, since only the one named
56//! link could be repointed at the moved header.
57//!
58//! # Scope
59//!
60//! It is deliberately strict: rather than silently produce a degraded file, it
61//! refuses with [`Error::EditUnsupported`] any case it cannot reproduce
62//! faithfully. Requirements:
63//!
64//! - The file uses 8-byte offsets/lengths. A **userblock** (non-zero base
65//! address, as every MATLAB v7.3 `.mat` file has) is supported: addresses are
66//! read and written relative to the base and the userblock bytes are preserved
67//! verbatim. Every edit works on a userblock file — value overwrites, additions
68//! of contiguous and chunked/filtered datasets, in-place and relocating
69//! overwrites of every layout (with the old storage reclaimed), object deletion
70//! (with base-aware subtree reclaim), in-file copy, cross-file copy into a
71//! userblock destination, group creation, compact attributes, and free-space
72//! reuse. The one userblock-specific limitation left is cross-file copy *from* a
73//! userblock source (the source must have base 0; see [`copy_from`](crate::File::copy_from)).
74//! Any superblock version (0–3) is accepted: a version 0/1
75//! (symbol-table) file is edited by converting each group on the edited path
76//! to the latest format and repointing the superblock's root symbol-table
77//! entry.
78//! - A version 2/3 group on an edited path stores its links compactly (not in a
79//! dense fractal heap) and does not track message creation order; headers
80//! split across continuation chunks (as the reference C library often writes)
81//! are collapsed into a single chunk when rewritten. A version 1 group is
82//! converted to a compact-link v2 header, carrying its links and attributes
83//! over (other group messages — symbol table, modification time — are
84//! dropped); an attribute it cannot reproduce is refused.
85//! - Added datasets may be contiguous *or* chunked, with any filter the
86//! whole-file writer supports (deflate, shuffle, fletcher32, scale-offset,
87//! LZF, ZFP), and may declare extensible (maximum, optionally unlimited)
88//! dimensions. A chunked dataset's data and index — and any filtered chunks —
89//! are produced by the same builder the whole-file writer uses and appended at
90//! end-of-file, so its object header is byte-identical to a freshly written
91//! one. A contiguous dataset may be empty (zero-element); chunking an empty
92//! shape is not supported. A provenance dataset (`with_provenance`) is
93//! supported, its attributes computed the same way the whole-file writer
94//! computes them. A contiguous dataset may carry a variable-length-string
95//! payload (`with_vlen_strings`) or per-element object-reference targets
96//! (`with_path_references`); chunking either is not supported. A
97//! path-resolved reference may target any object this commit is not itself
98//! still writing (an ancestor group, a same-depth sibling group ordered
99//! later in the same commit, a copy destination or its interior, a
100//! `write_dataset` target, or an object this commit deletes) — targeting
101//! one of those is refused, up front and before any byte of the commit is
102//! written, rather than resolved to a stale or wrong address; a path that
103//! resolves nowhere at all becomes an undefined reference, matching the
104//! whole-file writer. Every
105//! added dataset must have a fixed-size datatype, few enough attributes
106//! (compact or variable-length) to stay in compact storage. Group, root, and
107//! **dataset** attribute edits (`set_group_attr` / `set_dataset_attr`) may
108//! likewise be fixed-size or variable-length, under the same compact-storage
109//! limit; dense (fractal-heap) attribute storage is not editable. A dataset
110//! attribute edit relocates the dataset header and so requires a single hard
111//! link.
112//! - A new group's parent must already exist or be created in the same session
113//! (each level created explicitly); intermediate groups are not auto-created.
114//! - Rows can be appended to an existing chunked, unlimited, Extensible-Array
115//! dataset **immediately and in place** with an in-place append (amortized O(1),
116//! crash-atomic, no `commit`), interleaved with the staged edits above. A
117//! target the fast path cannot handle — a userblock or pre-v2 file, an
118//! unallocated index, a non-Extensible-Array or multi-hard-link dataset, a
119//! non-chunk-aligned filtered append — is refused with
120//! [`Error::AppendInPlaceUnsupported`]; use the staged `append_dataset` instead.
121//!
122//! # Free-space reuse (issue #21)
123//!
124//! Each commit vacates space: the object headers it rewrites are superseded, and
125//! a deletion abandons its target's blocks. Those regions are recorded in a
126//! session-local free list and reused by later commits in the same session —
127//! a new object is written into a fitting freed region instead of growing the
128//! file, and when freed space forms a run reaching end-of-file the file is
129//! physically truncated. The reuse is crash-safe: it only ever overwrites space
130//! freed by an *earlier*, already-durable commit (never space the current commit
131//! is mid-way through freeing), and truncation happens only after the superblock
132//! recording the smaller end-of-file is itself durable.
133//!
134//! Reclaim is best-effort and conservative. Contiguous and chunked datasets
135//! (chunk index plus chunk data) and whole group subtrees are reclaimed; a
136//! deleted object whose blocks cannot be enumerated exhaustively —
137//! variable-length global-heap storage, dense attribute/link heaps, a
138//! non–version-2 header, a version 2 B-tree chunk index — is left as dead bytes
139//! rather than risk freeing a region that is still in use; under-reclaiming only
140//! wastes space, while over-reclaiming would corrupt.
141//!
142//! Whether the free list outlives the session depends on how the file was
143//! created. For the default (non-persisting) file it is **not** persisted: it is
144//! forgotten on close, so reuse and shrinkage apply to churn within a session,
145//! and a single delete-then-close shrinks the file only when the freed bytes
146//! reach end-of-file. A file created with
147//! `H5Pset_file_space_strategy(persist = true)` instead **persists** its free
148//! space: `open` seeds the list from the on-disk free-space managers (the
149//! `FSHD`/`FSSE` blocks the superblock-extension File Space Info message points
150//! at), and each commit rewrites those managers, so freed regions survive
151//! close/reopen and are reused across sessions — by this crate and the reference
152//! C library alike. A persisting commit *retains* freed space (recording it on
153//! disk) rather than truncating it; the blocks holding the managers are appended
154//! past all live data and the superblock is repointed last, so a crash before the
155//! repoint leaves the prior file wholly intact. Whole-file compaction that
156//! reclaims every hole at once is still the separate repack path.
157
158use std::collections::{BTreeMap, HashMap, HashSet};
159use std::fs;
160use std::io::{Read, Seek, SeekFrom};
161use std::path::Path;
162
163use crate::checksum::jenkins_lookup3;
164use crate::chunk_index_inplace::{Located, Store, apply_ea_append, plan_ea_append};
165use crate::chunked_read::{
166 chunk_index_spans_from_source, enumerate_chunks_from_source, plan_dense_grid,
167};
168use crate::chunked_write::{
169 ChunkMeta, ChunkOptions, ChunkProvider, WrittenChunk, build_chunked_data_at_ext,
170 build_extensible_array_at, emit_chunked_data_verbatim, plan_chunked_data_verbatim,
171 serialize_v4_extensible_array, split_into_chunks,
172};
173use crate::convert::TryToUsize;
174use crate::data_layout::DataLayout;
175use crate::dataspace::{Dataspace, DataspaceType};
176use crate::datatype::{Datatype, DatatypeByteOrder};
177use crate::error::{Error, FormatError, OBJECT_HEADER_MESSAGE_MAX};
178use crate::extensible_array::ExtensibleArrayHeader;
179use crate::file_create_properties::FileCreateProperties;
180use crate::file_lock::{self, FileLocking};
181use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy, NUM_FILE_FSM_MANAGERS};
182use crate::file_writer::{
183 LENGTH_SIZE, OFFSET_SIZE, build_chunked_dataset_oh, build_dataset_oh, make_link,
184};
185use crate::filter_pipeline::{
186 FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZF, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
187 FilterPipeline,
188};
189use crate::filters::{ChunkContext, compress_chunk, decompress_chunk};
190use crate::free_space::FreeList;
191use crate::free_space_manager::{
192 self, FreeSection, FsmHeader, PageType, SECT_CLASS_SIMPLE, align_up, free_sections, fshd_len,
193 plan_paged_managers, serialize_file_fsm,
194};
195use crate::group_v2::resolve_group_entries_from_source;
196use crate::image::{FileImage, HandleImage, MirrorImage};
197use crate::link_message::{LinkMessage, LinkTarget};
198use crate::message_type::MessageType;
199use crate::object_header::ObjectHeader;
200use crate::reader::FileAccessProperties;
201use crate::signature;
202use crate::source::{BaseOffsetSource, BytesSource, MetadataCacheConfig, Source};
203use crate::superblock::Superblock;
204use crate::type_builders::{
205 AttrValue, DatasetBuilder, ObjectRefPatch, ObjectRefTarget, VlStringStaging,
206 build_attr_message, build_global_heap_collections, make_f32_type, make_f64_type, make_i8_type,
207 make_i16_type, make_i32_type, make_i64_type, make_u8_type, make_u16_type, make_u32_type,
208 make_u64_type, patch_vl_refs, patch_vl_refs_masked, write_reference_address,
209};
210
211/// An undefined on-disk address (all bits set), HDF5's "no address" sentinel.
212const UNDEF: u64 = u64::MAX;
213
214/// Maximum number of compact attributes; beyond this HDF5 switches a dataset to
215/// dense (fractal-heap) attribute storage, which this engine does not emit.
216/// Mirrors `DENSE_ATTR_THRESHOLD` in `file_writer`.
217const MAX_COMPACT_ATTRS: usize = 8;
218
219/// Recursion-depth cap for object copy, guarding against a stack overflow on a
220/// pathological or cyclic hard-link graph (HDF5 hard links can form cycles).
221/// Far deeper than any real group hierarchy.
222const MAX_COPY_DEPTH: u32 = 1000;
223
224/// Upper bound on the number of object headers walked when counting hard links
225/// across the file (issue #77 / reclaim safety). Far beyond any real file; a
226/// graph larger than this aborts the count, and the commit then leaves deleted
227/// objects unreclaimed (a safe leak) rather than risk an unbounded walk.
228const MAX_LINK_GRAPH_NODES: u32 = 1 << 24;
229
230/// Maximum number of object-header chunks to follow when gathering a header that
231/// spans continuation blocks, guarding against a cyclic continuation chain.
232/// Matches the reader's continuation-depth cap.
233const MAX_OH_CHUNKS: usize = 256;
234
235/// Maximum length of a version 2 object header's fixed prefix: signature (4) +
236/// version (1) + flags (1) + optional access/modification/change/birth times
237/// (16) + optional attribute phase-change thresholds (4) + the chunk-0 size
238/// field (up to 8). Reading this many bytes always covers the prefix, so
239/// [`oh_region_at`] can be handed one bounded window instead of a whole-file
240/// image.
241const OH_PREFIX_MAX: usize = 34;
242
243/// A path identified by its components (no leading/trailing empties); the root
244/// group is the empty vector.
245type PathKey = Vec<String>;
246
247/// Variable-length group/root attributes staged by [`apply_group_attr_ops`],
248/// each an (attribute message still carrying a placeholder heap address, its
249/// global heap collections) pair, resolved in the apply loop.
250type PendingVlAttrs = Vec<(crate::attribute::AttributeMessage, Vec<Vec<u8>>)>;
251
252/// Accumulates elements to append to an existing chunked, unlimited dataset via
253/// [`Dataset::append_staged`](crate::Dataset::append_staged), in call order along the dataset's first
254/// (axis-0) dimension.
255///
256/// It mirrors [`DatasetBuilder`]'s typed/generic vocabulary. Repeated typed or
257/// [`append_raw`](Self::append_raw) calls concatenate; each typed method also
258/// records the element datatype it implies, which `commit` checks against the
259/// dataset's on-disk datatype (a mismatch — including a mix of element types in
260/// one builder — is refused with [`Error::AppendUnsupported`], never written as
261/// garbage).
262pub struct AppendBuilder {
263 /// Accumulated little-endian element bytes to append, in call order.
264 raw: Vec<u8>,
265 /// The element datatype implied by the typed `append_*` calls, if any were
266 /// used. `None` when only [`append_raw`](Self::append_raw) was called (a raw
267 /// append is checked structurally — element-size alignment and little-endian
268 /// on-disk order — rather than by datatype equality).
269 elem_dt: Option<Datatype>,
270 /// Set when two typed calls implied different element datatypes; `commit`
271 /// refuses such a builder rather than write a mix of encodings.
272 dt_conflict: bool,
273}
274
275impl AppendBuilder {
276 pub(crate) fn new() -> Self {
277 Self {
278 raw: Vec::new(),
279 elem_dt: None,
280 dt_conflict: false,
281 }
282 }
283
284 /// Accumulated little-endian element bytes (for the general append writer,
285 /// which reuses this builder to gather typed/generic appends).
286 pub(crate) fn raw(&self) -> &[u8] {
287 &self.raw
288 }
289
290 /// The element datatype implied by typed appends, if any.
291 pub(crate) fn elem_dt(&self) -> Option<&Datatype> {
292 self.elem_dt.as_ref()
293 }
294
295 /// Whether two typed appends implied conflicting element datatypes.
296 pub(crate) fn dt_conflict(&self) -> bool {
297 self.dt_conflict
298 }
299
300 /// Record the datatype a typed append implies, flagging a conflict if an
301 /// earlier typed call implied a different one.
302 fn set_dt(&mut self, dt: Datatype) {
303 match &self.elem_dt {
304 Some(prev) if *prev != dt => self.dt_conflict = true,
305 Some(_) => {}
306 None => self.elem_dt = Some(dt),
307 }
308 }
309
310 /// Append already-little-endian element bytes verbatim. The concatenated
311 /// length must be a whole multiple of the dataset's on-disk element size, and
312 /// the dataset's element datatype must be little-endian; no datatype is
313 /// otherwise inferred. Prefer the typed methods when the element type is known.
314 pub fn append_raw(&mut self, bytes: &[u8]) -> &mut Self {
315 self.raw.extend_from_slice(bytes);
316 self
317 }
318
319 /// Generic append of a flat slice of any supported scalar type — the
320 /// counterpart of [`DatasetBuilder::with_data`](crate::DatasetBuilder::with_data).
321 pub fn append<T: crate::element::H5Element>(&mut self, data: &[T]) -> &mut Self {
322 T::append_into(self, data);
323 self
324 }
325}
326
327/// Generate the typed `append_*` methods: serialize each value little-endian and
328/// record the implied element datatype.
329macro_rules! append_typed {
330 ($($method:ident, $ty:ty, $make:ident;)*) => {
331 impl AppendBuilder {
332 $(
333 #[doc = concat!("Append `", stringify!($ty), "` values to the dataset.")]
334 pub fn $method(&mut self, data: &[$ty]) -> &mut Self {
335 self.set_dt($make());
336 for &v in data {
337 self.raw.extend_from_slice(&v.to_le_bytes());
338 }
339 self
340 }
341 )*
342 }
343 };
344}
345
346append_typed! {
347 append_f64, f64, make_f64_type;
348 append_f32, f32, make_f32_type;
349 append_i8, i8, make_i8_type;
350 append_i16, i16, make_i16_type;
351 append_i32, i32, make_i32_type;
352 append_i64, i64, make_i64_type;
353 append_u8, u8, make_u8_type;
354 append_u16, u16, make_u16_type;
355 append_u32, u32, make_u32_type;
356 append_u64, u64, make_u64_type;
357}
358
359/// The in-place write engine behind the owned read-write [`File`](crate::File)
360/// (its `Backend::Edit`).
361///
362/// Reads and edits the file through a [`FileImage`], which owns the writable
363/// handle and decides how much of the file is resident. It carries two commit
364/// models: staged tree edits applied by [`commit`](Self::commit), and immediate
365/// crash-atomic in-place appends ([`append_inplace_gathered`](Self::append_inplace_gathered)).
366pub(crate) struct WriteEngine {
367 /// The file bytes this session reads and edits, behind the [`FileImage`]
368 /// abstraction: reads go through its [`Source`] impl, and the write side —
369 /// the end-of-file cursor, `append`, `write_at`, `truncate`, and the
370 /// durability barriers — through its own primitives.
371 ///
372 /// Nothing in the engine assumes the whole file is resident, so one engine
373 /// serves both a whole-file mirror and a file-backed image that holds only
374 /// what it is reading (issue #198). [`image_slice`](Self::image_slice)
375 /// exposes the mirror's buffer where a caller can exploit it.
376 image: Box<dyn FileImage>,
377 /// Absolute offset of the superblock signature in the file.
378 sb_sig_off: usize,
379 /// Parsed superblock. On-disk addresses are stored relative to `base_address`;
380 /// the in-memory `root_group_address` is normalized to an absolute file offset
381 /// on open and converted back to a base-relative address when serialized on
382 /// commit. `base_address` equals the superblock's file location (`sb_sig_off`):
383 /// 0 for a plain file, the userblock size for one with a userblock.
384 superblock: Superblock,
385 /// Datasets staged by `create_dataset`, as (parent group path, builder).
386 pending_datasets: Vec<(PathKey, DatasetBuilder)>,
387 /// Value overwrites staged by `write_dataset`, as (full dataset path,
388 /// builder). Each replaces an existing dataset's values in place; the new
389 /// datatype and shape must match the on-disk ones byte-exactly (this is a
390 /// value overwrite, not a reshape/retype). Applied on the next `commit`.
391 pending_writes: Vec<(PathKey, DatasetBuilder)>,
392 /// Appends staged by `append_dataset`, as (full dataset path, builder). Each
393 /// grows an existing chunked, unlimited, Extensible-Array-indexed dataset
394 /// along axis 0 by keeping its existing chunk data in place and rebuilding the
395 /// index over the kept plus newly-appended (and any rewritten trailing) chunks.
396 /// Applied on the next `commit`.
397 pending_appends: Vec<(PathKey, AppendBuilder)>,
398 /// New groups staged by `create_group`, as full paths.
399 pending_groups: Vec<PathKey>,
400 /// Group attribute edits staged as (group path, operation). The path may be
401 /// a group created in this same session.
402 pending_group_attrs: Vec<(PathKey, AttrOp)>,
403 /// Dataset attribute edits staged as (full dataset path, operation), applied
404 /// on the next `commit`. Each relocates the dataset's object header (like a
405 /// relocating overwrite): the header is rebuilt with the compact-attribute
406 /// change, its single naming link is patched, and the old header freed — the
407 /// dataset's data and chunk index stay in place. The target must be an existing,
408 /// single-hard-link dataset using compact (not dense fractal-heap) attributes.
409 pending_dataset_attrs: Vec<(PathKey, AttrOp)>,
410 /// Links staged for removal by `delete`, as full paths.
411 pending_deletes: Vec<PathKey>,
412 /// Object copies staged by `copy`, as (source path, destination full path).
413 pending_copies: Vec<(PathKey, PathKey)>,
414 /// Cross-file object copies staged by `copy_from`, as (destination full path,
415 /// the source subtree already read out of the other file). The subtree is read
416 /// — and foreign-address-screened — eagerly in `copy_from` (the source file is
417 /// borrowed only for that call), then linked in at the next `commit`.
418 pending_cross_copies: Vec<(PathKey, CopyTree)>,
419 /// Session-local free-space tracker (issue #21). Holds regions vacated by
420 /// prior commits in this session — superseded object headers and the blocks
421 /// of deleted objects — so later commits reuse them instead of growing the
422 /// file, and so a freed run reaching end-of-file can be truncated away. It
423 /// starts empty on `open` for a non-persisting file: holes already present
424 /// from earlier sessions or other tools are not tracked. When the file
425 /// persists its free space (`persist` is `Some`), `open` instead seeds it
426 /// from the on-disk free-space managers, so reuse spans sessions.
427 free: FreeList,
428 /// Free-space persistence read from the file's superblock extension on
429 /// `open` (the file-creation `H5Pset_file_space_strategy(persist = true)`
430 /// setting). `None` for the default non-persisting file; when `Some`, every
431 /// [`commit`](Self::commit) rewrites the on-disk free-space managers so the
432 /// free list survives close/reopen.
433 persist: Option<PersistState>,
434 /// Per-dataset geometry cache for the immediate O(1) in-place append
435 /// ([`append_inplace_gathered`](Self::append_inplace_gathered)), keyed by the dataset's resolved
436 /// **object-header address** (not its path, so two hard links to one dataset
437 /// share one entry). Populated on the first append to a dataset and maintained
438 /// across appends; cleared wholesale at the entry of every non-trivial
439 /// [`commit`](Self::commit), since a commit can relocate a cached header or
440 /// free the region it points into (see `commit`).
441 located: HashMap<u64, LocatedState>,
442 /// True when this engine was opened for SWMR writing
443 /// ([`open_swmr_writer`](Self::open_swmr_writer)): the append engine then
444 /// enforces the SWMR subset (unfiltered, chunk-aligned) so a concurrent
445 /// reader never observes a torn view. `false` for an ordinary edit session.
446 swmr_mode: bool,
447 /// Paged-file state (`H5F_FSPACE_STRATEGY_PAGE`), read from the superblock
448 /// extension at `open` regardless of the persist flag; `None` for the common
449 /// non-paged file. When `Some`, [`commit`](Self::commit) takes a page-aware
450 /// tail that keeps pages homogeneous and rewrites the per-page-type managers
451 /// (issue #198). A paged file that does not *persist* its free space is still
452 /// refused: see [`PagedEdit`].
453 paged: Option<PagedEdit>,
454 /// Set by the first [`commit`](Self::commit) that does any work. A commit can
455 /// relocate an object header, and nothing on disk distinguishes a relocated
456 /// header from the intact bytes it vacated — the old header still parses, and
457 /// its data-layout message still points at the live chunk index. An
458 /// [`AppendTarget::Header`] captured before that commit would therefore append
459 /// successfully *into the dead header*, growing its dataspace while the live
460 /// dataset stayed put, and report `Ok`. A path is re-resolved on every append
461 /// and so survives a commit; a raw address does not, so it is refused once one
462 /// has run.
463 committed: bool,
464 /// Object-header address for each path an in-place append has resolved in
465 /// this session. A single `Dataset::append` asks for the target's geometry
466 /// and then appends to it, and a loop of appends repeats that; without this
467 /// the path would be walked from the root on every one of those steps.
468 ///
469 /// An in-place append never moves an object header — that is why
470 /// [`located`](Self::located) can be keyed by address and survive appends —
471 /// so only a commit can stale an entry, and it clears both together.
472 resolved: HashMap<String, u64>,
473 /// Whether this session splits a large in-place append into batches, trading
474 /// whole-call crash atomicity for a peak memory that does not scale with the
475 /// call. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy); see
476 /// [`batch_elems`](Self::batch_elems).
477 batched_appends: bool,
478 /// Whether this session reads through a handle rather than a whole-file
479 /// mirror. Set by [`open_rw_with_strategy`](Self::open_rw_with_strategy), and reported by
480 /// [`File::edit_backing`](crate::File::edit_backing) so a caller who
481 /// asked for [`MemoryStrategy::Auto`] can tell which one it got. Distinct
482 /// from [`batched_appends`](Self::batched_appends), which is a crash-atomicity
483 /// trade the bounded engine happens to make, not a statement about memory.
484 bounded: bool,
485 /// The file length when the on-disk free-space managers were last written,
486 /// for a file that persists them. Every immediate in-place append grows the
487 /// file past those managers and leaves them mid-file, so a session that ends
488 /// with `image.len() != fsm_len` owes a rewrite; that is what
489 /// [`finalize_persist`](Self::finalize_persist) settles at close. Meaningless
490 /// (and untouched) when `persist` is `None`.
491 fsm_len: u64,
492}
493
494/// How much memory a read-write open may use to hold the file being edited.
495///
496/// The two read-write backends differ in memory, not in what they can express: a
497/// *bounded* session reads through a handle and holds only what a commit is
498/// building, while a *mirrored* session materializes the whole file in memory.
499/// Bounded is the better default when it applies, but it cannot yet edit every
500/// file — a pre-v2 (non-latest-format) superblock or a userblock still needs the
501/// mirror.
502///
503/// This is what a caller says about that trade-off, on
504/// [`FileAccessProperties::with_memory_strategy`](crate::FileAccessProperties::with_memory_strategy).
505/// Leaving it unset lets the entry point decide: [`File::open_rw`](crate::File::open_rw)
506/// prefers the bounded engine and falls back to the mirror ([`Auto`](Self::Auto)),
507/// while the deprecated [`File::open_rw_bounded`](crate::File::open_rw_bounded)
508/// refuses instead of falling back ([`Bounded`](Self::Bounded)).
509///
510/// This is a *request*, so it is deliberately not the type a file answers with:
511/// [`File::edit_backing`](crate::File::edit_backing) returns an [`EditBacking`],
512/// which cannot express [`Auto`](Self::Auto).
513///
514/// Sealed: unlike [`FileLocking`] or [`FileSpaceStrategy`](crate::FileSpaceStrategy),
515/// whose variant sets mirror a closed C-library enum, this is a policy this crate
516/// invented, so a fourth strategy must not be a breaking change.
517#[derive(Debug, Clone, Copy, PartialEq, Eq)]
518#[non_exhaustive]
519pub enum MemoryStrategy {
520 /// Never build a whole-file mirror. A file the bounded engine cannot edit is
521 /// refused at open with [`Error::EditUnsupported`], before anything is
522 /// staged. This is what
523 /// [`File::open_rw_bounded`](crate::File::open_rw_bounded) has always done.
524 Bounded,
525 /// Prefer the bounded engine, but fall back to the whole-file mirror for a
526 /// file it cannot edit, rather than refusing. Memory then scales with the
527 /// file, which is the cost of the file opening at all. What
528 /// [`File::open_rw`](crate::File::open_rw) uses when nothing is asked for.
529 Auto,
530 /// Always build the whole-file mirror, whatever the file looks like. What
531 /// [`File::open_rw`](crate::File::open_rw) did before it learned to dispatch.
532 Mirrored,
533}
534
535/// Which of the two read-write backends a file's editing session is actually
536/// using, from [`File::edit_backing`](crate::File::edit_backing).
537///
538/// Deliberately a different type from [`MemoryStrategy`]: that one is what a
539/// caller *asks* for and includes [`Auto`](MemoryStrategy::Auto), which is a
540/// preference between these two rather than a third thing a session can be. A
541/// single shared type would make `file.backing() == Auto` a comparison that
542/// compiles and is false forever.
543///
544/// The two also evolve at different rates. A future `MemoryStrategy` may name a
545/// new *policy* — a byte budget, a size threshold — without the set of backends
546/// changing at all. Sealed for the rarer case that a third backend does appear.
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
548#[non_exhaustive]
549pub enum EditBacking {
550 /// Reads through a file handle, holding only what a commit is building.
551 /// Memory does not scale with the file.
552 Bounded,
553 /// Holds the whole file in memory for the life of the session.
554 Mirrored,
555}
556
557impl From<EditBacking> for MemoryStrategy {
558 /// Turns an outcome back into the request that pins it, so a caller can
559 /// reopen a file onto the backing it got the first time:
560 /// `with_memory_strategy(file.edit_backing().unwrap().into())`.
561 fn from(backing: EditBacking) -> Self {
562 match backing {
563 EditBacking::Bounded => Self::Bounded,
564 EditBacking::Mirrored => Self::Mirrored,
565 }
566 }
567}
568
569/// Why the bounded engine cannot edit a file, when the whole-file mirror can.
570///
571/// Kept separate from the refusals that apply to *both* engines: a fallback is
572/// only ever worth taking for a limitation the mirror does not share. A paged
573/// file with no persisted free-space managers, for instance, is refused by the
574/// staged commit as well, so mirroring it would trade a clear error at open for
575/// the same error later with work already staged.
576fn bounded_only_limitation(session: &WriteEngine) -> Option<&'static str> {
577 if session.superblock.version < 2 {
578 return Some(
579 "bounded read-write access requires a latest-format file (v2/v3 superblock); \
580 leave MemoryStrategy unset, or pass MemoryStrategy::Auto, to fall back to \
581 the whole-file mirror here",
582 );
583 }
584 if session.superblock.base_address != 0 {
585 return Some(
586 "bounded read-write access does not support a file with a userblock \
587 (non-zero base address); leave MemoryStrategy unset, or pass \
588 MemoryStrategy::Auto, to fall back to the whole-file mirror here",
589 );
590 }
591 None
592}
593
594/// Whether a file built from `create` could not then be opened read-write under
595/// `access`, and why.
596///
597/// [`File::create_with_options`](crate::File::create_with_options) writes a file
598/// and hands back an open read-write handle, so a creation/access pair that
599/// cannot survive that second half must be caught *before* the write — otherwise
600/// the call leaves a file on disk and returns `Err`, which reads like a failed
601/// create but is not one.
602///
603/// This mirrors the open-time refusals above and must be kept in step with them:
604/// [`bounded_only_limitation`] for the userblock, and the shared paged check in
605/// [`open_rw_with_strategy`](WriteEngine::open_rw_with_strategy) for a paged file
606/// with no persisted free space. Both are stated here in terms of the properties
607/// that *cause* them, because the open-time wording tells the caller to recreate
608/// the file — advice that is circular when the caller is creating it.
609pub(crate) fn create_would_refuse_reopen(
610 create: &FileCreateProperties,
611 access: &FileAccessProperties,
612) -> Option<&'static str> {
613 if let Some((FileSpaceStrategy::Page, false, _)) = create.file_space_strategy() {
614 return Some(
615 "a paged file (FileSpaceStrategy::Page) with persist = false cannot be reopened \
616 read-write, so creating one this way would write the file and then fail to open \
617 it; pass persist = true to with_file_space_strategy, or build the file with \
618 FileBuilder if it is only ever going to be read",
619 );
620 }
621 if create.userblock() != 0 && access.memory_strategy() == Some(MemoryStrategy::Bounded) {
622 return Some(
623 "a userblock cannot be combined with MemoryStrategy::Bounded: the bounded engine \
624 cannot edit a file with a non-zero base address, so creating one this way would \
625 write the file and then refuse to open it; drop the userblock, or leave \
626 MemoryStrategy unset to mirror this file",
627 );
628 }
629 None
630}
631
632/// Paged-file bookkeeping for the whole-file editor (issue #198, step 1).
633///
634/// A paged file never mixes metadata and raw data within one page, so this tracks
635/// free space per page type — matching the three managers such a file records —
636/// and keeps the commit's appends homogeneous by padding a tail page whenever the
637/// page type changes.
638///
639/// The free lists are seeded only when the file *persists* its free space. A paged
640/// non-persisting file has no on-disk record of which pages hold metadata and
641/// which hold raw data, so there is nothing to seed and no way to stay segregated;
642/// [`commit`](EditSession::commit) refuses it outright, exactly as the bounded
643/// backend does.
644struct PagedEdit {
645 page_size: u64,
646 /// Metadata free space: the SUPER manager, slot 0.
647 meta: FreeList,
648 /// Small-raw free space: the DRAW manager, slot 2.
649 raw_small: FreeList,
650 /// Large-raw fragments: the generic-large manager, slot 6.
651 raw_large: FreeList,
652 /// Page type of the current tail page. `None` until this session's first
653 /// typed append; the file is page-aligned at open, so the first append never
654 /// needs to pad regardless of this.
655 last: Option<PageType>,
656 /// Free tails left by padding a metadata page before a raw append.
657 meta_pad: Vec<(u64, u64)>,
658 /// Free tails left by padding a raw page before a metadata append.
659 raw_pad: Vec<(u64, u64)>,
660}
661
662impl PagedEdit {
663 /// Ensure the next allocation on `image` begins in a page holding page type
664 /// `ty`: when the tail page holds the *other* type and is only partially
665 /// filled, pad it to a page boundary and record the padding as free space of
666 /// the outgoing type.
667 ///
668 /// This is the whole of the paged-append rule, and it lives here so the two
669 /// places that grow a paged file — the staged commit through
670 /// [`WriteEngine::begin_page`](WriteEngine::begin_page), and the shared
671 /// Extensible-Array append engine through [`EditStore`] — cannot drift. They
672 /// used to keep separate copies of this state, one per engine, which is what
673 /// made an in-place append to a paged file unsafe from the whole-file editor
674 /// (issue #198).
675 ///
676 /// Call it **before** reading the image's end-of-file to compute an address
677 /// that will be embedded in the bytes being built: several callers build
678 /// content whose interior addresses assume it lands at the current
679 /// end-of-file, and padding inserted after that read would shift the landing
680 /// address out from under them.
681 fn begin(&mut self, image: &mut dyn FileImage, ty: PageType) -> Result<(), Error> {
682 let len = image.len();
683 if len % self.page_size != 0 {
684 let pad_len = self.page_size - len % self.page_size;
685 // `prev` is the outgoing page type to record the padding under, or
686 // `None` for a crash-recovery pad whose tail-page type is unknown.
687 let pad = match self.last {
688 // Normal case: the tail page holds a known type; pad only on a
689 // type switch, recording the tail as free of the outgoing type.
690 Some(prev) if prev != ty => Some(Some(prev)),
691 Some(_) => None, // same type: keep packing the tail page
692 // A previous session grew this paged file and was killed before
693 // its tail was page-aligned, so the file opened non-page-aligned
694 // with no known tail type. Pad it up (extending whatever the tail
695 // page holds, so the page stays homogeneous) and leave the padding
696 // untracked, since recording it under the wrong page type could
697 // let a reader reuse it and mix the page.
698 None => Some(None),
699 };
700 if let Some(prev) = pad {
701 let pad_at = len;
702 image.append(&vec![0u8; pad_len.to_usize()?])?;
703 match prev {
704 Some(PageType::Meta) => self.meta_pad.push((pad_at, pad_len)),
705 Some(PageType::Raw) => self.raw_pad.push((pad_at, pad_len)),
706 None => {} // crash-recovery pad: untracked (tail type unknown)
707 }
708 }
709 }
710 self.last = Some(ty);
711 Ok(())
712 }
713
714 fn new(page_size: u64) -> Self {
715 PagedEdit {
716 page_size,
717 meta: FreeList::new(),
718 raw_small: FreeList::new(),
719 raw_large: FreeList::new(),
720 last: None,
721 meta_pad: Vec::new(),
722 raw_pad: Vec::new(),
723 }
724 }
725
726 /// Record `(addr, size)` as free space of page type `ty` in the given lists.
727 /// A raw region at least a page long is a large-raw fragment (the
728 /// generic-large manager); everything else is small enough to stay in its
729 /// per-type SMALL manager. [`plan_paged_managers`] re-splits and re-classes at
730 /// serialization time, so this only has to route to the right *kind* of list.
731 ///
732 /// Takes the three lists rather than `&mut self` because a commit routes into
733 /// *copies* of them — nothing is free until the superblock repoint — and the
734 /// rule must not be restated at that call site.
735 fn route_free(
736 meta: &mut FreeList,
737 raw_small: &mut FreeList,
738 raw_large: &mut FreeList,
739 page_size: u64,
740 addr: u64,
741 size: u64,
742 ty: PageType,
743 ) {
744 match ty {
745 PageType::Meta => meta.free(addr, size),
746 PageType::Raw if size >= page_size => raw_large.free(addr, size),
747 PageType::Raw => raw_small.free(addr, size),
748 }
749 }
750
751 /// Every tracked free region across the three managers, ascending by address.
752 /// Used for space accounting, where the caller wants one total rather than a
753 /// per-page-type breakdown.
754 fn all_sections(&self) -> Vec<(u64, u64)> {
755 let mut out = self.meta.sections();
756 out.extend(self.raw_small.sections());
757 out.extend(self.raw_large.sections());
758 out.sort_by_key(|&(addr, _)| addr);
759 out
760 }
761}
762
763/// What an in-place append names its target by.
764///
765/// A handle with a resolvable path uses it, which lets the session compare the
766/// target against its own staged edits. A handle reached by object reference has
767/// no path, so it names the dataset by the object-header address it was reached
768/// through — the same key the geometry cache uses.
769#[derive(Clone, Copy)]
770pub(crate) enum AppendTarget<'a> {
771 Path(&'a str),
772 Header(u64),
773}
774
775/// Byte budget for one append batch on a session that batches: a large append is
776/// split into whole-chunk batches of at most this many raw bytes (always at least
777/// one chunk), each applied as its own crash-atomic fsync-barriered sequence, so
778/// peak append memory never scales with the caller's slice.
779const APPEND_BATCH_BYTES: u64 = 1 << 20;
780
781/// One dataset's append geometry, handed to the public append path so it can
782/// slice a large call into aligned batches without materializing the whole
783/// call's bytes first.
784pub(crate) struct AppendGeometry {
785 /// Elements per chunk along axis 0 (>= 1).
786 pub(crate) chunk_elems: u64,
787 /// Bytes per on-disk element.
788 pub(crate) element_size: usize,
789 /// Current length along the unlimited dimension.
790 pub(crate) current_dim: u64,
791 /// Whether a filter pipeline applies (whole-chunk appends only).
792 pub(crate) filtered: bool,
793 /// Whole-chunk elements in one full batch (>= one chunk's worth), or
794 /// [`u64::MAX`] when the session does not batch.
795 pub(crate) full_batch_elems: u64,
796}
797
798/// Superblock consistency-flag bits raised while a SWMR writer is active: bit 0
799/// (write access) | bit 2 (SWMR write access). Cleared on a clean close. Matches
800/// the reference C library, h5py, and [`crate::File::open_swmr_writer`]. These
801/// are the bits every open path checks — see [`file_lock::check_status_flags`].
802const SWMR_WRITE_FLAGS: u32 = file_lock::WRITE_ACCESS | file_lock::SWMR_WRITE_ACCESS;
803
804/// A dataset located once for [`Dataset::append`](crate::Dataset::append) (or the bounded
805/// backend's immediate append), then maintained across appends. Mirrors the
806/// append writer's per-dataset state.
807pub(crate) struct LocatedState {
808 pub(crate) loc: Located,
809 /// The dataset's on-disk element datatype (for the append type check and the
810 /// filter chunk context).
811 pub(crate) datatype: Datatype,
812 /// Spatial (rank-length) chunk dimensions in elements: `[chunk_elems]`.
813 pub(crate) spatial: Vec<u64>,
814 /// Bytes per element (datatype size).
815 pub(crate) element_size: usize,
816 /// The re-encodable filter pipeline, when the dataset is filtered.
817 pub(crate) pipeline: Option<FilterPipeline>,
818}
819
820/// State for a file that persists its free space on disk. Carries the file's
821/// fixed file-space parameters and the extents of the free-space-manager blocks
822/// (and superblock extension) the *current* on-disk file uses, so the next
823/// persisting commit can reclaim them when it writes fresh ones.
824struct PersistState {
825 strategy: FileSpaceStrategy,
826 threshold: u64,
827 page_size: u64,
828 /// `(addr, len)` of the on-disk superblock-extension header and every
829 /// free-space-manager `FSHD`/`FSSE` block currently in use. Superseded — and
830 /// therefore freed — by the next persisting commit.
831 old_blocks: Vec<(u64, u64)>,
832}
833
834/// A snapshot of a writable file's live space usage (issue #150).
835///
836/// This is the mutating-session counterpart of the read-only accounting on
837/// [`File`](crate::File) ([`file_size`](crate::File::file_size) and
838/// [`persisted_free_space`](crate::File::persisted_free_space)): it describes the
839/// file *as the session currently holds it*, taken atomically at the moment of
840/// the [`space_accounting`](crate::File::space_accounting) call.
841///
842/// It reflects the committed file plus any immediate in-place appends
843/// ([`append`](crate::Dataset::append)), but **not** edits still
844/// staged for the next [`commit`](crate::File::commit) — `create_group`,
845/// `create_dataset`, `write_dataset`, `append_dataset`, `delete`, `copy`,
846/// `copy_from`, and attribute edits change these figures only when they are
847/// applied at commit. Use [`has_staged_edits`](crate::File::has_staged_edits) to
848/// tell whether such pending work exists.
849#[derive(Debug, Clone, PartialEq, Eq)]
850#[non_exhaustive]
851pub struct SpaceAccounting {
852 /// The session's current logical size in bytes: the byte length of the file
853 /// as the session holds it. It equals what
854 /// [`File::file_size`](crate::File::file_size) reports for the file on disk
855 /// right now (the HDF5 `H5Fget_filesize` value), because the session keeps its
856 /// in-memory mirror byte-for-byte identical to the file — every committed
857 /// write and every immediate in-place append
858 /// ([`append`](crate::Dataset::append)) updates both together.
859 ///
860 /// It is not monotonic: [`commit`](crate::File::commit) can reclaim trailing
861 /// free space and *shrink* the file. It can also exceed the superblock's
862 /// recorded end-of-file address when the file was opened carrying unaccounted
863 /// trailing bytes (the same slack [`File::file_size`](crate::File::file_size)
864 /// surfaces), since opening does not rewrite that address.
865 pub logical_size: u64,
866 /// Total reusable free bytes the next allocation or [`commit`](crate::File::commit)
867 /// can draw from before the file has to grow — the summed length of
868 /// [`reusable_free_space`](Self::reusable_free_space).
869 ///
870 /// Counts holes left inside [`logical_size`](Self::logical_size) by this
871 /// session's earlier commits (superseded object headers, the blocks of
872 /// deleted objects) and, for a file created with
873 /// `H5Pset_file_space_strategy(persist = true)` and no userblock, the regions
874 /// seeded from the on-disk free-space managers when the session was opened (so
875 /// reuse spans sessions). A fresh non-persisting session reports `0` even if
876 /// the file contains holes left by other tools — those are never tracked. It
877 /// is neither a lower bound on the next write's growth nor a promise of
878 /// shrinkage: a region counted here may be truncated away at commit — rather
879 /// than reused — if adjacent space is later freed and the coalesced run
880 /// reaches end-of-file.
881 pub reusable_free_bytes: u64,
882 /// The reusable free regions as `(offset, length)` pairs, sorted ascending by
883 /// offset and fully coalesced (no two regions touch or overlap).
884 ///
885 /// The offsets are **absolute** file offsets (from byte 0, including any
886 /// userblock prefix), matching [`logical_size`](Self::logical_size). This
887 /// differs from [`File::persisted_free_space`](crate::File::persisted_free_space),
888 /// whose pairs are relative to the superblock base address; the two coincide
889 /// for a file with no userblock (base address 0), which is the only kind whose
890 /// persisted free space a session seeds. Empty when nothing is reusable.
891 pub reusable_free_space: Vec<(u64, u64)>,
892}
893
894impl WriteEngine {
895 /// Open an existing HDF5 file for in-place editing under an explicit
896 /// file-locking policy.
897 ///
898 /// Reads the file into memory and retains a read/write handle. Under
899 /// [`FileLocking::Enabled`] it takes an exclusive OS advisory lock so the file
900 /// cannot be opened concurrently by another writer or reader; the lock is
901 /// released automatically when the session is dropped or the process exits
902 /// (including on a crash). Fails with [`Error::FileLocked`] if the file is
903 /// already locked, or [`Error::EditUnsupported`] if the file is not a
904 /// supported target; its documentation enumerates the exact requirements.
905 /// `HDF5_USE_FILE_LOCKING` overrides the requested policy, as in the C
906 /// library.
907 pub fn open_with_locking<P: AsRef<Path>>(path: P, locking: FileLocking) -> Result<Self, Error> {
908 Self::open_inner(path.as_ref(), Some(locking))
909 }
910
911 /// Open exactly as [`open_with_locking`](Self::open_with_locking) does, but
912 /// behind an image that withholds its whole-file slice, so every read takes
913 /// the [`Source`] path rather than the slice fast path.
914 ///
915 /// Distinct from [`open_rw_with_strategy`](Self::open_rw_with_strategy), which withholds the
916 /// slice *and* the residency: this one still mirrors the file, so a test can
917 /// compare the two read forms on a file the bounded open would refuse.
918 #[cfg(test)]
919 pub(crate) fn open_source_only(path: &Path) -> Result<Self, Error> {
920 Self::open_imaged(path, Some(FileLocking::Enabled), |handle, _len| {
921 Ok(Box::new(crate::image::SourceOnlyImage::new(
922 Self::read_mirror(handle)?,
923 )))
924 })
925 }
926
927 /// Open a bounded session whose image counts the bytes read through it, so a
928 /// test can assert that an operation touches only a small part of the file.
929 /// The counter is shared with the caller.
930 #[cfg(test)]
931 pub(crate) fn open_bounded_counting(
932 path: &Path,
933 read_bytes: std::sync::Arc<std::sync::atomic::AtomicU64>,
934 ) -> Result<Self, Error> {
935 let mut session = Self::open_imaged(path, Some(FileLocking::Enabled), |handle, len| {
936 Ok(Box::new(crate::image::CountingImage::new(
937 Box::new(HandleImage::new(
938 handle,
939 len,
940 crate::source::MetadataCacheConfig::disabled(),
941 )),
942 read_bytes,
943 )))
944 })?;
945 session.batched_appends = true;
946 Ok(session)
947 }
948
949 /// Open an existing file for read-write editing under `strategy`: the one
950 /// place that picks between the bounded backing (a [`HandleImage`] keeping no
951 /// whole-file mirror, so resident memory is the metadata-cache budget plus
952 /// whatever is being parsed) and the whole-file mirror. Backs
953 /// [`File::open_rw`](crate::File::open_rw) and the deprecated
954 /// [`File::open_rw_bounded`](crate::File::open_rw_bounded), which differ only
955 /// in the strategy they default to.
956 ///
957 /// The eligibility rules are checked here rather than deferred, because a
958 /// caller who asked for bounded memory cannot be silently given the mirror
959 /// instead. Under [`MemoryStrategy::Bounded`] a file the bounded engine
960 /// cannot edit is refused up front; [`MemoryStrategy::Auto`] opts in to
961 /// falling back to the mirror instead (issue #198, steps 3 and 4).
962 ///
963 /// Only a *bounded-only* limitation is worth falling back for — see
964 /// [`bounded_only_limitation`]. Non-8-byte offsets or lengths are refused by
965 /// [`open_imaged`](Self::open_imaged) for both engines, and so is an
966 /// unsupported superblock version; a paged file without persisted free space
967 /// is refused below for both.
968 pub(crate) fn open_rw_with_strategy(
969 path: &Path,
970 cache: MetadataCacheConfig,
971 locking: FileLocking,
972 strategy: MemoryStrategy,
973 ) -> Result<Self, Error> {
974 if strategy == MemoryStrategy::Mirrored {
975 return Self::open_with_locking(path, locking);
976 }
977 let mut session = Self::open_imaged(path, Some(locking), |handle, len| {
978 Ok(Box::new(HandleImage::new(handle, len, cache)))
979 })?;
980 session.batched_appends = true;
981 session.bounded = true;
982 // Refusals that apply to *both* backings come first, or falling back for a
983 // bounded-only limitation would skip them and hand back a session that
984 // cannot commit. A paged file with no persisted managers has no on-disk
985 // record of which pages hold metadata and which hold raw data, so nothing
986 // can keep the pages segregated; the staged commit refuses it too, so
987 // deferring would only trade this error for the same one later, with work
988 // already staged. A userblock is one way to reach this state without the
989 // file saying `persist = false`: persisted free space is declined for a
990 // non-zero base address, which leaves the managers unseeded all the same.
991 if session.paged.is_some() && session.persist.is_none() {
992 return Err(Error::EditUnsupported(
993 "read-write access to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
994 persisted free space; recreate the file with \
995 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to grow it in place",
996 ));
997 }
998 if let Some(reason) = bounded_only_limitation(&session) {
999 if strategy == MemoryStrategy::Bounded {
1000 return Err(Error::EditUnsupported(reason));
1001 }
1002 // Release the handle and its exclusive lock before reopening, or the
1003 // mirrored open would contend with the probe we are discarding —
1004 // fatally so on Windows, where the OS lock is mandatory. Dropping a
1005 // bare `WriteEngine` writes nothing: the free-space finalize that a
1006 // dropped writer owes lives on `FileInner`, which this is not yet.
1007 // Another writer can take the lock in that window; the reopen then
1008 // reports `Error::FileLocked`, which is the truthful answer.
1009 drop(session);
1010 return Self::open_with_locking(path, locking);
1011 }
1012 Ok(session)
1013 }
1014
1015 /// Open an existing file for SWMR (single-writer/multiple-reader) writing:
1016 /// take **no** OS lock at all and raise the superblock's SWMR-write
1017 /// consistency flag. Backs [`File::open_swmr_writer`](crate::File::open_swmr_writer).
1018 ///
1019 /// The no-lock is unconditional — `lock = None` never reaches
1020 /// `acquire_exclusive`, so `HDF5_USE_FILE_LOCKING` cannot reintroduce a lock
1021 /// that would block the concurrent readers SWMR exists to permit (fatally so
1022 /// on Windows, where OS locks are mandatory). Requires a latest-format
1023 /// (version-3 superblock) file with no userblock and no persisted
1024 /// free-space, so the superblock can be rewritten in place.
1025 ///
1026 /// The version-3 requirement is the C library's (`H5F__super_read`: "superblock
1027 /// version for SWMR is less than 3"), and it is what keeps the SWMR-write flag
1028 /// meaningful: neither library reads the status-flags byte back on an older
1029 /// superblock, so a flag raised there would announce a live writer to nobody.
1030 /// This crate's writer emits version 3, so no file it produces is affected.
1031 pub(crate) fn open_swmr_writer<P: AsRef<Path>>(path: P) -> Result<Self, Error> {
1032 let mut session = Self::open_inner(path.as_ref(), None)?;
1033 if session.superblock.version < 3
1034 || session.superblock.base_address != 0
1035 || session.persist.is_some()
1036 {
1037 return Err(Error::SwmrAppendUnsupported(
1038 "SWMR writing requires a latest-format file (v3 superblock) with no userblock \
1039 and no persisted free-space",
1040 ));
1041 }
1042 session.swmr_mode = true;
1043 session.set_consistency_flags(SWMR_WRITE_FLAGS)?;
1044 Ok(session)
1045 }
1046
1047 /// Set the superblock's consistency flags in the mirror and on disk, then
1048 /// flush. Used to raise the SWMR-write flag on open and clear it on close.
1049 /// Requires a base-0, version-2/3 file (checked by `open_swmr_writer`), since
1050 /// [`Superblock::serialize`] emits the v2/v3 layout at the base address.
1051 pub(crate) fn set_consistency_flags(&mut self, flags: u32) -> Result<(), Error> {
1052 self.superblock.consistency_flags = flags;
1053 let bytes = self.superblock.serialize();
1054 self.write_at(self.sb_sig_off, &bytes)?;
1055 self.image.sync_data()?;
1056 Ok(())
1057 }
1058
1059 /// Shared open path. `lock = Some(policy)` acquires an exclusive OS lock under
1060 /// that policy (the ordinary read-write session); `lock = None` takes no lock
1061 /// at all (the SWMR writer — see [`open_swmr_writer`](Self::open_swmr_writer)).
1062 fn open_inner(path: &Path, lock: Option<FileLocking>) -> Result<Self, Error> {
1063 Self::open_imaged(path, lock, |handle, _len| {
1064 Ok(Box::new(Self::read_mirror(handle)?))
1065 })
1066 }
1067
1068 /// Read `handle` whole into a [`MirrorImage`]. The one place the engine
1069 /// still assumes it can hold the file, kept behind a named constructor so
1070 /// the mirrorless opens visibly do not call it.
1071 ///
1072 /// `read_to_end` reads from the handle's *current* cursor, and the open path
1073 /// has already read the superblock through it, so this rewinds first. Reading
1074 /// from wherever the last read landed would mirror a truncated file — with no
1075 /// error to say so, since a short mirror is a valid `Vec<u8>`.
1076 fn read_mirror(mut handle: fs::File) -> Result<MirrorImage, Error> {
1077 handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
1078 let mut data = Vec::new();
1079 handle.read_to_end(&mut data).map_err(Error::Io)?;
1080 Ok(MirrorImage::new(handle, data))
1081 }
1082
1083 /// Shared open path over any backing: acquire the handle (and, when asked,
1084 /// the exclusive lock), parse and validate the superblock through a borrowed
1085 /// view of the handle, and only then let `build` decide how the bytes are
1086 /// held.
1087 ///
1088 /// Every refusal comes before `build`, because `build` may read the whole
1089 /// file: reaching a refusal after it would spend `O(file size)` on a file
1090 /// that is then rejected — a 20 GB flagged file read into memory and thrown
1091 /// away. The superblock reads themselves are a few bounded windows either
1092 /// way, so nothing is read twice.
1093 ///
1094 /// `build` receives the file's length as well as the handle because a
1095 /// mirrorless image has to be told its end-of-file — it has no buffer whose
1096 /// length implies it.
1097 ///
1098 /// Nothing below this point reads the file as a slice, which is what lets
1099 /// one engine open a whole-file mirror, a mirrorless handle, and (in tests)
1100 /// a mirror that withholds its slice.
1101 fn open_imaged(
1102 path: &Path,
1103 lock: Option<FileLocking>,
1104 build: impl FnOnce(fs::File, u64) -> Result<Box<dyn FileImage>, Error>,
1105 ) -> Result<Self, Error> {
1106 let handle = fs::OpenOptions::new()
1107 .read(true)
1108 .write(true)
1109 .open(path)
1110 .map_err(Error::Io)?;
1111 // Acquire the exclusive lock before reading or mutating; the retained
1112 // `handle` holds it for the session's life. A `None` policy (SWMR) never
1113 // reaches `acquire_exclusive`, so no lock is ever taken.
1114 if let Some(policy) = lock {
1115 file_lock::acquire_exclusive(&handle, policy, path)?;
1116 }
1117 let len = handle.metadata().map_err(Error::Io)?.len();
1118 // Read the superblock through the handle itself, before any image owns
1119 // it. `probe` borrows, so it is gone by the time `build` takes the
1120 // handle; it leaves the handle's cursor wherever its last read ended,
1121 // which is why the mirror positions the handle before reading it whole.
1122 let probe = crate::image::BorrowedHandle::new(&handle, len);
1123 let sb_sig_off = signature::find_signature_in(&probe)?.to_usize()?;
1124 let mut superblock = Superblock::parse_from_source(&probe, sb_sig_off as u64)?;
1125
1126 if superblock.version > 3 {
1127 return Err(Error::EditUnsupported("unsupported superblock version"));
1128 }
1129 // Refuse a file a writer already holds, before anything is mutated. This
1130 // is the one exclusion the OS lock above cannot make: a SWMR writer
1131 // takes no lock, so its file is lock-free but flagged (issue #245).
1132 file_lock::check_status_flags(&superblock, file_lock::OpenIntent::Write, path)?;
1133 if superblock.offset_size != OFFSET_SIZE || superblock.length_size != LENGTH_SIZE {
1134 return Err(Error::EditUnsupported(
1135 "only 8-byte offsets and lengths are supported for in-place editing",
1136 ));
1137 }
1138 // A userblock shifts the whole HDF5 image forward by `base_address`: the
1139 // superblock sits at the base address and every stored address is relative
1140 // to it (the end-of-file address is the sole absolute field). The editor
1141 // supports this by reading at `stored + base` and writing back
1142 // `file_offset - base`. Only the canonical layout — superblock located
1143 // exactly at the base address (e.g. a MATLAB v7.3 `.mat` file's 512-byte
1144 // userblock) — is accepted; a base address that disagrees with the
1145 // superblock's location is a relocated or malformed file we will not rewrite.
1146 if superblock.base_address != sb_sig_off as u64 {
1147 return Err(Error::EditUnsupported(
1148 "a file whose superblock is not located at its base address is not editable in place",
1149 ));
1150 }
1151 // Normalize the root group address to an absolute file offset, exactly as
1152 // the reader does (`reader::parse_superblock`), so `resolve_path_any` and
1153 // the link-graph walk index the image correctly. It is converted back to a
1154 // stored (base-relative) address only when the superblock is serialized on
1155 // commit.
1156 superblock.root_group_address = superblock
1157 .root_group_address
1158 .checked_add(superblock.base_address)
1159 .ok_or(FormatError::OffsetOverflow {
1160 offset: superblock.root_group_address,
1161 length: superblock.base_address,
1162 })?;
1163
1164 // Everything that can refuse this file has run; only now is it worth
1165 // holding the bytes.
1166 let image = build(handle, len)?;
1167
1168 let mut session = Self {
1169 image,
1170 sb_sig_off,
1171 superblock,
1172 pending_datasets: Vec::new(),
1173 pending_writes: Vec::new(),
1174 pending_appends: Vec::new(),
1175 pending_groups: Vec::new(),
1176 pending_group_attrs: Vec::new(),
1177 pending_dataset_attrs: Vec::new(),
1178 pending_deletes: Vec::new(),
1179 pending_copies: Vec::new(),
1180 pending_cross_copies: Vec::new(),
1181 free: FreeList::new(),
1182 persist: None,
1183 located: HashMap::new(),
1184 swmr_mode: false,
1185 paged: None,
1186 committed: false,
1187 resolved: HashMap::new(),
1188 batched_appends: false,
1189 bounded: false,
1190 fsm_len: len,
1191 };
1192 // If the file persists its free space, seed the free list from the
1193 // on-disk managers and arm persistence for future commits. Best-effort:
1194 // an unreadable or non-persisting extension simply leaves the session in
1195 // the default, non-persisting mode.
1196 session.load_persisted_free_space();
1197 Ok(session)
1198 }
1199
1200 /// Read the superblock-extension File Space Info message; if it requests
1201 /// persistence, seed [`self.free`](Self::free) from the on-disk free-space
1202 /// managers and record the manager/extension block extents for reclamation on
1203 /// the next commit. Silent on any malformed or absent metadata — persistence
1204 /// is then simply off for this session.
1205 fn load_persisted_free_space(&mut self) {
1206 if self.superblock.version < 2 {
1207 return; // no superblock extension exists before v2
1208 }
1209 let Some(ext_rel) = self.superblock.superblock_extension_address else {
1210 return;
1211 };
1212 if ext_rel == UNDEF {
1213 return;
1214 }
1215 // The extension address is stored relative to the base address, so it is
1216 // shifted to an absolute file offset before the header is read. This is a
1217 // no-op on the base-0 file every path below the userblock check sees, but
1218 // that check itself needs the strategy of a *userblock* file.
1219 let Ok(ext_addr) = ext_rel
1220 .checked_add(self.superblock.base_address)
1221 .ok_or(())
1222 .and_then(|a| usize::try_from(a).map_err(|_| ()))
1223 else {
1224 return;
1225 };
1226 let Some(info) = self.extension_fsinfo(ext_addr) else {
1227 return;
1228 };
1229 // Free-space reuse and persistence are not yet base-address aware: the
1230 // persisted section addresses (and the extension/manager block walk below)
1231 // are read as absolute, so on a userblock file they would seed `self.free`
1232 // with wrong regions that `alloc_or_append` could later hand out into live
1233 // data. Leave persistence off for such a file — the on-disk managers stay
1234 // untouched and valid, this session simply appends rather than reusing.
1235 //
1236 // A *paged* userblock file is a different matter: appending without page
1237 // awareness would mix metadata and raw data in its pages and leave its end
1238 // of allocation unaligned, quietly producing a file that still claims the
1239 // paged strategy but no longer satisfies it. Install the paged marker
1240 // without persistence so the commit refusal below catches it, which is the
1241 // same rule a paged non-persisting file already takes.
1242 if self.superblock.base_address != 0 {
1243 if info.strategy == FileSpaceStrategy::Page && info.page_size > 0 {
1244 self.paged = Some(PagedEdit::new(info.page_size));
1245 }
1246 return;
1247 }
1248 // Record the paged strategy regardless of the persist flag: a paged commit
1249 // needs page-aware bookkeeping, and a paged file that does not persist its
1250 // free space is refused outright (see `PagedEdit` and the commit refusal).
1251 //
1252 // A zero page size is refused rather than installed: every page calculation
1253 // divides by it, so a corrupt or hostile file declaring `Page` with a page
1254 // size of 0 would panic the editor. Leaving `paged` unset makes the file
1255 // take the ordinary flat path, which needs no page geometry.
1256 let paged = info.strategy == FileSpaceStrategy::Page && info.page_size > 0;
1257 if paged {
1258 self.paged = Some(PagedEdit::new(info.page_size));
1259 }
1260 if !info.persist {
1261 return;
1262 }
1263 let os = self.superblock.offset_size;
1264 let file_len = self.image.len();
1265
1266 // Seed the free list(s) with every persisted section (addresses are stored
1267 // relative to the base address, which this editor requires to be 0).
1268 // Defensive against a malformed or corrupt manager: skip a section that is
1269 // empty, runs past end-of-file, or overlaps one already taken. A
1270 // well-formed file (this crate's or the C library's) has none of these;
1271 // tolerating them keeps a bad file from seeding a bogus or double-counted
1272 // free region that a later commit would hand out into live data.
1273 if paged {
1274 // A paged file's free space is segregated across per-page-type
1275 // managers, so read each slot on its own and keep the page type its
1276 // slot implies: SUPER (slot 0) is metadata, DRAW (slot 2) is small raw,
1277 // and the generic-large manager (slot 6) holds large-raw fragments.
1278 // Flattening them (as the non-paged path below does) would lose exactly
1279 // the distinction the commit has to preserve.
1280 let mut tagged: Vec<(FreeSection, PageType, bool)> = Vec::new();
1281 for (slot, &m) in info.manager_addrs.iter().enumerate() {
1282 if m == UNDEF {
1283 continue;
1284 }
1285 let Ok(sections) =
1286 free_space_manager::read_persisted_sections_source(&self.image(), &[m], 0, os)
1287 .map(|(sections, _)| sections)
1288 else {
1289 continue;
1290 };
1291 // slot 6 is the generic-large manager; slot 2 is small raw; every
1292 // other slot a genuine paged file uses is metadata.
1293 let (ty, large) = match slot {
1294 2 => (PageType::Raw, false),
1295 6 => (PageType::Raw, true),
1296 _ => (PageType::Meta, false),
1297 };
1298 for s in sections {
1299 tagged.push((s, ty, large));
1300 }
1301 }
1302 tagged.sort_by_key(|(s, _, _)| s.addr);
1303 let mut prev_end = 0u64;
1304 for (s, ty, large) in tagged {
1305 let Some(end) = s.addr.checked_add(s.size) else {
1306 continue;
1307 };
1308 if s.size == 0 || end > file_len || s.addr < prev_end {
1309 continue;
1310 }
1311 prev_end = end;
1312 let pg = self
1313 .paged
1314 .as_mut()
1315 .expect("the paged state was just installed");
1316 // Keep a section in the manager it came from rather than
1317 // re-deriving its class from its size: a large-raw fragment is
1318 // smaller than a page, so size alone cannot tell it from a DRAW
1319 // section.
1320 if large {
1321 pg.raw_large.free(s.addr, s.size);
1322 } else {
1323 match ty {
1324 PageType::Meta => pg.meta.free(s.addr, s.size),
1325 PageType::Raw => pg.raw_small.free(s.addr, s.size),
1326 }
1327 }
1328 }
1329 } else if let Ok(mut sections) = free_space_manager::read_persisted_sections_source(
1330 &self.image(),
1331 &info.manager_addrs,
1332 0,
1333 os,
1334 )
1335 .map(|(sections, _)| sections)
1336 {
1337 sections.sort_by_key(|s| s.addr);
1338 let mut prev_end = 0u64;
1339 for s in sections {
1340 let Some(end) = s.addr.checked_add(s.size) else {
1341 continue;
1342 };
1343 if s.size == 0 || end > file_len || s.addr < prev_end {
1344 continue;
1345 }
1346 prev_end = end;
1347 self.free.free(s.addr, s.size);
1348 }
1349 }
1350
1351 // Record the byte extents of the blocks the live file uses so the next
1352 // persisting commit frees them when it writes replacements: the
1353 // extension header, and each defined manager's FSHD + FSSE.
1354 let mut old_blocks = Vec::new();
1355 if let Ok(spans) = self.oh_chunk_spans(ext_addr) {
1356 old_blocks.extend(spans);
1357 }
1358 for &m in &info.manager_addrs {
1359 if m == UNDEF {
1360 continue;
1361 }
1362 let Ok(hdr_len) = fshd_len(os).to_usize() else {
1363 continue;
1364 };
1365 let Ok(fshd) = self.image().read_metadata_at(m, hdr_len) else {
1366 continue;
1367 };
1368 if let Ok(h) = FsmHeader::parse(&fshd, os) {
1369 // `FsmHeader::parse` succeeding guarantees the header's own bytes
1370 // are present, so the FSHD extent is in-bounds; validate the
1371 // section-info extent before recording it, so a malformed
1372 // `fsse_used` can't later free a region running past end-of-file.
1373 old_blocks.push((m, fshd_len(os)));
1374 if h.fsse_addr != UNDEF
1375 && h.fsse_addr
1376 .checked_add(h.fsse_used)
1377 .is_some_and(|end| end <= file_len)
1378 {
1379 old_blocks.push((h.fsse_addr, h.fsse_used));
1380 }
1381 }
1382 }
1383
1384 self.persist = Some(PersistState {
1385 strategy: info.strategy,
1386 threshold: info.threshold,
1387 page_size: info.page_size,
1388 old_blocks,
1389 });
1390 }
1391
1392 /// Parse the File Space Info message out of the superblock-extension object
1393 /// header at `ext_addr`, if present and readable.
1394 fn extension_fsinfo(&self, ext_addr: usize) -> Option<FileSpaceInfo> {
1395 let os = self.superblock.offset_size;
1396 let ls = self.superblock.length_size;
1397 let base = self.superblock.base_address;
1398 let oh =
1399 ObjectHeader::parse_from_source(&self.image(), ext_addr as u64, os, ls, base).ok()?;
1400 let msg = oh
1401 .messages
1402 .iter()
1403 .find(|m| m.msg_type == MessageType::FileSpaceInfo)?;
1404 FileSpaceInfo::parse(&msg.data, os, ls).ok()
1405 }
1406
1407 /// Stage a new dataset, added on the next [`commit`](Self::commit).
1408 ///
1409 /// `path` is the dataset's full path; everything before the last component
1410 /// names the parent group, which must exist (or be created in this session).
1411 /// `builder` is the same [`DatasetBuilder`] [`FileBuilder`](crate::FileBuilder)
1412 /// uses, configured by the caller; its name is taken from `path`, so the two
1413 /// cannot disagree.
1414 ///
1415 /// The builder is passed in finished rather than handed out as a `&mut` into
1416 /// this engine. That is deliberate: a borrow into the engine forces the
1417 /// caller to hold it — and, above this layer, its lock — for as long as the
1418 /// builder is being configured, which is what let a user closure deadlock
1419 /// against the same file it was reading (issue #200).
1420 ///
1421 /// The dataset may be contiguous or chunked, and chunked datasets may be
1422 /// filtered (`with_deflate`, `with_shuffle`, `with_fletcher32`,
1423 /// `with_scale_offset`, `with_zfp`) and/or extensible (`with_maxshape`). An
1424 /// empty (zero-element) contiguous dataset is supported (chunking one is
1425 /// not), a provenance dataset (`with_provenance`) is supported, and a
1426 /// contiguous dataset may carry variable-length attributes, a
1427 /// variable-length-string payload (`with_vlen_strings`), or path-resolved
1428 /// object-reference elements (`with_path_references`; chunking any of
1429 /// these is not supported, and dense attributes remain unsupported).
1430 pub(crate) fn stage_created_dataset(&mut self, path: &str, mut builder: DatasetBuilder) {
1431 let mut comps = split_path(path);
1432 builder.name = comps.pop().unwrap_or_default();
1433 self.pending_datasets.push((comps, builder));
1434 }
1435
1436 /// Stage an in-place overwrite of an **existing** dataset's values (the HDF5
1437 /// `H5Dwrite` whole-dataset write), applied on the next
1438 /// [`commit`](Self::commit).
1439 ///
1440 /// `path` is the full path of a dataset that must already exist; `builder`
1441 /// supplies the replacement data and is named from `path`, as in
1442 /// [`stage_created_dataset`](Self::stage_created_dataset).
1443 ///
1444 /// This is a *value* overwrite, not a reshape or retype: the new data's
1445 /// datatype and shape must match the on-disk dataset's exactly (byte-for-byte
1446 /// after serialization, so endianness and compound layout must agree), or
1447 /// `commit` reports [`Error::EditUnsupported`]. Contiguous, compact, and
1448 /// chunked (including filtered) datasets are all supported; the dataset's
1449 /// existing chunk geometry, filter pipeline, and chunk index are taken from the
1450 /// on-disk header (a builder that itself requests chunking/filtering is refused
1451 /// as "not a value overwrite"). A chunk index this engine cannot enumerate (a
1452 /// version-2 B-tree) is refused. Partial / sub-region writes are out of scope —
1453 /// the whole dataset is replaced.
1454 ///
1455 /// When the new data is the same length as the existing contiguous data block
1456 /// (the common case), the bytes are written straight into that block: no
1457 /// object header is rewritten and the superblock root is not flipped, so the
1458 /// commit's linearization point is the synced data write itself. A chunked
1459 /// dataset is handled the same way when every (re-encoded) chunk is the same
1460 /// byte length as the slot it replaces — an unfiltered overwrite (chunk sizes
1461 /// are fixed by the unchanged shape) or a filtered one whose re-encoded chunks
1462 /// match — so it too writes straight into the existing chunk slots. When the
1463 /// length differs (a resized contiguous block, or a filtered chunk that no
1464 /// longer fits), the dataset's storage is rebuilt at end-of-file (or in
1465 /// reusable freed space), the old extent is freed, the data-layout message is
1466 /// repointed, the object header is rewritten, and the parent group's link is
1467 /// patched — exactly like an addition relocates the path up to the root. A
1468 /// relocating overwrite moves the object header, so it is refused unless the
1469 /// dataset has a single hard link.
1470 pub(crate) fn stage_dataset_write(&mut self, path: &str, mut builder: DatasetBuilder) {
1471 let comps = split_path(path);
1472 builder.name = comps.last().cloned().unwrap_or_default();
1473 self.pending_writes.push((comps, builder));
1474 }
1475
1476 /// Stage an append of new elements to an **existing** chunked, unlimited
1477 /// dataset, applied on the next [`commit`](Self::commit).
1478 ///
1479 /// `path` names a dataset that must already exist; `builder` supplies the
1480 /// elements to add via its typed / generic / raw `append_*` methods.
1481 ///
1482 /// Unlike [`stage_dataset_write`](Self::stage_dataset_write) (a value
1483 /// overwrite that forbids any shape change) this **grows** the dataset along
1484 /// its first (axis-0) dimension. It works on **filtered** datasets: the
1485 /// appended chunks are compressed through the dataset's own on-disk filter
1486 /// pipeline (deflate / shuffle / fletcher32 / scale-offset / LZF, and ZFP
1487 /// with the `zfp` feature), and the pipeline, datatype, fill value, and attributes are
1488 /// preserved verbatim. Appends of any length are supported — when the
1489 /// dataset's current length is not a whole multiple of the chunk length, the
1490 /// single trailing partial chunk is read, extended, and re-encoded; every
1491 /// other existing chunk is carried by metadata alone, so the existing data is
1492 /// not rewritten and the file does not grow by the whole dataset per append.
1493 ///
1494 /// This does **not** use SWMR and sets no consistency flag. Like every other
1495 /// staged edit it commits by appending the new chunks and a rebuilt
1496 /// index at end-of-file and repointing the superblock last (under the
1497 /// session's exclusive lock), so a crash leaves either the original dataset or
1498 /// the fully-grown one, never a torn state.
1499 ///
1500 /// The first release supports the Extensible-Array chunk index (the index the
1501 /// reference C library and h5py select for a single unlimited dimension under
1502 /// the latest format, and the one this crate writes for every unlimited
1503 /// dataset), rank-1 datasets, and datasets with a single hard link. A dataset
1504 /// that is not chunked, not unlimited along axis 0, not Extensible-Array
1505 /// indexed, higher than rank 1, uses a filter this engine cannot re-encode,
1506 /// has a sparse chunk grid, or (for [`append_raw`](AppendBuilder::append_raw))
1507 /// has a big-endian element datatype is refused with
1508 /// [`Error::AppendUnsupported`]. Use [`Dataset::is_chunked`](crate::Dataset::is_chunked),
1509 /// [`maxshape`](crate::Dataset::maxshape), and [`filters`](crate::Dataset::filters)
1510 /// to check eligibility up front.
1511 pub(crate) fn stage_dataset_append(&mut self, path: &str, builder: AppendBuilder) {
1512 self.pending_appends.push((split_path(path), builder));
1513 }
1514
1515 /// Whether any staged tree edit is still uncommitted. In-place appends
1516 /// ([`append_inplace_gathered`](Self::append_inplace_gathered)) are applied immediately and are
1517 /// never staged, so they never affect this; it reflects only edits awaiting
1518 /// [`commit`](Self::commit) — `create_group`, `create_dataset`,
1519 /// `write_dataset`, `append_dataset`, group and dataset attribute edits,
1520 /// `delete`, `copy`, and `copy_from`. Dropping the session silently discards
1521 /// any staged edits.
1522 pub fn has_staged_edits(&self) -> bool {
1523 !self.pending_datasets.is_empty()
1524 || !self.pending_writes.is_empty()
1525 || !self.pending_appends.is_empty()
1526 || !self.pending_groups.is_empty()
1527 || !self.pending_group_attrs.is_empty()
1528 || !self.pending_dataset_attrs.is_empty()
1529 || !self.pending_deletes.is_empty()
1530 || !self.pending_copies.is_empty()
1531 || !self.pending_cross_copies.is_empty()
1532 }
1533
1534 /// This session's file image as one slice, when its backing holds the whole
1535 /// file in memory; `None` for a file-backed image.
1536 ///
1537 /// The slice reflects committed state plus immediate in-place appends, not
1538 /// edits still staged for `commit`. The owned read-write
1539 /// [`File`](crate::File) uses it to serve reads by borrowing rather than
1540 /// copying, and falls back to [`image`](Self::image) when it is absent.
1541 pub(crate) fn image_slice(&self) -> Option<&[u8]> {
1542 self.image.as_slice()
1543 }
1544
1545 /// A random-access [`Source`] view of this session's file image, for the
1546 /// parsers the edit engine drives.
1547 ///
1548 /// Every read the engine performs against the file goes through this, so the
1549 /// image needs to be no more than a source of bytes — whole-file mirror or
1550 /// not — without the parsers knowing which (issue #198).
1551 pub(crate) fn image(&self) -> &dyn Source {
1552 self.image.as_ref()
1553 }
1554
1555 /// This session's parsed superblock, with `root_group_address` normalized to
1556 /// an absolute file offset (the open-time convention) and `base_address` the
1557 /// userblock size. A relocating commit updates it, so a caller holding a
1558 /// clone from an earlier moment may be reading a stale root.
1559 pub(crate) fn superblock(&self) -> &Superblock {
1560 &self.superblock
1561 }
1562
1563 /// Which backend this session resolved to: [`Bounded`] when it reads through
1564 /// a handle, [`Mirrored`] when it holds a whole-file image.
1565 ///
1566 /// [`Bounded`]: EditBacking::Bounded
1567 /// [`Mirrored`]: EditBacking::Mirrored
1568 pub(crate) fn edit_backing(&self) -> EditBacking {
1569 if self.bounded {
1570 EditBacking::Bounded
1571 } else {
1572 EditBacking::Mirrored
1573 }
1574 }
1575
1576 /// A snapshot of this session's live space usage — the current file size and
1577 /// the free space it can reuse — as a [`SpaceAccounting`].
1578 ///
1579 /// This is the mutating-session analogue of the read-only accounting on
1580 /// [`File`](crate::File): it answers "how big is the file right now, and how
1581 /// much space can be reused before it must grow?" from the session's own live
1582 /// state. The snapshot reflects the committed file plus any immediate in-place
1583 /// appends ([`append_inplace_gathered`](Self::append_inplace_gathered)) but excludes edits still
1584 /// staged for the next [`commit`](Self::commit); see [`SpaceAccounting`] for
1585 /// the field-by-field semantics and [`has_staged_edits`](Self::has_staged_edits)
1586 /// for detecting pending work.
1587 ///
1588 /// On a paged file (`H5F_FSPACE_STRATEGY_PAGE`) the reported regions are the
1589 /// union of the per-page-type managers. They are recorded and handed back to
1590 /// the reference library, but a commit does not draw on them: a hole belongs
1591 /// to one page type, and reusing it for the other kind of allocation would
1592 /// re-mix its page, so such a commit appends instead.
1593 ///
1594 /// ```no_run
1595 /// use hdf5_pure::File;
1596 ///
1597 /// let file = File::open_rw("existing.h5")?;
1598 /// let acct = file.space_accounting()?;
1599 /// println!(
1600 /// "{} bytes on disk, {} reusable in {} free region(s)",
1601 /// acct.logical_size,
1602 /// acct.reusable_free_bytes,
1603 /// acct.reusable_free_space.len(),
1604 /// );
1605 /// # Ok::<(), hdf5_pure::Error>(())
1606 /// ```
1607 #[must_use]
1608 pub fn space_accounting(&self) -> SpaceAccounting {
1609 // A paged file tracks its free space per page type; report the union, since
1610 // the caller wants one total rather than a per-manager breakdown.
1611 let reusable_free_space = match &self.paged {
1612 Some(pg) => pg.all_sections(),
1613 None => self.free.sections(),
1614 };
1615 let reusable_free_bytes = reusable_free_space.iter().map(|(_, len)| len).sum();
1616 SpaceAccounting {
1617 logical_size: self.image.len(),
1618 reusable_free_bytes,
1619 reusable_free_space,
1620 }
1621 }
1622
1623 /// The shared Extensible-Array append engine's view of this session: the
1624 /// image, the superblock, and the paged-file state, paired as [`EditStore`].
1625 ///
1626 /// Takes `&mut self`, so a caller that also needs [`located`](Self::located)
1627 /// borrowed at the same time must destructure the fields itself rather than
1628 /// call this.
1629 fn store(&mut self) -> EditStore<'_> {
1630 EditStore {
1631 image: self.image.as_mut(),
1632 superblock: &mut self.superblock,
1633 sb_sig_off: self.sb_sig_off,
1634 paged: self.paged.as_mut(),
1635 }
1636 }
1637
1638 /// Flush this session's writes durably, data and metadata both: the final
1639 /// barrier [`File::close`](crate::File::close) issues before sealing the
1640 /// file. Each immediate append is already durable on its own; this covers
1641 /// the file length a preceding truncate or manager rewrite changed.
1642 pub(crate) fn sync(&mut self) -> Result<(), Error> {
1643 self.image.sync_all()
1644 }
1645
1646 /// Rewrite the on-disk free-space managers into canonical (manager-at-tail)
1647 /// shape for a file that persists them, if this session left them stale.
1648 ///
1649 /// Immediate in-place appends grow the file at end-of-file, which pushes the
1650 /// managers into the middle of it with live data after them. A staged
1651 /// [`commit`](Self::commit) re-homes them as part of its tail, but a session
1652 /// that only appends never runs one, so [`File::close`](crate::File::close)
1653 /// and `FileInner::drop` call this instead. It is the same tail the commit
1654 /// writes — appended past everything live, with the superblock repoint as the
1655 /// crash-atomic linearization point — with nothing to free and the root
1656 /// unchanged.
1657 ///
1658 /// A no-op for a non-persisting file, and skipped when the file has not grown
1659 /// past the managers since they were last written, so an unchanged session
1660 /// never grows the file.
1661 ///
1662 /// If a session that grew the file ends without `close` or `drop` running (a
1663 /// true crash — `SIGKILL`, power loss), the managers are left mid-file. Every
1664 /// append was durable and crash-atomic, so no data is lost, and both this
1665 /// crate and the reference C library reopen the file and read it correctly;
1666 /// the managers are simply non-canonical until a clean rewrite.
1667 pub(crate) fn finalize_persist(&mut self) -> Result<(), Error> {
1668 if self.persist.is_none() || self.image.len() == self.fsm_len {
1669 return Ok(());
1670 }
1671 self.commit_persisting(self.superblock.root_group_address, Vec::new())
1672 }
1673
1674 /// Resolve and locate an in-place append target, applying every rule that
1675 /// does not depend on the bytes being appended: the file-level eligibility
1676 /// guards, the staged-edit conflict check, and the geometry lookup that
1677 /// populates [`located`](Self::located). Returns the dataset's object-header
1678 /// address, which is that cache's key.
1679 ///
1680 /// Split out from the append itself so [`append_geometry`](Self::append_geometry)
1681 /// can report a dataset's batching geometry under exactly the same rules the
1682 /// append will apply — a caller slicing a large append into batches must be
1683 /// refused before the first batch, not part-way through.
1684 fn append_prepare(&mut self, target: AppendTarget<'_>) -> Result<u64, Error> {
1685 // The fast in-place append is only sound on a base-0 latest-format file:
1686 // the slot math assumes absolute addresses and the superblock is patched in
1687 // place per call. A userblock or pre-v2 file falls back to the staged
1688 // `append_dataset`, which rebuilds the index and repoints the superblock
1689 // last.
1690 if self.superblock.base_address != 0 {
1691 return Err(Error::AppendInPlaceUnsupported(
1692 "in-place append does not support a file with a userblock (non-zero base \
1693 address); use Dataset::append_staged",
1694 ));
1695 }
1696 if self.superblock.version < 2 {
1697 return Err(Error::AppendInPlaceUnsupported(
1698 "in-place append requires a latest-format file (v2/v3 superblock); use \
1699 Dataset::append_staged",
1700 ));
1701 }
1702 // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
1703 // space has no on-disk record of which pages hold metadata and which hold
1704 // raw data, so neither this immediate append nor the staged commit can keep
1705 // the two segregated; refuse it outright. A paged *persisting* file appends
1706 // through the page-aware `EditStore`, which pads a tail page whenever the
1707 // page type changes, and has its managers rewritten at the next commit or
1708 // at close (issue #198).
1709 if self.paged.is_some() && self.persist.is_none() {
1710 return Err(Error::AppendInPlaceUnsupported(
1711 "in-place append is not supported on a paged file \
1712 (H5F_FSPACE_STRATEGY_PAGE) without persisted free space; recreate the \
1713 file with with_file_space_strategy(FileSpaceStrategy::Page, true, ..)",
1714 ));
1715 }
1716
1717 // Refuse an append against a dataset (or a subtree) that a still-staged edit
1718 // in this same session will relocate, replace, or delete — which would
1719 // strand the durably-appended rows or plan against a header the commit
1720 // moves. The caller must commit those edits first.
1721 //
1722 // A target named by object-header address cannot be compared against the
1723 // staged paths, so any staged edit at all disqualifies it. That is a
1724 // superset of the path check, and the remedy is the same one.
1725 match target {
1726 AppendTarget::Path(dataset) => {
1727 if self.append_conflicts_with_pending(&split_path(dataset)) {
1728 return Err(Error::AppendInPlaceUnsupported(
1729 "the dataset or an ancestor has a staged edit pending in this session; \
1730 commit the staged edits before appending in place, or use \
1731 Dataset::append_staged",
1732 ));
1733 }
1734 }
1735 AppendTarget::Header(_) if self.has_staged_edits() || self.committed => {
1736 return Err(Error::AppendInPlaceUnsupported(
1737 "this append target was reached by object reference, so it names a dataset \
1738 by object-header address, and this session has staged or committed edits \
1739 that can move that header; re-open the dataset by path to append to it",
1740 ));
1741 }
1742 AppendTarget::Header(_) => {}
1743 }
1744
1745 // Resolve the dataset's object-header address — the geometry cache key.
1746 // base == 0 here, so a resolved address is absolute; two hard links to
1747 // one dataset share the one entry.
1748 let oh_addr = match target {
1749 AppendTarget::Path(dataset) => match self.resolved.get(dataset) {
1750 Some(&addr) => addr,
1751 None => {
1752 let addr = crate::group_v2::resolve_path_any_from_source(
1753 &self.image(),
1754 &self.superblock,
1755 dataset,
1756 )
1757 .map_err(|_| {
1758 Error::AppendInPlaceUnsupported("nothing to append to at the given path")
1759 })?;
1760 self.resolved.insert(dataset.to_string(), addr);
1761 addr
1762 }
1763 },
1764 AppendTarget::Header(addr) => addr,
1765 };
1766
1767 // Locate the dataset on the first append (cache miss) against the session's
1768 // own image — no second lock, no second view of the file, no re-read.
1769 if !self.located.contains_key(&oh_addr) {
1770 let store = self.store();
1771 let state = locate_dataset_state(&store, oh_addr)?;
1772 self.located.insert(oh_addr, state);
1773 }
1774 Ok(oh_addr)
1775 }
1776
1777 /// The append geometry of the dataset `target` names, so a caller can slice a
1778 /// large append into aligned batches *before* materializing each batch's
1779 /// bytes — which is what keeps a bounded session's peak memory at one batch
1780 /// rather than the whole call.
1781 pub(crate) fn append_geometry(
1782 &mut self,
1783 target: AppendTarget<'_>,
1784 ) -> Result<AppendGeometry, Error> {
1785 let oh_addr = self.append_prepare(target)?;
1786 let st = &self.located[&oh_addr];
1787 let chunk_elems = st.loc.chunk_elems.max(1);
1788 Ok(AppendGeometry {
1789 chunk_elems,
1790 element_size: st.element_size,
1791 current_dim: st.loc.current_dim,
1792 filtered: st.pipeline.is_some(),
1793 full_batch_elems: self.batch_elems(st.loc.chunk_bytes, chunk_elems),
1794 })
1795 }
1796
1797 /// Whole-chunk elements in one append batch.
1798 ///
1799 /// A bounded session caps a batch at [`APPEND_BATCH_BYTES`] of raw data (at
1800 /// least one chunk) so peak memory is independent of the call size, at the
1801 /// cost of splitting one crash-atomic append into several: a crash between
1802 /// batches leaves a valid shorter dataset, exactly as if the caller had
1803 /// looped. A mirror session already holds the whole file, so bounding the
1804 /// call buys nothing there and would trade that atomicity away for free;
1805 /// it takes the whole append as one batch.
1806 fn batch_elems(&self, chunk_bytes: usize, chunk_elems: u64) -> u64 {
1807 if !self.batched_appends {
1808 return u64::MAX;
1809 }
1810 (APPEND_BATCH_BYTES / (chunk_bytes.max(1) as u64)).max(1) * chunk_elems
1811 }
1812
1813 /// Apply a gathered in-place append (typed / generic / raw bytes) to the
1814 /// dataset `target` names, immediately and crash-atomically, driving the
1815 /// shared Extensible-Array engine against the session's own image through an
1816 /// [`EditStore`] adapter. Runs only the first `max_phase` durability phases;
1817 /// production callers pass 4, the crash-consistency tests stop at a boundary
1818 /// to simulate a crash.
1819 ///
1820 /// A bounded session splits the call into whole-chunk batches (see
1821 /// [`batch_elems`](Self::batch_elems)), each its own crash-atomic apply.
1822 /// Every predictable refusal is raised before the first batch, so a rejected
1823 /// append leaves the file untouched rather than partly grown.
1824 ///
1825 /// `Dataset::append` slices its own call the same way before it reaches here,
1826 /// so through the public API this loop runs once per call; it batches for the
1827 /// benefit of a caller that hands the engine one large builder directly, whose
1828 /// bytes are already materialized but whose plan need not be.
1829 pub(crate) fn append_inplace_gathered(
1830 &mut self,
1831 target: AppendTarget<'_>,
1832 b: &AppendBuilder,
1833 max_phase: u8,
1834 ) -> Result<(), Error> {
1835 if b.dt_conflict() {
1836 return Err(Error::AppendInPlaceUnsupported(
1837 "append mixes element types in one call; use one element type per append",
1838 ));
1839 }
1840 let oh_addr = self.append_prepare(target)?;
1841
1842 // Validate the appended bytes against the on-disk datatype.
1843 let raw = b.raw();
1844 let new_elems = validate_gathered_append(&self.located[&oh_addr], b)?;
1845 if new_elems == 0 {
1846 return Ok(());
1847 }
1848
1849 // In SWMR mode, hold to the subset a concurrent reader can follow safely:
1850 // unfiltered (a filtered element is a multi-field record whose in-place
1851 // repoint is not power-loss atomic) and chunk-aligned (so an append only
1852 // ever inserts new, not-yet-visible elements and never rewrites a visible
1853 // trailing chunk out from under a reader).
1854 if self.swmr_mode {
1855 let st = &self.located[&oh_addr];
1856 if st.pipeline.is_some() {
1857 return Err(Error::SwmrAppendUnsupported(
1858 "filtered datasets are not supported for SWMR append",
1859 ));
1860 }
1861 let chunk_elems = st.loc.chunk_elems;
1862 if chunk_elems == 0
1863 || st.loc.current_dim % chunk_elems != 0
1864 || new_elems % chunk_elems != 0
1865 {
1866 return Err(Error::SwmrAppendUnsupported(
1867 "SWMR append must be chunk-aligned: the current length and the appended \
1868 length must both be whole multiples of the chunk length",
1869 ));
1870 }
1871 }
1872
1873 let (chunk_elems, elem_bytes, full_batch_elems, filtered, current_dim) = {
1874 let st = &self.located[&oh_addr];
1875 (
1876 st.loc.chunk_elems.max(1),
1877 st.element_size as u64,
1878 self.batch_elems(st.loc.chunk_bytes, st.loc.chunk_elems.max(1)),
1879 st.pipeline.is_some(),
1880 st.loc.current_dim,
1881 )
1882 };
1883 // Refuse a non-chunk-aligned filtered append before ANY batch applies, so
1884 // the refusal is as atomic as an unbatched one. Left to `plan_ea_append`
1885 // it would surface only when the final (unaligned) batch was reached,
1886 // after earlier batches had durably committed.
1887 if filtered && (current_dim % chunk_elems != 0 || new_elems % chunk_elems != 0) {
1888 return Err(Error::AppendInPlaceUnsupported(
1889 "a filtered dataset can only be appended in place in whole chunks (the current \
1890 length and the appended length must both be multiples of the chunk length); \
1891 use Dataset::append_staged for a non-chunk-aligned filtered append",
1892 ));
1893 }
1894
1895 let mut done = 0u64;
1896 while done < new_elems {
1897 // Fill the trailing partial chunk first (so later batches start
1898 // chunk-aligned and never rewrite it again), then whole-chunk batches.
1899 // Filtered datasets are chunk-aligned by contract, so every batch stays
1900 // chunk-aligned there too.
1901 let current_dim = self.located[&oh_addr].loc.current_dim;
1902 let to_boundary = (chunk_elems - current_dim % chunk_elems) % chunk_elems;
1903 let take = (new_elems - done).min(to_boundary.saturating_add(full_batch_elems));
1904 let batch =
1905 &raw[(done * elem_bytes).to_usize()?..((done + take) * elem_bytes).to_usize()?];
1906
1907 // Read/plan phase (immutable borrows only, nothing published yet), then
1908 // the ordered, fsync-barriered write phase — both shared with
1909 // `Dataset::append` through the chunk-index engine. `EditStore` borrows
1910 // only the image-carrying fields, so `self.located` stays independently
1911 // borrowable.
1912 let plan_result = {
1913 let Self {
1914 image,
1915 superblock,
1916 sb_sig_off,
1917 paged,
1918 located,
1919 ..
1920 } = self;
1921 let st = &located[&oh_addr];
1922 let store = EditStore {
1923 image: image.as_mut(),
1924 superblock,
1925 sb_sig_off: *sb_sig_off,
1926 paged: paged.as_mut(),
1927 };
1928 plan_ea_append(
1929 &store,
1930 &st.loc,
1931 &st.datatype,
1932 &st.spatial,
1933 st.element_size,
1934 st.pipeline.as_ref(),
1935 batch,
1936 take,
1937 )
1938 };
1939 let plan = plan_result.map_err(as_inplace_error)?;
1940 {
1941 let Self {
1942 image,
1943 superblock,
1944 sb_sig_off,
1945 paged,
1946 located,
1947 ..
1948 } = self;
1949 let st = located.get_mut(&oh_addr).expect("dataset located above");
1950 let mut store = EditStore {
1951 image: image.as_mut(),
1952 superblock,
1953 sb_sig_off: *sb_sig_off,
1954 paged: paged.as_mut(),
1955 };
1956 apply_ea_append(&mut store, &mut st.loc, &plan, max_phase)
1957 .map_err(as_inplace_error)?;
1958 }
1959 if max_phase < 4 {
1960 // Crash-consistency hook: the caller asked to stop inside the first
1961 // batch's durability sequence, so there is no next batch.
1962 return Ok(());
1963 }
1964 done += take;
1965 }
1966 Ok(())
1967 }
1968
1969 /// Test-only phased in-place append (stops after `max_phase` durability phases)
1970 /// used by the crash-consistency tests, mirroring `Dataset::append`'s harness.
1971 #[cfg(test)]
1972 fn append_inplace_i32_phased(
1973 &mut self,
1974 dataset: &str,
1975 values: &[i32],
1976 max_phase: u8,
1977 ) -> Result<(), Error> {
1978 let mut b = AppendBuilder::new();
1979 b.append_i32(values);
1980 self.append_inplace_gathered(AppendTarget::Path(dataset), &b, max_phase)
1981 }
1982
1983 /// Whether `target` (an [`append_inplace_gathered`](Self::append_inplace_gathered) dataset path)
1984 /// or any of its ancestors is named by a staged edit that a later
1985 /// [`commit`](Self::commit) would relocate, replace, or delete. `create_group`
1986 /// and group-attribute edits are excluded: they rewrite a group header without
1987 /// moving a descendant dataset's header or freeing its storage, so they cannot
1988 /// stale the append geometry cache.
1989 fn append_conflicts_with_pending(&self, target: &[String]) -> bool {
1990 let hits = |p: &[String]| paths_overlap(target, p);
1991 self.pending_writes.iter().any(|(p, _)| hits(p))
1992 || self.pending_appends.iter().any(|(p, _)| hits(p))
1993 || self.pending_deletes.iter().any(|p| hits(p))
1994 || self.pending_copies.iter().any(|(_, dst)| hits(dst))
1995 || self.pending_cross_copies.iter().any(|(dst, _)| hits(dst))
1996 || self.pending_dataset_attrs.iter().any(|(p, _)| hits(p))
1997 || self.pending_datasets.iter().any(|(parent, db)| {
1998 let mut full = parent.clone();
1999 full.push(db.name.clone());
2000 paths_overlap(target, &full)
2001 })
2002 }
2003
2004 /// Stage a new (empty) group at `path`, created on the next
2005 /// [`commit`](Self::commit). The parent must already exist or be created in
2006 /// the same session; populate the group with datasets via
2007 /// [`create_dataset`](Self::create_dataset) using a path under it.
2008 pub fn create_group(&mut self, path: &str) {
2009 self.pending_groups.push(split_path(path));
2010 }
2011
2012 /// Stage an attribute add or replacement on a group, applied on the next
2013 /// [`commit`](Self::commit).
2014 ///
2015 /// `path` names the group to edit; `""` or `"/"` names the root group. The
2016 /// group may already exist or may be created earlier in the same session
2017 /// with [`create_group`](Self::create_group). Attributes — fixed-size or
2018 /// variable-length (`AttrValue::VarLenAsciiArray`) — are stored compactly in
2019 /// the rebuilt group header; an edit that would exceed the compact-attribute
2020 /// limit, or a group using dense (fractal-heap) attribute storage, is
2021 /// refused before any file bytes are changed.
2022 pub fn set_group_attr(&mut self, path: &str, name: &str, value: AttrValue) -> &mut Self {
2023 self.pending_group_attrs.push((
2024 split_path(path),
2025 AttrOp::Set {
2026 name: name.to_string(),
2027 value,
2028 },
2029 ));
2030 self
2031 }
2032
2033 /// Stage removal of a compact attribute from a group, applied on the next
2034 /// [`commit`](Self::commit).
2035 ///
2036 /// `path` names the group to edit; `""` or `"/"` names the root group. The
2037 /// named attribute must exist in the committed group state after any earlier
2038 /// staged attribute operations for the same group have been applied.
2039 pub fn remove_group_attr(&mut self, path: &str, name: &str) -> &mut Self {
2040 self.pending_group_attrs.push((
2041 split_path(path),
2042 AttrOp::Remove {
2043 name: name.to_string(),
2044 },
2045 ));
2046 self
2047 }
2048
2049 /// Stage an attribute add or replacement on an **existing dataset**, applied on
2050 /// the next [`commit`](Self::commit).
2051 ///
2052 /// `path` names the dataset to edit. Attributes — fixed-size or variable-length
2053 /// (`AttrValue::VarLenAsciiArray`) — are stored compactly in the rebuilt dataset
2054 /// header. Applying it relocates the dataset's object header (the header is
2055 /// rewritten and its single naming link repointed; the dataset's data and chunk
2056 /// index stay in place), so it is supported only when the dataset has a **single
2057 /// hard link**. An edit that would exceed the compact-attribute limit, or a
2058 /// dataset using dense (fractal-heap) attribute storage, is refused before any
2059 /// file bytes change. To set attributes on a dataset being *created* in this
2060 /// session, use the builder's [`set_attr`](crate::DatasetBuilder::set_attr)
2061 /// instead.
2062 pub fn set_dataset_attr(&mut self, path: &str, name: &str, value: AttrValue) -> &mut Self {
2063 self.pending_dataset_attrs.push((
2064 split_path(path),
2065 AttrOp::Set {
2066 name: name.to_string(),
2067 value,
2068 },
2069 ));
2070 self
2071 }
2072
2073 /// Stage removal of a compact attribute from an **existing dataset**, applied on
2074 /// the next [`commit`](Self::commit).
2075 ///
2076 /// `path` names the dataset to edit; the named attribute must exist in the
2077 /// committed dataset state after any earlier staged attribute operations for the
2078 /// same dataset have been applied. Like [`set_dataset_attr`](Self::set_dataset_attr)
2079 /// it relocates the dataset header and requires a single hard link.
2080 pub fn remove_dataset_attr(&mut self, path: &str, name: &str) -> &mut Self {
2081 self.pending_dataset_attrs.push((
2082 split_path(path),
2083 AttrOp::Remove {
2084 name: name.to_string(),
2085 },
2086 ));
2087 self
2088 }
2089
2090 /// Stage removal of the link at `path` (the HDF5 `H5Ldelete`), applied on the
2091 /// next [`commit`](Self::commit). The link's object — and, for a group, its
2092 /// whole subtree — becomes unreachable. The bytes it occupied are returned to
2093 /// this session's free list (issue #21): a later commit reuses them for new
2094 /// objects instead of growing the file, and if a freed run reaches
2095 /// end-of-file the file is truncated. Contiguous and chunked datasets (their
2096 /// chunk index and chunk data blocks) and whole group subtrees are all
2097 /// reclaimed. Reclaim is best-effort — an object whose blocks this engine
2098 /// cannot enumerate exhaustively (variable-length global-heap storage, dense
2099 /// attribute/link heaps, a version 2 B-tree chunk index) is left as dead
2100 /// bytes rather than risk freeing a region that is still in use. Freed space is
2101 /// reused within the open session; for a file created with
2102 /// `H5Pset_file_space_strategy(persist = true)` it is also recorded on disk so
2103 /// it survives reopen, otherwise it is forgotten
2104 /// on close. After reuse, an object reference to a deleted object may resolve
2105 /// to an unrelated object (deleting a referenced object is undefined in HDF5).
2106 ///
2107 /// The path must exist. A deletion may not overlap another staged change in
2108 /// the same commit (e.g. delete `/a` while adding `/a/b`); split such
2109 /// edits into separate commits. The link's parent group must itself be
2110 /// editable in place (compact links, single-chunk header); the target being
2111 /// removed has no such restriction.
2112 pub fn delete(&mut self, path: &str) {
2113 self.pending_deletes.push(split_path(path));
2114 }
2115
2116 /// Stage a deep copy of the object at `src` to a new link at `dst` (the HDF5
2117 /// `H5Ocopy`), applied on the next [`commit`](Self::commit). The source — a
2118 /// dataset or a whole group subtree — is duplicated: fresh copies of every
2119 /// object's data and header are written, internal links and the contiguous
2120 /// data address are repointed to the copies, and a link named by `dst`'s last
2121 /// component is added to `dst`'s parent group. The original is untouched.
2122 ///
2123 /// The copy reflects the file's on-disk state at commit time. `src` must
2124 /// exist and `dst` must not (and may not lie inside `src`). A chunked (and
2125 /// filtered) dataset is copied with its chunk payloads and filter pipeline
2126 /// preserved byte-for-byte (the index is rebuilt at the new location, so a
2127 /// source using a B-tree-v1 or implicit index is reproduced with an equivalent
2128 /// v4 index). The source subtree must otherwise be copyable in place: compact
2129 /// links and attributes, single-chunk headers, and a chunk index this engine
2130 /// can enumerate (a version-2 B-tree, or a sparse/unallocated chunk grid, is
2131 /// refused) — otherwise `commit` reports [`Error::EditUnsupported`].
2132 pub fn copy(&mut self, src: &str, dst: &str) {
2133 self.pending_copies.push((split_path(src), split_path(dst)));
2134 }
2135
2136 /// Stage a deep copy of the object at `src` in another open file `source` to a
2137 /// new link at `dst` in this file — a *cross-file* HDF5 `H5Ocopy` — applied on
2138 /// the next [`commit`](Self::commit). Like [`copy`](Self::copy) but the source
2139 /// lives in a separate, independently-opened [`File`](crate::File) reader
2140 /// rather than the file being edited.
2141 ///
2142 /// The source — a dataset or a whole group subtree — is duplicated faithfully:
2143 /// fresh, byte-identical copies of every object's header and data are appended
2144 /// to this file, internal links repointed, and a link named by `dst`'s last
2145 /// component added to `dst`'s parent group (which must already exist or be
2146 /// created earlier in this session). Both files are left otherwise untouched;
2147 /// the destination only changes on `commit`.
2148 ///
2149 /// Unlike the same-file [`copy`](Self::copy), the source is read **eagerly**
2150 /// here (the `source` borrow need not outlive the call), so this returns
2151 /// `Result`: the source subtree is resolved, validated, and read out before
2152 /// returning, and only an already-validated copy is queued for `commit`.
2153 ///
2154 /// # Errors
2155 ///
2156 /// Returns [`Error::EditUnsupported`] if the copy cannot be reproduced exactly
2157 /// in another file. Because the copy is byte-for-byte verbatim, anything that
2158 /// embeds a *source-file* absolute address is refused (it would dangle here):
2159 /// **variable-length** or **reference** datasets and attributes (including a
2160 /// chunked dataset whose elements are variable-length or references, whose
2161 /// chunk payloads embed such addresses), and any **shared header message** (a
2162 /// committed datatype, or an SOHM-shared dataspace, fill value, or filter
2163 /// pipeline). As with [`copy`](Self::copy) a chunked/filtered source is copied
2164 /// with its chunk payloads and pipeline preserved (index rebuilt at the new
2165 /// location); the source must use compact links and attributes, single-chunk
2166 /// version-2 headers, and a chunk index this engine can enumerate (a
2167 /// version-2 B-tree, or a sparse chunk grid, is refused). The
2168 /// `source` must be a buffered file ([`File::open`](crate::File::open) or
2169 /// [`File::from_bytes`](crate::File::from_bytes), not
2170 /// [`open_streaming`](crate::File::open_streaming)) using 8-byte offsets and no
2171 /// userblock, and `src` must exist in it and not be the root group.
2172 pub fn copy_from(
2173 &mut self,
2174 source: &crate::reader::File,
2175 src: &str,
2176 dst: &str,
2177 ) -> Result<(), Error> {
2178 // The source bytes must be addressable: a streaming file is refused.
2179 let src_data = source.in_memory_image().ok_or(Error::EditUnsupported(
2180 "cross-file copy requires a buffered source file (File::open or File::from_bytes), not a streaming one",
2181 ))?;
2182 let src_sb = source.superblock();
2183 if src_sb.offset_size != OFFSET_SIZE || src_sb.length_size != LENGTH_SIZE {
2184 return Err(Error::EditUnsupported(
2185 "cross-file copy requires the source file to use 8-byte offsets and lengths",
2186 ));
2187 }
2188 if source.base_address() != 0 {
2189 return Err(Error::EditUnsupported(
2190 "cross-file copy requires the source file to have no userblock (base address 0)",
2191 ));
2192 }
2193
2194 let src = split_path(src);
2195 if src.is_empty() {
2196 return Err(Error::EditUnsupported("cannot copy the root group"));
2197 }
2198 let dst = split_path(dst);
2199 if dst.is_empty() {
2200 return Err(Error::EditUnsupported("copy destination path is empty"));
2201 }
2202
2203 let src_addr = crate::group_v2::resolve_path_any(src_data, src_sb, &src.join("/"))
2204 .map_err(|_| Error::EditUnsupported("copy source does not exist in the source file"))?;
2205 // Read (and foreign-address-screen) the whole subtree now, while `source`
2206 // is borrowed; the owned tree carries every byte the commit will write. The
2207 // source is gated to base 0 above, so its stored addresses are absolute.
2208 let tree = Self::read_copy_subtree(&BytesSource::new(src_data), src_addr, 0, true, 0)?;
2209 self.pending_cross_copies.push((dst, tree));
2210 Ok(())
2211 }
2212
2213 /// Apply all staged additions and deletions to the file in place and flush.
2214 ///
2215 /// Appends each new dataset (its data — a contiguous blob, or the chunk data
2216 /// and index for a chunked/filtered dataset — plus its object header) and
2217 /// each new group, then appends rewritten object headers for every touched
2218 /// group and its ancestors up to the root (omitting any deleted links), then
2219 /// repoints the superblock at the new root. On success the staged set is
2220 /// cleared and the session can be reused. On any [`Error::EditUnsupported`]
2221 /// the file on disk is left untouched: the checks that raise it — including
2222 /// each dataset's filter-pipeline and chunk-geometry validation — all run
2223 /// before the first byte is written. Should a later step fail mid-apply (an
2224 /// I/O error, or a residual build error), the superblock — repointed last —
2225 /// still names the prior root, so the file stays valid and the appended bytes
2226 /// are unreferenced slack.
2227 pub fn commit(&mut self) -> Result<(), Error> {
2228 if self.pending_datasets.is_empty()
2229 && self.pending_writes.is_empty()
2230 && self.pending_appends.is_empty()
2231 && self.pending_groups.is_empty()
2232 && self.pending_group_attrs.is_empty()
2233 && self.pending_dataset_attrs.is_empty()
2234 && self.pending_deletes.is_empty()
2235 && self.pending_copies.is_empty()
2236 && self.pending_cross_copies.is_empty()
2237 {
2238 return Ok(());
2239 }
2240
2241 // A paged file (`H5F_FSPACE_STRATEGY_PAGE`) that does not persist its free
2242 // space has no on-disk record of which pages hold metadata and which hold
2243 // raw data, so this commit could not keep the two segregated and would
2244 // silently degrade the paging. Refuse up front, before any writes, exactly
2245 // as the bounded backend does. A paged *persisting* file is committed
2246 // through the page-aware tail below (issue #198).
2247 if self.paged.is_some() && self.persist.is_none() {
2248 return Err(Error::EditUnsupported(
2249 "committing an edit to a paged file (H5F_FSPACE_STRATEGY_PAGE) requires \
2250 persisted free space; recreate the file with \
2251 with_file_space_strategy(FileSpaceStrategy::Page, true, ..) to edit it in place",
2252 ));
2253 }
2254
2255 // Invalidate the in-place-append geometry cache before doing any work. A
2256 // commit that reaches here rewrites and relocates object headers, frees
2257 // vacated regions into `self.free`, and may truncate the file — any of
2258 // which can leave a cached `Located` pointing at a moved header or into a
2259 // now-free-eligible region. Clearing at *entry* (rather than the success
2260 // tail) means a later failure — including one after the durable root flip,
2261 // which leaves the session reusable — never strands a stale cache. The
2262 // no-op fast return above does no such work, so it keeps the cache. The
2263 // The next in-place append re-locates against the fresh file.
2264 self.located.clear();
2265 self.resolved.clear();
2266 // Past this point the commit may relocate object headers, so every address
2267 // a caller captured earlier is suspect; see `committed`.
2268 self.committed = true;
2269
2270 // On a file with a userblock, stored addresses are relative to this base
2271 // and the editor converts at every disk boundary (read `stored + base`,
2272 // write `file_offset - base`). Userblock support covers value overwrites,
2273 // additions of contiguous and chunked/filtered datasets, in-place and
2274 // relocating overwrites of every layout (chunked, contiguous, compact) with
2275 // reclaim, object deletion (with base-aware subtree reclaim), object copy
2276 // (in-file, and cross-file into a userblock destination), group creation,
2277 // and compact group attributes. Cross-file copy still requires a base-0
2278 // *source* (see [`copy_from`](Self::copy_from)).
2279 let base = self.superblock.base_address;
2280
2281 // --- Preflight value overwrites (`write_dataset`) before any write, under
2282 // the same all-or-nothing contract as additions. Each is resolved,
2283 // validated (datatype and shape must match the on-disk dataset exactly),
2284 // and classified: a same-length contiguous overwrite is applied straight
2285 // in place (no header rewrite, no superblock flip), while a resize or
2286 // compact rewrite relocates the header and is staged against its parent
2287 // group so the commit below rebuilds it and patches the link. ---
2288 let writes = std::mem::take(&mut self.pending_writes);
2289 let mut inplace_writes: Vec<(usize, Vec<u8>)> = Vec::new();
2290 let mut moving_writes: Vec<(PathKey, String, MovingWrite)> = Vec::new();
2291 let mut write_targets: Vec<PathKey> = Vec::new();
2292 // The file-wide hard-link count, computed lazily the first time a write
2293 // relocates a header: such a write moves the dataset's object header and
2294 // patches only the one parent link that names it, so a dataset reachable
2295 // through more than one hard link would have its other links left pointing
2296 // at the stale header. Refuse that rather than silently diverge the aliases
2297 // (a same-length in-place overwrite is unaffected — it rewrites the shared
2298 // data block, which every link sees).
2299 let mut incoming_links: Option<Option<HashMap<u64, u32>>> = None;
2300 for (full, db) in writes {
2301 if full.is_empty() {
2302 return Err(Error::EditUnsupported("cannot overwrite the root group"));
2303 }
2304 // A path named twice in one commit would write it twice (and double-
2305 // free a resized extent); require separate commits.
2306 if write_targets.contains(&full) {
2307 return Err(Error::EditUnsupported(
2308 "the same dataset is overwritten twice in one commit; use separate commits",
2309 ));
2310 }
2311 let path_str = full.join("/");
2312 let addr = crate::group_v2::resolve_path_any_from_source(
2313 &self.image(),
2314 &self.superblock,
2315 &path_str,
2316 )
2317 .map_err(|_| Error::EditUnsupported("nothing to overwrite at the given path"))?;
2318 let addr = usize::try_from(addr)
2319 .map_err(|_| Error::EditUnsupported("dataset address exceeds this platform"))?;
2320 let fd = flatten_dataset(db)?;
2321 match Self::prepare_write(&self.image(), addr as u64, &fd, base)? {
2322 WritePlan::InPlace { data_addr, raw } => inplace_writes.push((data_addr, raw)),
2323 WritePlan::InPlaceChunks { writes } => inplace_writes.extend(writes),
2324 WritePlan::Moving(mw) => {
2325 // A relocating overwrite rewrites the dataset's header and data
2326 // address. Every variant is base-aware on a userblock file: the
2327 // chunked one rebuilds the chunk blob with stored addresses and
2328 // reclaims the old storage base-relative, the contiguous one
2329 // stores the relocated data address base-relative (and frees the
2330 // old extent at its absolute offset), and the compact one carries
2331 // its data inline. The parent link to the rewritten header is
2332 // patched base-relative below.
2333 //
2334 // A relocating overwrite is safe only when this is the
2335 // dataset's sole hard link. Compute the link graph once.
2336 let counts = incoming_links
2337 .get_or_insert_with(|| self.count_incoming_hard_links())
2338 .as_ref();
2339 match counts.and_then(|c| c.get(&(addr as u64))) {
2340 Some(&1) => {}
2341 _ => {
2342 return Err(Error::EditUnsupported(
2343 "overwriting a dataset that resizes or relocates its header is \
2344 only supported when it has a single hard link",
2345 ));
2346 }
2347 }
2348 let leaf = full.last().unwrap().clone();
2349 let parent = full[..full.len() - 1].to_vec();
2350 moving_writes.push((parent, leaf, mw));
2351 }
2352 }
2353 write_targets.push(full);
2354 }
2355
2356 // --- Preflight appends (`append_dataset`) under the same all-or-nothing,
2357 // single-hard-link contract. Each plans a relocating append — existing
2358 // chunk data stays in place; the appended (and any rewritten trailing)
2359 // chunks and a rebuilt Extensible-Array index are staged, and the whole is
2360 // treated like a relocating overwrite of the dataset's header (staged
2361 // against its parent group so the commit patches the link). A zero-length
2362 // append is a no-op and is dropped here. ---
2363 let appends = std::mem::take(&mut self.pending_appends);
2364 for (full, ab) in appends {
2365 if full.is_empty() {
2366 return Err(Error::AppendUnsupported("cannot append to the root group"));
2367 }
2368 if ab.raw.is_empty() {
2369 continue; // nothing to append
2370 }
2371 // A dataset overwritten or appended earlier in this commit would be
2372 // planned against a stale header and its old storage double-freed;
2373 // require separate commits.
2374 if write_targets.contains(&full) {
2375 return Err(Error::AppendUnsupported(
2376 "the same dataset is edited more than once in one commit; use separate commits",
2377 ));
2378 }
2379 let path_str = full.join("/");
2380 let addr = crate::group_v2::resolve_path_any_from_source(
2381 &self.image(),
2382 &self.superblock,
2383 &path_str,
2384 )
2385 .map_err(|_| Error::AppendUnsupported("nothing to append to at the given path"))?;
2386 let addr = usize::try_from(addr)
2387 .map_err(|_| Error::AppendUnsupported("dataset address exceeds this platform"))?;
2388 let mw = Self::prepare_append(&self.image(), addr as u64, &ab, base)?;
2389 // A relocating append moves the dataset's object header and patches only
2390 // the one parent link that names it, so it is safe only when this is the
2391 // dataset's sole hard link (same rule as a relocating overwrite).
2392 let counts = incoming_links
2393 .get_or_insert_with(|| self.count_incoming_hard_links())
2394 .as_ref();
2395 match counts.and_then(|c| c.get(&(addr as u64))) {
2396 Some(&1) => {}
2397 _ => {
2398 return Err(Error::AppendUnsupported(
2399 "appending relocates the dataset header; only supported when it \
2400 has a single hard link",
2401 ));
2402 }
2403 }
2404 let leaf = full.last().unwrap().clone();
2405 let parent = full[..full.len() - 1].to_vec();
2406 moving_writes.push((parent, leaf, mw));
2407 write_targets.push(full);
2408 }
2409
2410 // --- Preflight dataset attribute edits (`set_dataset_attr` /
2411 // `remove_dataset_attr`) under the same all-or-nothing, single-hard-link
2412 // contract. Each gathers the dataset's verbatim object-header region,
2413 // applies the compact attribute ops to it, and stages a relocating
2414 // `AttrEdit` header rewrite against the parent group — like a value
2415 // overwrite, but the data-layout message (and thus the chunk data and index)
2416 // is preserved verbatim, so only the header moves. ---
2417 let dataset_attrs = std::mem::take(&mut self.pending_dataset_attrs);
2418 if !dataset_attrs.is_empty() {
2419 // Collect the ops per dataset in first-seen path order, so multiple edits
2420 // to one dataset produce a single relocating header rewrite.
2421 let mut order: Vec<PathKey> = Vec::new();
2422 let mut ops_by_path: HashMap<PathKey, Vec<AttrOp>> = HashMap::new();
2423 for (path, op) in dataset_attrs {
2424 if !ops_by_path.contains_key(&path) {
2425 order.push(path.clone());
2426 }
2427 ops_by_path.entry(path).or_default().push(op);
2428 }
2429 for full in order {
2430 let ops = ops_by_path.remove(&full).unwrap();
2431 if full.is_empty() {
2432 return Err(Error::EditUnsupported(
2433 "cannot set a dataset attribute on the root group; use set_group_attr",
2434 ));
2435 }
2436 // A dataset already overwritten or appended in this commit would be
2437 // planned against a stale header; require separate commits.
2438 if write_targets.contains(&full) {
2439 return Err(Error::EditUnsupported(
2440 "the same dataset is edited more than once in one commit (an attribute \
2441 edit plus another edit); use separate commits",
2442 ));
2443 }
2444 let path_str = full.join("/");
2445 let addr = crate::group_v2::resolve_path_any_from_source(
2446 &self.image(),
2447 &self.superblock,
2448 &path_str,
2449 )
2450 .map_err(|_| {
2451 Error::EditUnsupported("nothing to set an attribute on at the given path")
2452 })?;
2453 let addr = usize::try_from(addr)
2454 .map_err(|_| Error::EditUnsupported("dataset address exceeds this platform"))?;
2455 // An attribute edit relocates the dataset's object header and patches
2456 // only the one naming link, so it is safe only when this is the
2457 // dataset's sole hard link (same rule as a relocating overwrite).
2458 let counts = incoming_links
2459 .get_or_insert_with(|| self.count_incoming_hard_links())
2460 .as_ref();
2461 match counts.and_then(|c| c.get(&(addr as u64))) {
2462 Some(&1) => {}
2463 _ => {
2464 return Err(Error::EditUnsupported(
2465 "editing a dataset attribute relocates its header; only supported \
2466 when it has a single hard link",
2467 ));
2468 }
2469 }
2470 let region = Self::gather_oh_messages(&self.image(), addr as u64, base)?;
2471 let (region, pending_vl_attrs) = apply_group_attr_ops(®ion, &ops)?;
2472 let leaf = full.last().unwrap().clone();
2473 let parent = full[..full.len() - 1].to_vec();
2474 moving_writes.push((
2475 parent,
2476 leaf,
2477 MovingWrite::AttrEdit {
2478 region,
2479 pending_vl_attrs,
2480 },
2481 ));
2482 write_targets.push(full);
2483 }
2484 }
2485
2486 // Fast path: when the only staged edits are same-length in-place
2487 // overwrites, apply them straight to their data blocks and return without
2488 // rebuilding any header or flipping the superblock root. The commit's
2489 // linearization point is the synced data write — there is no tree to
2490 // repoint, so each overwrite stands alone. (A persisting file takes the
2491 // same path: no free-space change occurs.)
2492 //
2493 // Because this path never rewrites the superblock, it deliberately leaves
2494 // it untouched — including a pre-existing stale consistency flag (e.g. one
2495 // left by a crashed SWMR writer). A lone same-length value overwrite does
2496 // not introduce any inconsistency, so it does not clear one either; an edit
2497 // that takes the full path below (any header/root change) clears the flag
2498 // as usual.
2499 if moving_writes.is_empty()
2500 && self.pending_datasets.is_empty()
2501 && self.pending_groups.is_empty()
2502 && self.pending_group_attrs.is_empty()
2503 && self.pending_deletes.is_empty()
2504 && self.pending_copies.is_empty()
2505 && self.pending_cross_copies.is_empty()
2506 {
2507 for (data_addr, raw) in &inplace_writes {
2508 self.write_at(*data_addr, raw)?;
2509 }
2510 self.image.sync_all()?;
2511 return Ok(());
2512 }
2513
2514 // --- Plan: build the tree of "dirty" groups (root plus every group on a
2515 // path to an addition or deletion), validating every target before any
2516 // write. `add_targets` records the full paths created this commit, used
2517 // to reject a deletion that overlaps an addition. ---
2518 let mut nodes: BTreeMap<PathKey, Node> = BTreeMap::new();
2519 nodes.entry(PathKey::new()).or_default(); // root is always dirty
2520 let mut add_targets: Vec<PathKey> = Vec::new();
2521 let mut attr_targets: Vec<PathKey> = Vec::new();
2522
2523 // Mark explicitly-created new groups, ensuring their ancestor chain.
2524 for path in std::mem::take(&mut self.pending_groups) {
2525 if path.is_empty() {
2526 return Err(Error::EditUnsupported("cannot create the root group"));
2527 }
2528 ensure_ancestors(&mut nodes, &path);
2529 nodes.entry(path.clone()).or_default().is_new = true;
2530 add_targets.push(path);
2531 }
2532
2533 // Attach datasets to their parent group nodes, ensuring ancestor chains.
2534 for (parent, db) in std::mem::take(&mut self.pending_datasets) {
2535 let mut full = parent.clone();
2536 full.push(db.name.clone());
2537 add_targets.push(full);
2538 ensure_ancestors(&mut nodes, &parent);
2539 nodes.entry(parent).or_default().datasets.push(db);
2540 }
2541
2542 // Attach relocating value overwrites (resized contiguous or compact) to
2543 // their parent group nodes: the new header is written below and the
2544 // parent's existing link patched to it, like an existing child group.
2545 for (parent, leaf, mw) in moving_writes {
2546 ensure_ancestors(&mut nodes, &parent);
2547 nodes.entry(parent).or_default().writes.push((leaf, mw));
2548 }
2549
2550 // Stage group attribute edits against their target groups. A target may
2551 // be a newly-created group from this same commit, but not a copied
2552 // destination or a dataset being added in the same commit.
2553 for (path, op) in std::mem::take(&mut self.pending_group_attrs) {
2554 ensure_ancestors(&mut nodes, &path);
2555 nodes.entry(path.clone()).or_default().attr_ops.push(op);
2556 attr_targets.push(path);
2557 }
2558
2559 // Stage copies: validate the source subtree is copyable (read-only),
2560 // then treat the destination like an addition to its parent group.
2561 for (src, dst) in std::mem::take(&mut self.pending_copies) {
2562 if src.is_empty() {
2563 return Err(Error::EditUnsupported("cannot copy the root group"));
2564 }
2565 if dst.is_empty() {
2566 return Err(Error::EditUnsupported("copy destination path is empty"));
2567 }
2568 if is_prefix(&src, &dst) {
2569 return Err(Error::EditUnsupported(
2570 "cannot copy an object into itself or its own subtree",
2571 ));
2572 }
2573 let src_str = src.join("/");
2574 let src_addr = crate::group_v2::resolve_path_any_from_source(
2575 &self.image(),
2576 &self.superblock,
2577 &src_str,
2578 )
2579 .map_err(|_| Error::EditUnsupported("copy source does not exist"))?;
2580 let src_addr = usize::try_from(src_addr)
2581 .map_err(|_| Error::EditUnsupported("source address exceeds this platform"))?;
2582 // Read the source subtree from this file's own mirror (`cross_file`
2583 // false: same address space, so verbatim addresses stay valid). On a
2584 // userblock file the stored addresses are base-relative, so pass this
2585 // session's base for the read to absolutize them.
2586 let tree = Self::read_copy_subtree(&self.image(), src_addr as u64, 0, false, base)?;
2587 add_targets.push(dst.clone());
2588 let leaf = dst.last().unwrap().clone();
2589 let parent = dst[..dst.len() - 1].to_vec();
2590 ensure_ancestors(&mut nodes, &parent);
2591 nodes.entry(parent).or_default().copies.push((leaf, tree));
2592 }
2593
2594 // Stage cross-file copies: their subtrees were already read out of the
2595 // source file (with foreign-address screening) when `copy_from` was
2596 // called, so here they are simply linked into the destination parent like
2597 // any other addition.
2598 for (dst, tree) in std::mem::take(&mut self.pending_cross_copies) {
2599 if dst.is_empty() {
2600 return Err(Error::EditUnsupported("copy destination path is empty"));
2601 }
2602 add_targets.push(dst.clone());
2603 let leaf = dst.last().unwrap().clone();
2604 let parent = dst[..dst.len() - 1].to_vec();
2605 ensure_ancestors(&mut nodes, &parent);
2606 nodes.entry(parent).or_default().copies.push((leaf, tree));
2607 }
2608
2609 // Stage deletions: each must exist, must not overlap any other staged
2610 // change, and is recorded against its parent group (which becomes dirty).
2611 // `deleted_addrs` keeps each removed object's header address so its owned
2612 // blocks can be reclaimed after the commit lands (issue #21).
2613 let delete_targets = std::mem::take(&mut self.pending_deletes);
2614 let mut deleted_addrs: Vec<usize> = Vec::new();
2615 for (i, d) in delete_targets.iter().enumerate() {
2616 if d.is_empty() {
2617 return Err(Error::EditUnsupported("cannot delete the root group"));
2618 }
2619 let path_str = d.join("/");
2620 let del_addr = crate::group_v2::resolve_path_any_from_source(
2621 &self.image(),
2622 &self.superblock,
2623 &path_str,
2624 )
2625 .map_err(|_| Error::EditUnsupported("nothing to delete at the given path"))?;
2626 if let Ok(a) = usize::try_from(del_addr) {
2627 deleted_addrs.push(a);
2628 }
2629 for t in &add_targets {
2630 if is_prefix(d, t) || is_prefix(t, d) {
2631 return Err(Error::EditUnsupported(
2632 "a deletion overlaps an addition in the same commit; use separate commits",
2633 ));
2634 }
2635 }
2636 for t in &attr_targets {
2637 if is_prefix(d, t) {
2638 return Err(Error::EditUnsupported(
2639 "a deletion overlaps a group-attribute edit in the same commit; use separate commits",
2640 ));
2641 }
2642 }
2643 for t in &write_targets {
2644 if is_prefix(d, t) {
2645 return Err(Error::EditUnsupported(
2646 "a deletion overlaps a value overwrite in the same commit; use separate commits",
2647 ));
2648 }
2649 }
2650 for (j, d2) in delete_targets.iter().enumerate() {
2651 if i != j && is_prefix(d, d2) {
2652 return Err(Error::EditUnsupported(
2653 "overlapping deletions in one commit; delete the common parent only",
2654 ));
2655 }
2656 }
2657 let parent = d[..d.len() - 1].to_vec();
2658 ensure_ancestors(&mut nodes, &parent);
2659 nodes
2660 .entry(parent)
2661 .or_default()
2662 .deletes
2663 .push(d.last().unwrap().clone());
2664 }
2665
2666 // Resolve / validate each node's base object-header region up front.
2667 // Every existing dirty group is rewritten to a freshly-appended header,
2668 // so its old header becomes dead bytes once the superblock is repointed;
2669 // `superseded_addrs` records those old headers for reclamation (#21).
2670 let keys: Vec<PathKey> = nodes.keys().cloned().collect();
2671 let mut superseded_addrs: Vec<usize> = Vec::new();
2672 for key in &keys {
2673 let is_new = nodes[key].is_new;
2674 if is_new {
2675 nodes.get_mut(key).unwrap().base_region = fresh_group_region();
2676 } else {
2677 let path_str = key.join("/");
2678 let addr = crate::group_v2::resolve_path_any_from_source(
2679 &self.image(),
2680 &self.superblock,
2681 &path_str,
2682 )
2683 .map_err(|_| {
2684 Error::EditUnsupported(
2685 "a target group does not exist; create it first in this session",
2686 )
2687 })?;
2688 let addr = usize::try_from(addr)
2689 .map_err(|_| Error::EditUnsupported("group address exceeds this platform"))?;
2690 let info = self.inspect_group(addr)?;
2691 superseded_addrs.push(addr);
2692 let node = nodes.get_mut(key).unwrap();
2693 node.base_region = info.region;
2694 node.existing_links = info.link_names;
2695 }
2696 }
2697
2698 // Apply and validate group attribute edits before any writes. This keeps
2699 // unsupported attribute edits under the same all-or-nothing preflight
2700 // contract as unsupported dataset additions. A variable-length attribute
2701 // is not fully resolved here — its global heap collection is built (it
2702 // is self-contained, no address needed yet) but placed and patched into
2703 // `base_region` only in the apply loop below, once its address is known.
2704 for key in &keys {
2705 let node = nodes.get_mut(key).unwrap();
2706 let ops = std::mem::take(&mut node.attr_ops);
2707 if !ops.is_empty() {
2708 let region = std::mem::take(&mut node.base_region);
2709 let (region, pending_vl_attrs) = apply_group_attr_ops(®ion, &ops)?;
2710 node.base_region = region;
2711 node.pending_vl_attrs = pending_vl_attrs;
2712 }
2713 }
2714
2715 // Map each node to its direct child group nodes (for link wiring).
2716 let mut children: BTreeMap<PathKey, Vec<PathKey>> = BTreeMap::new();
2717 for key in &keys {
2718 if !key.is_empty() {
2719 let parent = key[..key.len() - 1].to_vec();
2720 children.entry(parent).or_default().push(key.clone());
2721 }
2722 }
2723
2724 // Validate names: no addition may collide with an existing link or with
2725 // another addition under the same parent.
2726 for key in &keys {
2727 let node = &nodes[key];
2728 let mut adding: Vec<&str> = Vec::new();
2729 for db in &node.datasets {
2730 adding.push(&db.name);
2731 }
2732 for child in children.get(key).into_iter().flatten() {
2733 if nodes[child].is_new {
2734 adding.push(child.last().unwrap());
2735 }
2736 }
2737 for (leaf, _) in &node.copies {
2738 adding.push(leaf);
2739 }
2740 for (i, name) in adding.iter().enumerate() {
2741 if node.existing_links.iter().any(|n| n == name) || adding[..i].contains(name) {
2742 return Err(Error::EditUnsupported(
2743 "a link with this name already exists in the target group",
2744 ));
2745 }
2746 }
2747 }
2748
2749 // Flatten datasets (more guards) before any write, so a rejected one
2750 // leaves the commit unapplied.
2751 let mut flat: BTreeMap<PathKey, Vec<FlatDataset>> = BTreeMap::new();
2752 for key in &keys {
2753 let dbs = std::mem::take(&mut nodes.get_mut(key).unwrap().datasets);
2754 let mut v = Vec::with_capacity(dbs.len());
2755 for db in dbs {
2756 v.push(flatten_dataset(db)?);
2757 }
2758 flat.insert(key.clone(), v);
2759 }
2760
2761 // Prove every object-reference target resolves before any write (see
2762 // `preflight_reference_targets`'s doc comment): otherwise a reference
2763 // resolution failure discovered mid-apply-loop would leave every
2764 // earlier-processed group's real writes (headers, data, copied
2765 // subtrees) orphaned in the file despite `commit()` returning `Err`.
2766 Self::preflight_reference_targets(
2767 &keys,
2768 &flat,
2769 &nodes,
2770 &add_targets,
2771 &write_targets,
2772 &delete_targets,
2773 &self.image(),
2774 &self.superblock,
2775 )?;
2776
2777 // Gather the regions this commit will vacate, read from the current
2778 // on-disk layout before any byte moves: every deleted object's owned
2779 // blocks plus every superseded group header. These are not added to the
2780 // free list until after the superblock repoint (they remain live until
2781 // then), so the appends below never reuse them. Enumeration is
2782 // best-effort — `collect_free_spans` simply omits anything it cannot
2783 // account for exhaustively, so the worst case is unreclaimed dead bytes,
2784 // never a freed-but-live region.
2785 let mut to_free: Vec<(u64, u64, PageType)> = Vec::new();
2786
2787 // An object's storage is reclaimed only when the link being removed is
2788 // its LAST hard link: HDF5 objects can have several hard links, and one
2789 // reachable through a surviving link is still live (freeing it would
2790 // corrupt the survivor). Count every hard link in the pre-commit file
2791 // and reclaim a deleted object only when its count is exactly 1.
2792 // `deleted_addrs` is de-duplicated first so two delete paths that are
2793 // hard links to the same object are not visited (and freed) twice. If
2794 // the link graph cannot be walked in full, no deleted object is
2795 // reclaimed (a safe leak), but superseded headers — always dead once the
2796 // root is repointed — still are.
2797 deleted_addrs.sort_unstable();
2798 deleted_addrs.dedup();
2799 if !deleted_addrs.is_empty() {
2800 if let Some(incoming) = self.count_incoming_hard_links() {
2801 for &a in &deleted_addrs {
2802 self.collect_free_spans(a, 0, &incoming, &mut to_free);
2803 }
2804 }
2805 }
2806 // A superseded group header is dead once the root is repointed. Its chunk
2807 // spans are enumerated base-aware (`oh_chunk_spans` shifts continuation
2808 // addresses by the userblock base and returns absolute file offsets), as is
2809 // the delete path (`collect_free_spans`), so all of this reclamation works
2810 // on userblock files too.
2811 for &a in &superseded_addrs {
2812 if let Ok(spans) = self.oh_chunk_spans(a) {
2813 to_free.extend(spans.into_iter().map(|(a, l)| (a, l, PageType::Meta)));
2814 }
2815 }
2816
2817 // A relocating overwrite (`write_dataset` resize, or any compact rewrite)
2818 // vacates the dataset's old object header, and a resized contiguous one
2819 // also vacates its old data block: both become dead once the parent's
2820 // relinked header lands. `superseded_addrs` covers only the rebuilt group
2821 // headers, not the relocated dataset's own header, so record that here too.
2822 // The pre-commit dataset-header address is resolved from the live file; its
2823 // chunks and old data extent are freed only after the superblock repoint.
2824 // The single-hard-link guard in the write preflight makes freeing the old
2825 // header safe (no surviving link still points at it).
2826 for key in &keys {
2827 for (leaf, mw) in &nodes[key].writes {
2828 match mw {
2829 MovingWrite::Contiguous {
2830 old_extent: Some(extent),
2831 ..
2832 } => to_free.push((extent.0, extent.1, PageType::Raw)),
2833 // A relocated chunked dataset vacates its old chunk index and
2834 // chunk data blocks. `chunked_storage_spans` returns `None` for
2835 // anything it cannot enumerate exhaustively (leaving dead bytes
2836 // rather than freeing a region still in use); the old header
2837 // chunks are freed generically below.
2838 MovingWrite::Chunked { old_addr, .. } => {
2839 if let Ok(a) = usize::try_from(*old_addr) {
2840 if let Some(spans) = self.chunked_storage_spans(a) {
2841 to_free.extend(spans);
2842 }
2843 }
2844 }
2845 // A relocating append keeps the existing chunk *data* in place
2846 // (shared by both indexes during the commit), so only the old
2847 // index structure and the relocated old trailing chunk are dead.
2848 // The old header chunks are freed by the generic path below.
2849 MovingWrite::AppendedChunks {
2850 old_addr,
2851 old_tail_extent,
2852 ..
2853 } => {
2854 if let Ok(a) = usize::try_from(*old_addr) {
2855 if let Some(spans) = self.chunked_index_spans(a) {
2856 // A chunk index lives in a raw page in this crate's
2857 // files; see `chunked_storage_spans` for why the tag
2858 // has to follow the placement rather than the
2859 // format's metadata/raw taxonomy.
2860 to_free
2861 .extend(spans.into_iter().map(|(a, l)| (a, l, PageType::Raw)));
2862 }
2863 }
2864 if let Some(ext) = old_tail_extent {
2865 // The relocated old trailing chunk is raw data.
2866 to_free.push((ext.0, ext.1, PageType::Raw));
2867 }
2868 }
2869 _ => {}
2870 }
2871 // The relocated dataset's old header chunks are dead too.
2872 let mut full = key.clone();
2873 full.push(leaf.clone());
2874 let path_str = full.join("/");
2875 if let Ok(addr) = crate::group_v2::resolve_path_any_from_source(
2876 &self.image(),
2877 &self.superblock,
2878 &path_str,
2879 ) {
2880 if let Ok(a) = usize::try_from(addr) {
2881 if let Ok(spans) = self.oh_chunk_spans(a) {
2882 to_free.extend(spans.into_iter().map(|(a, l)| (a, l, PageType::Meta)));
2883 }
2884 }
2885 }
2886 }
2887 }
2888
2889 // Defense in depth: never hand the free list an out-of-bounds or
2890 // overlapping span. The last-link guard plus the per-object checks
2891 // should already make the accumulated spans disjoint; this enforces it
2892 // as a whole-commit invariant against the pre-commit end-of-file. Any
2893 // dropped span (which should not occur for a well-formed file) only
2894 // leaks, never corrupts.
2895 retain_disjoint_in_bounds(&mut to_free, self.image.len());
2896
2897 // --- Apply: process deepest groups first so each parent sees its
2898 // children's new addresses, then repoint the superblock last.
2899 // `path_addr` accumulates every group's and dataset's address as it is
2900 // placed — read by `resolve_reference_target` to resolve a same-commit
2901 // object-reference target (see the dataset-placement loop below for the
2902 // group/dataset key convention: a group's own path, or a dataset's
2903 // full parent+name path). ---
2904 let mut path_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
2905 let mut by_depth = keys.clone();
2906 by_depth.sort_by_key(|k| std::cmp::Reverse(k.len())); // deepest first
2907 for key in &by_depth {
2908 let (mut region, deletes, copies, writes, pending_vl_attrs) = {
2909 let node = nodes.get_mut(key).unwrap();
2910 (
2911 std::mem::take(&mut node.base_region),
2912 std::mem::take(&mut node.deletes),
2913 std::mem::take(&mut node.copies),
2914 std::mem::take(&mut node.writes),
2915 std::mem::take(&mut node.pending_vl_attrs),
2916 )
2917 };
2918
2919 // Remove deleted links first (verbatim-preserving the rest).
2920 for name in &deletes {
2921 region = remove_link_from_region(®ion, name)?;
2922 }
2923
2924 // Write each staged source subtree and link its root into this group.
2925 // `write_copy_subtree` returns an absolute header address; the parent
2926 // link stores it relative to the userblock base.
2927 for (leaf, tree) in copies {
2928 let root = self.write_copy_subtree(&tree)?;
2929 region.extend_from_slice(&encode_link_message(&leaf, root - base));
2930 }
2931
2932 // Datasets directly under this group. Appended addresses are absolute
2933 // file offsets; the contiguous data-layout address and the parent link
2934 // target are stored relative to the base address (`- base`). Placed
2935 // non-reference datasets first (recording each into `path_addr`), then
2936 // reference datasets — a reference to a *non-reference* sibling added
2937 // in the same group's batch resolves regardless of `pending_datasets`
2938 // call order (`Vec::sort_by_key` is stable, so within each of the two
2939 // groups the original order is preserved). Two reference datasets that
2940 // target each other in the same batch are still call-order-dependent —
2941 // whichever is placed first resolves the other, and the reverse
2942 // direction is safely refused as "still writing" (never corrupted),
2943 // caught up front by `preflight_reference_targets`.
2944 let mut group_datasets: Vec<FlatDataset> =
2945 flat.remove(key).into_iter().flatten().collect();
2946 group_datasets.sort_by_key(|fd| fd.reference_targets.is_some());
2947 for mut fd in group_datasets {
2948 // Place each variable-length attribute's global heap collection
2949 // and patch its placeholder heap address. Unlike VL-string
2950 // *data* (`vl_string_staging`, refused when chunked below), a
2951 // chunked/extensible dataset can carry a VL *attribute* just
2952 // fine — attributes live in the object header, not inside a
2953 // chunk, so patching them here before either apply branch runs
2954 // covers both.
2955 for (idx, collections) in std::mem::take(&mut fd.vl_attrs) {
2956 let addrs = self.place_vl_collections(&collections)?;
2957 patch_vl_refs(&mut fd.attrs[idx].raw_data, &addrs);
2958 }
2959 // Resolve an object-reference dataset's per-element targets now
2960 // that every earlier-placed object in this commit is in
2961 // `path_addr` (chunked datasets never carry these —
2962 // `flatten_dataset` refuses that combination).
2963 if let Some(patches) = fd.reference_targets.take() {
2964 for patch in &patches {
2965 let addr = Self::resolve_reference_target(
2966 &patch.target,
2967 &path_addr,
2968 &nodes,
2969 &add_targets,
2970 &write_targets,
2971 &delete_targets,
2972 &self.image(),
2973 &self.superblock,
2974 )?;
2975 write_reference_address(&mut fd.raw, patch.byte_offset, addr);
2976 }
2977 }
2978 let oh = if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
2979 self.build_chunked_dataset(&fd)?
2980 } else {
2981 // A staged variable-length-string dataset's element
2982 // references still carry a placeholder heap address; place
2983 // its collection and patch them before `raw` is appended
2984 // (chunked datasets never carry staging — refused above).
2985 if let Some(staging) = fd.vl_string_staging.take() {
2986 if !staging.collections.is_empty() {
2987 let addrs = self.place_vl_collections(&staging.collections)?;
2988 patch_vl_refs_masked(&mut fd.raw, &staging.patch_offsets, &addrs);
2989 }
2990 }
2991 // A zero-element dataset has no data block to allocate; its
2992 // layout address is the undefined-address sentinel (never
2993 // base-relative — see `build_dataset_oh`'s empty-data callers
2994 // in the whole-file writer), matching every reader's and the
2995 // reference C library's convention for "no storage allocated".
2996 let data_addr = if fd.raw.is_empty() {
2997 u64::MAX
2998 } else {
2999 self.alloc_or_append_typed(&fd.raw, PageType::Raw)? - base
3000 };
3001 build_dataset_oh(
3002 &fd.dt,
3003 &fd.ds,
3004 data_addr,
3005 fd.raw.len() as u64,
3006 &fd.attrs,
3007 None,
3008 fd.fill.as_deref(),
3009 )?
3010 };
3011 let oh_addr = self.alloc_or_append_typed(&oh, PageType::Meta)?;
3012 region.extend_from_slice(&encode_link_message(&fd.name, oh_addr - base));
3013 let mut full = key.clone();
3014 full.push(fd.name.clone());
3015 path_addr.insert(full, oh_addr);
3016 }
3017
3018 // Relocating value overwrites under this group: write the new data and
3019 // rewritten header, then patch this group's existing link to it. The
3020 // link target is stored relative to the base address (`- base`); on a
3021 // userblock file only the chunked variant reaches here (contiguous and
3022 // compact resizes are refused in the write preflight).
3023 for (leaf, mw) in &writes {
3024 let new_oh = self.write_moving(mw)?;
3025 patch_link_target(&mut region, leaf, new_oh - base)?;
3026 }
3027
3028 // Wire links to dirty child groups (new → add a link; existing →
3029 // patch the existing link to the child's new address). Link targets are
3030 // stored relative to the base address.
3031 for child in children.get(key).into_iter().flatten() {
3032 let child_name = child.last().unwrap();
3033 let child_addr = path_addr[child] - base;
3034 if nodes[child].is_new {
3035 region.extend_from_slice(&encode_link_message(child_name, child_addr));
3036 } else {
3037 patch_link_target(&mut region, child_name, child_addr)?;
3038 }
3039 }
3040
3041 // Variable-length group/root attributes staged by
3042 // `apply_group_attr_ops`: place each collection and patch its
3043 // attribute message's placeholder heap address, then append the
3044 // resolved message to this group's header region.
3045 for (mut msg, collections) in pending_vl_attrs {
3046 let addrs = self.place_vl_collections(&collections)?;
3047 patch_vl_refs(&mut msg.raw_data, &addrs);
3048 region.extend_from_slice(®ion_message(
3049 MessageType::Attribute,
3050 &msg.serialize(LENGTH_SIZE),
3051 ));
3052 }
3053
3054 let oh = build_v2_object_header(®ion);
3055 let addr = self.alloc_or_append_typed(&oh, PageType::Meta)?;
3056 path_addr.insert(key.clone(), addr);
3057 }
3058
3059 // Same-length in-place overwrites (`write_dataset`) write straight into
3060 // their existing, already-referenced data blocks. Those blocks are
3061 // reachable from both the old and the new root (the dataset's header is
3062 // unchanged), so the write is independent of the superblock flip; it is
3063 // ordered before the barrier sync below so the new bytes are durable
3064 // alongside everything else this commit appended.
3065 for (data_addr, raw) in &inplace_writes {
3066 self.write_at(*data_addr, raw)?;
3067 }
3068
3069 // Repoint the superblock at the new root last: this is the commit's
3070 // linearization point. Until it lands, the file on disk still points at
3071 // the old root (the appended objects are merely unreferenced trailing
3072 // bytes), so a failure here leaves a valid file.
3073 //
3074 // That ordering is only crash-safe if the appended objects are durable
3075 // before the root pointer is flipped; otherwise a power loss could
3076 // persist the flip ahead of the data it references, leaving the root
3077 // pointing at bytes that never reached disk. `flush` on a plain `File`
3078 // does not force a write-back, so sync the appended bytes to disk first
3079 // (the barrier), then flip the pointer, then sync the flip.
3080 let new_root = path_addr[&PathKey::new()];
3081
3082 // A persisting file keeps its freed space recorded on disk rather than
3083 // truncating it away, so its commit takes a different, append-only tail.
3084 if self.persist.is_some() {
3085 return self.commit_persisting(new_root, to_free);
3086 }
3087
3088 // The new tree is fully written, so the regions this commit vacated are
3089 // now dead: hand them to the session free list. If the resulting free
3090 // space forms a run reaching end-of-file, the file can be physically
3091 // truncated to where that run starts; otherwise the end-of-file is
3092 // unchanged. `take_trailing` removes the trimmed run so it is not also
3093 // counted as reusable interior space.
3094 for (a, l, _) in to_free.drain(..) {
3095 self.free.free(a, l);
3096 }
3097 let cur_eof = self.image.len();
3098 let trunc_to = self.free.take_trailing(cur_eof);
3099 let new_eof = trunc_to.unwrap_or(cur_eof);
3100
3101 self.image.sync_all()?;
3102 // The root address is stored relative to the base address; the end-of-file
3103 // address is absolute. After writing the relative root to disk, keep the
3104 // in-memory `root_group_address` absolute (the open-time convention).
3105 if self.superblock.version >= 2 {
3106 // Build the new superblock off a clone and adopt it only once the
3107 // write succeeds, so a failed write does not desync the in-memory
3108 // state. The v2/v3 superblock carries its own checksum.
3109 let mut new_sb = self.superblock.clone();
3110 new_sb.root_group_address = new_root - base;
3111 new_sb.eof_address = new_eof;
3112 // Clear any write/SWMR consistency flag rather than re-emitting one
3113 // the source file carried (e.g. left set by a crashed SWMR writer):
3114 // this clean commit leaves the file properly closed for the C library
3115 // (issue #73). serialize() recomputes the v2/v3 checksum.
3116 new_sb.consistency_flags = 0;
3117 let sb_bytes = new_sb.serialize();
3118 self.write_at(self.sb_sig_off, &sb_bytes)?;
3119 self.image.sync_all()?;
3120 new_sb.root_group_address = new_root;
3121 self.superblock = new_sb;
3122 } else {
3123 self.repoint_v0v1_root(new_root - base, new_eof)?;
3124 self.image.sync_all()?;
3125 self.superblock.root_group_address = new_root;
3126 self.superblock.eof_address = new_eof;
3127 }
3128
3129 // Physically shrink the file only after the superblock — now carrying the
3130 // smaller end-of-file — is durable. A crash between the two leaves a file
3131 // whose superblock end-of-file is correct and whose trailing bytes are
3132 // mere unreferenced slack, which the next open ignores; the reverse order
3133 // could advertise an end-of-file past the actual file length.
3134 if let Some(cut) = trunc_to {
3135 self.image.truncate(cut)?;
3136 self.image.sync_all()?;
3137 }
3138 Ok(())
3139 }
3140
3141 /// Commit tail for a file that persists its free space (issue #21). Unlike
3142 /// the non-persisting path, freed space is *retained* and recorded on disk —
3143 /// matching the reference library's persistent free-space strategy — so a
3144 /// later reopen (by this crate or the C library) recovers it.
3145 ///
3146 /// The post-commit free list (this commit's vacated regions plus the now-dead
3147 /// old free-space-manager and extension blocks) is serialized into a fresh
3148 /// `FSHD`/`FSSE` pair and a rewritten superblock-extension File Space Info
3149 /// message, all appended at the current end-of-file. Nothing live or
3150 /// still-referenced is overwritten: the new blocks sit strictly past the old
3151 /// ones, and the superblock — repointed last — is the linearization point. A
3152 /// crash before it leaves the prior file (root, extension, and managers)
3153 /// wholly intact.
3154 fn commit_persisting(
3155 &mut self,
3156 new_root: u64,
3157 to_free: Vec<(u64, u64, PageType)>,
3158 ) -> Result<(), Error> {
3159 // A paged file records its free space in per-page-type managers and keeps
3160 // its allocation page-aligned, so it takes its own tail (issue #198).
3161 if self.paged.is_some() {
3162 return self.commit_persisting_paged(new_root, to_free);
3163 }
3164 let os = self.superblock.offset_size;
3165 let (strategy, threshold, page_size, old_blocks) = {
3166 // Copy what we need so no borrow of `self.persist` is held across the
3167 // `&mut self` writes below; the old state stays in place so a failure
3168 // leaves the session reusable.
3169 let ps = self
3170 .persist
3171 .as_ref()
3172 .expect("commit_persisting is only called when persistence is armed");
3173 (
3174 ps.strategy,
3175 ps.threshold,
3176 ps.page_size,
3177 ps.old_blocks.clone(),
3178 )
3179 };
3180
3181 // The free list the new managers will record: this commit's vacated
3182 // regions plus the superseded FSM/extension blocks (dead once we
3183 // repoint), coalesced. Built in a temp so `self.free` and the on-disk old
3184 // blocks stay untouched until after the superblock repoint.
3185 let mut post = self.free.clone();
3186 for &(a, l, _) in &to_free {
3187 post.free(a, l);
3188 }
3189 for &(a, l) in &old_blocks {
3190 post.free(a, l);
3191 }
3192 let sections: Vec<FreeSection> = post
3193 .sections()
3194 .into_iter()
3195 .map(|(addr, size)| FreeSection { addr, size })
3196 .collect();
3197
3198 let old_ext_rel = self
3199 .superblock
3200 .superblock_extension_address
3201 .filter(|&a| a != UNDEF)
3202 .ok_or(Error::EditUnsupported(
3203 "a persisting file has no superblock extension to update",
3204 ))?;
3205 let old_ext_addr = usize::try_from(old_ext_rel)
3206 .map_err(|_| Error::EditUnsupported("extension address exceeds this platform"))?;
3207
3208 // The persist File Space Info message is fixed-size, so the rewritten
3209 // extension's length is independent of the addresses it will carry: size
3210 // it with a placeholder to place the FSM blocks that follow it.
3211 let placeholder =
3212 FileSpaceInfo::persistent_single_manager(strategy, threshold, page_size, 0, 0);
3213 let ext_len =
3214 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)
3215 .len() as u64;
3216
3217 let ext_addr = self.image.len();
3218 let fshd_addr = ext_addr + ext_len;
3219
3220 // Build the real extension and the FSM blocks. With no free space to
3221 // record we still refresh the extension (persist on, managers undefined).
3222 let (ext_oh, fsm_blocks, final_eof) = if sections.is_empty() {
3223 let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
3224 let ext_oh =
3225 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?);
3226 let final_eof = ext_addr + ext_oh.len() as u64;
3227 (ext_oh, None, final_eof)
3228 } else {
3229 let fsse_addr = fshd_addr + fshd_len(os);
3230 // `eoa_pre_fsm` is the end-of-allocation before the free-space-manager
3231 // section blocks (`FSHD`/`FSSE`) were allocated: a consumer may shrink
3232 // back to here and rebuild them. It points at the FSHD, not the
3233 // extension — the extension sits below it and persists, so shrinking
3234 // leaves the superblock and its extension pointer valid (only the
3235 // manager blocks, which are rewritten every commit, are discarded).
3236 // This matches the C library's convention of keeping the superblock
3237 // extension stable across closes, and is the value `H5Fget_freespace`
3238 // accounts for correctly (verified in the crosscheck).
3239 let eoa_pre_fsm = fshd_addr;
3240 let info = FileSpaceInfo::persistent_single_manager(
3241 strategy,
3242 threshold,
3243 page_size,
3244 fshd_addr,
3245 eoa_pre_fsm,
3246 );
3247 let ext_oh =
3248 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?);
3249 debug_assert_eq!(
3250 ext_oh.len() as u64,
3251 ext_len,
3252 "extension length must be stable across the placeholder and real messages"
3253 );
3254 let (fshd, fsse) =
3255 serialize_file_fsm(§ions, fshd_addr, fsse_addr, os, SECT_CLASS_SIMPLE);
3256 let final_eof = fsse_addr + fsse.len() as u64;
3257 (ext_oh, Some((fshd, fsse)), final_eof)
3258 };
3259
3260 // Append the extension, then the FSM blocks, at end-of-file. They are
3261 // unreferenced until the superblock repoint, so a crash here is harmless.
3262 let written_ext = self.append(&ext_oh)?;
3263 debug_assert_eq!(written_ext, ext_addr);
3264 let mut new_old_blocks = vec![(ext_addr, ext_oh.len() as u64)];
3265 if let Some((fshd, fsse)) = fsm_blocks {
3266 let wf = self.append(&fshd)?;
3267 debug_assert_eq!(wf, fshd_addr);
3268 new_old_blocks.push((fshd_addr, fshd.len() as u64));
3269 let ws = self.append(&fsse)?;
3270 new_old_blocks.push((ws, fsse.len() as u64));
3271 }
3272
3273 // Barrier, then repoint the superblock (root, eof, and the new extension)
3274 // — the linearization point — and sync it.
3275 self.image.sync_all()?;
3276 let mut new_sb = self.superblock.clone();
3277 new_sb.root_group_address = new_root;
3278 new_sb.eof_address = final_eof;
3279 new_sb.superblock_extension_address = Some(ext_addr);
3280 // Clear any leftover write/SWMR consistency flag on a clean commit (see
3281 // the non-persisting path above and issue #73).
3282 new_sb.consistency_flags = 0;
3283 let sb_bytes = new_sb.serialize();
3284 self.write_at(self.sb_sig_off, &sb_bytes)?;
3285 self.image.sync_all()?;
3286 self.superblock = new_sb;
3287
3288 // The repoint is durable: the prior free list plus this commit's vacated
3289 // regions are now genuinely free, and the freshly written blocks become
3290 // the ones a future commit will supersede.
3291 self.free = post;
3292 self.persist = Some(PersistState {
3293 strategy,
3294 threshold,
3295 page_size,
3296 old_blocks: new_old_blocks,
3297 });
3298 // The managers now sit at the tail, so nothing is owed until the file
3299 // grows past them again.
3300 self.fsm_len = self.image.len();
3301 Ok(())
3302 }
3303
3304 /// Commit tail for a genuine paged file (`H5F_FSPACE_STRATEGY_PAGE`, issue
3305 /// #198). The paged counterpart of [`commit_persisting`](Self::commit_persisting).
3306 ///
3307 /// Two things differ from the flat tail. Free space is recorded in *per-page-type*
3308 /// managers — SUPER (slot 0) for metadata, DRAW (slot 2) for small raw, and the
3309 /// generic-large manager (slot 6) for whole free pages and large-raw fragments —
3310 /// rather than one generic manager, so a paged file reopened by the reference
3311 /// library still finds its free space segregated. And every allocation boundary
3312 /// is page-aligned: the file is padded to a page before the rewritten extension
3313 /// is laid down (so the extension and the manager blocks sit in metadata pages),
3314 /// and the end-of-allocation is the page-aligned end of those blocks, matching
3315 /// the paged file the from-scratch writer produces.
3316 ///
3317 /// Crash atomicity is identical to the flat path: everything is appended past
3318 /// the live file and is unreferenced until the superblock repoint, which is the
3319 /// linearization point.
3320 fn commit_persisting_paged(
3321 &mut self,
3322 new_root: u64,
3323 to_free: Vec<(u64, u64, PageType)>,
3324 ) -> Result<(), Error> {
3325 let os = self.superblock.offset_size;
3326 let (strategy, threshold, page_size, old_blocks) = {
3327 let ps = self
3328 .persist
3329 .as_ref()
3330 .expect("commit_persisting is only called when persistence is armed");
3331 (
3332 ps.strategy,
3333 ps.threshold,
3334 ps.page_size,
3335 ps.old_blocks.clone(),
3336 )
3337 };
3338
3339 // Page-align the file before anything else, so the rewritten extension and
3340 // the manager blocks begin on a page boundary and stay in metadata pages.
3341 // The padded tail becomes free space of whatever type that page held.
3342 self.pad_to_page()?;
3343
3344 // Fold this commit's vacated regions into their page-type managers, along
3345 // with the page-padding tails this commit's appends left behind and the
3346 // superseded extension/manager blocks (all metadata, dead once we repoint).
3347 //
3348 // Built in temporaries, exactly as the flat path builds `post`: every
3349 // region gathered here is still *live* until the superblock repoint below,
3350 // so the session's own lists must not learn about it until that repoint
3351 // succeeds. Everything between here and there can fail (the extension
3352 // rewrite, each append, each barrier), and a session that survived a failed
3353 // commit while believing live extents were free would hand them out on the
3354 // next commit — silently destroying the objects still occupying them.
3355 let (post_meta, post_raw_small, post_raw_large) = {
3356 let pg = self
3357 .paged
3358 .as_ref()
3359 .expect("commit_persisting_paged is only called on a paged file");
3360 let (mut meta, mut raw_small, mut raw_large) =
3361 (pg.meta.clone(), pg.raw_small.clone(), pg.raw_large.clone());
3362 for &(a, l) in &pg.meta_pad {
3363 meta.free(a, l);
3364 }
3365 for &(a, l) in &pg.raw_pad {
3366 raw_small.free(a, l);
3367 }
3368 for &(a, l, ty) in &to_free {
3369 PagedEdit::route_free(
3370 &mut meta,
3371 &mut raw_small,
3372 &mut raw_large,
3373 page_size,
3374 a,
3375 l,
3376 ty,
3377 );
3378 }
3379 for &(a, l) in &old_blocks {
3380 meta.free(a, l);
3381 }
3382 (meta, raw_small, raw_large)
3383 };
3384
3385 let old_ext_rel = self
3386 .superblock
3387 .superblock_extension_address
3388 .filter(|&a| a != UNDEF)
3389 .ok_or(Error::EditUnsupported(
3390 "a persisting file has no superblock extension to update",
3391 ))?;
3392 let old_ext_addr = usize::try_from(old_ext_rel)
3393 .map_err(|_| Error::EditUnsupported("extension address exceeds this platform"))?;
3394
3395 // The 12-slot persist message is fixed-size, so a placeholder sizes the
3396 // rewritten extension before its manager addresses are known.
3397 let placeholder = FileSpaceInfo::persistent_managers(
3398 strategy,
3399 threshold,
3400 page_size,
3401 [UNDEF; NUM_FILE_FSM_MANAGERS],
3402 0,
3403 );
3404 let ext_len =
3405 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &placeholder)?)
3406 .len() as u64;
3407
3408 let ext_addr = self.image.len();
3409 debug_assert_eq!(
3410 ext_addr % page_size,
3411 0,
3412 "the extension begins on a page boundary"
3413 );
3414
3415 // Class the free space into its managers and place their blocks after the
3416 // extension. Shared with the bounded backend so both lay out identically.
3417 let plan = plan_paged_managers(
3418 &free_sections(&post_meta),
3419 &free_sections(&post_raw_small),
3420 &free_sections(&post_raw_large),
3421 page_size,
3422 ext_addr + ext_len,
3423 os,
3424 );
3425
3426 let (ext_oh, final_eof) = if plan.is_empty() {
3427 // No free space to track: an empty persist message, page-aligned.
3428 let info = FileSpaceInfo::persistent_empty(strategy, threshold, page_size);
3429 let ext_oh =
3430 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?);
3431 let final_eof = align_up(ext_addr + ext_oh.len() as u64, page_size);
3432 (ext_oh, final_eof)
3433 } else {
3434 let final_eof = align_up(plan.end_of_managers, page_size);
3435 // Paged convention (matching the from-scratch writer): the managers are
3436 // ordinary metadata below a page-aligned end-of-allocation.
3437 let info = FileSpaceInfo::persistent_managers(
3438 strategy, threshold, page_size, plan.slots, final_eof,
3439 );
3440 let ext_oh =
3441 build_v2_object_header(&self.rewrite_extension_region(old_ext_addr, &info)?);
3442 debug_assert_eq!(
3443 ext_oh.len() as u64,
3444 ext_len,
3445 "extension length must be stable across the placeholder and real messages"
3446 );
3447 (ext_oh, final_eof)
3448 };
3449
3450 // Append the extension, then every manager block, at (page-aligned) EOF.
3451 // They are unreferenced until the repoint, so a crash here is harmless.
3452 let written_ext = self.append(&ext_oh)?;
3453 debug_assert_eq!(written_ext, ext_addr);
3454 let mut new_old_blocks = vec![(ext_addr, ext_oh.len() as u64)];
3455 for b in &plan.blocks {
3456 let (fshd, fsse) =
3457 serialize_file_fsm(&b.sections, b.fshd_addr, b.fsse_addr, os, b.class);
3458 let wf = self.append(&fshd)?;
3459 debug_assert_eq!(wf, b.fshd_addr);
3460 new_old_blocks.push((b.fshd_addr, fshd.len() as u64));
3461 let ws = self.append(&fsse)?;
3462 debug_assert_eq!(ws, b.fsse_addr);
3463 new_old_blocks.push((ws, fsse.len() as u64));
3464 }
3465 // Pad the final metadata page to its boundary. This trailing tail is left
3466 // untracked (a valid free-space under-report), keeping the manager layout
3467 // closed-form rather than self-referential.
3468 self.pad_zeros_to(final_eof)?;
3469
3470 // Barrier, then repoint the superblock (root, eof, and the new extension)
3471 // — the linearization point — and sync it.
3472 self.image.sync_all()?;
3473 let mut new_sb = self.superblock.clone();
3474 new_sb.root_group_address = new_root;
3475 new_sb.eof_address = final_eof;
3476 new_sb.superblock_extension_address = Some(ext_addr);
3477 new_sb.consistency_flags = 0;
3478 let sb_bytes = new_sb.serialize();
3479 self.write_at(self.sb_sig_off, &sb_bytes)?;
3480 self.image.sync_all()?;
3481 self.superblock = new_sb;
3482
3483 // The repoint is durable. Only now are this commit's vacated regions
3484 // genuinely free, so adopt the lists built above and drop the padding tails
3485 // they already account for. The blocks just written become the ones the next
3486 // commit supersedes, and the tail page is fresh metadata: the managers sit
3487 // in it, so a following append of metadata may keep packing that page.
3488 if let Some(pg) = self.paged.as_mut() {
3489 pg.meta = post_meta;
3490 pg.raw_small = post_raw_small;
3491 pg.raw_large = post_raw_large;
3492 pg.meta_pad.clear();
3493 pg.raw_pad.clear();
3494 pg.last = Some(PageType::Meta);
3495 }
3496 self.persist = Some(PersistState {
3497 strategy,
3498 threshold,
3499 page_size,
3500 old_blocks: new_old_blocks,
3501 });
3502 // The managers now sit at the tail, so nothing is owed until the file
3503 // grows past them again.
3504 self.fsm_len = self.image.len();
3505 Ok(())
3506 }
3507
3508 /// Pad a paged file to a page boundary if its tail page is partially filled,
3509 /// recording the padding as free space of the tail page's type. A no-op on a
3510 /// non-paged file or an already-aligned one.
3511 fn pad_to_page(&mut self) -> Result<(), Error> {
3512 let len = self.image.len();
3513 let pad = match &self.paged {
3514 Some(pg) if len % pg.page_size != 0 => {
3515 Some((pg.last, pg.page_size - len % pg.page_size))
3516 }
3517 _ => None,
3518 };
3519 if let Some((last, pad_len)) = pad {
3520 let pad_at = len;
3521 self.append(&vec![0u8; pad_len.to_usize()?])?;
3522 if let Some(pg) = self.paged.as_mut() {
3523 match last {
3524 // A partially-filled tail page at commit time is a raw page (the
3525 // last thing the apply loop writes for a dataset is its header,
3526 // but a commit that only wrote raw data ends on one); default an
3527 // unknown tail (no typed append this commit) to raw, matching the
3528 // bounded backend.
3529 Some(PageType::Meta) => pg.meta_pad.push((pad_at, pad_len)),
3530 _ => pg.raw_pad.push((pad_at, pad_len)),
3531 }
3532 }
3533 }
3534 Ok(())
3535 }
3536
3537 /// Extend the file with zeros up to `target` (>= the current length), used by
3538 /// the paged tail to pad the final metadata page to its boundary.
3539 fn pad_zeros_to(&mut self, target: u64) -> Result<(), Error> {
3540 let len = self.image.len();
3541 if target > len {
3542 let pad = (target - len).to_usize()?;
3543 self.append(&vec![0u8; pad])?;
3544 }
3545 debug_assert_eq!(self.image.len(), target);
3546 Ok(())
3547 }
3548
3549 /// Rebuild the superblock-extension object header's message region with its
3550 /// File Space Info message replaced by `info` (every other message preserved
3551 /// verbatim), ready to wrap with [`build_v2_object_header`]. The persisting
3552 /// message is fixed-size, so this never changes the region's length.
3553 fn rewrite_extension_region(
3554 &self,
3555 ext_addr: usize,
3556 info: &FileSpaceInfo,
3557 ) -> Result<Vec<u8>, Error> {
3558 let region =
3559 Self::gather_oh_messages(&self.image(), ext_addr as u64, self.superblock.base_address)?;
3560 rewrite_extension_region_bytes(®ion, info)
3561 }
3562
3563 /// Repoint a version 0/1 superblock at the rebuilt (now v2) root group and
3564 /// update its end-of-file field, patching the raw bytes in place — these
3565 /// superblocks carry no checksum. The root symbol-table entry is switched to
3566 /// cache type 0 (its scratch-pad B-tree / local-heap addresses, which
3567 /// describe the old symbol-table group, no longer apply). The
3568 /// object-header-address write is done last so it is the linearization point.
3569 fn repoint_v0v1_root(&mut self, new_root: u64, new_eof: u64) -> Result<(), Error> {
3570 let os = self.superblock.offset_size as usize;
3571 // Field layout after the fixed prefix: base / free-space / EOF / driver
3572 // addresses, then the root symbol-table entry (link-name offset, object
3573 // header address, cache type(4), reserved(4), scratch(16)). The prefix is
3574 // 24 bytes for v0 and 28 for v1 (the latter adds indexed-storage-K).
3575 let var_start = if self.superblock.version == 0 { 24 } else { 28 };
3576 let base = self.sb_sig_off + var_start;
3577 let eof_off = base + 2 * os;
3578 let ste = base + 4 * os;
3579 let oh_addr_off = ste + os;
3580 let cache_off = ste + 2 * os;
3581 self.write_at(eof_off, &new_eof.to_le_bytes()[..os])?;
3582 self.write_at(cache_off, &[0u8; 4])?; // cache type = none
3583 self.write_at(cache_off + 8, &[0u8; 16])?; // clear scratch-pad
3584 self.write_at(oh_addr_off, &new_root.to_le_bytes()[..os])?;
3585 Ok(())
3586 }
3587
3588 /// Collect every message of the object header at `addr` into one contiguous
3589 /// region, following continuation blocks across chunks and dropping the
3590 /// `Continuation` messages themselves. Re-emitting the result through
3591 /// [`build_v2_object_header`] collapses a multi-chunk header (as the
3592 /// reference C library often writes) into a single chunk, which is how this
3593 /// editor rebuilds headers. The chunk-0 prefix is validated by
3594 /// [`oh_region_at`]; each continuation block must be a well-formed `OCHK`
3595 /// block within the file.
3596 ///
3597 /// Reads each header chunk out of `src` as one bounded buffer rather than
3598 /// indexing a whole-file image, so this serves a session whose file is not
3599 /// mirrored in memory (issue #198).
3600 fn gather_oh_messages<S: Source + ?Sized>(
3601 src: &S,
3602 addr: u64,
3603 base: u64,
3604 ) -> Result<Vec<u8>, Error> {
3605 let mut out = Vec::new();
3606 for chunk in read_oh_chunks(src, addr, base)? {
3607 let (region, mut p) = chunk.message_region();
3608 while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
3609 if msg_type != MessageType::ObjectHeaderContinuation {
3610 out.extend_from_slice(®ion[p..body_end]);
3611 }
3612 p = body_end;
3613 }
3614 }
3615 Ok(out)
3616 }
3617
3618 /// Reconstruct a version-1 (symbol-table) group as a fresh v2 compact-link
3619 /// message region: a LinkInfo message, one Link message per existing child,
3620 /// and the group's existing attributes (re-wrapped as v2 messages). The
3621 /// symbol-table message and other non-link/non-attribute messages
3622 /// (modification time, comment, …) are dropped — editing a v0/v1 group
3623 /// converts it to the latest format. Refuses an attribute it cannot
3624 /// reproduce (shared, or larger than a v2 message can hold).
3625 fn reconstruct_v1_group(&self, addr: usize) -> Result<GroupInfo, Error> {
3626 let os = self.superblock.offset_size;
3627 let ls = self.superblock.length_size;
3628 let base = self.superblock.base_address;
3629 let oh = ObjectHeader::parse_from_source(&self.image(), addr as u64, os, ls, base)?;
3630 if oh
3631 .messages
3632 .iter()
3633 .any(|m| m.msg_type == MessageType::DataLayout)
3634 {
3635 return Err(Error::EditUnsupported(
3636 "a target path names a dataset, not a group",
3637 ));
3638 }
3639 let entries = resolve_group_entries_from_source(&self.image(), &oh, os, ls, base)?;
3640
3641 let mut region = fresh_group_region();
3642 let mut link_names = Vec::with_capacity(entries.len());
3643 for e in &entries {
3644 // Group-entry addresses are already stored relative to the base address,
3645 // matching how `encode_link_message` stores link targets — so they are
3646 // re-emitted verbatim, no base conversion needed.
3647 region.extend_from_slice(&encode_link_message(&e.name, e.object_header_address));
3648 link_names.push(e.name.clone());
3649 }
3650 for m in &oh.messages {
3651 if m.msg_type == MessageType::Attribute {
3652 if m.flags != 0 {
3653 return Err(Error::EditUnsupported(
3654 "a v0/v1 group has a shared attribute message (not convertible in place yet)",
3655 ));
3656 }
3657 if m.data.len() > OBJECT_HEADER_MESSAGE_MAX {
3658 return Err(Error::EditUnsupported(
3659 "a v0/v1 group attribute is too large to convert in place",
3660 ));
3661 }
3662 // Re-wrap the attribute message body (it is self-describing) in a
3663 // v2 message record.
3664 #[expect(
3665 clippy::cast_possible_truncation,
3666 reason = "message type ids are a small enum that fits the 1-byte v2 type field"
3667 )]
3668 region.push(MessageType::Attribute.to_u16() as u8);
3669 #[expect(
3670 clippy::cast_possible_truncation,
3671 reason = "attribute body length fits the 2-byte message-size field (oversized \
3672 bodies are rejected above)"
3673 )]
3674 region.extend_from_slice(&(m.data.len() as u16).to_le_bytes());
3675 region.push(0); // message flags
3676 region.extend_from_slice(&m.data);
3677 }
3678 }
3679 Ok(GroupInfo { region, link_names })
3680 }
3681
3682 /// Parse and validate a group's object header, returning its message region
3683 /// — the bytes to copy when rewriting the header — and the names of its
3684 /// existing links. A version 2 header is rebuilt from its own message bytes
3685 /// (collapsing continuation chunks, preserving every message); a version 1
3686 /// symbol-table group is converted to v2 via [`reconstruct_v1_group`].
3687 fn inspect_group(&self, addr: usize) -> Result<GroupInfo, Error> {
3688 let sig = self.image().read_metadata_at(addr as u64, 4);
3689 if sig.as_deref() != Ok(&b"OHDR"[..]) {
3690 return self.reconstruct_v1_group(addr);
3691 }
3692 let mut region =
3693 Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)?;
3694 let mut p = 0;
3695 let mut has_link_info = false;
3696 let mut link_names = Vec::new();
3697 while let Some((msg_type, body, body_end)) = next_message(®ion, p)? {
3698 match msg_type {
3699 MessageType::LinkInfo => {
3700 has_link_info = true;
3701 // LinkInfo: version(1) flags(1) [max_creation_index(8) if
3702 // flags&0x01] fractal_heap_addr(8) … — dense storage has a
3703 // defined fractal-heap address. Bound the read by this
3704 // message's own body, not just the region, so a short or
3705 // malformed LinkInfo can't make us read the next message.
3706 let mut q = body + 2;
3707 if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
3708 q += 8;
3709 }
3710 if q + 8 <= body_end {
3711 let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
3712 if heap_addr != u64::MAX {
3713 return Err(Error::EditUnsupported(
3714 "a target group uses dense (fractal-heap) link storage (not supported in place yet)",
3715 ));
3716 }
3717 }
3718 }
3719 MessageType::Link => {
3720 if let Ok(link) = LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
3721 link_names.push(link.name);
3722 }
3723 }
3724 MessageType::DataLayout => {
3725 return Err(Error::EditUnsupported(
3726 "a target path names a dataset, not a group",
3727 ));
3728 }
3729 _ => {}
3730 }
3731 p = body_end;
3732 }
3733 if !has_link_info {
3734 return Err(Error::EditUnsupported(
3735 "a target group's object header has no link-info message",
3736 ));
3737 }
3738 // Heal headers written by older hdf5-pure releases that omitted the
3739 // Group Info message, so the rewritten group stays writable by the C
3740 // library.
3741 ensure_group_info(&mut region)?;
3742 Ok(GroupInfo { region, link_names })
3743 }
3744
3745 /// Preflight a staged value overwrite (`write_dataset`): resolve the dataset
3746 /// at `addr`, validate that the staged `fd` matches it byte-exactly in
3747 /// datatype and shape, and classify how the bytes will be applied. No file
3748 /// bytes are written here — this is part of the all-or-nothing preflight, so a
3749 /// rejected write leaves the commit unapplied.
3750 ///
3751 /// Contiguous, compact, and chunked (including filtered) datasets are all
3752 /// supported; the chunk geometry, filter pipeline, and chunk index come from
3753 /// the on-disk header (a staged builder that itself requests chunking/filters/an
3754 /// extensible shape is refused as "not a value overwrite", and a chunk index
3755 /// this engine cannot enumerate — a version-2 B-tree — is refused too). A
3756 /// datatype or shape that differs from the on-disk dataset's is likewise
3757 /// refused — this is a value overwrite, not a reshape or retype.
3758 fn prepare_write<S: Source + ?Sized>(
3759 src: &S,
3760 addr: u64,
3761 fd: &FlatDataset,
3762 base: u64,
3763 ) -> Result<WritePlan, Error> {
3764 // A value overwrite never introduces chunking, filters, or an extensible
3765 // shape: those would change the storage layout, not just the bytes.
3766 if fd.chunk_options.is_chunked() || fd.maxshape.is_some() {
3767 return Err(Error::EditUnsupported(
3768 "write_dataset overwrites values only; it cannot make a dataset \
3769 chunked, filtered, or extensible",
3770 ));
3771 }
3772
3773 // `write_dataset` overwrites element bytes only; it does not touch the
3774 // object header's attribute messages. Attributes staged on the returned
3775 // builder would otherwise be silently dropped (the in-place path rewrites
3776 // only the data block, and the moving path reuses the verbatim on-disk
3777 // header), so refuse rather than degrade — set them in a separate edit.
3778 if !fd.attrs.is_empty() {
3779 return Err(Error::EditUnsupported(
3780 "write_dataset overwrites values only; it cannot set attributes \
3781 (set them with a separate edit)",
3782 ));
3783 }
3784
3785 // `write_dataset` overwrites element bytes only; it reuses the dataset's
3786 // existing Fill Value message (the in-place path rewrites only the data
3787 // block, and the moving path keeps every header message but the layout
3788 // verbatim). A fill value staged on the returned builder would otherwise
3789 // be silently ignored, so refuse rather than degrade — set the fill value
3790 // when the dataset is first created.
3791 if fd.fill.is_some() {
3792 return Err(Error::EditUnsupported(
3793 "write_dataset overwrites values only; it cannot change the fill \
3794 value (set it when the dataset is created)",
3795 ));
3796 }
3797
3798 // `with_vlen_strings` stages placeholder element references that only the
3799 // add path's apply loop knows how to resolve (place the global heap
3800 // collection, then patch the placeholders once its address is known,
3801 // before the data block itself is written). `prepare_write` runs during
3802 // preflight, before any bytes are written and without `&mut self`
3803 // access to place a heap collection, and its result can be flushed by
3804 // the same-length fast path with no apply loop at all — so refuse
3805 // rather than write unpatched (heap address 0) placeholders as if they
3806 // were final.
3807 if fd.vl_string_staging.is_some() {
3808 return Err(Error::EditUnsupported(
3809 "write_dataset cannot overwrite a variable-length-string dataset's \
3810 data in place yet",
3811 ));
3812 }
3813
3814 let region = Self::gather_oh_messages(src, addr, base)?;
3815
3816 // Locate the datatype, dataspace, and data-layout messages, and detect a
3817 // filter pipeline (filtered storage is always chunked, never contiguous).
3818 let mut datatype: Option<(usize, usize)> = None;
3819 let mut dataspace: Option<(usize, usize)> = None;
3820 let mut layout: Option<(usize, usize)> = None;
3821 let mut filter: Option<(usize, usize)> = None;
3822 let mut has_link = false;
3823 let mut p = 0;
3824 while let Some((msg_type, body, body_end)) = next_message(®ion, p)? {
3825 match msg_type {
3826 MessageType::Datatype => datatype = Some((body, body_end)),
3827 MessageType::Dataspace => dataspace = Some((body, body_end)),
3828 MessageType::DataLayout => layout = Some((body, body_end)),
3829 MessageType::FilterPipeline => filter = Some((body, body_end)),
3830 MessageType::Link | MessageType::LinkInfo | MessageType::SymbolTable => {
3831 has_link = true;
3832 }
3833 _ => {}
3834 }
3835 p = body_end;
3836 }
3837
3838 if has_link {
3839 return Err(Error::EditUnsupported(
3840 "write_dataset target is a group, not a dataset",
3841 ));
3842 }
3843 let (dt_b, dt_e) =
3844 datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
3845 let (ds_b, ds_e) =
3846 dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
3847 let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
3848
3849 // Compare datatype and shape structurally against the staged data. A
3850 // value overwrite must keep both exactly: the datatype (including its
3851 // class, size, endianness, and any compound/array/enumeration layout) so
3852 // the bytes are interpreted the same, and the *current* dimensions so the
3853 // byte count is unchanged. Parsing both sides and comparing the decoded
3854 // values — rather than the raw message bytes — tolerates the harmless
3855 // encoding differences between this crate's writer and the reference C
3856 // library (e.g. the C library records a maximum-dimensions array equal to
3857 // the current dimensions, which this crate omits) while still refusing any
3858 // real retype or reshape.
3859 let (disk_dt, _) = crate::datatype::Datatype::parse(®ion[dt_b..dt_e])
3860 .map_err(|_| Error::EditUnsupported("dataset header datatype could not be parsed"))?;
3861 if disk_dt != fd.dt {
3862 return Err(Error::EditUnsupported(
3863 "write_dataset datatype does not match the on-disk dataset (overwrite, not retype)",
3864 ));
3865 }
3866 let disk_ds = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE)
3867 .map_err(|_| Error::EditUnsupported("dataset header dataspace could not be parsed"))?;
3868 if disk_ds.space_type != fd.ds.space_type
3869 || disk_ds.rank != fd.ds.rank
3870 || disk_ds.dimensions != fd.ds.dimensions
3871 {
3872 return Err(Error::EditUnsupported(
3873 "write_dataset shape does not match the on-disk dataset (overwrite, not reshape)",
3874 ));
3875 }
3876
3877 // Classify the layout. Version 3/4 compact (class 0), contiguous (class
3878 // 1), and chunked (class 2) are supported; an old-version layout or a
3879 // virtual layout (class 3) is refused.
3880 if le - lb < 2 {
3881 return Err(Error::EditUnsupported("malformed data-layout message"));
3882 }
3883 let version = region[lb];
3884 if version != 3 && version != 4 {
3885 return Err(Error::EditUnsupported(
3886 "an unsupported data-layout version cannot be overwritten in place yet",
3887 ));
3888 }
3889 match region[lb + 1] {
3890 // Compact: the data is inline in the header. Rebuild the header with
3891 // the new inline bytes (relocating it), patching the parent link.
3892 0 => Ok(WritePlan::Moving(MovingWrite::Compact {
3893 region,
3894 raw: fd.raw.clone(),
3895 })),
3896 1 => {
3897 if le - lb < 18 {
3898 return Err(Error::EditUnsupported("malformed contiguous data layout"));
3899 }
3900 let addr_off = lb + 2;
3901 let data_addr =
3902 u64::from_le_bytes(region[addr_off..addr_off + 8].try_into().unwrap());
3903 let data_size = u64::from_le_bytes(region[lb + 10..lb + 18].try_into().unwrap());
3904
3905 // Same length and a defined, in-bounds data block: overwrite the
3906 // bytes straight in place. No header rewrite, no relink. The stored
3907 // address is base-relative; the in-place write targets the absolute
3908 // file offset `data_addr + base`.
3909 if data_addr != UNDEF && data_size == fd.raw.len() as u64 {
3910 if let Some(start) = data_addr
3911 .checked_add(base)
3912 .and_then(|a| usize::try_from(a).ok())
3913 {
3914 if start
3915 .checked_add(fd.raw.len())
3916 .is_some_and(|e| e as u64 <= src.len())
3917 {
3918 return Ok(WritePlan::InPlace {
3919 data_addr: start,
3920 raw: fd.raw.clone(),
3921 });
3922 }
3923 }
3924 }
3925
3926 // Length differs or the block was undefined/out of bounds: the new
3927 // data goes elsewhere and the old extent (if any) is freed. The
3928 // freed extent is recorded as an absolute file offset (`+ base`) to
3929 // match the session free list.
3930 let old_extent = if data_addr != UNDEF && data_size > 0 {
3931 Some((data_addr + base, data_size))
3932 } else {
3933 None
3934 };
3935 Ok(WritePlan::Moving(MovingWrite::Contiguous {
3936 region,
3937 addr_off,
3938 raw: fd.raw.clone(),
3939 old_extent,
3940 }))
3941 }
3942 // Chunked: overwrite each chunk in place when every new (re-encoded)
3943 // chunk is the same byte length as its slot, else rebuild and relocate
3944 // the whole chunk storage. The chunk geometry, filter pipeline, and
3945 // index type all come from the existing on-disk header (the staged
3946 // builder carries none — chunked/filtered/extensible builders are
3947 // refused at the top of this function as "not a value overwrite").
3948 2 => {
3949 // Chunked overwrite (in-place or relocating). On a userblock file
3950 // every stored chunk-index and chunk address is relative to `base`:
3951 // the in-place path below walks the index on a base-relative view of
3952 // the file and shifts the resulting write offsets back by `base`,
3953 // and the relocating path rebuilds the chunk blob with stored
3954 // addresses (see `write_chunked_relocatable`).
3955 let dl =
3956 DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE).map_err(|_| {
3957 Error::EditUnsupported("dataset header data layout could not be parsed")
3958 })?;
3959 let DataLayout::Chunked {
3960 version: lversion,
3961 chunk_index_type,
3962 ..
3963 } = dl
3964 else {
3965 return Err(Error::EditUnsupported("dataset is not chunked"));
3966 };
3967 if !chunk_index_enumerable(lversion, chunk_index_type) {
3968 return Err(Error::EditUnsupported(
3969 "a chunked dataset with a version-2 B-tree or unknown chunk index \
3970 cannot be overwritten in place yet",
3971 ));
3972 }
3973
3974 let ChunkedGeometry {
3975 spatial,
3976 element_size,
3977 raw_size,
3978 maxshape,
3979 } = chunked_geometry(&fd.dt, &disk_ds, &dl)?;
3980
3981 // Split the new value into full-size chunk buffers in dense
3982 // row-major grid order (edge overhang zero-filled, matching how
3983 // unfiltered chunks are stored), then re-encode through the on-disk
3984 // pipeline when the dataset is filtered.
3985 let split = split_into_chunks(&fd.raw, &disk_ds.dimensions, &spatial, element_size);
3986 let pipeline_message: Option<Vec<u8>> =
3987 filter.map(|(fb, fe)| region[fb..fe].to_vec());
3988
3989 let new_chunk_bytes: Vec<Vec<u8>> = if let Some(pm) = &pipeline_message {
3990 let pipeline = FilterPipeline::parse(pm).map_err(|_| {
3991 Error::EditUnsupported("dataset filter pipeline could not be parsed")
3992 })?;
3993 if !pipeline_reencodable(&pipeline) {
3994 return Err(Error::EditUnsupported(
3995 "a chunked dataset using a filter this engine cannot re-encode \
3996 cannot be overwritten in place yet",
3997 ));
3998 }
3999 let ctx = ChunkContext::from_datatype(&spatial, &fd.dt);
4000 let mut encoded = Vec::with_capacity(split.len());
4001 for (_, buf) in &split {
4002 encoded.push(compress_chunk(buf, &pipeline, ctx)?);
4003 }
4004 encoded
4005 } else {
4006 split.into_iter().map(|(_, buf)| buf).collect()
4007 };
4008
4009 // Fast path: overwrite each chunk straight in its slot when every
4010 // new chunk fits. No header rewrite and no superblock flip — the
4011 // chunk (and index) blocks are reachable from both roots. The index
4012 // is left untouched when chunks keep their size and rebuilt in place
4013 // when they shrink. The index walk runs on a base-relative view of
4014 // the file (so the layout's stored addresses index correctly), and
4015 // the returned write offsets are shifted back to absolute file
4016 // offsets by adding `base` (a no-op on a base-0 file).
4017 let base_off = usize::try_from(base).map_err(|_| {
4018 Error::EditUnsupported("userblock base address exceeds this platform")
4019 })?;
4020 if let Some(writes) = try_inplace_chunk_writes(
4021 &BaseOffsetSource { inner: src, base },
4022 &dl,
4023 &disk_ds,
4024 &spatial,
4025 raw_size,
4026 &new_chunk_bytes,
4027 ) {
4028 let writes = writes
4029 .into_iter()
4030 .map(|(off, b)| (off + base_off, b))
4031 .collect();
4032 return Ok(WritePlan::InPlaceChunks { writes });
4033 }
4034
4035 // Otherwise relocate: rebuild a fresh chunk blob + index at
4036 // end-of-file (carrying the re-encoded chunk bytes and the source
4037 // pipeline verbatim), swap the data-layout message in the verbatim
4038 // header, and free the old chunk storage after the commit lands.
4039 let meta = new_chunk_bytes
4040 .iter()
4041 .map(|c| ChunkMeta {
4042 compressed_size: c.len() as u64,
4043 filter_mask: 0,
4044 })
4045 .collect();
4046 Ok(WritePlan::Moving(MovingWrite::Chunked {
4047 region,
4048 chunk_dims: spatial,
4049 element_size,
4050 raw_size,
4051 maxshape,
4052 pipeline_message,
4053 meta,
4054 chunk_bytes: new_chunk_bytes,
4055 old_addr: addr,
4056 }))
4057 }
4058 _ => Err(Error::EditUnsupported(
4059 "an unsupported data-layout class cannot be overwritten in place yet",
4060 )),
4061 }
4062 }
4063
4064 /// Plan a relocating append to an existing chunked, unlimited,
4065 /// Extensible-Array-indexed dataset at `addr`. Validates the target, splits
4066 /// the appended elements into new (and one rewritten trailing) chunks —
4067 /// compressed through the on-disk pipeline when filtered — and gathers the
4068 /// existing complete chunks by metadata alone. Returns the
4069 /// [`MovingWrite::AppendedChunks`] plan; the commit machinery appends the new
4070 /// chunks and a rebuilt index and repoints the header (see
4071 /// [`write_appended_chunks`](Self::write_appended_chunks)).
4072 ///
4073 /// Reads only; no bytes are written here. `src` is the file image and `base`
4074 /// its userblock base; the dataset's stored (base-relative) structures are read
4075 /// through a `base`-shifted view.
4076 fn prepare_append<S: Source + ?Sized>(
4077 src: &S,
4078 addr: u64,
4079 ab: &AppendBuilder,
4080 base: u64,
4081 ) -> Result<MovingWrite, Error> {
4082 if ab.dt_conflict {
4083 return Err(Error::AppendUnsupported(
4084 "append mixes element types in one builder; use one element type per \
4085 append_dataset call",
4086 ));
4087 }
4088
4089 let region = Self::gather_oh_messages(src, addr, base)?;
4090
4091 // Locate the datatype, dataspace, data-layout, and filter-pipeline
4092 // messages, and detect a group (link) header.
4093 let mut datatype: Option<(usize, usize)> = None;
4094 let mut dataspace: Option<(usize, usize)> = None;
4095 let mut layout: Option<(usize, usize)> = None;
4096 let mut filter: Option<(usize, usize)> = None;
4097 let mut has_link = false;
4098 let mut p = 0;
4099 while let Some((msg_type, body, body_end)) = next_message(®ion, p)? {
4100 match msg_type {
4101 MessageType::Datatype => datatype = Some((body, body_end)),
4102 MessageType::Dataspace => dataspace = Some((body, body_end)),
4103 MessageType::DataLayout => layout = Some((body, body_end)),
4104 MessageType::FilterPipeline => filter = Some((body, body_end)),
4105 MessageType::Link | MessageType::LinkInfo | MessageType::SymbolTable => {
4106 has_link = true;
4107 }
4108 _ => {}
4109 }
4110 p = body_end;
4111 }
4112 if has_link {
4113 return Err(Error::AppendUnsupported(
4114 "append target is a group, not a dataset",
4115 ));
4116 }
4117 let (dt_b, dt_e) =
4118 datatype.ok_or(Error::AppendUnsupported("dataset header has no datatype"))?;
4119 let (ds_b, ds_e) =
4120 dataspace.ok_or(Error::AppendUnsupported("dataset header has no dataspace"))?;
4121 let (lb, le) = layout.ok_or(Error::AppendUnsupported(
4122 "dataset header has no data layout",
4123 ))?;
4124
4125 let (disk_dt, _) = Datatype::parse(®ion[dt_b..dt_e])
4126 .map_err(|_| Error::AppendUnsupported("dataset header datatype could not be parsed"))?;
4127 let disk_ds = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE).map_err(|_| {
4128 Error::AppendUnsupported("dataset header dataspace could not be parsed")
4129 })?;
4130 let dl = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE).map_err(|_| {
4131 Error::AppendUnsupported("dataset header data layout could not be parsed")
4132 })?;
4133
4134 // Require chunked, data-layout version 4, Extensible-Array index (type 4).
4135 let DataLayout::Chunked {
4136 version: lversion,
4137 chunk_index_type,
4138 btree_address,
4139 ..
4140 } = &dl
4141 else {
4142 return Err(Error::AppendUnsupported(
4143 "append requires a chunked dataset",
4144 ));
4145 };
4146 if *lversion != 4 || *chunk_index_type != Some(4) {
4147 return Err(Error::AppendUnsupported(
4148 "append requires an Extensible-Array-indexed chunked dataset (a single \
4149 unlimited dimension under the latest format)",
4150 ));
4151 }
4152
4153 // Require rank 1, unlimited along axis 0.
4154 if disk_ds.space_type != DataspaceType::Simple || disk_ds.dimensions.len() != 1 {
4155 return Err(Error::AppendUnsupported(
4156 "append requires a rank-1 dataset in this release",
4157 ));
4158 }
4159 match &disk_ds.max_dimensions {
4160 Some(md) if md.first() == Some(&u64::MAX) => {}
4161 _ => {
4162 return Err(Error::AppendUnsupported(
4163 "append requires a dataset that is unlimited along its first dimension",
4164 ));
4165 }
4166 }
4167
4168 let ChunkedGeometry {
4169 spatial,
4170 element_size,
4171 raw_size,
4172 ..
4173 } = chunked_geometry(&disk_dt, &disk_ds, &dl)?;
4174 let chunk_elems = spatial[0];
4175 if chunk_elems == 0 {
4176 return Err(Error::AppendUnsupported(
4177 "append requires a nonzero chunk length",
4178 ));
4179 }
4180
4181 // Validate the appended bytes against the on-disk element type.
4182 if ab.raw.len() % element_size != 0 {
4183 return Err(Error::AppendUnsupported(
4184 "appended byte length is not a whole number of elements",
4185 ));
4186 }
4187 match &ab.elem_dt {
4188 // A typed append must match the on-disk datatype exactly (class, size,
4189 // and byte order) — this is a value append, not a retype.
4190 Some(expected) if *expected != disk_dt => {
4191 return Err(Error::AppendUnsupported(
4192 "append datatype does not match the on-disk dataset (wrong element \
4193 type or byte order)",
4194 ));
4195 }
4196 Some(_) => {}
4197 // A raw append trusts the caller's bytes but still refuses any datatype
4198 // whose flat little-endian bytes cannot be written verbatim: a
4199 // big-endian numeric leaf would silently misencode, and a
4200 // variable-length or reference leaf embeds heap/object addresses a byte
4201 // append cannot reproduce. A typed append is byte-order- and
4202 // class-checked by the datatype-equality arm above.
4203 None => {
4204 if !datatype_is_raw_appendable(&disk_dt) {
4205 return Err(Error::AppendUnsupported(
4206 "append_raw onto this dataset's datatype (non-little-endian, \
4207 variable-length, or reference) could misencode the bytes; use a \
4208 typed append",
4209 ));
4210 }
4211 }
4212 }
4213
4214 let new_elems = (ab.raw.len() / element_size) as u64;
4215 let current_dim0 = disk_ds.dimensions[0];
4216 let new_dim0 = current_dim0
4217 .checked_add(new_elems)
4218 .ok_or(Error::AppendUnsupported(
4219 "append would overflow the dataset dimension",
4220 ))?;
4221
4222 // The filter pipeline is preserved verbatim in the rebuilt header; parse it
4223 // to re-encode the new chunks. An engine-unencodable filter is refused.
4224 let pipeline_message: Option<Vec<u8>> = filter.map(|(fb, fe)| region[fb..fe].to_vec());
4225 let has_filters = pipeline_message.is_some();
4226 let pipeline = match &pipeline_message {
4227 Some(pm) => {
4228 let parsed = FilterPipeline::parse(pm).map_err(|_| {
4229 Error::AppendUnsupported("dataset filter pipeline could not be parsed")
4230 })?;
4231 if !pipeline_reencodable(&parsed) {
4232 return Err(Error::AppendUnsupported(
4233 "dataset uses a filter this engine cannot re-encode",
4234 ));
4235 }
4236 Some(parsed)
4237 }
4238 None => None,
4239 };
4240
4241 if base > src.len() {
4242 return Err(Error::AppendUnsupported(
4243 "userblock base address past end-of-file",
4244 ));
4245 }
4246 let view = BaseOffsetSource { inner: src, base };
4247
4248 // The rebuilt index's element format (bare address vs address+size+mask) is
4249 // chosen by `has_filters`; it must agree with the source index's client id,
4250 // or the kept chunks — carried by metadata into the new index — would be
4251 // re-encoded in the wrong element width.
4252 if let Some(idx_addr) = *btree_address {
4253 let hdr =
4254 ExtensibleArrayHeader::parse_from_source(&view, idx_addr, OFFSET_SIZE, LENGTH_SIZE)
4255 .map_err(|_| {
4256 Error::AppendUnsupported(
4257 "dataset extensible-array header could not be parsed",
4258 )
4259 })?;
4260 if (hdr.client_id == 1) != has_filters {
4261 return Err(Error::AppendUnsupported(
4262 "dataset filter metadata is inconsistent (chunk-index client id \
4263 disagrees with the filter pipeline)",
4264 ));
4265 }
4266 }
4267
4268 // Enumerate the existing chunks (base-relative addresses) and require a
4269 // dense grid: `plan_dense_grid` returns the chunks in index order and
4270 // `None` on any hole, duplicate, or count mismatch against the dimension.
4271 let infos = enumerate_chunks_from_source(&view, &dl, &disk_ds, OFFSET_SIZE, LENGTH_SIZE)
4272 .map_err(|_| Error::AppendUnsupported("dataset chunk index could not be enumerated"))?;
4273 let grid = plan_dense_grid(infos, &disk_ds.dimensions, &spatial).ok_or(
4274 Error::AppendUnsupported(
4275 "dataset has a sparse or inconsistent chunk grid; cannot append",
4276 ),
4277 )?;
4278 let grid_order = grid.grid_order;
4279
4280 // Complete chunks are kept by metadata; a trailing partial chunk (when the
4281 // current length is not chunk-aligned) is rewritten.
4282 let n_full = usize::try_from(current_dim0 / chunk_elems)
4283 .map_err(|_| Error::AppendUnsupported("chunk count exceeds this platform"))?;
4284 let has_partial = current_dim0 % chunk_elems != 0;
4285
4286 let mut kept_chunks: Vec<WrittenChunk> = Vec::with_capacity(n_full);
4287 for ci in grid_order.iter().take(n_full) {
4288 kept_chunks.push(WrittenChunk {
4289 address: ci.address,
4290 compressed_size: u64::from(ci.chunk_size),
4291 raw_size,
4292 // Preserve the source mask verbatim: a C/h5py file records a nonzero
4293 // mask for a chunk whose filter was skipped (e.g. deflate on
4294 // incompressible data), and forcing it to 0 would corrupt that chunk.
4295 filter_mask: ci.filter_mask,
4296 });
4297 }
4298
4299 // Build the raw byte region for the tail (from the last chunk boundary to
4300 // the new end): the live prefix of any rewritten partial chunk, then the
4301 // appended bytes.
4302 let mut tail_raw: Vec<u8> = Vec::new();
4303 let mut old_tail_extent: Option<(u64, u64)> = None;
4304 if has_partial {
4305 let partial = &grid_order[n_full];
4306 let len = partial.chunk_size as usize;
4307 partial
4308 .address
4309 .checked_add(len as u64)
4310 .filter(|&e| e <= view.len())
4311 .ok_or(Error::AppendUnsupported(
4312 "trailing chunk extends past end-of-file",
4313 ))?;
4314 let stored = view
4315 .read_exact_at(partial.address, len)
4316 .map_err(|_| Error::AppendUnsupported("trailing chunk could not be read"))?;
4317 let full = if let Some(pl) = &pipeline {
4318 let ctx = ChunkContext::from_datatype(&spatial, &disk_dt);
4319 decompress_chunk(&stored, pl, ctx, partial.filter_mask).map_err(Error::Format)?
4320 } else {
4321 stored
4322 };
4323 let live_elems = usize::try_from(current_dim0 % chunk_elems)
4324 .map_err(|_| Error::AppendUnsupported("chunk length exceeds this platform"))?;
4325 let live_bytes = live_elems * element_size;
4326 if full.len() < live_bytes {
4327 return Err(Error::AppendUnsupported(
4328 "trailing chunk decoded shorter than its live element count",
4329 ));
4330 }
4331 tail_raw.extend_from_slice(&full[..live_bytes]);
4332 // The old partial chunk's data block is dead once the new index lands.
4333 old_tail_extent = Some((partial.address + base, u64::from(partial.chunk_size)));
4334 }
4335 tail_raw.extend_from_slice(&ab.raw);
4336
4337 // Split the tail into full chunk buffers (edge overhang zero-filled) and
4338 // compress each through the pipeline when filtered.
4339 let tail_len_elems = new_dim0 - (n_full as u64) * chunk_elems;
4340 let split = split_into_chunks(&tail_raw, &[tail_len_elems], &spatial, element_size);
4341 let new_chunk_bytes: Vec<Vec<u8>> = if let Some(pl) = &pipeline {
4342 let ctx = ChunkContext::from_datatype(&spatial, &disk_dt);
4343 let mut out = Vec::with_capacity(split.len());
4344 for (_, buf) in &split {
4345 out.push(compress_chunk(buf, pl, ctx).map_err(Error::Format)?);
4346 }
4347 out
4348 } else {
4349 split.into_iter().map(|(_, buf)| buf).collect()
4350 };
4351
4352 // Grow the dataspace along axis 0, preserving the (unlimited) max-dims.
4353 let mut grown = disk_ds.clone();
4354 grown.dimensions[0] = new_dim0;
4355 let new_dataspace_body = grown.serialize(LENGTH_SIZE);
4356
4357 #[expect(
4358 clippy::cast_possible_truncation,
4359 reason = "spatial chunk dims come from the on-disk u32 chunk_dimensions, so they fit u32"
4360 )]
4361 let chunk_dims_u32: Vec<u32> = spatial.iter().map(|&dm| dm as u32).collect();
4362
4363 Ok(MovingWrite::AppendedChunks {
4364 region,
4365 new_dataspace_body,
4366 chunk_dims_u32,
4367 element_size,
4368 raw_size,
4369 has_filters,
4370 kept_chunks,
4371 new_chunk_bytes,
4372 old_addr: addr,
4373 old_tail_extent,
4374 })
4375 }
4376
4377 /// Parse the object header at `addr` into a copyable model, validating that
4378 /// every message can be reproduced faithfully (verbatim message bytes, with
4379 /// only the contiguous data address and child link targets repointed).
4380 /// Dense (fractal-heap) attribute storage is read out of the source heap into
4381 /// a parsed attribute set carried on the model (`dense_attrs`) and re-emitted
4382 /// into a fresh heap on write, within the bounds that heap declares (see
4383 /// `file_writer::dense_attrs_check`); an attribute too large to hold as a
4384 /// managed object is re-emitted as a *huge* object. Rejects multi-chunk
4385 /// headers, dense or soft/external links, chunked/old-version data layouts, and
4386 /// headers that are neither a dataset nor a group.
4387 fn read_object<S: Source + ?Sized>(src: &S, addr: u64, base: u64) -> Result<ObjModel, Error> {
4388 let region = Self::gather_oh_messages(src, addr, base)?;
4389
4390 // First pass: detect whether attributes are stored densely (a defined
4391 // fractal-heap address in the Attribute Info message). A dense object is
4392 // copied by reading its attributes out of the source heap and rebuilding
4393 // a fresh heap on write, so its Attribute Info message and any inline
4394 // Attribute messages are dropped from the verbatim region — the rebuilt
4395 // region carries neither, and `dense_attrs` carries the parsed set.
4396 let mut dense = false;
4397 let mut p = 0;
4398 while let Some((msg_type, body, body_end)) = next_message(®ion, p)? {
4399 if msg_type == MessageType::AttributeInfo {
4400 // An Attribute Info message does not by itself mean dense
4401 // storage: the reference C library and h5py emit one (with an
4402 // *undefined* fractal-heap address) even for compact, inline
4403 // attributes in the latest format, to carry attribute
4404 // creation-order metadata. Only a *defined* heap address is real
4405 // dense (fractal-heap) storage. A message that cannot be parsed
4406 // is refused conservatively.
4407 let ai = crate::attribute_info::AttributeInfoMessage::parse(
4408 ®ion[body..body_end],
4409 OFFSET_SIZE,
4410 )
4411 .map_err(|_| {
4412 Error::EditUnsupported(
4413 "a source attribute-info message could not be parsed for copying",
4414 )
4415 })?;
4416 if ai.fractal_heap_address.is_some() {
4417 dense = true;
4418 }
4419 }
4420 p = body_end;
4421 }
4422
4423 // If dense, read the attribute set out of the source fractal heap now (so
4424 // the source buffer need not outlive the read) and validate it can be
4425 // re-emitted into a fresh heap on write. `extract_attributes_full` reads
4426 // both compact and dense attributes; a dense object carries no inline
4427 // Attribute messages, so it returns exactly the heap-resident set.
4428 let dense_attrs = if dense {
4429 let header =
4430 ObjectHeader::parse_from_source(src, addr, OFFSET_SIZE, LENGTH_SIZE, base).map_err(|_| {
4431 Error::EditUnsupported(
4432 "a source object header with dense attributes could not be parsed for copying",
4433 )
4434 })?;
4435 // The heap address in the Attribute Info message is stored relative to
4436 // the base address, so the walk gets the source framed past its
4437 // userblock — the same view the reader uses. `base` is 0 for a plain
4438 // file, where this is `src` itself.
4439 if base > src.len() {
4440 return Err(Error::EditUnsupported(
4441 "a source file's userblock is larger than the file itself",
4442 ));
4443 }
4444 let framed = BaseOffsetSource { inner: src, base };
4445 let attrs = crate::attribute::extract_attributes_full_from_source(
4446 &framed,
4447 &header,
4448 OFFSET_SIZE,
4449 LENGTH_SIZE,
4450 )
4451 .map_err(|_| {
4452 Error::EditUnsupported(
4453 "a source object's dense (fractal-heap) attributes could not be read for copying",
4454 )
4455 })?;
4456 // The typed error names the offending attribute, which the previous
4457 // blanket `EditUnsupported` message could not.
4458 crate::file_writer::dense_attrs_check(&attrs).map_err(Error::Format)?;
4459 attrs
4460 } else {
4461 Vec::new()
4462 };
4463
4464 let mut layout: Option<(usize, usize)> = None; // (body offset in kept, size)
4465 let mut has_link_info = false;
4466 let mut children: Vec<(String, u64)> = Vec::new();
4467 // The rebuilt chunk-0 region: every message kept verbatim except hard
4468 // Link messages (carried as `children`) and, when dense, the Attribute
4469 // Info message and inline Attribute messages (carried as `dense_attrs`).
4470 let mut kept: Vec<u8> = Vec::new();
4471
4472 let mut p = 0;
4473 while let Some((msg_type, body, body_end)) = next_message(®ion, p)? {
4474 let mut keep = true;
4475 match msg_type {
4476 MessageType::AttributeInfo => {
4477 // Already parsed in the first pass; drop the dense Attribute
4478 // Info message so the rebuilt header references the fresh heap
4479 // (spliced in on write) rather than the source one. A compact
4480 // (undefined-heap) Attribute Info message is kept verbatim.
4481 if dense {
4482 keep = false;
4483 }
4484 }
4485 MessageType::Attribute => {
4486 // A dense object should carry no inline Attribute messages,
4487 // but drop any defensively so the rebuilt header's only
4488 // attribute storage is the fresh heap.
4489 if dense {
4490 keep = false;
4491 }
4492 }
4493 MessageType::LinkInfo => {
4494 has_link_info = true;
4495 let mut q = body + 2;
4496 if body_end - body >= 2 && region[body + 1] & 0x01 != 0 {
4497 q += 8;
4498 }
4499 if q + 8 <= body_end {
4500 let heap_addr = u64::from_le_bytes(region[q..q + 8].try_into().unwrap());
4501 if heap_addr != u64::MAX {
4502 return Err(Error::EditUnsupported(
4503 "a group uses dense (fractal-heap) link storage (not supported in place yet)",
4504 ));
4505 }
4506 }
4507 }
4508 MessageType::Link => {
4509 keep = false;
4510 match LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
4511 Ok(LinkMessage {
4512 name,
4513 link_target:
4514 LinkTarget::Hard {
4515 object_header_address,
4516 },
4517 ..
4518 }) => children.push((name, object_header_address)),
4519 _ => {
4520 return Err(Error::EditUnsupported(
4521 "a group contains a soft/external link (not copyable in place yet)",
4522 ));
4523 }
4524 }
4525 }
4526 MessageType::DataLayout => {
4527 // Record the layout body offset within the *kept* region so a
4528 // contiguous dataset's data-address field can be repointed
4529 // even after earlier messages were dropped.
4530 layout = Some((kept.len() + (body - p), body_end - body));
4531 }
4532 _ => {}
4533 }
4534 if keep {
4535 kept.extend_from_slice(®ion[p..body_end]);
4536 }
4537 p = body_end;
4538 }
4539
4540 if let Some((lbody, lsize)) = layout {
4541 let version = kept[lbody];
4542 if !(version == 3 || version == 4) || lsize < 2 {
4543 return Err(Error::EditUnsupported(
4544 "an unsupported data-layout version cannot be copied in place yet",
4545 ));
4546 }
4547 let class = kept[lbody + 1];
4548 match class {
4549 0 => Ok(ObjModel::DatasetVerbatim {
4550 region: kept,
4551 dense_attrs,
4552 }),
4553 1 => {
4554 if lbody + 18 > kept.len() {
4555 return Err(Error::EditUnsupported("malformed contiguous data layout"));
4556 }
4557 let data_addr =
4558 u64::from_le_bytes(kept[lbody + 2..lbody + 10].try_into().unwrap());
4559 let data_size =
4560 u64::from_le_bytes(kept[lbody + 10..lbody + 18].try_into().unwrap());
4561 Ok(ObjModel::DatasetContiguous {
4562 region: kept,
4563 addr_off: lbody + 2,
4564 data_addr,
4565 data_size,
4566 dense_attrs,
4567 })
4568 }
4569 // Chunked: the verbatim header carries the data-layout and filter-
4570 // pipeline messages; `read_copy_subtree` (which holds the source
4571 // buffer) enumerates and captures the chunk bytes and rebuilds the
4572 // index on write.
4573 2 => Ok(ObjModel::DatasetChunked {
4574 region: kept,
4575 dense_attrs,
4576 }),
4577 _ => Err(Error::EditUnsupported(
4578 "an unsupported data-layout class cannot be copied in place yet",
4579 )),
4580 }
4581 } else if has_link_info {
4582 // A copied group must carry a Group Info message so the copy stays
4583 // writable by the C library, even when the source omitted it.
4584 ensure_group_info(&mut kept)?;
4585 Ok(ObjModel::Group {
4586 non_link_region: kept,
4587 children,
4588 dense_attrs,
4589 })
4590 } else {
4591 Err(Error::EditUnsupported(
4592 "an object is neither a contiguous/compact dataset nor a group",
4593 ))
4594 }
4595 }
4596
4597 /// Read the object at `addr` in the source buffer `d` — and, for a group, its
4598 /// whole subtree — into an owned [`CopyTree`], the read half of an object copy.
4599 /// No bytes are written; this both validates that the subtree is copyable and
4600 /// captures the bytes the write half ([`write_copy_subtree`](Self::write_copy_subtree))
4601 /// later appends, so the source buffer need not outlive the read.
4602 ///
4603 /// `src` is the image the source object lives in: this session's own file image
4604 /// for an in-file [`copy`](Self::copy), or another file's image for a cross-file
4605 /// [`copy_from`](Self::copy_from). `base` is that image's userblock base (the
4606 /// session's own base for an in-file copy, always 0 for a cross-file copy, whose
4607 /// source is gated to base 0): the stored, base-relative addresses read out of
4608 /// the source headers are shifted by it to index `src`. When `cross_file` is set,
4609 /// every copied object header is additionally screened by
4610 /// [`reject_foreign_addresses`] — verbatim bytes that embed a *source-file*
4611 /// absolute address (variable-length or reference data, a committed datatype)
4612 /// would dangle in another file and are refused, whereas an in-file copy keeps
4613 /// them valid by sharing the source file's heaps and objects.
4614 fn read_copy_subtree<S: Source + ?Sized>(
4615 src: &S,
4616 addr: u64,
4617 depth: u32,
4618 cross_file: bool,
4619 base: u64,
4620 ) -> Result<CopyTree, Error> {
4621 if depth >= MAX_COPY_DEPTH {
4622 return Err(Error::EditUnsupported(
4623 "copy source nests too deeply (possible hard-link cycle)",
4624 ));
4625 }
4626 // `base` is the userblock base of the image `src`: this session's own base
4627 // for an in-file copy, and always 0 for a cross-file copy (the source is
4628 // gated to base 0 in `copy_from`). `addr` is an absolute offset into `src`;
4629 // the stored (base-relative) addresses `read_object` returns for contiguous
4630 // data, chunk storage, and child links are converted to absolute offsets by
4631 // adding `base` before `src` is read or a child is descended into.
4632 match Self::read_object(src, addr, base)? {
4633 ObjModel::DatasetVerbatim {
4634 region,
4635 dense_attrs,
4636 } => {
4637 if cross_file {
4638 reject_foreign_addresses(®ion)?;
4639 reject_foreign_dense_attrs(&dense_attrs)?;
4640 }
4641 Ok(CopyTree::DatasetVerbatim {
4642 region,
4643 dense_attrs,
4644 })
4645 }
4646 ObjModel::DatasetContiguous {
4647 region,
4648 addr_off,
4649 data_addr,
4650 data_size,
4651 dense_attrs,
4652 } => {
4653 if cross_file {
4654 reject_foreign_addresses(®ion)?;
4655 reject_foreign_dense_attrs(&dense_attrs)?;
4656 }
4657 // The stored data address is base-relative; shift it to an absolute
4658 // offset into `src` before reading the data block out.
4659 let start = data_addr
4660 .checked_add(base)
4661 .ok_or(Error::EditUnsupported("data address exceeds this platform"))?;
4662 let len = usize::try_from(data_size)
4663 .map_err(|_| Error::EditUnsupported("data size exceeds this platform"))?;
4664 start
4665 .checked_add(len as u64)
4666 .filter(|&e| e <= src.len())
4667 .ok_or(Error::EditUnsupported("dataset data is out of bounds"))?;
4668 Ok(CopyTree::DatasetContiguous {
4669 region,
4670 addr_off,
4671 data: src
4672 .read_exact_at(start, len)
4673 .map_err(|_| Error::EditUnsupported("dataset data is out of bounds"))?,
4674 dense_attrs,
4675 })
4676 }
4677 ObjModel::DatasetChunked {
4678 region,
4679 dense_attrs,
4680 } => {
4681 // Screen the verbatim header on the cross-file path. This refuses a
4682 // variable-length or reference datatype (whose chunk payload embeds
4683 // source-file global-heap / object addresses that would dangle in
4684 // another file) and any shared message — exactly the forms repack
4685 // also refuses for a cross-file verbatim chunk copy. An in-file copy
4686 // keeps them valid by sharing the source file's heaps.
4687 if cross_file {
4688 reject_foreign_addresses(®ion)?;
4689 reject_foreign_dense_attrs(&dense_attrs)?;
4690 }
4691 let ChunkedHeaderParts {
4692 dt,
4693 ds,
4694 layout,
4695 pipeline_message,
4696 } = parse_chunked_header(®ion)?;
4697 let DataLayout::Chunked {
4698 version: lversion,
4699 chunk_index_type,
4700 ..
4701 } = layout
4702 else {
4703 return Err(Error::EditUnsupported("dataset is not chunked"));
4704 };
4705 if !chunk_index_enumerable(lversion, chunk_index_type) {
4706 return Err(Error::EditUnsupported(
4707 "a chunked dataset with a version-2 B-tree or unknown chunk index \
4708 cannot be copied in place yet",
4709 ));
4710 }
4711 let ChunkedGeometry {
4712 spatial: chunk_dims,
4713 element_size,
4714 raw_size,
4715 maxshape,
4716 } = chunked_geometry(&dt, &ds, &layout)?;
4717
4718 // The layout's chunk-index address and every chunk address it leads
4719 // to are stored base-relative, so enumerate and read on a
4720 // base-relative view of the source image (the identity on a base-0
4721 // file). The returned addresses are then offsets into `dview`.
4722 let dview = BaseOffsetSource { inner: src, base };
4723
4724 // Enumerate the source chunks and map them onto a dense grid; a
4725 // sparse (holed/unallocated) dataset cannot be reproduced by the
4726 // verbatim layout path, which needs every grid slot filled.
4727 let infos =
4728 enumerate_chunks_from_source(&dview, &layout, &ds, OFFSET_SIZE, LENGTH_SIZE)?;
4729 let grid = plan_dense_grid(infos, &ds.dimensions, &chunk_dims).ok_or(
4730 Error::EditUnsupported(
4731 "a chunked dataset with unallocated (sparse) chunks cannot be copied in place yet",
4732 ),
4733 )?;
4734 if grid.grid_order.is_empty() {
4735 return Err(Error::EditUnsupported(
4736 "an empty chunked dataset cannot be copied in place yet",
4737 ));
4738 }
4739
4740 // Capture each chunk's already-compressed bytes (no decode) into an
4741 // owned buffer, in dense row-major grid order, so the copy can be
4742 // written after the source buffer is gone (cross-file copy reads at
4743 // staging time). Sizes and masks are carried verbatim.
4744 let mut meta = Vec::with_capacity(grid.grid_order.len());
4745 let mut chunk_bytes = Vec::with_capacity(grid.grid_order.len());
4746 for ci in &grid.grid_order {
4747 let len = ci.chunk_size as usize;
4748 ci.address
4749 .checked_add(len as u64)
4750 .filter(|&e| e <= dview.len())
4751 .ok_or(Error::EditUnsupported("chunk data is out of bounds"))?;
4752 chunk_bytes.push(
4753 dview
4754 .read_exact_at(ci.address, len)
4755 .map_err(|_| Error::EditUnsupported("chunk data is out of bounds"))?,
4756 );
4757 meta.push(ChunkMeta {
4758 compressed_size: ci.chunk_size as u64,
4759 filter_mask: ci.filter_mask,
4760 });
4761 }
4762
4763 Ok(CopyTree::DatasetChunked {
4764 region,
4765 chunk_dims,
4766 element_size,
4767 raw_size,
4768 maxshape,
4769 pipeline_message,
4770 meta,
4771 chunk_bytes,
4772 dense_attrs,
4773 })
4774 }
4775 ObjModel::Group {
4776 non_link_region,
4777 children,
4778 dense_attrs,
4779 } => {
4780 if cross_file {
4781 reject_foreign_addresses(&non_link_region)?;
4782 reject_foreign_dense_attrs(&dense_attrs)?;
4783 }
4784 let mut kids = Vec::with_capacity(children.len());
4785 for (name, child) in children {
4786 // Child link targets are stored base-relative; re-absolutize
4787 // before descending so `addr` stays an absolute offset into `src`.
4788 let child = child.checked_add(base).ok_or(Error::EditUnsupported(
4789 "child address exceeds this platform",
4790 ))?;
4791 kids.push((
4792 name,
4793 Self::read_copy_subtree(src, child, depth + 1, cross_file, base)?,
4794 ));
4795 }
4796 Ok(CopyTree::Group {
4797 non_link_region,
4798 children: kids,
4799 dense_attrs,
4800 })
4801 }
4802 }
4803 }
4804
4805 /// Append the fresh copies described by `node` (data blobs and headers) into
4806 /// this session at end-of-file or into reusable freed regions, returning the
4807 /// new object-header address of the copied root. The write half of an object
4808 /// copy; children are written before their parent group so each parent links
4809 /// its children's new addresses, and a contiguous dataset's data-address field
4810 /// is repointed at the freshly-written copy. Every address the copy writes into
4811 /// a header (a contiguous data block, a child link) is stored relative to the
4812 /// userblock base (`- base`, a no-op on a base-0 file); the chunked storage and
4813 /// dense attribute heaps are laid out base-relative by their own builders.
4814 fn write_copy_subtree(&mut self, node: &CopyTree) -> Result<u64, Error> {
4815 let base = self.superblock.base_address;
4816 match node {
4817 CopyTree::DatasetVerbatim {
4818 region,
4819 dense_attrs,
4820 } => {
4821 let mut region = region.clone();
4822 self.append_dense_attrs(&mut region, dense_attrs)?;
4823 let oh = build_v2_object_header(®ion);
4824 self.alloc_or_append_typed(&oh, PageType::Meta)
4825 }
4826 CopyTree::DatasetContiguous {
4827 region,
4828 addr_off,
4829 data,
4830 dense_attrs,
4831 } => {
4832 let new_data_addr = self.alloc_or_append_typed(data, PageType::Raw)?;
4833 let mut region = region.clone();
4834 // `alloc_or_append` returns an absolute offset; the data-layout
4835 // address field stores it relative to the userblock base.
4836 region[*addr_off..*addr_off + 8]
4837 .copy_from_slice(&(new_data_addr - base).to_le_bytes());
4838 // Append the dense heap *after* the data so the heap's base
4839 // equals end-of-file (see `append_dense_attrs`).
4840 self.append_dense_attrs(&mut region, dense_attrs)?;
4841 let oh = build_v2_object_header(®ion);
4842 self.alloc_or_append_typed(&oh, PageType::Meta)
4843 }
4844 CopyTree::DatasetChunked {
4845 region,
4846 chunk_dims,
4847 element_size,
4848 raw_size,
4849 maxshape,
4850 pipeline_message,
4851 meta,
4852 chunk_bytes,
4853 dense_attrs,
4854 } => self.write_chunked_relocatable(
4855 region,
4856 chunk_dims,
4857 *element_size,
4858 *raw_size,
4859 maxshape.as_deref(),
4860 pipeline_message.as_deref(),
4861 meta,
4862 chunk_bytes,
4863 dense_attrs,
4864 ),
4865 CopyTree::Group {
4866 non_link_region,
4867 children,
4868 dense_attrs,
4869 } => {
4870 let mut region = non_link_region.clone();
4871 for (name, child) in children {
4872 let new_child = self.write_copy_subtree(child)?;
4873 // The link target is stored relative to the userblock base.
4874 region.extend_from_slice(&encode_link_message(name, new_child - base));
4875 }
4876 // Append the dense heap after the children's headers/data so its
4877 // base equals end-of-file (see `append_dense_attrs`).
4878 self.append_dense_attrs(&mut region, dense_attrs)?;
4879 let oh = build_v2_object_header(®ion);
4880 self.alloc_or_append_typed(&oh, PageType::Meta)
4881 }
4882 }
4883 }
4884
4885 /// Write a chunked dataset's storage at end-of-file and return its new
4886 /// object-header address — the shared write half of a chunked copy
4887 /// ([`CopyTree::DatasetChunked`]) and a relocating chunked overwrite
4888 /// ([`MovingWrite::Chunked`]).
4889 ///
4890 /// A fresh chunk-data blob and index are laid out relocatably at the current
4891 /// end-of-file via [`plan_chunked_data_verbatim`] / [`emit_chunked_data_verbatim`],
4892 /// pulling each chunk's already-compressed bytes from `chunk_bytes` (in dense
4893 /// row-major grid order) and carrying `meta`'s sizes and filter masks and the
4894 /// source `pipeline_message` verbatim — no recompression, no filter-parameter
4895 /// reconstruction. The blob is *appended* (not placed via [`alloc_or_append`])
4896 /// because its embedded addresses assume `base == end-of-file`, exactly like
4897 /// [`build_chunked_dataset`](Self::build_chunked_dataset). The verbatim header
4898 /// `region`'s data-layout message is then swapped for the one the planner
4899 /// produced (every other message preserved), any dense attribute heap is
4900 /// appended after the blob, and the header is written into reusable freed space
4901 /// or at end-of-file.
4902 #[expect(
4903 clippy::too_many_arguments,
4904 reason = "the chunked rebuild needs the full geometry, \
4905 pipeline, and chunk payloads; bundling them into a struct would only move the list"
4906 )]
4907 fn write_chunked_relocatable(
4908 &mut self,
4909 region: &[u8],
4910 chunk_dims: &[u64],
4911 element_size: usize,
4912 raw_size: u64,
4913 maxshape: Option<&[u64]>,
4914 pipeline_message: Option<&[u8]>,
4915 meta: &[ChunkMeta],
4916 chunk_bytes: &[Vec<u8>],
4917 dense_attrs: &[crate::attribute::AttributeMessage],
4918 ) -> Result<u64, Error> {
4919 // The blob is raw data, and its embedded addresses are computed from the
4920 // end-of-file it lands at, so open a raw page *before* reading that offset.
4921 self.begin_page(PageType::Raw)?;
4922 let eof = self.image.len();
4923 // Build with the *stored* (base-relative) address the blob will occupy, so
4924 // its embedded addresses resolve to its real file offset once the reader adds
4925 // the userblock base back (see `build_chunked_dataset`). On a base-0 file this
4926 // equals `eof`.
4927 let stored_base = eof - self.superblock.base_address;
4928 let layout = plan_chunked_data_verbatim(
4929 meta,
4930 chunk_dims,
4931 element_size,
4932 raw_size,
4933 pipeline_message,
4934 stored_base,
4935 maxshape,
4936 )?;
4937 let mut buf = Vec::with_capacity(usize::try_from(layout.plan.total_len).unwrap_or(0));
4938 emit_chunked_data_verbatim(
4939 &mut buf,
4940 &layout.plan,
4941 &SliceChunkProvider {
4942 chunks: chunk_bytes,
4943 },
4944 )?;
4945 let written = self.append(&buf)?;
4946 debug_assert_eq!(written, eof, "chunk blob must land at end-of-file",);
4947 // Swap the data-layout message for the rebuilt one; keep every other header
4948 // message (datatype, dataspace, fill value, filter pipeline, attributes)
4949 // verbatim. A dense attribute heap, if any, is appended after the blob so
4950 // its base equals end-of-file (see `append_dense_attrs`).
4951 let mut new_region = replace_layout_message(region, &layout.layout_message)?;
4952 self.append_dense_attrs(&mut new_region, dense_attrs)?;
4953 let oh = build_v2_object_header(&new_region);
4954 self.alloc_or_append_typed(&oh, PageType::Meta)
4955 }
4956
4957 /// When `attrs` is non-empty, build a fresh dense (fractal-heap) attribute
4958 /// blob for it, append it at end-of-file, and splice the matching Attribute
4959 /// Info message onto `region`. A no-op for an empty set.
4960 ///
4961 /// The blob produced by [`file_writer::build_dense_attrs`] is fully
4962 /// relocatable: every address it embeds is `base + fixed offset`, so passing
4963 /// the current end-of-file as the base makes those addresses land exactly
4964 /// where the bytes are written. Like [`build_chunked_dataset`](Self::build_chunked_dataset)
4965 /// the blob is therefore *appended* (never placed into an interior freed
4966 /// region), and the caller must append it before any later append in the same
4967 /// node so `base == end-of-file` still holds. The freshly built heap is
4968 /// always same-file, so it never aliases the source heap even for an in-file
4969 /// copy. The caller has already validated [`file_writer::dense_attrs_check`].
4970 fn append_dense_attrs(
4971 &mut self,
4972 region: &mut Vec<u8>,
4973 attrs: &[crate::attribute::AttributeMessage],
4974 ) -> Result<(), Error> {
4975 if attrs.is_empty() {
4976 return Ok(());
4977 }
4978 // A fractal-heap attribute blob is metadata, and its embedded addresses are
4979 // computed from the end-of-file it lands at, so open a metadata page
4980 // *before* reading that offset.
4981 self.begin_page(PageType::Meta)?;
4982 let eof = self.image.len();
4983 // Build with the *stored* (base-relative) address the blob will occupy, so
4984 // every address it embeds resolves to its real file offset once the reader
4985 // adds the userblock base back (see `build_chunked_dataset`). On a base-0
4986 // file this equals `eof`.
4987 let stored_base = eof - self.superblock.base_address;
4988 let blob = crate::file_writer::build_dense_attrs(attrs, stored_base);
4989 let written = self.append(&blob.blob)?;
4990 debug_assert_eq!(
4991 written, eof,
4992 "dense attribute blob must land at end-of-file",
4993 );
4994 region.extend_from_slice(®ion_message(
4995 MessageType::AttributeInfo,
4996 &blob.attr_info_message,
4997 ));
4998 Ok(())
4999 }
5000
5001 /// Apply a relocating value overwrite (`write_dataset` resize / compact
5002 /// rewrite): write the new data and a rewritten object header at end-of-file
5003 /// (or into reusable freed space) and return the new header address. The
5004 /// caller patches the parent group's link to this address. The old data
5005 /// extent (for a resized contiguous dataset) is freed separately, after the
5006 /// commit's superblock repoint, so it is never reused mid-commit.
5007 fn write_moving(&mut self, mw: &MovingWrite) -> Result<u64, Error> {
5008 let base = self.superblock.base_address;
5009 match mw {
5010 MovingWrite::Contiguous {
5011 region,
5012 addr_off,
5013 raw,
5014 ..
5015 } => {
5016 let new_data_addr = self.alloc_or_append_typed(raw, PageType::Raw)?;
5017 let mut region = region.clone();
5018 // `alloc_or_append` returns an absolute file offset; the contiguous
5019 // data-layout field stores it relative to the userblock base (`-
5020 // base`, a no-op on a base-0 file).
5021 region[*addr_off..*addr_off + 8]
5022 .copy_from_slice(&(new_data_addr - base).to_le_bytes());
5023 // The data size field follows the 8-byte address in the contiguous
5024 // layout body; keep it in sync with the new length.
5025 let size_off = *addr_off + 8;
5026 region[size_off..size_off + 8].copy_from_slice(&(raw.len() as u64).to_le_bytes());
5027 let oh = build_v2_object_header(®ion);
5028 self.alloc_or_append_typed(&oh, PageType::Meta)
5029 }
5030 MovingWrite::Compact { region, raw } => {
5031 let region = rebuild_compact_layout_region(region, raw)?;
5032 let oh = build_v2_object_header(®ion);
5033 self.alloc_or_append_typed(&oh, PageType::Meta)
5034 }
5035 MovingWrite::Chunked {
5036 region,
5037 chunk_dims,
5038 element_size,
5039 raw_size,
5040 maxshape,
5041 pipeline_message,
5042 meta,
5043 chunk_bytes,
5044 ..
5045 } => self.write_chunked_relocatable(
5046 region,
5047 chunk_dims,
5048 *element_size,
5049 *raw_size,
5050 maxshape.as_deref(),
5051 pipeline_message.as_deref(),
5052 meta,
5053 chunk_bytes,
5054 &[],
5055 ),
5056 MovingWrite::AppendedChunks {
5057 region,
5058 new_dataspace_body,
5059 chunk_dims_u32,
5060 element_size,
5061 raw_size,
5062 has_filters,
5063 kept_chunks,
5064 new_chunk_bytes,
5065 ..
5066 } => self.write_appended_chunks(
5067 region,
5068 new_dataspace_body,
5069 chunk_dims_u32,
5070 *element_size,
5071 *raw_size,
5072 *has_filters,
5073 kept_chunks,
5074 new_chunk_bytes,
5075 ),
5076 MovingWrite::AttrEdit {
5077 region,
5078 pending_vl_attrs,
5079 } => {
5080 // `region` already carries the fixed-size attribute edits (applied
5081 // in the commit preflight). Place each variable-length attribute's
5082 // global heap collection, patch its placeholder heap address, and
5083 // append the resolved message — exactly as the group-attribute apply
5084 // loop does — then build and place the relocated dataset header. The
5085 // data-layout message is untouched, so the dataset's chunk data and
5086 // index stay in place; only the header moves.
5087 let mut region = region.clone();
5088 for (msg, collections) in pending_vl_attrs {
5089 let mut msg = msg.clone();
5090 let addrs = self.place_vl_collections(collections)?;
5091 patch_vl_refs(&mut msg.raw_data, &addrs);
5092 region.extend_from_slice(®ion_message(
5093 MessageType::Attribute,
5094 &msg.serialize(LENGTH_SIZE),
5095 ));
5096 }
5097 let oh = build_v2_object_header(®ion);
5098 self.alloc_or_append_typed(&oh, PageType::Meta)
5099 }
5100 }
5101 }
5102
5103 /// Apply a relocating append ([`MovingWrite::AppendedChunks`]): append the new
5104 /// (and any rewritten trailing) chunk bytes at end-of-file, rebuild a fresh
5105 /// Extensible Array over the kept plus appended chunks, grow the dataspace and
5106 /// repoint the data layout in the verbatim header `region`, and write the
5107 /// relocated header. Returns the new header address; the caller patches the
5108 /// parent link. The kept chunk data is untouched (referenced by both the old
5109 /// and new index during the commit); the old index/header/trailing chunk are
5110 /// freed only after the superblock repoint.
5111 #[expect(
5112 clippy::too_many_arguments,
5113 reason = "the append rebuild needs the header region, grown dataspace, chunk \
5114 geometry, and both chunk sets; bundling them into a struct would only move the list"
5115 )]
5116 fn write_appended_chunks(
5117 &mut self,
5118 region: &[u8],
5119 new_dataspace_body: &[u8],
5120 chunk_dims_u32: &[u32],
5121 element_size: usize,
5122 raw_size: u64,
5123 has_filters: bool,
5124 kept_chunks: &[WrittenChunk],
5125 new_chunk_bytes: &[Vec<u8>],
5126 ) -> Result<u64, Error> {
5127 let base = self.superblock.base_address;
5128 // Append each new chunk at true end-of-file (never `alloc_or_append`: the
5129 // rebuilt index below records base-relative addresses computed from the
5130 // end-of-file the appends land at). Existing chunks keep their in-place
5131 // addresses and are carried by metadata alone.
5132 let mut combined: Vec<WrittenChunk> = kept_chunks.to_vec();
5133 if !new_chunk_bytes.is_empty() {
5134 // Chunk contents are raw data; one page switch covers the whole run.
5135 self.begin_page(PageType::Raw)?;
5136 }
5137 for cb in new_chunk_bytes {
5138 let abs = self.append(cb)?;
5139 combined.push(WrittenChunk {
5140 address: abs - base,
5141 compressed_size: cb.len() as u64,
5142 raw_size,
5143 // This engine applies every filter to a new chunk (no per-chunk
5144 // skipping), so an appended chunk's mask is always 0. Kept chunks
5145 // carry their own (possibly nonzero) mask in `combined` already.
5146 filter_mask: 0,
5147 });
5148 }
5149
5150 // Build the fresh Extensible Array at the current end-of-file. Its embedded
5151 // block addresses are computed from `ea_base` (base-relative), so appending
5152 // the blob at the matching file offset makes them resolve correctly, on a
5153 // userblock (`base != 0`) file too.
5154 //
5155 // The index goes in a *raw* page, not a metadata one: every other writer in
5156 // this crate places a chunk index in the same run as the chunk data, and
5157 // `chunked_storage_spans` reclaims every index as raw on that basis. Placing
5158 // this one in a metadata page would make it the single exception the reclaim
5159 // side then mis-files, advertising a metadata hole inside a raw page. Opening
5160 // the page before reading the offset keeps `begin_page`'s contract: no pad
5161 // may be inserted after an address the built bytes embed.
5162 self.begin_page(PageType::Raw)?;
5163 let ea_base = self.image.len() - base;
5164 let ea_bytes =
5165 build_extensible_array_at(&combined, OFFSET_SIZE, LENGTH_SIZE, has_filters, ea_base)
5166 .map_err(Error::Format)?;
5167 let written = self.append(&ea_bytes)?;
5168 debug_assert_eq!(
5169 written,
5170 ea_base + base,
5171 "extensible-array index must land at end-of-file",
5172 );
5173
5174 // Swap the dataspace (grown) and data-layout (repointed at the new index)
5175 // messages; every other header message is preserved verbatim.
5176 #[expect(
5177 clippy::cast_possible_truncation,
5178 reason = "element size is a datatype byte width that fits u32"
5179 )]
5180 let layout_body = serialize_v4_extensible_array(
5181 chunk_dims_u32,
5182 ea_base,
5183 OFFSET_SIZE,
5184 element_size as u32,
5185 );
5186 let region = replace_dataspace_message(region, new_dataspace_body)?;
5187 let region = replace_layout_message(®ion, &layout_body)?;
5188 let oh = build_v2_object_header(®ion);
5189 self.alloc_or_append_typed(&oh, PageType::Meta)
5190 }
5191
5192 /// Append `bytes` at end-of-file, returning the absolute address they were
5193 /// written at.
5194 fn append(&mut self, bytes: &[u8]) -> Result<u64, Error> {
5195 self.image.append(bytes)
5196 }
5197
5198 /// Overwrite bytes in place at `offset`. The caller guarantees the range
5199 /// already exists.
5200 fn write_at(&mut self, offset: usize, bytes: &[u8]) -> Result<(), Error> {
5201 self.image.write_at(offset as u64, bytes)
5202 }
5203
5204 /// Ensure the next allocation begins in a page holding page type `ty`, on a
5205 /// paged file. A no-op on the common non-paged file.
5206 ///
5207 /// A paged file never mixes metadata and raw data within one page, so when the
5208 /// tail page holds the *other* type and is only partially filled it is first
5209 /// padded to a page boundary, the padding being recorded as free space of the
5210 /// outgoing type.
5211 ///
5212 /// Call this **before** reading the image's end-of-file ([`Source::len`]) to compute an
5213 /// address that will be embedded in the bytes being built: several callers
5214 /// (the chunk blob, the extensible-array index, the dense-attribute blob)
5215 /// build content whose interior addresses assume it lands at the current
5216 /// end-of-file, and padding inserted after that read would shift the landing
5217 /// address out from under them.
5218 fn begin_page(&mut self, ty: PageType) -> Result<(), Error> {
5219 // Destructure so the page state and the image are borrowed as the
5220 // separate fields they are.
5221 let Self { image, paged, .. } = self;
5222 match paged.as_mut() {
5223 Some(pg) => pg.begin(image.as_mut(), ty),
5224 None => Ok(()),
5225 }
5226 }
5227
5228 /// Append `bytes` at end-of-file as page type `ty`: [`begin_page`](Self::begin_page)
5229 /// followed by [`append`](Self::append).
5230 fn append_typed(&mut self, bytes: &[u8], ty: PageType) -> Result<u64, Error> {
5231 self.begin_page(ty)?;
5232 self.append(bytes)
5233 }
5234
5235 /// Place `bytes` as page type `ty`, reusing a free region where that is safe.
5236 ///
5237 /// On a paged file this never reuses: a hole belongs to one page type, and
5238 /// handing it to an allocation of the other type would re-mix the page it sits
5239 /// in. Such a file appends into a page of the right type instead, exactly as
5240 /// the bounded backend does, and recovers the space at the next commit's
5241 /// manager rewrite rather than within the commit.
5242 fn alloc_or_append_typed(&mut self, bytes: &[u8], ty: PageType) -> Result<u64, Error> {
5243 if self.paged.is_some() {
5244 return self.append_typed(bytes, ty);
5245 }
5246 self.alloc_or_append(bytes)
5247 }
5248
5249 /// Place `bytes` either in a reusable free region left by a prior commit
5250 /// (overwriting it in place) or, failing that, by appending at end-of-file.
5251 /// Returns the address written to.
5252 ///
5253 /// Reuse only ever draws from [`self.free`](Self::free), which holds regions
5254 /// vacated by *earlier* commits in this session — never space the current
5255 /// commit is about to free — so the bytes it overwrites are already
5256 /// unreachable from the on-disk root and a mid-commit crash cannot corrupt
5257 /// the live tree (the superblock still points at the prior, intact root).
5258 ///
5259 /// Callers on the commit path go through [`alloc_or_append_typed`](Self::alloc_or_append_typed)
5260 /// so a paged file stays page-segregated; this is the non-paged primitive it
5261 /// delegates to.
5262 fn alloc_or_append(&mut self, bytes: &[u8]) -> Result<u64, Error> {
5263 debug_assert!(
5264 self.paged.is_none(),
5265 "a paged file must allocate through alloc_or_append_typed"
5266 );
5267 if let Some(addr) = self.free.alloc(bytes.len() as u64) {
5268 self.write_at(
5269 usize::try_from(addr).map_err(|_| {
5270 Error::EditUnsupported("free-region address exceeds this platform")
5271 })?,
5272 bytes,
5273 )?;
5274 Ok(addr)
5275 } else {
5276 self.append(bytes)
5277 }
5278 }
5279
5280 /// Place one variable-length dataset's or attribute's already-built,
5281 /// self-contained global heap collections (from
5282 /// [`build_global_heap_collections`] or a
5283 /// [`VlStringStaging::collections`]) and return, in the same order, the
5284 /// base-relative addresses its variable-length references should be patched
5285 /// to. A `GCOL` blob embeds no addresses of its own, so it can be appended
5286 /// (or dropped into reused free space) at any point in the apply loop,
5287 /// unlike a group or dataset header, which must be built last so it can name
5288 /// its children's real addresses. Each collection is placed independently,
5289 /// so they need not land contiguously.
5290 fn place_vl_collections(&mut self, collections: &[Vec<u8>]) -> Result<Vec<u64>, Error> {
5291 collections
5292 .iter()
5293 .map(|collection| {
5294 let addr = self.alloc_or_append_typed(collection, PageType::Meta)?;
5295 Ok(addr - self.superblock.base_address)
5296 })
5297 .collect()
5298 }
5299
5300 /// Resolve one object-reference element's target to the base-relative
5301 /// address that should be stored on disk. [`ObjectRefTarget::Raw`] is
5302 /// written back verbatim (a null or undefined reference is a sentinel, not
5303 /// a real address, so it needs no base adjustment — mirrors the whole-file
5304 /// writer). [`ObjectRefTarget::Path`] resolves, in order:
5305 ///
5306 /// 1. Against `path_addr` — every group and dataset this commit has
5307 /// already placed (a sibling dataset placed earlier in the same
5308 /// group's batch — see the apply loop's non-reference-first ordering —
5309 /// or a descendant subtree fully processed earlier in the deepest-first
5310 /// walk).
5311 /// 2. Against the pre-commit on-disk file
5312 /// ([`resolve_path_any`](crate::group_v2::resolve_path_any)), but only
5313 /// when the path is untouched by this commit, so its pre-commit
5314 /// address is guaranteed to still be valid post-commit. "Touched"
5315 /// means: a dirty group (`nodes`, new or merely rewritten because an
5316 /// addition lives under it — its own address changes either way); a
5317 /// path this commit adds, or that lies under a subtree this commit
5318 /// copies in (`add_targets`, checked by prefix so a copy's interior is
5319 /// covered even though only its root is enumerated there); or a
5320 /// `write_dataset` target (`write_targets`) — conservatively refused
5321 /// even for a same-length overwrite that does not actually relocate,
5322 /// since resolving that distinction here is not worth the complexity.
5323 /// 3. If the path resolves nowhere at all (neither this commit nor the
5324 /// pre-commit file has ever heard of it), as an undefined reference
5325 /// (`HADDR_UNDEF`) — mirroring [`ObjectRefTarget::Path`]'s existing
5326 /// whole-file-writer resolution convention for the same builder type.
5327 ///
5328 /// A path that step 1 misses but step 2 identifies as commit-touched is
5329 /// refused with a clear [`Error::EditUnsupported`] rather than resolved to
5330 /// a stale or wrong address — the one case this engine cannot resolve
5331 /// without the whole-file writer's two-pass dummy/real-address scheme.
5332 /// "Touched" also covers a path this same commit deletes (`pending_deletes`):
5333 /// without that check the deleted object's pre-commit address would still
5334 /// resolve via step 2, and the reference would end up pointing at storage
5335 /// this same commit is about to reclaim and hand out to something else.
5336 fn resolve_reference_target(
5337 target: &ObjectRefTarget,
5338 path_addr: &BTreeMap<PathKey, u64>,
5339 nodes: &BTreeMap<PathKey, Node>,
5340 add_targets: &[PathKey],
5341 write_targets: &[PathKey],
5342 pending_deletes: &[PathKey],
5343 src: &(impl Source + ?Sized),
5344 superblock: &Superblock,
5345 ) -> Result<u64, Error> {
5346 let path = match target {
5347 ObjectRefTarget::Raw(addr) => return Ok(*addr),
5348 ObjectRefTarget::Path(path) => path,
5349 };
5350 let base = superblock.base_address;
5351 let key = split_path(path);
5352 if let Some(&addr) = path_addr.get(&key) {
5353 return Ok(addr - base);
5354 }
5355 if nodes.contains_key(&key)
5356 || add_targets.iter().any(|t| is_prefix(t, &key))
5357 || write_targets.contains(&key)
5358 || pending_deletes.contains(&key)
5359 {
5360 return Err(Error::EditUnsupported(
5361 "an object-reference dataset targets a path this commit is still writing; \
5362 use separate commits",
5363 ));
5364 }
5365 match crate::group_v2::resolve_path_any_from_source(src, superblock, path) {
5366 Ok(addr) => Ok(addr - base),
5367 Err(_) => Ok(UNDEF),
5368 }
5369 }
5370
5371 /// Prove, before any byte of this commit is written, that every
5372 /// object-reference target across every staged dataset will resolve
5373 /// successfully — either against a pre-existing untouched object or
5374 /// against something this same commit places. [`resolve_reference_target`]
5375 /// classifies a target purely from *whether* a `PathKey` has been placed
5376 /// yet (`path_addr.get`), never from the address *value*, so replaying the
5377 /// apply loop's placement order here with placeholder addresses (`0`)
5378 /// standing in for "already placed" reproduces the exact same verdict the
5379 /// apply loop's own calls will reach later, without writing anything. If
5380 /// this preflight pass returns `Ok`, none of the apply loop's own
5381 /// `resolve_reference_target` calls can fail, so a reference-resolution
5382 /// error can no longer leave earlier-processed groups' real writes
5383 /// orphaned in the file (the failure surfaces here instead, before the
5384 /// apply loop's first `alloc_or_append`/`write_at`).
5385 fn preflight_reference_targets(
5386 keys: &[PathKey],
5387 flat: &BTreeMap<PathKey, Vec<FlatDataset>>,
5388 nodes: &BTreeMap<PathKey, Node>,
5389 add_targets: &[PathKey],
5390 write_targets: &[PathKey],
5391 pending_deletes: &[PathKey],
5392 src: &(impl Source + ?Sized),
5393 superblock: &Superblock,
5394 ) -> Result<(), Error> {
5395 let mut by_depth = keys.to_vec();
5396 by_depth.sort_by_key(|k| std::cmp::Reverse(k.len()));
5397 let mut sim_addr: BTreeMap<PathKey, u64> = BTreeMap::new();
5398 for key in &by_depth {
5399 if let Some(datasets) = flat.get(key) {
5400 // Mirrors the apply loop's `group_datasets.sort_by_key(|fd|
5401 // fd.reference_targets.is_some())`: non-reference datasets are
5402 // placed (and so become resolvable) before any reference
5403 // dataset in the same group.
5404 let mut ordered: Vec<&FlatDataset> = datasets.iter().collect();
5405 ordered.sort_by_key(|fd| fd.reference_targets.is_some());
5406 for fd in ordered {
5407 if let Some(patches) = &fd.reference_targets {
5408 for patch in patches {
5409 Self::resolve_reference_target(
5410 &patch.target,
5411 &sim_addr,
5412 nodes,
5413 add_targets,
5414 write_targets,
5415 pending_deletes,
5416 src,
5417 superblock,
5418 )?;
5419 }
5420 }
5421 let mut full = key.clone();
5422 full.push(fd.name.clone());
5423 sim_addr.insert(full, 0);
5424 }
5425 }
5426 sim_addr.insert(key.clone(), 0);
5427 }
5428 Ok(())
5429 }
5430
5431 /// Lay out a chunked / filtered / extensible dataset and return its object
5432 /// header bytes (which the caller links into the parent group).
5433 ///
5434 /// The chunk data and index (B-tree v1 / fixed-array / extensible-array, with
5435 /// any filter pipeline applied) are produced as one relocatable blob by
5436 /// [`build_chunked_data_at_ext`], whose internal layout — and therefore total
5437 /// size — is independent of the base address it is given. The blob is
5438 /// appended at end-of-file, so passing the current end-of-file as the base
5439 /// makes every absolute address it embeds (chunk addresses, index-structure
5440 /// addresses, the addresses in the data-layout message) land exactly where
5441 /// the bytes are written. The header is then built with
5442 /// [`build_chunked_dataset_oh`] — the same function the whole-file writer
5443 /// uses — so the header is byte-identical to one written fresh.
5444 ///
5445 /// Unlike the contiguous path the blob is always *appended* rather than
5446 /// placed via [`alloc_or_append`]: reusing an interior freed region would
5447 /// require knowing the blob's size before building it at that region's
5448 /// address, and appending keeps the address known up front. Freed space is
5449 /// still reused for the object header and for every other object in the
5450 /// commit.
5451 fn build_chunked_dataset(&mut self, fd: &FlatDataset) -> Result<Vec<u8>, Error> {
5452 // The blob is raw data whose embedded addresses are computed from the
5453 // end-of-file it lands at, so open a raw page *before* reading that offset.
5454 self.begin_page(PageType::Raw)?;
5455 let eof = self.image.len();
5456 // The blob embeds *stored* (base-relative) addresses, so the planner base is
5457 // the stored address the blob will occupy: its end-of-file offset minus the
5458 // userblock base. The reader recovers each as `stored + base_address`, which
5459 // resolves back to the blob's real file offset. On a base-0 file this is just
5460 // `eof`.
5461 let stored_base = eof - self.superblock.base_address;
5462 let chunk_dims = fd.chunk_options.resolve_chunk_dims(&fd.ds.dimensions);
5463 let ctx = ChunkContext::from_datatype(&chunk_dims, &fd.dt);
5464 let result = build_chunked_data_at_ext(
5465 &fd.raw,
5466 &fd.ds.dimensions,
5467 ctx,
5468 &fd.chunk_options,
5469 stored_base,
5470 fd.maxshape.as_deref(),
5471 )?;
5472 // `append` writes at the current end-of-file, which equals `eof`: the blob
5473 // lands exactly where its embedded (stored) addresses expect once the reader
5474 // adds the base back.
5475 let written = self.append(&result.data_bytes)?;
5476 debug_assert_eq!(written, eof, "chunk blob must land at end-of-file",);
5477 Ok(build_chunked_dataset_oh(
5478 &fd.dt,
5479 &fd.ds,
5480 &result.layout_message,
5481 result.pipeline_message.as_deref(),
5482 &fd.attrs,
5483 None,
5484 fd.fill.as_deref(),
5485 )?)
5486 }
5487
5488 /// On-disk byte spans `(addr, len)` of every chunk of the version 2 object
5489 /// header at `addr`: chunk 0 (signature, prefix, messages, checksum) plus
5490 /// each continuation (`OCHK`) block. Used to reclaim a header's storage when
5491 /// its object is deleted. An error (propagated from [`oh_region_at`] or a
5492 /// malformed continuation) means the header is not a plain v2 header this
5493 /// engine can fully account for, and the caller leaves it as dead bytes
5494 /// rather than guess its extent.
5495 fn oh_chunk_spans(&self, addr: usize) -> Result<Vec<(u64, u64)>, Error> {
5496 Ok(
5497 read_oh_chunks(&self.image(), addr as u64, self.superblock.base_address)?
5498 .into_iter()
5499 .map(|chunk| chunk.span)
5500 .collect(),
5501 )
5502 }
5503
5504 /// Count, for every object-header address reachable from the root, how many
5505 /// hard links in the *pre-commit* file point to it. The result drives the
5506 /// last-hard-link reclaim guard in [`collect_free_spans`](Self::collect_free_spans):
5507 /// an object is freed only when its count is 1.
5508 ///
5509 /// Walks the whole link graph from the root, following hard links through
5510 /// groups of any on-disk format (v0/v1 symbol-table, v2 compact, v2 dense)
5511 /// via [`resolve_group_entries`], tallying each hard-link edge. Datasets and
5512 /// other leaves contribute no edges. Returns `None` — so the caller reclaims
5513 /// nothing for the deletions, a safe leak — if the graph cannot be walked in
5514 /// full: an unparseable header, a group whose links cannot be enumerated, or
5515 /// more than [`MAX_LINK_GRAPH_NODES`] objects. Cycles are handled by visiting
5516 /// each object once. Base-aware: stored child addresses are shifted by the
5517 /// userblock base, so the returned keys are absolute file offsets.
5518 fn count_incoming_hard_links(&self) -> Option<HashMap<u64, u32>> {
5519 let os = self.superblock.offset_size;
5520 let ls = self.superblock.length_size;
5521 let base = self.superblock.base_address;
5522 let mut counts: HashMap<u64, u32> = HashMap::new();
5523 let mut visited: HashSet<u64> = HashSet::new();
5524 let mut stack: Vec<u64> = vec![self.superblock.root_group_address];
5525 let mut budget = MAX_LINK_GRAPH_NODES;
5526 while let Some(addr) = stack.pop() {
5527 if !visited.insert(addr) {
5528 continue; // already expanded (also breaks hard-link cycles)
5529 }
5530 if budget == 0 {
5531 return None; // graph larger than we will walk; leak conservatively
5532 }
5533 budget -= 1;
5534 let off = usize::try_from(addr).ok()?;
5535 let header =
5536 ObjectHeader::parse_from_source(&self.image(), off as u64, os, ls, base).ok()?;
5537 // Datasets and other leaves are not groups and own no links.
5538 let is_group = header.messages.iter().any(|m| {
5539 matches!(
5540 m.msg_type,
5541 MessageType::SymbolTable | MessageType::Link | MessageType::LinkInfo
5542 )
5543 });
5544 if !is_group {
5545 continue;
5546 }
5547 // A group we cannot enumerate fully would undercount incoming links
5548 // and risk over-reclaim; bail to the safe-leak fallback instead.
5549 let entries =
5550 resolve_group_entries_from_source(&self.image(), &header, os, ls, base).ok()?;
5551 for e in entries {
5552 let child = e.object_header_address.checked_add(base)?;
5553 *counts.entry(child).or_insert(0) += 1;
5554 stack.push(child);
5555 }
5556 }
5557 Some(counts)
5558 }
5559
5560 /// Best-effort enumeration of every on-disk block owned by the object at
5561 /// `addr` (and, for a group, its whole subtree), accumulating `(addr, len)`
5562 /// spans into `out` for reclamation after a delete.
5563 ///
5564 /// Contiguous datasets (header + data block), chunked datasets (header +
5565 /// chunk index + chunk data, via [`chunked_storage_spans`](Self::chunked_storage_spans)),
5566 /// and whole group subtrees are reclaimed. Deliberately conservative: any
5567 /// object whose layout it cannot fully account for — a non-v2 header, an
5568 /// unsupported or only-partially-enumerable chunk index, a group holding a
5569 /// soft/external link, dense attribute storage — contributes nothing and is
5570 /// not descended into, so `out` never names a region that might still be in
5571 /// use. Bounded by [`MAX_COPY_DEPTH`] against a hard-link cycle.
5572 /// Variable-length data in global-heap collections is never reclaimed here (a
5573 /// collection can be shared between objects), so it is simply left behind.
5574 ///
5575 /// `incoming` is the file-wide hard-link count per object-header address
5576 /// (from [`count_incoming_hard_links`](Self::count_incoming_hard_links)). An
5577 /// object is reclaimed — and, for a group, descended into — only when its
5578 /// count is exactly 1, i.e. the link being removed is its last: an object
5579 /// still reachable through another hard link is live and is left untouched
5580 /// (so is everything below a surviving group), which is what keeps deleting
5581 /// one of several hard links from corrupting the survivor.
5582 fn collect_free_spans(
5583 &self,
5584 addr: usize,
5585 depth: u32,
5586 incoming: &HashMap<u64, u32>,
5587 out: &mut Vec<(u64, u64, PageType)>,
5588 ) {
5589 // `addr` is an absolute file offset (the caller resolves it from the live
5590 // file, and the group recursion below re-absolutizes each child). `incoming`
5591 // is keyed by absolute offset, and `oh_chunk_spans`/`chunked_storage_spans`
5592 // both take an absolute address and return absolute spans, so the whole
5593 // walk works in absolute file offsets. The one shift this method must apply
5594 // itself is on the *stored* (base-relative) addresses `read_object` returns
5595 // for a contiguous data block and a group's child links: each is converted
5596 // to an absolute offset by adding `base` (a no-op on a base-0 file) before
5597 // it is bounds-checked, recorded, or descended into.
5598 let base = self.superblock.base_address;
5599 let file_len = self.image().len();
5600 if depth >= MAX_COPY_DEPTH {
5601 return;
5602 }
5603 // Reclaim only when this delete removes the object's last hard link. A
5604 // count other than 1 (it has surviving links, or the graph walk could
5605 // not account for it) means the object — and a group's whole subtree —
5606 // stays live and must not be freed.
5607 if incoming.get(&(addr as u64)) != Some(&1) {
5608 return;
5609 }
5610 // The header's own chunks. If they cannot be mapped, account for nothing.
5611 let spans = match self.oh_chunk_spans(addr) {
5612 Ok(s) => s,
5613 Err(_) => return,
5614 };
5615 match Self::read_object(&self.image(), addr as u64, self.superblock.base_address) {
5616 Ok(ObjModel::DatasetVerbatim { .. }) => out.extend(meta_spans(spans)),
5617 Ok(ObjModel::DatasetContiguous {
5618 data_addr,
5619 data_size,
5620 ..
5621 }) => {
5622 out.extend(meta_spans(spans));
5623 // A defined, in-bounds contiguous data block is owned outright;
5624 // an empty dataset stores the undefined address and owns none. The
5625 // stored address is base-relative, so shift it to an absolute file
5626 // offset before bounds-checking and recording it.
5627 if data_addr != u64::MAX && data_size > 0 {
5628 if let (Some(abs), Ok(len)) =
5629 (data_addr.checked_add(base), usize::try_from(data_size))
5630 {
5631 if let Ok(start) = usize::try_from(abs) {
5632 if start.checked_add(len).is_some_and(|e| e as u64 <= file_len) {
5633 // A contiguous data block is raw data.
5634 out.push((abs, data_size, PageType::Raw));
5635 }
5636 }
5637 }
5638 }
5639 }
5640 Ok(ObjModel::Group { children, .. }) => {
5641 out.extend(meta_spans(spans));
5642 // Child link targets are stored base-relative; re-absolutize each
5643 // before descending so the recursion keeps working in absolute
5644 // offsets (matching `incoming`'s keys and `oh_chunk_spans`).
5645 for (_, child) in children {
5646 if let Some(c) = child
5647 .checked_add(base)
5648 .and_then(|a| usize::try_from(a).ok())
5649 {
5650 self.collect_free_spans(c, depth + 1, incoming, out);
5651 }
5652 }
5653 }
5654 // A chunked dataset: reclaim its chunk index and chunk data blocks
5655 // alongside its header. `chunked_storage_spans` returns `None` for
5656 // anything it cannot account for exhaustively (an index type with no
5657 // walker, an undefined index address, or spans that fail the
5658 // bounds/overlap check), leaving the whole dataset as dead bytes
5659 // rather than freeing a region that might still be in use.
5660 Ok(ObjModel::DatasetChunked { .. }) => {
5661 if let Some(storage) = self.chunked_storage_spans(addr) {
5662 out.extend(meta_spans(spans));
5663 // Already page-typed: chunk data raw, index structure metadata.
5664 out.extend(storage);
5665 }
5666 }
5667 // A truly unsupported object (one `read_object` cannot model): leave
5668 // its bytes in place rather than guess its extent.
5669 Err(_) => {}
5670 }
5671 }
5672
5673 /// Best-effort enumeration of every on-disk block a *chunked* dataset at
5674 /// `addr` owns: its chunk index structure (B-tree v1 nodes, or fixed- /
5675 /// extensible-array header, index, super, and data blocks) plus every
5676 /// allocated chunk data block. The object-header chunks are freed by the
5677 /// caller ([`collect_free_spans`](Self::collect_free_spans)); this returns
5678 /// only the storage the data-layout message points at.
5679 ///
5680 /// Returns `None` — contribute nothing, leave the object as dead bytes —
5681 /// whenever the dataset cannot be enumerated *exhaustively* and safely: a
5682 /// header that does not parse or is not a chunked dataset, a chunk index
5683 /// with no walker (a version 2 B-tree, index type 5), an undefined index
5684 /// address (an empty, never-written dataset), or any resulting span that
5685 /// falls outside the file image or overlaps another. This upholds the
5686 /// editor's invariant that reclaimed space is never a region still in use:
5687 /// under-reclaiming only wastes space, while over-reclaiming would corrupt.
5688 ///
5689 /// Chunk data addresses and sizes come from the same index walkers the
5690 /// reader uses, so they match the bytes the writer laid down exactly. The
5691 /// per-layout enumeration lives in
5692 /// [`chunked_read::collect_chunked_storage_spans`](crate::chunked_read::collect_chunked_storage_spans);
5693 /// this method only locates the layout and dataspace messages and validates
5694 /// the result. Variable-length data in global-heap collections is still
5695 /// never reclaimed (a collection can be shared between objects); see the
5696 /// [module docs](self).
5697 fn chunked_storage_spans(&self, addr: usize) -> Option<Vec<(u64, u64, PageType)>> {
5698 // Locate the data-layout and dataspace messages in the object header.
5699 let region =
5700 Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)
5701 .ok()?;
5702 let mut layout_msg: Option<(usize, usize)> = None;
5703 let mut dataspace_msg: Option<(usize, usize)> = None;
5704 let mut p = 0;
5705 loop {
5706 match next_message(®ion, p) {
5707 Ok(Some((msg_type, body, body_end))) => {
5708 match msg_type {
5709 MessageType::DataLayout => layout_msg = Some((body, body_end)),
5710 MessageType::Dataspace => dataspace_msg = Some((body, body_end)),
5711 _ => {}
5712 }
5713 p = body_end;
5714 }
5715 Ok(None) => break,
5716 Err(_) => return None,
5717 }
5718 }
5719 let (lb, le) = layout_msg?;
5720 let (db, de) = dataspace_msg?;
5721
5722 let layout = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
5723 if !matches!(layout, DataLayout::Chunked { .. }) {
5724 return None;
5725 }
5726 let dataspace = Dataspace::parse(®ion[db..de], LENGTH_SIZE).ok()?;
5727
5728 // Delegate the per-index-type enumeration to the chunked reader (the
5729 // single owner of chunk-storage layout knowledge), then validate: every
5730 // span must lie inside the current file image and be pairwise disjoint,
5731 // or the free list would later hand out live bytes (and a debug build
5732 // would panic on the double-free). On any error or violation, leave the
5733 // whole dataset unreclaimed rather than free a region still in use.
5734 //
5735 // The layout's stored addresses are relative to the userblock base, so the
5736 // enumeration runs on a base-relative view of the file and each returned
5737 // span address is shifted back to an absolute file offset by adding `base`
5738 // (a no-op on a base-0 file). The free list and the bounds check below both
5739 // work in absolute file offsets.
5740 let base = self.superblock.base_address;
5741 let split = crate::chunked_read::collect_chunked_storage_spans(
5742 &BaseOffsetSource {
5743 inner: &self.image(),
5744 base,
5745 },
5746 &layout,
5747 &dataspace,
5748 OFFSET_SIZE,
5749 LENGTH_SIZE,
5750 )
5751 .ok()?;
5752 // Both halves are tagged raw, because on a paged file both halves *live* in
5753 // raw pages: every writer in this crate places a chunked dataset's index
5754 // structure immediately after its chunk data in one run — the from-scratch
5755 // paged writer builds the whole blob inside the raw region, and
5756 // `build_chunked_dataset` / `write_chunked_relocatable` append it under a
5757 // single `begin_page(PageType::Raw)`.
5758 //
5759 // A chunk index is metadata by the format's taxonomy, so tagging it that way
5760 // is tempting; it is also wrong here. The tag decides which manager the
5761 // freed region is recorded in, and recording an index that sits among live
5762 // chunk data in the *metadata* manager would advertise space inside a raw
5763 // page — letting the reference library place metadata there and mixing the
5764 // page, which is the one thing a paged file must never do. The tag has to
5765 // follow the placement, so `write_appended_chunks` places its rebuilt index
5766 // in a raw page too, keeping every index in this crate's files raw.
5767 let mut spans: Vec<(u64, u64, PageType)> = Vec::new();
5768 for (addr, len) in split.data.into_iter().chain(split.index) {
5769 spans.push((addr.checked_add(base)?, len, PageType::Raw));
5770 }
5771 let mut plain: Vec<(u64, u64)> = spans.iter().map(|&(a, l, _)| (a, l)).collect();
5772 if !spans_disjoint_in_bounds(&mut plain, self.image.len()) {
5773 return None;
5774 }
5775 Some(spans)
5776 }
5777
5778 /// Every on-disk byte span of a chunked dataset's *index structure only* (not
5779 /// its chunk data), for reclaiming the old index after a relocating append
5780 /// ([`MovingWrite::AppendedChunks`]) that keeps the chunk data in place. Mirror
5781 /// of [`chunked_storage_spans`](Self::chunked_storage_spans) but delegating to
5782 /// [`chunk_index_spans_buffered`], which enumerates only the EA header/index/
5783 /// data/super blocks and never a chunk-data address, so the shared kept chunk
5784 /// data is never freed. Base-aware and validated disjoint/in-bounds; returns
5785 /// `None` (leave unreclaimed) on any error or violation.
5786 fn chunked_index_spans(&self, addr: usize) -> Option<Vec<(u64, u64)>> {
5787 let region =
5788 Self::gather_oh_messages(&self.image(), addr as u64, self.superblock.base_address)
5789 .ok()?;
5790 let mut layout_msg: Option<(usize, usize)> = None;
5791 let mut p = 0;
5792 loop {
5793 match next_message(®ion, p) {
5794 Ok(Some((msg_type, body, body_end))) => {
5795 if msg_type == MessageType::DataLayout {
5796 layout_msg = Some((body, body_end));
5797 }
5798 p = body_end;
5799 }
5800 Ok(None) => break,
5801 Err(_) => return None,
5802 }
5803 }
5804 let (lb, le) = layout_msg?;
5805 let layout = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE).ok()?;
5806 if !matches!(layout, DataLayout::Chunked { .. }) {
5807 return None;
5808 }
5809 let base = self.superblock.base_address;
5810 let mut spans = chunk_index_spans_from_source(
5811 &BaseOffsetSource {
5812 inner: &self.image(),
5813 base,
5814 },
5815 &layout,
5816 OFFSET_SIZE,
5817 LENGTH_SIZE,
5818 )
5819 .ok()?;
5820 for (a, _) in &mut spans {
5821 *a = a.checked_add(base)?;
5822 }
5823 if !spans_disjoint_in_bounds(&mut spans, self.image.len()) {
5824 return None;
5825 }
5826 Some(spans)
5827 }
5828}
5829
5830/// A dirty group in the edit plan: its base object-header message region and the
5831/// additions targeting it.
5832#[derive(Default)]
5833struct Node {
5834 is_new: bool,
5835 datasets: Vec<DatasetBuilder>,
5836 /// Compact group-attribute operations to apply to this group.
5837 attr_ops: Vec<AttrOp>,
5838 /// Names of links to remove from this group (from `delete`).
5839 deletes: Vec<String>,
5840 /// Copies to add to this group: (new link name, the source subtree read out
5841 /// for writing). Built at staging time from either this file (an in-file
5842 /// [`copy`](crate::File::copy)) or another open file (a cross-file
5843 /// [`copy_from`](crate::File::copy_from)).
5844 copies: Vec<(String, CopyTree)>,
5845 /// Value overwrites whose dataset header relocates (a resize or compact
5846 /// rewrite by `write_dataset`), as (child link name, the relocation plan). On
5847 /// apply, the new data and header are written and this group's existing link
5848 /// to the moved header is patched to its new address — exactly like an
5849 /// existing child group's link.
5850 writes: Vec<(String, MovingWrite)>,
5851 base_region: Vec<u8>,
5852 existing_links: Vec<String>,
5853 /// Variable-length group/root attributes staged by [`apply_group_attr_ops`],
5854 /// each still carrying a placeholder heap address: (the attribute message,
5855 /// its global heap collection bytes). Resolved in the apply loop right
5856 /// before this node's header is built — [`WriteEngine::place_vl_collection`]
5857 /// appends the collection, then the patched message is appended to
5858 /// `base_region`.
5859 pending_vl_attrs: PendingVlAttrs,
5860}
5861
5862/// A staged compact attribute edit for a group or dataset (shared by
5863/// [`Group::set_attr`](crate::Group::set_attr)/`remove_group_attr` and
5864/// [`Dataset::set_attr`](crate::Dataset::set_attr)/`remove_dataset_attr`).
5865enum AttrOp {
5866 Set { name: String, value: AttrValue },
5867 Remove { name: String },
5868}
5869
5870/// A source object parsed for copying. Headers are reproduced from their
5871/// verbatim message bytes; only the contiguous data address and child link
5872/// targets are repointed to the freshly-written copies.
5873enum ObjModel {
5874 /// A compact dataset (data inline in the header): copy the region verbatim.
5875 /// `dense_attrs` is empty unless the source stored its attributes densely, in
5876 /// which case the Attribute Info message and inline Attribute messages have
5877 /// been stripped from `region` and the parsed set is carried here to be
5878 /// re-emitted into a fresh fractal heap on write.
5879 DatasetVerbatim {
5880 region: Vec<u8>,
5881 dense_attrs: Vec<crate::attribute::AttributeMessage>,
5882 },
5883 /// A contiguous dataset: copy the region, repointing the data address at
5884 /// `addr_off` (region-relative) to a fresh copy of `[data_addr, +data_size)`.
5885 /// See [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
5886 DatasetContiguous {
5887 region: Vec<u8>,
5888 addr_off: usize,
5889 data_addr: u64,
5890 data_size: u64,
5891 dense_attrs: Vec<crate::attribute::AttributeMessage>,
5892 },
5893 /// A chunked (and possibly filtered) dataset: the verbatim header `region`
5894 /// (datatype, dataspace, fill value, data layout, and filter pipeline kept as
5895 /// written). The chunk data is not captured here — [`read_copy_subtree`](WriteEngine::read_copy_subtree)
5896 /// enumerates and reads the chunks (it holds the source buffer), repointing the
5897 /// rebuilt index on write. See [`DatasetVerbatim`](ObjModel::DatasetVerbatim)
5898 /// for `dense_attrs`.
5899 DatasetChunked {
5900 region: Vec<u8>,
5901 dense_attrs: Vec<crate::attribute::AttributeMessage>,
5902 },
5903 /// A group: every non-link message verbatim, plus its hard-link children to
5904 /// copy and re-link by name. See
5905 /// [`DatasetVerbatim`](ObjModel::DatasetVerbatim) for `dense_attrs`.
5906 Group {
5907 non_link_region: Vec<u8>,
5908 children: Vec<(String, u64)>,
5909 dense_attrs: Vec<crate::attribute::AttributeMessage>,
5910 },
5911}
5912
5913/// An object subtree fully read out of a source buffer and owning every byte it
5914/// will write, the read result of [`WriteEngine::read_copy_subtree`] and the
5915/// input to [`WriteEngine::write_copy_subtree`]. Unlike [`ObjModel`] (a single
5916/// object still referencing source addresses) it is recursive and self-contained:
5917/// a contiguous dataset owns its data bytes, and a group owns its children, so it
5918/// can be written into the destination without the source buffer still in hand —
5919/// which is what lets a cross-file copy read the source at staging time and apply
5920/// it at commit time.
5921enum CopyTree {
5922 /// A compact dataset: the header region is written verbatim (data is inline).
5923 /// `dense_attrs`, when non-empty, is re-emitted into a freshly built fractal
5924 /// heap appended just before the header, whose Attribute Info message is
5925 /// spliced into the region on write.
5926 DatasetVerbatim {
5927 region: Vec<u8>,
5928 dense_attrs: Vec<crate::attribute::AttributeMessage>,
5929 },
5930 /// A contiguous dataset: `data` is written first and its new address patched
5931 /// into the header `region` at `addr_off` before the header is written. See
5932 /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
5933 DatasetContiguous {
5934 region: Vec<u8>,
5935 addr_off: usize,
5936 data: Vec<u8>,
5937 dense_attrs: Vec<crate::attribute::AttributeMessage>,
5938 },
5939 /// A chunked (and possibly filtered) dataset. The header `region` is written
5940 /// verbatim except its data-layout message, which is swapped for one naming the
5941 /// freshly rebuilt index; `chunk_bytes` (each chunk's already-compressed bytes,
5942 /// in dense row-major grid order, with sizes/masks in `meta`) and the source
5943 /// `pipeline_message` are carried unchanged, so the copy preserves the filter
5944 /// pipeline and chunk payloads byte-for-byte. The on-disk index *type* is
5945 /// reselected from `maxshape`/chunk count (single / fixed-array / extensible-
5946 /// array), so a B-tree-v1 or implicit source is reproduced with a v4 index. See
5947 /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
5948 DatasetChunked {
5949 region: Vec<u8>,
5950 chunk_dims: Vec<u64>,
5951 element_size: usize,
5952 raw_size: u64,
5953 maxshape: Option<Vec<u64>>,
5954 pipeline_message: Option<Vec<u8>>,
5955 meta: Vec<ChunkMeta>,
5956 chunk_bytes: Vec<Vec<u8>>,
5957 dense_attrs: Vec<crate::attribute::AttributeMessage>,
5958 },
5959 /// A group: every non-link message verbatim, plus the (name, child) subtrees
5960 /// to write first and re-link by name. See
5961 /// [`DatasetVerbatim`](CopyTree::DatasetVerbatim) for `dense_attrs`.
5962 Group {
5963 non_link_region: Vec<u8>,
5964 children: Vec<(String, CopyTree)>,
5965 dense_attrs: Vec<crate::attribute::AttributeMessage>,
5966 },
5967}
5968
5969/// The validated, chunk-collapsed message region and existing link names of a
5970/// group header.
5971struct GroupInfo {
5972 region: Vec<u8>,
5973 link_names: Vec<String>,
5974}
5975
5976/// How a staged value overwrite (`write_dataset`) will be applied, decided by
5977/// [`WriteEngine::prepare_write`] during the all-or-nothing preflight.
5978enum WritePlan {
5979 /// A contiguous dataset whose new data is the same length as its existing,
5980 /// defined data block: overwrite the bytes straight in place at `data_addr`.
5981 /// No object header is rewritten and the superblock root is not flipped.
5982 InPlace { data_addr: usize, raw: Vec<u8> },
5983 /// A chunked dataset overwritten chunk-by-chunk in place: each `(addr, bytes)`
5984 /// pair is written straight over an existing chunk slot. Used when every new
5985 /// (re-encoded) chunk is the same byte length as the slot it replaces — an
5986 /// unfiltered chunked overwrite (chunk sizes are fixed by the unchanged shape)
5987 /// or a filtered one whose re-encoded chunks happen to match. Like
5988 /// [`InPlace`](WritePlan::InPlace) it touches no header and no chunk index, so
5989 /// the superblock root is not flipped.
5990 InPlaceChunks { writes: Vec<(usize, Vec<u8>)> },
5991 /// The dataset's header relocates: a contiguous resize, a compact rewrite, or
5992 /// a chunked rebuild. The parent group is rebuilt and its link patched. See
5993 /// [`MovingWrite`].
5994 Moving(MovingWrite),
5995}
5996
5997/// A value overwrite that relocates the dataset's object header — a contiguous
5998/// dataset whose data length changed (or had no data block) or a compact dataset
5999/// whose inline bytes are replaced. On apply the new data and a rewritten header
6000/// are written at end-of-file (or into reusable freed space), and the parent
6001/// group's link is repointed at the new header address.
6002enum MovingWrite {
6003 /// A contiguous dataset: write `raw` elsewhere, patch the data-layout address
6004 /// at `addr_off` in the verbatim header `region`, rewrite the header, and free
6005 /// `old_extent` (the prior data block, if any) after the commit lands.
6006 Contiguous {
6007 region: Vec<u8>,
6008 addr_off: usize,
6009 raw: Vec<u8>,
6010 old_extent: Option<(u64, u64)>,
6011 },
6012 /// A compact dataset: rebuild the header `region` with `raw` inline.
6013 Compact { region: Vec<u8>, raw: Vec<u8> },
6014 /// A chunked dataset whose new (re-encoded) chunks do not all fit their
6015 /// existing slots, so its whole storage is rebuilt and relocated. A fresh
6016 /// chunk-data blob and index are appended at end-of-file (via the verbatim
6017 /// layout path, carrying `chunk_bytes` and the source filter `pipeline_message`
6018 /// unchanged — no recompression and no filter-parameter reconstruction), the
6019 /// data-layout message in the verbatim header `region` is swapped for the new
6020 /// one (every other header message — datatype, dataspace, fill value, filter
6021 /// pipeline, and attributes, including a dense attribute heap referenced by an
6022 /// untouched Attribute Info message — is preserved verbatim), and the old
6023 /// chunk storage at `old_addr` is freed after the commit lands.
6024 Chunked {
6025 region: Vec<u8>,
6026 chunk_dims: Vec<u64>,
6027 element_size: usize,
6028 raw_size: u64,
6029 maxshape: Option<Vec<u64>>,
6030 pipeline_message: Option<Vec<u8>>,
6031 meta: Vec<ChunkMeta>,
6032 chunk_bytes: Vec<Vec<u8>>,
6033 old_addr: u64,
6034 },
6035 /// A relocating **append** to a chunked, unlimited, Extensible-Array-indexed
6036 /// dataset (`append_dataset`). The dataset's existing chunk *data* stays in
6037 /// place; only the newly-appended chunks and any rewritten trailing partial
6038 /// chunk (`new_chunk_bytes`, already compressed through the on-disk pipeline)
6039 /// are appended at end-of-file, a fresh Extensible Array is rebuilt over
6040 /// `kept_chunks ++ new_chunk_bytes`, the verbatim header `region`'s dataspace
6041 /// message is grown (`new_dataspace_body`) and its data-layout message
6042 /// repointed at the new index (every other message — datatype, filter
6043 /// pipeline, fill value, attributes — preserved verbatim), and the header is
6044 /// relocated. After the commit lands, only the old index structure at
6045 /// `old_addr`, the old header, and the relocated old trailing chunk
6046 /// (`old_tail_extent`) are freed — never the kept chunk data, which both the
6047 /// old and new index share during the commit.
6048 AppendedChunks {
6049 region: Vec<u8>,
6050 /// The grown dataspace message body (v2-serialized), current axis-0
6051 /// dimension increased, maximum dimensions (unlimited) preserved.
6052 new_dataspace_body: Vec<u8>,
6053 /// Rank-only spatial chunk dimensions, for the rebuilt v4 layout message.
6054 chunk_dims_u32: Vec<u32>,
6055 element_size: usize,
6056 /// Full (uncompressed) chunk byte size = product(spatial) * element_size.
6057 raw_size: u64,
6058 has_filters: bool,
6059 /// Existing complete chunks, in index order, carried by metadata alone —
6060 /// their base-relative addresses, on-disk stored sizes, and filter masks
6061 /// preserved exactly (a nonzero mask from a C/h5py-skipped filter is kept).
6062 kept_chunks: Vec<WrittenChunk>,
6063 /// The appended chunks in index order: the recompressed trailing partial
6064 /// chunk first (when present), then the remaining new full chunks.
6065 new_chunk_bytes: Vec<Vec<u8>>,
6066 /// The dataset header address, for old-index and old-header reclaim.
6067 old_addr: u64,
6068 /// The absolute `(addr, len)` of the old trailing partial chunk's data
6069 /// block when it was rewritten, freed after the commit lands. `None` when
6070 /// the append was chunk-aligned (no partial chunk to rewrite).
6071 old_tail_extent: Option<(u64, u64)>,
6072 },
6073 /// A compact dataset-attribute edit (`set_dataset_attr` / `remove_dataset_attr`).
6074 /// The verbatim header `region` already carries the fixed-size attribute change
6075 /// (applied by [`apply_group_attr_ops`] in the commit preflight); any
6076 /// variable-length attribute is placed and patched in [`WriteEngine::write_moving`]
6077 /// via `pending_vl_attrs`. The rewritten header is relocated and the parent link
6078 /// repointed, exactly like the other relocating writes — but the data-layout
6079 /// message is preserved verbatim, so the dataset's chunk data and index stay in
6080 /// place; only the old header is freed.
6081 AttrEdit {
6082 region: Vec<u8>,
6083 pending_vl_attrs: PendingVlAttrs,
6084 },
6085}
6086
6087/// A staged dataset reduced to the pieces the writer needs.
6088struct FlatDataset {
6089 name: String,
6090 dt: crate::datatype::Datatype,
6091 ds: Dataspace,
6092 raw: Vec<u8>,
6093 attrs: Vec<crate::attribute::AttributeMessage>,
6094 /// Chunked/filtered storage options. When [`ChunkOptions::is_chunked`] is
6095 /// false and `maxshape` is `None`, the dataset is written as contiguous,
6096 /// unfiltered storage; otherwise its chunk data and index are built by
6097 /// [`build_chunked_data_at_ext`] and appended at end-of-file.
6098 chunk_options: ChunkOptions,
6099 /// Maximum dimensions for an extensible dataset (an unlimited dimension is
6100 /// `u64::MAX`), mirrored into `ds.max_dimensions`. `None` for a fixed-shape
6101 /// dataset. A maxshape with an unlimited dimension selects the
6102 /// extensible-array chunk index; a finite maxshape stays fixed-array/single.
6103 maxshape: Option<Vec<u64>>,
6104 /// Variable-length attributes still carrying a placeholder heap address:
6105 /// (index into `attrs`, that attribute's global heap collections).
6106 /// Resolved in the apply loop right before this dataset's header is built.
6107 vl_attrs: Vec<(usize, Vec<Vec<u8>>)>,
6108 /// A staged variable-length-string dataset's element references (still
6109 /// carrying placeholder heap addresses in `raw`) and global heap
6110 /// collections. Resolved in the apply loop right before `raw` is appended.
6111 vl_string_staging: Option<VlStringStaging>,
6112 /// An object-reference dataset's per-element targets, still unresolved.
6113 /// Resolved (see [`WriteEngine::resolve_reference_target`]) and patched
6114 /// into `raw` in the apply loop, once every object this commit places has
6115 /// a known address. `None` for an ordinary dataset.
6116 reference_targets: Option<Vec<ObjectRefPatch>>,
6117 /// A user-defined fill value, encoded in the dataset's datatype, or `None`
6118 /// for the library default. Validated against the datatype element size in
6119 /// [`flatten_dataset`].
6120 fill: Option<Vec<u8>>,
6121}
6122
6123/// A borrow adapter that drives the shared Extensible-Array append engine
6124/// ([`crate::chunk_index_inplace`]) against the engine's *own* image and
6125/// superblock, so a session runs an immediate O(1) in-place append without
6126/// constructing a second writable handle (which would take a second exclusive
6127/// lock and keep a divergent view of the file). It borrows only those two
6128/// fields, leaving [`WriteEngine::located`] independently borrowable.
6129///
6130/// [`Store`] is the append engine's view of a file — an image *plus* the
6131/// superblock, which the image itself knows nothing about. Pairing them here is
6132/// all this adapter does; every primitive delegates, so the image's own
6133/// write-ordering discipline is what applies.
6134///
6135/// It carries the session's paged-file state too, so `append_raw` keeps a paged
6136/// file's pages homogeneous through exactly the rule the staged commit uses
6137/// ([`PagedEdit::begin`]). Before issue #198 there were two copies of that state —
6138/// one per engine — and the whole-file editor's copy was reachable only from the
6139/// commit path, so it had to refuse an in-place append to a paged file outright.
6140struct EditStore<'a> {
6141 image: &'a mut dyn FileImage,
6142 superblock: &'a mut Superblock,
6143 sb_sig_off: usize,
6144 /// The session's paged state when the file is paged, `None` otherwise. A
6145 /// borrow rather than a copy: padding recorded here has to reach the manager
6146 /// rewrite at the next commit or at close.
6147 paged: Option<&'a mut PagedEdit>,
6148}
6149
6150impl EditStore<'_> {
6151 /// Append `bytes` into a raw page, padding the tail page first when a paged
6152 /// file's tail holds metadata. A plain append on the common non-paged file.
6153 ///
6154 /// Raw is the only page type this adapter allocates: see
6155 /// [`Store::append_raw`](crate::chunk_index_inplace::Store::append_raw) for why
6156 /// an extensible-array index block belongs in a raw page here.
6157 fn append_into_raw_page(&mut self, bytes: &[u8]) -> Result<u64, Error> {
6158 if let Some(pg) = self.paged.as_deref_mut() {
6159 pg.begin(self.image, PageType::Raw)?;
6160 }
6161 self.image.append(bytes)
6162 }
6163}
6164
6165impl crate::source::Source for EditStore<'_> {
6166 fn len(&self) -> u64 {
6167 self.image.len()
6168 }
6169 fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), crate::error::FormatError> {
6170 self.image.read_at(offset, buf)
6171 }
6172 fn read_metadata_at(
6173 &self,
6174 offset: u64,
6175 len: usize,
6176 ) -> Result<Vec<u8>, crate::error::FormatError> {
6177 self.image.read_metadata_at(offset, len)
6178 }
6179}
6180
6181impl Store for EditStore<'_> {
6182 fn offset_size(&self) -> u8 {
6183 self.superblock.offset_size
6184 }
6185 fn length_size(&self) -> u8 {
6186 self.superblock.length_size
6187 }
6188 fn append_bytes(&mut self, bytes: &[u8]) -> Result<u64, Error> {
6189 self.image.append(bytes)
6190 }
6191 fn append_raw(&mut self, bytes: &[u8]) -> Result<u64, Error> {
6192 self.append_into_raw_page(bytes)
6193 }
6194 fn write_at(&mut self, offset: u64, bytes: &[u8]) -> Result<(), Error> {
6195 self.image.write_at(offset, bytes)
6196 }
6197 fn patch_superblock_eof(&mut self) -> Result<(), Error> {
6198 // Advance only the recorded end-of-file and re-serialize the superblock in
6199 // place. Unlike `WriteEngine::commit`, this deliberately does NOT clear the
6200 // consistency flags and does NOT repoint the root group: base_address is 0
6201 // for every in-place-append-eligible file, so the normalized-absolute root
6202 // address serializes back to the same stored value.
6203 let eof = self.image.len();
6204 self.superblock.eof_address = eof;
6205 let bytes = self.superblock.serialize();
6206 self.write_at(self.sb_sig_off as u64, &bytes)
6207 }
6208 fn sync(&mut self) -> Result<(), Error> {
6209 self.image.sync_data()
6210 }
6211}
6212
6213/// Whether two object paths are equal or one is an ancestor of the other.
6214fn paths_overlap(a: &[String], b: &[String]) -> bool {
6215 a.starts_with(b) || b.starts_with(a)
6216}
6217
6218/// Re-tag a refusal from the shared append engine (`AppendUnsupported`) as the
6219/// fast-path [`Error::AppendInPlaceUnsupported`], so a caller can catch it and fall
6220/// back to the staged [`append_dataset`](WriteEngine::append_dataset) — which
6221/// handles the non-chunk-aligned filtered case, index-geometry limits, and
6222/// platform-width limits that the engine reports this way. Genuine I/O and format
6223/// errors pass through unchanged.
6224pub(crate) fn as_inplace_error(e: Error) -> Error {
6225 match e {
6226 Error::AppendUnsupported(m) => Error::AppendInPlaceUnsupported(m),
6227 other => other,
6228 }
6229}
6230
6231/// Validate a gathered append's bytes against a located dataset: the byte
6232/// length must be a whole number of elements, and the element datatype must
6233/// match the on-disk datatype (or, for a raw append, be raw-appendable).
6234/// Returns the appended element count (`0` = nothing to do). Shared by
6235/// [`WriteEngine::append_inplace_gathered`]'s path and the bounded backend's immediate
6236/// append so the acceptance rules stay identical.
6237pub(crate) fn validate_gathered_append(st: &LocatedState, b: &AppendBuilder) -> Result<u64, Error> {
6238 let raw = b.raw();
6239 if raw.len() % st.element_size != 0 {
6240 return Err(Error::AppendInPlaceUnsupported(
6241 "appended byte length is not a whole number of elements",
6242 ));
6243 }
6244 match b.elem_dt() {
6245 Some(expected) if *expected != st.datatype => {
6246 return Err(Error::AppendInPlaceUnsupported(
6247 "append datatype does not match the on-disk dataset (wrong element \
6248 type or byte order)",
6249 ));
6250 }
6251 Some(_) => {}
6252 None => {
6253 if !datatype_is_raw_appendable(&st.datatype) {
6254 return Err(Error::AppendInPlaceUnsupported(
6255 "append_raw onto this dataset's datatype (non-little-endian, \
6256 variable-length, or reference) could misencode the bytes; use a \
6257 typed append",
6258 ));
6259 }
6260 }
6261 }
6262 Ok((raw.len() / st.element_size) as u64)
6263}
6264
6265/// Locate the dataset at `oh_addr` in `file` and build its [`LocatedState`],
6266/// validating in-place append eligibility (rank-1 / unlimited / Extensible-Array
6267/// indexed, a nonzero chunk length, and a re-encodable filter pipeline). Mirrors
6268/// the append writer's `ensure_located`, reporting through
6269/// [`Error::AppendInPlaceUnsupported`].
6270pub(crate) fn locate_dataset_state<F: Store>(
6271 file: &F,
6272 oh_addr: u64,
6273) -> Result<LocatedState, Error> {
6274 let result = Located::locate_at(file, oh_addr, Error::AppendInPlaceUnsupported)?;
6275 if result.located.chunk_elems == 0 {
6276 return Err(Error::AppendInPlaceUnsupported(
6277 "in-place append requires a nonzero chunk length",
6278 ));
6279 }
6280 let (dt_off, dt_size) = result.spans.datatype;
6281 let dt_bytes = file
6282 .read_metadata_at(dt_off, dt_size)
6283 .map_err(|_| Error::AppendInPlaceUnsupported("dataset datatype could not be parsed"))?;
6284 let (datatype, _) = Datatype::parse(&dt_bytes)
6285 .map_err(|_| Error::AppendInPlaceUnsupported("dataset datatype could not be parsed"))?;
6286 let pipeline = match result.spans.filter {
6287 Some((fb, fsize)) => {
6288 let fp_bytes = file.read_metadata_at(fb, fsize).map_err(|_| {
6289 Error::AppendInPlaceUnsupported("dataset filter pipeline could not be parsed")
6290 })?;
6291 let parsed = FilterPipeline::parse(&fp_bytes).map_err(|_| {
6292 Error::AppendInPlaceUnsupported("dataset filter pipeline could not be parsed")
6293 })?;
6294 if !pipeline_reencodable(&parsed) {
6295 return Err(Error::AppendInPlaceUnsupported(
6296 "dataset uses a filter this engine cannot re-encode",
6297 ));
6298 }
6299 Some(parsed)
6300 }
6301 None => None,
6302 };
6303 let element_size = result.located.elem_bytes;
6304 let spatial = vec![result.located.chunk_elems];
6305 Ok(LocatedState {
6306 loc: result.located,
6307 datatype,
6308 spatial,
6309 element_size,
6310 pipeline,
6311 })
6312}
6313
6314/// Split a path into non-empty components.
6315fn split_path(path: &str) -> PathKey {
6316 path.split('/')
6317 .filter(|s| !s.is_empty())
6318 .map(String::from)
6319 .collect()
6320}
6321
6322/// Ensure a node exists for every ancestor prefix of `path` (so each is rebuilt
6323/// and can re-wire its child link). Does not set `is_new`.
6324fn ensure_ancestors(nodes: &mut BTreeMap<PathKey, Node>, path: &[String]) {
6325 for len in 0..=path.len() {
6326 nodes.entry(path[..len].to_vec()).or_default();
6327 }
6328}
6329
6330/// Validate that every reclaim span `(addr, len)` is non-empty, ends at or
6331/// before `eof`, and that no two overlap; sorts `spans` by address as a side
6332/// effect. Returns `false` on any violation so the caller can decline to
6333/// reclaim the object rather than feed the free list an out-of-bounds or
6334/// overlapping (double-free) region. Touching spans are allowed — the free list
6335/// coalesces them.
6336fn spans_disjoint_in_bounds(spans: &mut [(u64, u64)], eof: u64) -> bool {
6337 for &(addr, len) in spans.iter() {
6338 match addr.checked_add(len) {
6339 Some(end) if len > 0 && end <= eof => {}
6340 _ => return false,
6341 }
6342 }
6343 spans.sort_unstable_by_key(|&(addr, _)| addr);
6344 spans.windows(2).all(|w| w[0].0 + w[0].1 <= w[1].0)
6345}
6346
6347/// Sanitize the accumulated free spans for a whole commit so the free list never
6348/// sees an out-of-bounds or overlapping (double-free) region: drop empty or
6349/// past-`eof` spans, sort by address, then drop any span overlapping one already
6350/// kept. Dropping only leaks (the bytes stay allocated); it never frees a live
6351/// region. With the last-hard-link guard in force nothing should be dropped for
6352/// a well-formed file — this is a backstop, not the primary defense.
6353fn retain_disjoint_in_bounds(spans: &mut Vec<(u64, u64, PageType)>, eof: u64) {
6354 spans.retain(|&(addr, len, _)| len > 0 && addr.checked_add(len).is_some_and(|e| e <= eof));
6355 spans.sort_unstable_by_key(|&(addr, _, _)| addr);
6356 let mut kept_end = 0u64;
6357 spans.retain(|&(addr, len, _)| {
6358 if addr >= kept_end {
6359 kept_end = addr + len;
6360 true
6361 } else {
6362 false // overlaps a span already kept; leak it rather than double-free
6363 }
6364 });
6365}
6366
6367/// Tag object-header chunk spans as file metadata. Every span
6368/// [`oh_chunk_spans`](EditSession::oh_chunk_spans) returns is part of an object
6369/// header, so the page type is the same for all of them.
6370fn meta_spans(spans: Vec<(u64, u64)>) -> impl Iterator<Item = (u64, u64, PageType)> {
6371 spans.into_iter().map(|(a, l)| (a, l, PageType::Meta))
6372}
6373
6374/// Validate a staged dataset and reduce it to a [`FlatDataset`]. Contiguous,
6375/// unfiltered datasets are emitted as such; chunked, filtered, or extensible
6376/// datasets carry their [`ChunkOptions`] and maxshape through to the commit,
6377/// where [`build_chunked_data_at_ext`] lays out their chunk data and index. An
6378/// empty (zero-element) shape is allowed for contiguous storage (mirroring the
6379/// whole-file writer, its data address is `HADDR_UNDEF` — see the apply loop),
6380/// but chunking one stays refused via the geometry validation below. A
6381/// `provenance` dataset has its SHA-256/creator/timestamp/source attributes
6382/// computed here from `raw`, exactly as the whole-file writer does. A
6383/// variable-length attribute's global heap collection is built here (it is
6384/// fully self-contained — no address of its own) but placed and patched later,
6385/// in the apply loop, once its final address is known; likewise a
6386/// variable-length-string dataset's staged references and collection
6387/// (`db.vl_string_staging`) are carried through unresolved. An object-reference
6388/// dataset's per-element targets (`db.reference_targets`) are likewise carried
6389/// through unresolved — resolving a path target requires knowing every other
6390/// object this commit places, which is only known well into the apply loop
6391/// (see [`WriteEngine::resolve_reference_target`]). Rejects any remaining
6392/// feature this engine cannot reproduce faithfully: dense attributes, a
6393/// chunked/extensible variable-length-string or object-reference dataset, or a
6394/// filter pipeline the build cannot construct.
6395fn flatten_dataset(db: DatasetBuilder) -> Result<FlatDataset, Error> {
6396 if db.name.is_empty() {
6397 return Err(Error::EditUnsupported("dataset path has an empty name"));
6398 }
6399 let dt = db
6400 .datatype
6401 .ok_or(Error::EditUnsupported("dataset has no datatype/data"))?;
6402 let shape = db
6403 .shape
6404 .ok_or(Error::EditUnsupported("dataset has no shape"))?;
6405 let is_empty = shape.contains(&0);
6406 let chunked = db.chunk_options.is_chunked() || db.maxshape.is_some();
6407 if is_empty && chunked {
6408 return Err(Error::EditUnsupported(
6409 "chunked or extensible empty (zero-element) datasets cannot be added in place yet",
6410 ));
6411 }
6412 // Variable-length string element references live in the global heap, whose
6413 // address is only known once the apply loop places the collection. For
6414 // chunked/filtered/resizable storage the references sit inside chunks
6415 // written before that address exists, so patching them in is impossible.
6416 //
6417 // The whole-file writer lifted the same restriction by placing such a
6418 // dataset's collections ahead of everything else (issue #109); this engine
6419 // appends into an existing layout, where there is no "ahead" to place them
6420 // in, so the equivalent fix is a separate piece of work.
6421 if db.vl_string_staging.is_some() && chunked {
6422 return Err(Error::EditUnsupported(
6423 "chunked or extensible variable-length-string datasets cannot be added in place yet",
6424 ));
6425 }
6426 // Object-reference elements are resolved (see `resolve_reference_target`)
6427 // and patched into `raw` right before it is appended; for chunked storage
6428 // that patch would need to reach inside already-built chunk data, which
6429 // this engine does not support (mirrors the variable-length-string
6430 // refusal above — untested and unneeded combination for v1).
6431 if db.reference_targets.is_some() && chunked {
6432 return Err(Error::EditUnsupported(
6433 "chunked or extensible object-reference datasets cannot be added in place yet",
6434 ));
6435 }
6436 let raw = if is_empty {
6437 db.data.unwrap_or_default()
6438 } else {
6439 db.data
6440 .ok_or(Error::EditUnsupported("dataset has no data"))?
6441 };
6442
6443 let elem = dt.type_size() as u64;
6444 if elem > 0 {
6445 // Multiply with checked arithmetic: an absurd shape whose element count
6446 // (or byte size) overflows `u64` is refused rather than panicking in a
6447 // debug build or silently wrapping in release (which could let a wrapped
6448 // product spuriously match `raw.len()`). For a zero-element shape this
6449 // expected length is always 0 (a `0` dimension makes every checked
6450 // multiplication `Some(0)` regardless of the other dimensions), so this
6451 // also catches data mistakenly supplied for a shape that holds nothing.
6452 let expected = shape
6453 .iter()
6454 .try_fold(1u64, |acc, &d| acc.checked_mul(d))
6455 .and_then(|n| n.checked_mul(elem));
6456 match expected {
6457 Some(expected) if raw.len() as u64 == expected => {}
6458 Some(_) => {
6459 return Err(Error::EditUnsupported(
6460 "dataset data length does not match its shape",
6461 ));
6462 }
6463 None => {
6464 return Err(Error::EditUnsupported(
6465 "dataset shape is too large to address on this platform",
6466 ));
6467 }
6468 }
6469 }
6470
6471 if chunked {
6472 // Refuse malformed chunk geometry up front (the same validation the
6473 // whole-file writer applies), so a bad request — chunk dimensions of the
6474 // wrong rank, a zero chunk dimension, an inconsistent maximum shape, or
6475 // chunking a scalar — never reaches and panics the chunk splitter, nor
6476 // yields a dataset the reader cannot decode.
6477 db.chunk_options
6478 .validate_geometry(&shape, db.maxshape.as_deref())
6479 .map_err(Error::EditUnsupported)?;
6480 // Deflate is compiled out unless the `deflate` feature is on, but
6481 // `build_pipeline` emits its descriptor regardless; catch a
6482 // disabled-feature request here so it is refused up front rather than
6483 // failing mid-apply when a chunk is compressed.
6484 #[cfg(not(feature = "deflate"))]
6485 if db.chunk_options.deflate_level.is_some() {
6486 return Err(Error::EditUnsupported(
6487 "deflate compression requires the `deflate` crate feature",
6488 ));
6489 }
6490 // Validate the requested filter pipeline now — before any file bytes are
6491 // written — so an unsupported filter, an incompatible datatype, or a
6492 // disabled compression feature is refused up front; the chunk data
6493 // itself is laid out in the commit's apply phase. Chunked/filtered
6494 // storage flows through the very builder the normal writer uses
6495 // ([`build_chunked_data_at_ext`] + [`build_chunked_dataset_oh`]), so the
6496 // resulting object header is byte-identical to a freshly written one.
6497 let chunk_dims = db.chunk_options.resolve_chunk_dims(&shape);
6498 let ctx = ChunkContext::from_datatype(&chunk_dims, &dt);
6499 db.chunk_options
6500 .build_pipeline(
6501 ctx.element_size,
6502 &chunk_dims,
6503 ctx.element_type,
6504 ctx.scale_offset_type,
6505 )
6506 .map_err(|_| {
6507 Error::EditUnsupported(
6508 "this dataset's filter pipeline cannot be added in place \
6509 (an unsupported filter, an incompatible datatype, or a \
6510 compression feature that is not enabled)",
6511 )
6512 })?;
6513 }
6514
6515 // The link message body (whose length is independent of the address) must
6516 // fit the object-header message's u16 size field; a pathologically long
6517 // name would otherwise overflow it into silent corruption.
6518 if make_link(&db.name, 0).serialize(OFFSET_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
6519 return Err(Error::EditUnsupported(
6520 "dataset name is too long to encode as a link message",
6521 ));
6522 }
6523
6524 let ds = Dataspace {
6525 space_type: if shape.is_empty() {
6526 DataspaceType::Scalar
6527 } else {
6528 DataspaceType::Simple
6529 },
6530 #[expect(
6531 clippy::cast_possible_truncation,
6532 reason = "dataspace rank fits the 1-byte dimensionality field (HDF5 caps rank at 32)"
6533 )]
6534 rank: shape.len() as u8,
6535 dimensions: shape,
6536 // A chunked, extensible dataset records its maximum dimensions (an
6537 // unlimited dimension is `u64::MAX`); a fixed-shape dataset has none.
6538 max_dimensions: db.maxshape.clone(),
6539 };
6540 let mut attrs: Vec<crate::attribute::AttributeMessage> = Vec::with_capacity(db.attrs.len());
6541 for (n, v) in &db.attrs {
6542 attrs.push(build_attr_message(n, v));
6543 }
6544 // `build_attr_message` already writes a placeholder (heap address 0) for a
6545 // `VarLenAsciiArray` attribute; stage its self-contained global heap
6546 // collections here (no address of their own to resolve yet) and record which
6547 // `attrs` slot they patch once the apply loop places them.
6548 let mut vl_attrs: Vec<(usize, Vec<Vec<u8>>)> = Vec::new();
6549 for (i, (_, v)) in db.attrs.iter().enumerate() {
6550 if let AttrValue::VarLenAsciiArray(strings) = v {
6551 let str_refs: Vec<&str> = strings.iter().map(String::as_str).collect();
6552 vl_attrs.push((i, build_global_heap_collections(&str_refs)));
6553 }
6554 }
6555 #[cfg(feature = "provenance")]
6556 if let Some(ref prov) = db.provenance {
6557 let p = crate::provenance::Provenance {
6558 creator: prov.creator.clone(),
6559 timestamp: prov.timestamp.clone(),
6560 source: prov.source.clone(),
6561 };
6562 attrs.extend(p.build_attrs(&raw));
6563 }
6564 // The object-header message-size field is 2 bytes wide, so an oversized
6565 // attribute (most reachable via a `VarLenAsciiArray` with many/long
6566 // strings) would silently truncate and corrupt the header if written
6567 // as-is; refuse it instead, mirroring `apply_group_attr_ops`'s and
6568 // `encode_attr_message`'s equivalent checks for group/root attributes.
6569 for a in &attrs {
6570 if a.serialize(LENGTH_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
6571 return Err(Error::EditUnsupported(
6572 "dataset attribute is too large to encode in place",
6573 ));
6574 }
6575 }
6576 if attrs.len() > MAX_COMPACT_ATTRS {
6577 return Err(Error::EditUnsupported(
6578 "datasets with dense (many) attributes cannot be added in place yet",
6579 ));
6580 }
6581
6582 // A user-defined fill value is one element wide, so its byte length must
6583 // equal the datatype's element size (mirrors the whole-file writer's check).
6584 if let Some(fill) = &db.fill {
6585 let expected = elem.to_usize()?;
6586 if fill.len() != expected {
6587 return Err(Error::Format(FormatError::FillValueSizeMismatch {
6588 expected,
6589 actual: fill.len(),
6590 }));
6591 }
6592 }
6593
6594 Ok(FlatDataset {
6595 name: db.name,
6596 dt,
6597 ds,
6598 raw,
6599 attrs,
6600 chunk_options: db.chunk_options,
6601 maxshape: db.maxshape,
6602 vl_attrs,
6603 vl_string_staging: db.vl_string_staging,
6604 reference_targets: db.reference_targets,
6605 fill: db.fill,
6606 })
6607}
6608
6609/// A minimal Group Info message body (type 0x000A): version 0 with neither the
6610/// link-phase-change nor the estimated-entry fields stored. With both absent the
6611/// HDF5 C library fills `max_compact`/`min_dense` from its own defaults (8 and
6612/// 6). See [`ensure_group_info`] for why every group needs this message.
6613const GROUP_INFO_BODY: [u8; 2] = [0, 0];
6614
6615/// Frame one chunk-0 object-header message record: a 1-byte type, a 2-byte
6616/// little-endian body length, a 1-byte flags field (always 0 here), then the
6617/// body. This is the v2 message-record layout used throughout a group's chunk-0
6618/// message region. Callers pass bodies that fit the u16 length field: link
6619/// bodies are validated in [`flatten_dataset`], and the Link Info / Group Info
6620/// bodies are fixed and short.
6621/// Whether a chunked dataset with this data-layout version and chunk index type
6622/// can be enumerated chunk-by-chunk (and therefore overwritten or copied in
6623/// place). Mirrors the dispatch in
6624/// [`chunked_read::collect_chunks_for_layout_from_source`](crate::chunked_read):
6625/// version-3 B-tree v1 and the version-4 single / implicit / fixed-array /
6626/// extensible-array indexes have walkers; a version-2 B-tree (index type 5) or
6627/// any unknown index type does not.
6628fn chunk_index_enumerable(version: u8, chunk_index_type: Option<u8>) -> bool {
6629 matches!((version, chunk_index_type), (3, _) | (4, Some(1..=4)))
6630}
6631
6632/// Whether every filter in `pipeline` is one this crate can *apply* (re-encode a
6633/// chunk through) — not merely decode. A pipeline with any other filter cannot be
6634/// re-encoded for an in-place overwrite, so the caller refuses with a typed error
6635/// rather than letting [`compress_chunk`] surface a raw `UnsupportedFilter`.
6636pub(crate) fn pipeline_reencodable(pipeline: &FilterPipeline) -> bool {
6637 pipeline.filters.iter().all(|f| match f.filter_id {
6638 FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 | FILTER_SCALEOFFSET | FILTER_LZF => {
6639 true
6640 }
6641 #[cfg(feature = "zfp")]
6642 crate::filter_pipeline::FILTER_ZFP => true,
6643 _ => false,
6644 })
6645}
6646
6647/// Rebuild a header message `region`, replacing the single Data Layout message's
6648/// record with one carrying `new_layout_body` and leaving every other message
6649/// (datatype, dataspace, fill value, filter pipeline, attributes, attribute info)
6650/// byte-for-byte. The replacement may differ in length from the original — a
6651/// chunked rebuild can change the index type and thus the layout message size — so
6652/// the record is rebuilt via [`region_message`] rather than patched in place. The
6653/// chunked overwrite and copy paths use this to relocate a dataset's chunk storage
6654/// while preserving the rest of its header exactly.
6655fn replace_layout_message(region: &[u8], new_layout_body: &[u8]) -> Result<Vec<u8>, Error> {
6656 let mut out = Vec::with_capacity(region.len());
6657 let mut p = 0;
6658 let mut replaced = false;
6659 while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
6660 if msg_type == MessageType::DataLayout && !replaced {
6661 out.extend_from_slice(®ion_message(MessageType::DataLayout, new_layout_body));
6662 replaced = true;
6663 } else {
6664 out.extend_from_slice(®ion[p..body_end]);
6665 }
6666 p = body_end;
6667 }
6668 if !replaced {
6669 return Err(Error::EditUnsupported(
6670 "chunked dataset header has no data-layout message to relocate",
6671 ));
6672 }
6673 Ok(out)
6674}
6675
6676/// Rebuild a header message `region`, replacing the single Dataspace message's
6677/// record with one carrying `new_dataspace_body` (the grown current dimensions,
6678/// v2-serialized, maximum dimensions preserved) and leaving every other message
6679/// byte-for-byte. Used by the append path to grow a dataset's axis-0 dimension.
6680/// The replacement may differ in length from the original (a v1 on-disk
6681/// dataspace is normalized to v2 in the rebuilt header), so the record is rebuilt
6682/// via [`region_message`] rather than patched in place.
6683fn replace_dataspace_message(region: &[u8], new_dataspace_body: &[u8]) -> Result<Vec<u8>, Error> {
6684 let mut out = Vec::with_capacity(region.len());
6685 let mut p = 0;
6686 let mut replaced = false;
6687 while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
6688 if msg_type == MessageType::Dataspace && !replaced {
6689 out.extend_from_slice(®ion_message(MessageType::Dataspace, new_dataspace_body));
6690 replaced = true;
6691 } else {
6692 out.extend_from_slice(®ion[p..body_end]);
6693 }
6694 p = body_end;
6695 }
6696 if !replaced {
6697 return Err(Error::AppendUnsupported(
6698 "dataset header has no dataspace message to grow",
6699 ));
6700 }
6701 Ok(out)
6702}
6703
6704/// Whether a datatype's raw on-disk bytes can be appended verbatim from a caller
6705/// via [`AppendBuilder::append_raw`]. True only when every scalar leaf is safe to
6706/// write as flat little-endian bytes:
6707///
6708/// - numeric leaves (fixed-point, floating-point, time, bit field) must be
6709/// little-endian, or the caller's little-endian bytes would silently misencode
6710/// into a big-endian (or VAX) field;
6711/// - string and opaque leaves are byte arrays with no numeric byte order, so they
6712/// are order-agnostic and safe;
6713/// - aggregates (enumeration, array, compound) are appendable iff every leaf is;
6714/// - variable-length and reference leaves embed global-heap or object addresses
6715/// that a flat byte append cannot reproduce, so they are never raw-appendable.
6716///
6717/// A typed `append_*` bypasses this: it checks full datatype equality instead, so
6718/// it already refuses every non-little-endian and non-scalar dataset.
6719pub(crate) fn datatype_is_raw_appendable(dt: &Datatype) -> bool {
6720 match dt {
6721 Datatype::FixedPoint { byte_order, .. }
6722 | Datatype::FloatingPoint { byte_order, .. }
6723 | Datatype::Time { byte_order, .. }
6724 | Datatype::BitField { byte_order, .. } => *byte_order == DatatypeByteOrder::LittleEndian,
6725 Datatype::String { .. } | Datatype::Opaque { .. } => true,
6726 Datatype::Enumeration { base_type, .. } | Datatype::Array { base_type, .. } => {
6727 datatype_is_raw_appendable(base_type)
6728 }
6729 Datatype::Compound { members, .. } => members
6730 .iter()
6731 .all(|m| datatype_is_raw_appendable(&m.datatype)),
6732 Datatype::VariableLength { .. } | Datatype::Reference { .. } => false,
6733 }
6734}
6735
6736/// The datatype, dataspace, parsed chunked data layout, and verbatim filter-
6737/// pipeline message bytes (if any) of a chunked dataset header, parsed by
6738/// [`parse_chunked_header`].
6739struct ChunkedHeaderParts {
6740 dt: crate::datatype::Datatype,
6741 ds: Dataspace,
6742 layout: DataLayout,
6743 pipeline_message: Option<Vec<u8>>,
6744}
6745
6746/// Parse the datatype, dataspace, chunked data layout, and verbatim filter-
6747/// pipeline message bytes (if any) from a chunked dataset header `region`. Used by
6748/// the chunked copy path to derive chunk geometry and the on-disk filter pipeline.
6749/// Errors if any required message is missing or the layout is not chunked.
6750fn parse_chunked_header(region: &[u8]) -> Result<ChunkedHeaderParts, Error> {
6751 let mut datatype: Option<(usize, usize)> = None;
6752 let mut dataspace: Option<(usize, usize)> = None;
6753 let mut layout: Option<(usize, usize)> = None;
6754 let mut pipeline: Option<(usize, usize)> = None;
6755 let mut p = 0;
6756 while let Some((msg_type, body, body_end)) = next_message(region, p)? {
6757 match msg_type {
6758 MessageType::Datatype => datatype = Some((body, body_end)),
6759 MessageType::Dataspace => dataspace = Some((body, body_end)),
6760 MessageType::DataLayout => layout = Some((body, body_end)),
6761 MessageType::FilterPipeline => pipeline = Some((body, body_end)),
6762 _ => {}
6763 }
6764 p = body_end;
6765 }
6766 let (dt_b, dt_e) = datatype.ok_or(Error::EditUnsupported("dataset header has no datatype"))?;
6767 let (ds_b, ds_e) =
6768 dataspace.ok_or(Error::EditUnsupported("dataset header has no dataspace"))?;
6769 let (lb, le) = layout.ok_or(Error::EditUnsupported("dataset header has no data layout"))?;
6770 let (dt, _) = crate::datatype::Datatype::parse(®ion[dt_b..dt_e])
6771 .map_err(|_| Error::EditUnsupported("dataset header datatype could not be parsed"))?;
6772 let ds = Dataspace::parse(®ion[ds_b..ds_e], LENGTH_SIZE)
6773 .map_err(|_| Error::EditUnsupported("dataset header dataspace could not be parsed"))?;
6774 let dl = DataLayout::parse(®ion[lb..le], OFFSET_SIZE, LENGTH_SIZE)
6775 .map_err(|_| Error::EditUnsupported("dataset header data layout could not be parsed"))?;
6776 if !matches!(dl, DataLayout::Chunked { .. }) {
6777 return Err(Error::EditUnsupported("dataset is not chunked"));
6778 }
6779 let pipeline_message = pipeline.map(|(b, e)| region[b..e].to_vec());
6780 Ok(ChunkedHeaderParts {
6781 dt,
6782 ds,
6783 layout: dl,
6784 pipeline_message,
6785 })
6786}
6787
6788/// The chunk geometry a verbatim chunked rebuild needs, derived by
6789/// [`chunked_geometry`] from a chunked dataset's datatype, dataspace, and parsed
6790/// [`DataLayout::Chunked`].
6791struct ChunkedGeometry {
6792 /// Rank-only spatial chunk dimensions.
6793 spatial: Vec<u64>,
6794 /// Element size in bytes.
6795 element_size: usize,
6796 /// Full (uncompressed) chunk byte size, `product(spatial) * element_size`.
6797 raw_size: u64,
6798 /// The on-disk maximum dimensions when they differ from the current shape; an
6799 /// unlimited dimension selects the extensible-array index, a finite one the
6800 /// fixed-array index. `None` keeps the fixed-array / single-chunk index.
6801 maxshape: Option<Vec<u64>>,
6802}
6803
6804/// Derive the [`ChunkedGeometry`] for a chunked dataset from its datatype,
6805/// dataspace, and parsed [`DataLayout::Chunked`].
6806fn chunked_geometry(
6807 dt: &crate::datatype::Datatype,
6808 ds: &Dataspace,
6809 layout: &DataLayout,
6810) -> Result<ChunkedGeometry, Error> {
6811 let DataLayout::Chunked {
6812 chunk_dimensions, ..
6813 } = layout
6814 else {
6815 return Err(Error::EditUnsupported("dataset is not chunked"));
6816 };
6817 let rank = ds.dimensions.len();
6818 if chunk_dimensions.len() <= rank {
6819 return Err(Error::EditUnsupported(
6820 "chunked layout has malformed dimensions",
6821 ));
6822 }
6823 let spatial: Vec<u64> = chunk_dimensions[..rank]
6824 .iter()
6825 .map(|&c| u64::from(c))
6826 .collect();
6827 let element_size = dt.type_size() as usize;
6828 if element_size == 0 {
6829 return Err(Error::EditUnsupported(
6830 "chunked dataset has a zero element size",
6831 ));
6832 }
6833 let raw_size = spatial
6834 .iter()
6835 .copied()
6836 .product::<u64>()
6837 .saturating_mul(element_size as u64);
6838 let maxshape = ds
6839 .max_dimensions
6840 .as_ref()
6841 .filter(|ms| *ms != &ds.dimensions)
6842 .cloned();
6843 Ok(ChunkedGeometry {
6844 spatial,
6845 element_size,
6846 raw_size,
6847 maxshape,
6848 })
6849}
6850
6851/// Try to overwrite a chunked dataset's chunks in place. When the dataset's
6852/// on-disk chunks form a dense grid aligned with `new_bytes` (dense row-major
6853/// order), every slot is unmasked (`filter_mask == 0`), and every new chunk
6854/// **fits** the slot it replaces (`new_len <= slot`), return the in-place
6855/// `(address, bytes)` writes:
6856///
6857/// - When every new chunk is **exactly** its slot's size, only the chunk data is
6858/// written; the index is untouched (so any enumerable index type works, and a
6859/// crash can tear at most a chunk's value bytes, not the structure).
6860/// - When some new chunks are **smaller** (fit with slack), the chunk index
6861/// records each chunk's stored size, so the index is rebuilt in place to record
6862/// the new sizes (see [`try_rebuild_index_in_place`]). This is supported only
6863/// for a v4 fixed-array or extensible-array index occupying a single contiguous
6864/// on-disk region; any other case returns `None` to relocate.
6865///
6866/// Returns `None` — so the caller relocates the dataset instead — when the index
6867/// cannot be enumerated, the grid is sparse, a slot is masked, a new chunk does
6868/// not fit, the index cannot be rebuilt in place, or any write would be out of
6869/// bounds or overlap another.
6870fn try_inplace_chunk_writes<S: Source + ?Sized>(
6871 src: &S,
6872 layout: &DataLayout,
6873 ds: &Dataspace,
6874 spatial: &[u64],
6875 raw_size: u64,
6876 new_bytes: &[Vec<u8>],
6877) -> Option<Vec<(usize, Vec<u8>)>> {
6878 let infos = enumerate_chunks_from_source(src, layout, ds, OFFSET_SIZE, LENGTH_SIZE).ok()?;
6879 let grid = plan_dense_grid(infos, &ds.dimensions, spatial)?;
6880 if grid.grid_order.len() != new_bytes.len() {
6881 return None;
6882 }
6883 let mut writes = Vec::with_capacity(new_bytes.len() + 1);
6884 let mut spans: Vec<(u64, u64)> = Vec::with_capacity(new_bytes.len() + 1);
6885 let mut any_shrunk = false;
6886 for (ci, bytes) in grid.grid_order.iter().zip(new_bytes.iter()) {
6887 // A nonzero filter mask means the source left some filter unapplied for
6888 // this chunk; re-encoding always applies every filter (mask 0), so an
6889 // in-place overwrite would desync the index-recorded mask. Relocate.
6890 if ci.filter_mask != 0 {
6891 return None;
6892 }
6893 let new_len = bytes.len() as u64;
6894 let slot = u64::from(ci.chunk_size);
6895 // A chunk that no longer fits its slot must relocate.
6896 if new_len > slot {
6897 return None;
6898 }
6899 if new_len < slot {
6900 any_shrunk = true;
6901 }
6902 let start = usize::try_from(ci.address).ok()?;
6903 start
6904 .checked_add(bytes.len())
6905 .filter(|&e| e as u64 <= src.len())?;
6906 writes.push((start, bytes.clone()));
6907 spans.push((ci.address, new_len));
6908 }
6909
6910 // A shrinking overwrite changes the index-recorded chunk sizes, so the index
6911 // must be rebuilt in place to match; an equal-size one leaves it untouched.
6912 if any_shrunk {
6913 let (index_addr, index_bytes) =
6914 try_rebuild_index_in_place(src, layout, raw_size, &grid.grid_order, new_bytes)?;
6915 spans.push((index_addr as u64, index_bytes.len() as u64));
6916 writes.push((index_addr, index_bytes));
6917 }
6918
6919 // Refuse to perform overlapping in-place writes (a malformed source index, or
6920 // an index region that overlaps a chunk slot); relocate instead so two writes
6921 // never clobber each other.
6922 if !spans_disjoint_in_bounds(&mut spans, src.len()) {
6923 return None;
6924 }
6925 Some(writes)
6926}
6927
6928/// Rebuild a chunked dataset's index **in place** so it records the new
6929/// (smaller) per-chunk stored sizes after a fits-with-slack overwrite, returning
6930/// the `(address, bytes)` write that replaces it. The chunks keep their existing
6931/// addresses (only their stored bytes shrank), so the rebuilt index points at the
6932/// same slots with the new sizes.
6933///
6934/// Supported only for a v4 **fixed-array** or **extensible-array** index whose
6935/// on-disk structure is a single contiguous region starting at the index address
6936/// — the layout this crate's own writer produces. The element width derives from
6937/// the unchanged raw chunk size, so the rebuilt structure is byte-for-byte the
6938/// same length as the original; this is required to match exactly, which rejects a
6939/// scattered or differently-laid-out (e.g. C-written) index, leaving the caller
6940/// to relocate. Single-chunk (size in the layout message) and B-tree-v1 (no
6941/// writer) indexes are not rebuilt here.
6942///
6943/// Like any in-place value overwrite (the HDF5 `H5Dwrite` model) this is not
6944/// atomic: a crash mid-write can tear the index and leave the dataset needing a
6945/// rewrite. It is used only on the in-place path, whose linearization point is the
6946/// synced data write.
6947fn try_rebuild_index_in_place<S: Source + ?Sized>(
6948 src: &S,
6949 layout: &DataLayout,
6950 raw_size: u64,
6951 grid_order: &[crate::chunked_read::ChunkInfo],
6952 new_bytes: &[Vec<u8>],
6953) -> Option<(usize, Vec<u8>)> {
6954 let DataLayout::Chunked {
6955 btree_address: Some(index_addr),
6956 chunk_index_type,
6957 version,
6958 ..
6959 } = layout
6960 else {
6961 return None;
6962 };
6963 let written: Vec<crate::chunked_write::WrittenChunk> = grid_order
6964 .iter()
6965 .zip(new_bytes)
6966 .map(|(ci, b)| crate::chunked_write::WrittenChunk {
6967 address: ci.address,
6968 compressed_size: b.len() as u64,
6969 raw_size,
6970 filter_mask: 0,
6971 })
6972 .collect();
6973 let new_index = match (version, chunk_index_type) {
6974 (4, Some(3)) => crate::chunked_write::build_fixed_array_at(
6975 &written,
6976 OFFSET_SIZE,
6977 LENGTH_SIZE,
6978 true,
6979 *index_addr,
6980 ),
6981 (4, Some(4)) => crate::chunked_write::build_extensible_array_at(
6982 &written,
6983 OFFSET_SIZE,
6984 LENGTH_SIZE,
6985 true,
6986 *index_addr,
6987 )
6988 .ok()?,
6989 // Single-chunk records its size in the layout message (a header rewrite),
6990 // and a B-tree-v1 index has no writer; both relocate instead.
6991 _ => return None,
6992 };
6993
6994 // The on-disk index must be a single contiguous region starting at the index
6995 // address, and the rebuilt structure must be exactly the same length (true for
6996 // an index this crate wrote). A scattered or different on-disk layout fails
6997 // the check and the caller relocates.
6998 let mut spans =
6999 crate::chunked_read::chunk_index_spans_from_source(src, layout, OFFSET_SIZE, LENGTH_SIZE)
7000 .ok()?;
7001 if spans.is_empty() {
7002 return None;
7003 }
7004 spans.sort_unstable_by_key(|&(a, _)| a);
7005 if spans[0].0 != *index_addr {
7006 return None;
7007 }
7008 let mut end = *index_addr;
7009 for &(a, l) in &spans {
7010 if a != end {
7011 return None; // a gap means the index is not contiguous
7012 }
7013 end = a.checked_add(l)?;
7014 }
7015 if new_index.len() as u64 != end - *index_addr {
7016 return None;
7017 }
7018 let start = usize::try_from(*index_addr).ok()?;
7019 start
7020 .checked_add(new_index.len())
7021 .filter(|&e| e as u64 <= src.len())?;
7022 Some((start, new_index))
7023}
7024
7025/// A [`ChunkProvider`] over chunk bytes already held in memory, in dense
7026/// row-major grid order. Used by the editor's chunked copy and relocating
7027/// overwrite, which own each chunk's bytes (a [`CopyTree`] or [`MovingWrite`]
7028/// captured them) rather than streaming from a source file like repack.
7029struct SliceChunkProvider<'a> {
7030 chunks: &'a [Vec<u8>],
7031}
7032
7033impl ChunkProvider for SliceChunkProvider<'_> {
7034 fn chunk_bytes(&self, index: usize, out: &mut Vec<u8>) -> Result<(), FormatError> {
7035 let chunk = self.chunks.get(index).ok_or_else(|| {
7036 FormatError::ChunkedReadError("chunk index out of range for in-memory provider".into())
7037 })?;
7038 out.extend_from_slice(chunk);
7039 Ok(())
7040 }
7041}
7042
7043fn region_message(msg_type: MessageType, body: &[u8]) -> Vec<u8> {
7044 let mut m = Vec::with_capacity(4 + body.len());
7045 #[expect(
7046 clippy::cast_possible_truncation,
7047 reason = "message type ids are a small enum that fits the 1-byte v2 type field"
7048 )]
7049 m.push(msg_type.to_u16() as u8);
7050 #[expect(
7051 clippy::cast_possible_truncation,
7052 reason = "callers pass bodies that fit the 2-byte message-size field (see doc comment)"
7053 )]
7054 m.extend_from_slice(&(body.len() as u16).to_le_bytes());
7055 m.push(0); // message flags
7056 m.extend_from_slice(body);
7057 m
7058}
7059
7060/// The chunk-0 message region of a fresh, empty compact-link group: a LinkInfo
7061/// message advertising no dense storage, followed by a GroupInfo message.
7062/// Mirrors `build_group_oh`.
7063fn fresh_group_region() -> Vec<u8> {
7064 let mut li = Vec::with_capacity(18);
7065 li.push(0); // version
7066 li.push(0); // flags
7067 li.extend_from_slice(&u64::MAX.to_le_bytes()); // fractal heap addr = UNDEF
7068 li.extend_from_slice(&u64::MAX.to_le_bytes()); // btree name index addr = UNDEF
7069 let mut region = region_message(MessageType::LinkInfo, &li);
7070 region.extend_from_slice(®ion_message(MessageType::GroupInfo, &GROUP_INFO_BODY));
7071 region
7072}
7073
7074/// Ensure a group's chunk-0 message `region` carries a Group Info message,
7075/// appending a minimal one when absent.
7076///
7077/// The HDF5 C library refuses to insert a link into a group whose object header
7078/// has a Link Info message but no Group Info message: on the new-format path
7079/// `H5G_obj_insert` reads the Group Info message unconditionally and fails with
7080/// "message type not found". Such a group round-trips for *reading* but cannot
7081/// be *modified* by the C library. Earlier hdf5-pure releases wrote groups that
7082/// way, so heal any such header whenever we rewrite one in place.
7083fn ensure_group_info(region: &mut Vec<u8>) -> Result<(), Error> {
7084 let mut p = 0;
7085 while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
7086 if msg_type == MessageType::GroupInfo {
7087 return Ok(());
7088 }
7089 p = body_end;
7090 }
7091 region.extend_from_slice(®ion_message(MessageType::GroupInfo, &GROUP_INFO_BODY));
7092 Ok(())
7093}
7094
7095/// Encode a complete object-header Link message (4-byte record header + body)
7096/// for a hard link `name -> addr`. The caller must have validated that the body
7097/// fits the u16 size field (see [`flatten_dataset`]); group names are short.
7098fn encode_link_message(name: &str, addr: u64) -> Vec<u8> {
7099 let body = make_link(name, addr).serialize(OFFSET_SIZE);
7100 region_message(MessageType::Link, &body)
7101}
7102
7103/// Patch an existing hard Link message in a chunk-0 message `region`, retargeting
7104/// the link named `name` to `new_addr` (used to repoint a parent at a relocated
7105/// child group). The target address is the trailing `OFFSET_SIZE` bytes of the
7106/// link body for a hard link.
7107fn patch_link_target(region: &mut [u8], name: &str, new_addr: u64) -> Result<(), Error> {
7108 let mut p = 0;
7109 while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7110 if msg_type == MessageType::Link {
7111 if let Ok(link) = LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
7112 if link.name == name {
7113 return match link.link_target {
7114 LinkTarget::Hard { .. } => {
7115 let ofs = body_end - OFFSET_SIZE as usize;
7116 region[ofs..body_end].copy_from_slice(&new_addr.to_le_bytes());
7117 Ok(())
7118 }
7119 _ => Err(Error::EditUnsupported(
7120 "a group on the edited path is reached by a soft/external link",
7121 )),
7122 };
7123 }
7124 }
7125 }
7126 p = body_end;
7127 }
7128 Err(Error::EditUnsupported(
7129 "expected child link not found in parent group",
7130 ))
7131}
7132
7133/// Bytes a compact Data Layout message carries ahead of its inline data:
7134/// version(1) + class(1) + the 2-byte inline size.
7135const COMPACT_LAYOUT_PREAMBLE: usize = 4;
7136
7137/// Copy a chunk-0 message `region`, replacing the single (compact) Data Layout
7138/// message's inline data with `raw` and preserving every other message verbatim.
7139/// Used by `write_dataset` to overwrite a compact dataset's values. The message
7140/// header (type and flags) and version byte are kept; only the inline data — and
7141/// the message size and 2-byte inline-size fields — change. `raw` must fit both
7142/// the compact layout's own 2-byte size field (HDF5's 64 KiB compact-storage
7143/// limit) and, once the 4-byte layout preamble is added, the object header's
7144/// 2-byte message-size field — the tighter of the two, which an overwrite of an
7145/// existing compact dataset always satisfies.
7146fn rebuild_compact_layout_region(region: &[u8], raw: &[u8]) -> Result<Vec<u8>, Error> {
7147 // The bound is on the *message body* the layout becomes — version, class,
7148 // and the 2-byte inline size ahead of the data — not on `raw` alone, or the
7149 // last four lengths below the limit would truncate the size field written
7150 // for them.
7151 if raw.len() > OBJECT_HEADER_MESSAGE_MAX - COMPACT_LAYOUT_PREAMBLE {
7152 return Err(Error::EditUnsupported(
7153 "compact dataset data is too large to overwrite in place",
7154 ));
7155 }
7156 let mut out = Vec::with_capacity(region.len() + raw.len());
7157 let mut p = 0;
7158 let mut replaced = false;
7159 while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7160 if msg_type == MessageType::DataLayout {
7161 if body_end - body < 2 || region[body + 1] != 0 {
7162 return Err(Error::EditUnsupported(
7163 "compact-layout overwrite found a non-compact data layout",
7164 ));
7165 }
7166 // New compact layout body: version (kept), class=0, 2-byte inline
7167 // size, then the data.
7168 let mut layout = Vec::with_capacity(COMPACT_LAYOUT_PREAMBLE + raw.len());
7169 layout.push(region[body]); // version (3 or 4)
7170 layout.push(0); // class = compact
7171 #[expect(
7172 clippy::cast_possible_truncation,
7173 reason = "raw.len() bounded below the u16 inline-size field above"
7174 )]
7175 layout.extend_from_slice(&(raw.len() as u16).to_le_bytes());
7176 layout.extend_from_slice(raw);
7177 // Message record: type byte, 2-byte size (LE), flags byte (kept).
7178 out.push(region[p]);
7179 #[expect(
7180 clippy::cast_possible_truncation,
7181 reason = "the guard above bounds COMPACT_LAYOUT_PREAMBLE + raw.len(), this \
7182 body's exact length, to the 2-byte message-size field"
7183 )]
7184 out.extend_from_slice(&(layout.len() as u16).to_le_bytes());
7185 out.push(region[p + 3]);
7186 out.extend_from_slice(&layout);
7187 replaced = true;
7188 } else {
7189 out.extend_from_slice(®ion[p..body_end]);
7190 }
7191 p = body_end;
7192 }
7193 if p < region.len() {
7194 out.extend_from_slice(®ion[p..]);
7195 }
7196 if !replaced {
7197 return Err(Error::EditUnsupported(
7198 "compact dataset header has no data-layout message",
7199 ));
7200 }
7201 Ok(out)
7202}
7203
7204/// Copy a chunk-0 message `region`, dropping the single Link message named
7205/// `name` and preserving every other message verbatim (used by `delete`). Errors
7206/// if no such link is present.
7207fn remove_link_from_region(region: &[u8], name: &str) -> Result<Vec<u8>, Error> {
7208 let mut out = Vec::with_capacity(region.len());
7209 let mut p = 0;
7210 let mut removed = false;
7211 while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7212 let mut skip = false;
7213 if msg_type == MessageType::Link {
7214 if let Ok(link) = LinkMessage::parse(®ion[body..body_end], OFFSET_SIZE) {
7215 if link.name == name {
7216 skip = true;
7217 removed = true;
7218 }
7219 }
7220 }
7221 if !skip {
7222 out.extend_from_slice(®ion[p..body_end]);
7223 }
7224 p = body_end;
7225 }
7226 if p < region.len() {
7227 out.extend_from_slice(®ion[p..]);
7228 }
7229 if !removed {
7230 return Err(Error::EditUnsupported(
7231 "link to delete not found in its parent group",
7232 ));
7233 }
7234 Ok(out)
7235}
7236
7237/// Apply compact attribute edits to a group message `region`, preserving every
7238/// non-attribute message verbatim. A fixed-size `Set`/`Remove` is resolved
7239/// into `region` directly; a variable-length `Set` (`VarLenAsciiArray`) is
7240/// instead collected into the returned `pending_vl_attrs` — its placeholder
7241/// heap address is only patched, and the message appended to the group's
7242/// header, by the apply loop once its global heap collection's real address
7243/// is known (see [`WriteEngine::place_vl_collection`]). A later op for the
7244/// same name (another `Set`, fixed-size or not, or a `Remove`) replaces or
7245/// cancels an earlier still-pending variable-length entry, keeping the net
7246/// effect the same regardless of op order within one commit. `region`'s
7247/// fixed-size portion is a complete compact-attribute header on return; dense
7248/// attribute storage and shared attribute messages are refused.
7249fn apply_group_attr_ops(region: &[u8], ops: &[AttrOp]) -> Result<(Vec<u8>, PendingVlAttrs), Error> {
7250 let mut out = region.to_vec();
7251 let mut pending_vl: PendingVlAttrs = Vec::new();
7252 let mut wrote_attr = false;
7253 for op in ops {
7254 match op {
7255 AttrOp::Set { name, value } => {
7256 wrote_attr = true;
7257 pending_vl.retain(|(msg, _)| &msg.name != name);
7258 if let AttrValue::VarLenAsciiArray(strings) = value {
7259 // Nothing yet to remove from `region` if this name has
7260 // never been set as a fixed-size attribute.
7261 out = remove_attr_from_region(&out, name, false)?;
7262 let msg = build_attr_message(name, value);
7263 if msg.serialize(LENGTH_SIZE).len() > OBJECT_HEADER_MESSAGE_MAX {
7264 return Err(Error::EditUnsupported(
7265 "attribute is too large to encode in place",
7266 ));
7267 }
7268 let str_refs: Vec<&str> = strings.iter().map(String::as_str).collect();
7269 pending_vl.push((msg, build_global_heap_collections(&str_refs)));
7270 } else {
7271 out = set_attr_in_region(&out, name, value)?;
7272 }
7273 }
7274 AttrOp::Remove { name } => {
7275 let before = pending_vl.len();
7276 pending_vl.retain(|(msg, _)| &msg.name != name);
7277 if pending_vl.len() == before {
7278 out = remove_attr_from_region(&out, name, true)?;
7279 }
7280 }
7281 }
7282 }
7283 if wrote_attr && compact_attr_count(&out)? + pending_vl.len() > MAX_COMPACT_ATTRS {
7284 return Err(Error::EditUnsupported(
7285 "attributes would exceed compact storage; dense attribute edits are not supported in place yet",
7286 ));
7287 }
7288 Ok((out, pending_vl))
7289}
7290
7291/// Whether an Attribute Info (0x0015) message body denotes *dense* (fractal-heap)
7292/// attribute storage — a *defined* heap address. The reference C library and h5py
7293/// emit an Attribute Info message with an *undefined* heap address even for
7294/// compact, inline attributes in the latest format (to carry creation-order
7295/// metadata), so its mere presence is not dense storage; only a defined heap
7296/// address is. An unparseable message is treated as dense (refused conservatively).
7297/// Mirrors the copy path's dense detection so the compact-attribute editors accept
7298/// the undefined-address message that nearly every real-world object carries.
7299fn attribute_info_is_dense(body: &[u8]) -> bool {
7300 match crate::attribute_info::AttributeInfoMessage::parse(body, OFFSET_SIZE) {
7301 Ok(ai) => ai.fractal_heap_address.is_some(),
7302 Err(_) => true,
7303 }
7304}
7305
7306/// Copy a message region, dropping all Attribute messages named `name` and then
7307/// appending a fresh compact Attribute message for `value`.
7308fn set_attr_in_region(region: &[u8], name: &str, value: &AttrValue) -> Result<Vec<u8>, Error> {
7309 let new_msg = encode_attr_message(name, value)?;
7310 let mut out = Vec::with_capacity(region.len() + new_msg.len());
7311 let mut p = 0;
7312 while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7313 match msg_type {
7314 MessageType::AttributeInfo => {
7315 if attribute_info_is_dense(®ion[body..body_end]) {
7316 return Err(Error::EditUnsupported(
7317 "a target object uses dense (fractal-heap) attribute storage (not supported in place yet)",
7318 ));
7319 }
7320 // An undefined-heap Attribute Info message is creation-order
7321 // metadata, not dense storage; preserve it verbatim (fall through
7322 // to copy the message below).
7323 }
7324 MessageType::Attribute => {
7325 let attr_name = parse_compact_attr_name(region, p, body, body_end)?;
7326 if attr_name == name {
7327 p = body_end;
7328 continue;
7329 }
7330 }
7331 _ => {}
7332 }
7333 out.extend_from_slice(®ion[p..body_end]);
7334 p = body_end;
7335 }
7336 out.extend_from_slice(&new_msg);
7337 if p < region.len() {
7338 out.extend_from_slice(®ion[p..]);
7339 }
7340 Ok(out)
7341}
7342
7343/// Copy a message region, dropping all Attribute messages named `name`. When
7344/// `required` is true, an absent `name` is an [`Error::EditUnsupported`] (a
7345/// `Remove` of a nonexistent attribute); when false, it is not an error (a
7346/// `Set` of a fresh variable-length attribute may have no fixed-size message
7347/// to remove from the region yet).
7348fn remove_attr_from_region(region: &[u8], name: &str, required: bool) -> Result<Vec<u8>, Error> {
7349 let mut out = Vec::with_capacity(region.len());
7350 let mut p = 0;
7351 let mut removed = false;
7352 while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7353 let mut skip = false;
7354 match msg_type {
7355 MessageType::AttributeInfo => {
7356 if attribute_info_is_dense(®ion[body..body_end]) {
7357 return Err(Error::EditUnsupported(
7358 "a target object uses dense (fractal-heap) attribute storage (not supported in place yet)",
7359 ));
7360 }
7361 // An undefined-heap Attribute Info message is creation-order
7362 // metadata, not dense storage; preserve it verbatim.
7363 }
7364 MessageType::Attribute => {
7365 let attr_name = parse_compact_attr_name(region, p, body, body_end)?;
7366 if attr_name == name {
7367 skip = true;
7368 removed = true;
7369 }
7370 }
7371 _ => {}
7372 }
7373 if !skip {
7374 out.extend_from_slice(®ion[p..body_end]);
7375 }
7376 p = body_end;
7377 }
7378 if p < region.len() {
7379 out.extend_from_slice(®ion[p..]);
7380 }
7381 if !removed && required {
7382 return Err(Error::EditUnsupported("attribute to remove was not found"));
7383 }
7384 Ok(out)
7385}
7386
7387fn compact_attr_count(region: &[u8]) -> Result<usize, Error> {
7388 let mut count = 0usize;
7389 let mut p = 0;
7390 while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7391 if msg_type == MessageType::AttributeInfo
7392 && attribute_info_is_dense(®ion[body..body_end])
7393 {
7394 return Err(Error::EditUnsupported(
7395 "a target object uses dense (fractal-heap) attribute storage (not supported in place yet)",
7396 ));
7397 }
7398 if msg_type == MessageType::Attribute {
7399 count += 1;
7400 }
7401 p = body_end;
7402 }
7403 Ok(count)
7404}
7405
7406fn parse_compact_attr_name(
7407 region: &[u8],
7408 msg_start: usize,
7409 body: usize,
7410 body_end: usize,
7411) -> Result<String, Error> {
7412 if region[msg_start + 3] != 0 {
7413 return Err(Error::EditUnsupported(
7414 "a target object has a shared attribute message (not editable in place yet)",
7415 ));
7416 }
7417 crate::attribute::AttributeMessage::parse(®ion[body..body_end], LENGTH_SIZE)
7418 .map(|attr| attr.name)
7419 .map_err(|_| Error::EditUnsupported("a target object has an unreadable attribute message"))
7420}
7421
7422fn encode_attr_message(name: &str, value: &AttrValue) -> Result<Vec<u8>, Error> {
7423 // `apply_group_attr_ops`'s `Set` branch — this function's only caller —
7424 // handles `VarLenAsciiArray` itself (staging it into `pending_vl` instead
7425 // of calling `set_attr_in_region`/here), so this value is always
7426 // fixed-size by construction, not by a check made at this call site.
7427 debug_assert!(
7428 !matches!(value, AttrValue::VarLenAsciiArray(_)),
7429 "VarLenAsciiArray must be intercepted by apply_group_attr_ops before reaching encode_attr_message"
7430 );
7431 let body = build_attr_message(name, value).serialize(LENGTH_SIZE);
7432 if body.len() > OBJECT_HEADER_MESSAGE_MAX {
7433 return Err(Error::EditUnsupported(
7434 "group attribute is too large to encode in place",
7435 ));
7436 }
7437 Ok(region_message(MessageType::Attribute, &body))
7438}
7439
7440/// Whether `a` is a path prefix of (or equal to) `b`.
7441fn is_prefix(a: &[String], b: &[String]) -> bool {
7442 a.len() <= b.len() && b[..a.len()] == *a
7443}
7444
7445/// Parse the version-2 object-header message record at `p` within a chunk-0
7446/// message region, returning `(message type, body start, body end)`; the next
7447/// record begins at `body end`. Returns `Ok(None)` once fewer than 4 bytes
7448/// remain (a clean end of the region), and `Err` if a record's declared body
7449/// runs past the region. Centralizes the bounds check shared by every walker.
7450/// Rebuild a superblock-extension object header's message region (as collapsed by
7451/// [`WriteEngine::gather_oh_messages`]) with
7452/// its File Space Info message replaced by `info`, preserving every other message
7453/// verbatim. The persisting message is fixed-size, so the region length is stable.
7454/// Shared by the whole-file mirror commit and the bounded finalize so both write
7455/// the same extension bytes.
7456pub(crate) fn rewrite_extension_region_bytes(
7457 region: &[u8],
7458 info: &FileSpaceInfo,
7459) -> Result<Vec<u8>, Error> {
7460 let new_body = info.serialize();
7461 // The message body is the fixed-size File Space Info record (≤ 125 bytes),
7462 // so it always fits the u16 size field; `try_from` keeps this off the
7463 // 32-bit narrowing-cast ledger.
7464 let new_len = u16::try_from(new_body.len())
7465 .map_err(|_| Error::EditUnsupported("File Space Info message too large"))?;
7466 let mut out = Vec::with_capacity(region.len());
7467 let mut p = 0;
7468 let mut replaced = false;
7469 while let Some((msg_type, _body, body_end)) = next_message(region, p)? {
7470 if msg_type == MessageType::FileSpaceInfo {
7471 out.push(region[p]); // message type byte
7472 out.extend_from_slice(&new_len.to_le_bytes());
7473 out.push(region[p + 3]); // preserve the message flags (0x14)
7474 out.extend_from_slice(&new_body);
7475 replaced = true;
7476 } else {
7477 out.extend_from_slice(®ion[p..body_end]);
7478 }
7479 p = body_end;
7480 }
7481 if !replaced {
7482 // Persistence is armed only when the extension already carries a File
7483 // Space Info message, so this is unreachable; refuse rather than
7484 // silently restructure an extension we did not understand.
7485 return Err(Error::EditUnsupported(
7486 "a persisting file's superblock extension has no File Space Info message",
7487 ));
7488 }
7489 Ok(out)
7490}
7491
7492/// Parse and validate a version 2 object header's prefix, returning the absolute
7493/// `[start, end)` byte range of its chunk-0 message region.
7494///
7495/// `prefix` holds the bytes at `[addr, addr + prefix.len())` — up to
7496/// [`OH_PREFIX_MAX`], fewer when the header sits near the end of the image — and
7497/// `file_len` is the length of the image the header lives in, which bounds the
7498/// region. Rejects headers that are not OHDR v2 and headers that track message
7499/// creation order, whose 6-byte message records this engine does not emit.
7500fn oh_region_at(prefix: &[u8], addr: u64, file_len: u64) -> Result<(u64, u64), Error> {
7501 if prefix.len() < 6 || &prefix[..4] != b"OHDR" || prefix[4] != 2 {
7502 return Err(Error::EditUnsupported(
7503 "an object does not use a version 2 object header",
7504 ));
7505 }
7506 let flags = prefix[5];
7507 if flags & 0x04 != 0 {
7508 return Err(Error::EditUnsupported(
7509 "an object tracks message creation order (not supported in place yet)",
7510 ));
7511 }
7512 let mut pos = 6usize;
7513 if flags & 0x20 != 0 {
7514 pos += 16; // optional timestamps
7515 }
7516 if flags & 0x10 != 0 {
7517 pos += 4; // optional attribute phase-change thresholds
7518 }
7519 let size_width = match flags & 0x03 {
7520 0 => 1usize,
7521 1 => 2,
7522 2 => 4,
7523 _ => 8,
7524 };
7525 if prefix.len() < pos + size_width {
7526 return Err(Error::EditUnsupported("truncated object header"));
7527 }
7528 let chunk0_size = read_le(&prefix[pos..pos + size_width]) as u64;
7529 pos += size_width;
7530 let region_start = addr
7531 .checked_add(pos as u64)
7532 .ok_or(Error::EditUnsupported("truncated object header"))?;
7533 // The region is followed by a 4-byte checksum, which must also be present.
7534 let region_end = region_start
7535 .checked_add(chunk0_size)
7536 .filter(|e| e.checked_add(4).is_some_and(|end| end <= file_len))
7537 .ok_or(Error::EditUnsupported("truncated object header"))?;
7538 Ok((region_start, region_end))
7539}
7540
7541/// One chunk of a version 2 object header, read out of a file image.
7542///
7543/// The buffer stops at the end of the chunk's message region: the trailing
7544/// checksum is never walked, and it has already been confirmed present. `span`
7545/// covers the *whole* on-disk chunk including that checksum, so it can be handed
7546/// to the free list when the header is reclaimed.
7547struct OhChunk {
7548 /// Absolute file address and full on-disk length of the chunk.
7549 span: (u64, u64),
7550 /// The chunk's bytes, from `span.0` through the end of its message region.
7551 buf: Vec<u8>,
7552 /// Offset of the first message within [`buf`](Self::buf).
7553 messages_start: usize,
7554}
7555
7556impl OhChunk {
7557 /// The slice to walk messages in, and the offset to start at. The two are
7558 /// returned together because [`next_message`] must not read past the end of
7559 /// the message region into the checksum.
7560 fn message_region(&self) -> (&[u8], usize) {
7561 (&self.buf, self.messages_start)
7562 }
7563}
7564
7565/// Read chunk 0 of the version 2 object header at `addr` out of `src`.
7566fn read_oh_chunk0<S: Source + ?Sized>(src: &S, addr: u64) -> Result<OhChunk, Error> {
7567 let file_len = src.len();
7568 let window = file_len
7569 .saturating_sub(addr)
7570 .min(OH_PREFIX_MAX as u64)
7571 .to_usize()?;
7572 let prefix = src.read_metadata_at(addr, window)?;
7573 let (rs, re) = oh_region_at(&prefix, addr, file_len)?;
7574 // `re >= rs > addr`, so both differences are non-negative, and `oh_region_at`
7575 // has checked that the 4-byte checksum past `re` is present.
7576 let len = (re - addr).to_usize()?;
7577 Ok(OhChunk {
7578 span: (addr, len as u64 + 4),
7579 buf: src.read_metadata_at(addr, len)?,
7580 messages_start: (rs - addr).to_usize()?,
7581 })
7582}
7583
7584/// Read every chunk of the version 2 object header at `addr`, chunk 0 first,
7585/// following each `Continuation` message to its `OCHK` block.
7586///
7587/// This is the one traversal of a header's chunk chain: [`gather_oh_messages`]
7588/// collects the messages out of the result and
7589/// [`oh_chunk_spans`](WriteEngine::oh_chunk_spans) collects the extents, so the
7590/// two cannot disagree about what a header occupies.
7591fn read_oh_chunks<S: Source + ?Sized>(
7592 src: &S,
7593 addr: u64,
7594 base: u64,
7595) -> Result<Vec<OhChunk>, Error> {
7596 let mut chunks = vec![read_oh_chunk0(src, addr)?];
7597 let mut i = 0;
7598 while i < chunks.len() {
7599 if chunks.len() > MAX_OH_CHUNKS {
7600 return Err(Error::EditUnsupported(
7601 "object header has too many continuation chunks",
7602 ));
7603 }
7604 // Collect this chunk's continuations before extending the worklist, so the
7605 // borrow of `chunks[i]` ends first.
7606 let mut found = Vec::new();
7607 let (region, mut p) = chunks[i].message_region();
7608 while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7609 if msg_type == MessageType::ObjectHeaderContinuation {
7610 found.push(read_oh_continuation(src, region, body, body_end, base)?);
7611 }
7612 p = body_end;
7613 }
7614 i += 1;
7615 chunks.extend(found);
7616 }
7617 Ok(chunks)
7618}
7619
7620/// Read the `OCHK` continuation block a continuation message points at.
7621///
7622/// `region[body..body_end]` is the continuation message's body: the block's
7623/// base-relative address followed by its length.
7624fn read_oh_continuation<S: Source + ?Sized>(
7625 src: &S,
7626 region: &[u8],
7627 body: usize,
7628 body_end: usize,
7629 base: u64,
7630) -> Result<OhChunk, Error> {
7631 if body_end - body < (OFFSET_SIZE + LENGTH_SIZE) as usize {
7632 return Err(Error::EditUnsupported("malformed continuation message"));
7633 }
7634 let off = u64::from_le_bytes(region[body..body + 8].try_into().unwrap());
7635 let len = u64::from_le_bytes(region[body + 8..body + 16].try_into().unwrap());
7636 // The block address is stored relative to the base address; shift it to an
7637 // absolute file offset before reading.
7638 let off = off
7639 .checked_add(base)
7640 .ok_or(Error::EditUnsupported("continuation address overflow"))?;
7641 // An OCHK block is signature(4) + messages + checksum(4).
7642 let end = off
7643 .checked_add(len)
7644 .filter(|&e| e <= src.len() && len >= 8)
7645 .ok_or(Error::EditUnsupported("continuation block out of bounds"))?;
7646 let want = (end - off)
7647 .to_usize()
7648 .map_err(|_| Error::EditUnsupported("continuation length exceeds this platform"))?;
7649 let mut buf = src.read_metadata_at(off, want)?;
7650 if buf[..4] != *b"OCHK" {
7651 return Err(Error::EditUnsupported(
7652 "invalid continuation block signature",
7653 ));
7654 }
7655 // Trim the trailing checksum so the message walk stops at the last message.
7656 buf.truncate(want - 4);
7657 Ok(OhChunk {
7658 span: (off, len),
7659 buf,
7660 messages_start: 4,
7661 })
7662}
7663
7664pub(crate) fn next_message(
7665 region: &[u8],
7666 p: usize,
7667) -> Result<Option<(MessageType, usize, usize)>, Error> {
7668 if p + 4 > region.len() {
7669 return Ok(None);
7670 }
7671 let msg_type = MessageType::from_u16(region[p] as u16);
7672 let msg_size = u16::from_le_bytes([region[p + 1], region[p + 2]]) as usize;
7673 let body = p + 4;
7674 let body_end = body + msg_size;
7675 if body_end > region.len() {
7676 return Err(Error::EditUnsupported("malformed object header message"));
7677 }
7678 Ok(Some((msg_type, body, body_end)))
7679}
7680
7681/// Version-2 object-header message flag bit marking a message as *shared* (stored
7682/// once in the shared-message table and referenced by an object-header address or
7683/// fractal-heap id) rather than inline. Whatever the message type, that reference
7684/// points into the source file and is meaningless after a cross-file copy.
7685const MSG_FLAG_SHARED: u8 = 0x02;
7686
7687/// Refuse to copy an object whose header embeds a *source-file* absolute address
7688/// that a verbatim copy into another file cannot translate. An in-file copy keeps
7689/// these valid by sharing the source file's heaps and objects; a cross-file copy
7690/// cannot. Three things qualify:
7691///
7692/// - a **variable-length** datatype, whose element bytes are global-heap
7693/// references (collection address + index) into the source file's heap;
7694/// - a **reference** datatype (object or dataset-region), whose element bytes are
7695/// absolute object addresses in the source file;
7696/// - any **shared message** (the `MSG_FLAG_SHARED` bit set) — a committed datatype,
7697/// but also a shared dataspace, fill value, or filter-pipeline message — whose
7698/// body is a reference into the source file's shared-message storage.
7699///
7700/// The scan covers a copied object's whole message region (a dataset's or a
7701/// group's): it refuses any shared message outright, and inspects Datatype
7702/// messages (the element type) and Attribute messages (their own datatype),
7703/// recursing through compound members, array elements, and enumeration bases so a
7704/// nested variable-length or reference occurrence is caught too. It is applied
7705/// only on the cross-file path; the same-file [`copy`](crate::File::copy)
7706/// deliberately keeps these forms (their addresses stay valid in one file).
7707fn reject_foreign_addresses(region: &[u8]) -> Result<(), Error> {
7708 let mut p = 0;
7709 while let Some((msg_type, body, body_end)) = next_message(region, p)? {
7710 // A *shared* message stores, in place of its real body, a reference into
7711 // the source file's shared-message storage — an object-header address or a
7712 // fractal-heap (SOHM) id — which means nothing in another file. This
7713 // catches committed (shared) datatypes and shared attributes as well as a
7714 // shared dataspace, fill value, or filter-pipeline message, all of which
7715 // HDF5 may place in the shared-message table. Refuse any of them, whatever
7716 // the message type. The flags byte is the 4th of the record header (type,
7717 // size, flags); `next_message` returning `Some` guarantees
7718 // `p + 4 <= region.len()`.
7719 if region[p + 3] & MSG_FLAG_SHARED != 0 {
7720 return Err(Error::EditUnsupported(
7721 "a shared (committed/SOHM) object-header message cannot be copied to another file yet",
7722 ));
7723 }
7724 match msg_type {
7725 MessageType::Datatype => {
7726 let (dt, _) =
7727 crate::datatype::Datatype::parse(®ion[body..body_end]).map_err(|_| {
7728 Error::EditUnsupported("a source datatype could not be parsed for copying")
7729 })?;
7730 if datatype_copies_foreign_address(&dt) {
7731 return Err(Error::EditUnsupported(
7732 "variable-length or reference datasets cannot be copied to another file yet",
7733 ));
7734 }
7735 }
7736 MessageType::Attribute => {
7737 let attr =
7738 crate::attribute::AttributeMessage::parse(®ion[body..body_end], LENGTH_SIZE)
7739 .map_err(|_| {
7740 Error::EditUnsupported(
7741 "a source attribute could not be parsed for copying",
7742 )
7743 })?;
7744 if datatype_copies_foreign_address(&attr.datatype) {
7745 return Err(Error::EditUnsupported(
7746 "variable-length or reference attributes cannot be copied to another file yet",
7747 ));
7748 }
7749 }
7750 _ => {}
7751 }
7752 p = body_end;
7753 }
7754 Ok(())
7755}
7756
7757/// Cross-file screen for a dense (fractal-heap) attribute set. The bytes parsed
7758/// out of the source heap can embed source-file absolute addresses just as inline
7759/// attribute messages can — variable-length (global-heap) or reference attribute
7760/// data — which would dangle in another file. [`reject_foreign_addresses`] screens
7761/// the verbatim object-header region but not heap-resident attribute bytes, so a
7762/// dense attribute set is screened here instead. Same-file copies skip this (their
7763/// addresses stay valid); the fresh heap built on write is same-file by
7764/// construction, so only the source datatypes matter.
7765fn reject_foreign_dense_attrs(attrs: &[crate::attribute::AttributeMessage]) -> Result<(), Error> {
7766 for attr in attrs {
7767 if datatype_copies_foreign_address(&attr.datatype) {
7768 return Err(Error::EditUnsupported(
7769 "variable-length or reference dense (fractal-heap) attributes cannot be copied to another file yet",
7770 ));
7771 }
7772 }
7773 Ok(())
7774}
7775
7776/// Whether `dt` stores, anywhere in its structure, a value that is a source-file
7777/// absolute address: a variable-length (global-heap) or reference datatype, or a
7778/// compound / array / enumeration built over one. See [`reject_foreign_addresses`].
7779fn datatype_copies_foreign_address(dt: &crate::datatype::Datatype) -> bool {
7780 use crate::datatype::Datatype;
7781 match dt {
7782 Datatype::VariableLength { .. } | Datatype::Reference { .. } => true,
7783 Datatype::Compound { members, .. } => members
7784 .iter()
7785 .any(|m| datatype_copies_foreign_address(&m.datatype)),
7786 Datatype::Array { base_type, .. } | Datatype::Enumeration { base_type, .. } => {
7787 datatype_copies_foreign_address(base_type)
7788 }
7789 _ => false,
7790 }
7791}
7792
7793/// Wrap a chunk-0 message region in a fresh single-chunk version 2 object header
7794/// (`OHDR` prefix + region + Jenkins checksum). Mirrors the encoding in
7795/// [`crate::object_header_writer::ObjectHeaderWriter::serialize`].
7796pub(crate) fn build_v2_object_header(region: &[u8]) -> Vec<u8> {
7797 let total = region.len();
7798 let (flags, width) = if total <= 255 {
7799 (0u8, 1usize)
7800 } else if total <= 65535 {
7801 (1u8, 2)
7802 } else {
7803 (2u8, 4)
7804 };
7805 let mut buf = Vec::with_capacity(8 + total + 4);
7806 buf.extend_from_slice(b"OHDR");
7807 buf.push(2); // version
7808 buf.push(flags);
7809 #[expect(
7810 clippy::cast_possible_truncation,
7811 reason = "width was selected just above to be the smallest field that holds total"
7812 )]
7813 match width {
7814 1 => buf.push(total as u8),
7815 2 => buf.extend_from_slice(&(total as u16).to_le_bytes()),
7816 _ => buf.extend_from_slice(&(total as u32).to_le_bytes()),
7817 }
7818 buf.extend_from_slice(region);
7819 let checksum = jenkins_lookup3(&buf);
7820 buf.extend_from_slice(&checksum.to_le_bytes());
7821 buf
7822}
7823
7824/// Read a little-endian unsigned integer of `bytes.len()` (≤ 8) bytes.
7825#[expect(
7826 clippy::cast_possible_truncation,
7827 reason = "callers parse in-file sizes/offsets bounded by the in-memory image; downstream \
7828 slicing is length-checked, so a malformed oversized field errors rather than reads OOB"
7829)]
7830fn read_le(bytes: &[u8]) -> usize {
7831 let mut v = 0u64;
7832 for (i, &b) in bytes.iter().enumerate() {
7833 v |= (b as u64) << (8 * i);
7834 }
7835 v as usize
7836}
7837
7838#[cfg(test)]
7839mod tests {
7840 use super::*;
7841
7842 /// Collect the message types present in a chunk-0 region, in order.
7843 fn region_types(region: &[u8]) -> Vec<MessageType> {
7844 let mut out = Vec::new();
7845 let mut p = 0;
7846 while let Some((mt, _, end)) = next_message(region, p).unwrap() {
7847 out.push(mt);
7848 p = end;
7849 }
7850 out
7851 }
7852
7853 /// Stopping an in-place append (`append_inplace`) at any phase boundary must
7854 /// leave the file readable as a consistent prefix — the old length until the
7855 /// phase-4 dimension commit, the new length after it — even though a
7856 /// partial-tail append repoints the visible trailing element in place. Mirrors
7857 /// `Dataset::append`'s crash-consistency harness, but driven through
7858 /// the in-place edit engine's own mirror (disk-before-mirror ordering) to prove the shared
7859 /// engine is crash-safe under both owners. Two starting layouts: the trailing
7860 /// element inline in the index block (chunk 4, n 6), and in a data block
7861 /// (chunk 2, n 9, slot 0).
7862 #[test]
7863 fn append_inplace_crash_consistency_partial_tail_prefix() {
7864 use crate::reader::File as PureFile;
7865 use crate::writer::FileBuilder;
7866 use tempfile::tempdir;
7867
7868 let build = |path: &std::path::Path, n: i32, chunk: u64| {
7869 let data: Vec<i32> = (0..n).collect();
7870 let mut b = FileBuilder::new();
7871 b.create_dataset("d")
7872 .with_i32_data(&data)
7873 .with_shape(&[n as u64])
7874 .with_maxshape(&[u64::MAX])
7875 .with_chunks(&[chunk]);
7876 b.write(path).unwrap();
7877 };
7878
7879 for (n, chunk, add) in [(6i32, 4u64, 5i32), (9, 2, 6)] {
7880 let dir = tempdir().unwrap();
7881 let base = dir.path().join("base.h5");
7882 build(&base, n, chunk);
7883
7884 for max_phase in 1u8..=4 {
7885 let p = dir.path().join(format!("crash_{n}_{chunk}_{max_phase}.h5"));
7886 std::fs::copy(&base, &p).unwrap();
7887 {
7888 let mut s = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
7889 s.append_inplace_i32_phased("d", &(n..n + add).collect::<Vec<_>>(), max_phase)
7890 .unwrap();
7891 // session dropped here, simulating a crash after `max_phase`
7892 }
7893 let expected_len = if max_phase == 4 { n + add } else { n };
7894 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
7895 assert_eq!(
7896 f.dataset("d").unwrap().read_i32().unwrap(),
7897 (0..expected_len).collect::<Vec<_>>(),
7898 "inconsistent view after crash at phase {max_phase} (n={n}, chunk={chunk})"
7899 );
7900 }
7901 }
7902 }
7903
7904 /// Build a one-element-per-chunk unlimited `d` holding `0..n`, the shape the
7905 /// crash-consistency harnesses below grow.
7906 fn build_unit_chunked(path: &std::path::Path, n: i32) {
7907 use crate::writer::FileBuilder;
7908 let data: Vec<i32> = (0..n).collect();
7909 let mut b = FileBuilder::new();
7910 b.create_dataset("d")
7911 .with_i32_data(&data)
7912 .with_shape(&[n as u64])
7913 .with_maxshape(&[u64::MAX])
7914 .with_chunks(&[1]);
7915 b.write(path).unwrap();
7916 }
7917
7918 /// Stop an append after `max_phase` durability phases and hand back the
7919 /// resulting file. Dropping the engine inside is the simulated crash: no
7920 /// further phases run and no close barrier is written.
7921 fn append_stopped_at(
7922 base: &std::path::Path,
7923 out: &std::path::Path,
7924 values: std::ops::Range<i32>,
7925 max_phase: u8,
7926 ) {
7927 std::fs::copy(base, out).unwrap();
7928 let mut s = WriteEngine::open_with_locking(out, FileLocking::Enabled).unwrap();
7929 s.append_inplace_i32_phased("d", &values.collect::<Vec<_>>(), max_phase)
7930 .unwrap();
7931 }
7932
7933 /// Growing an Extensible-Array index across its inline -> direct-block ->
7934 /// super-block boundaries touches far more index structure than the
7935 /// partial-tail case above. Stopping at any phase boundary must still read
7936 /// back as a consistent prefix.
7937 ///
7938 /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
7939 /// owned path drives the same `apply_ea_append` engine.
7940 #[test]
7941 fn append_inplace_crash_consistency_across_ea_boundaries() {
7942 use crate::reader::File as PureFile;
7943 use tempfile::tempdir;
7944
7945 let dir = tempdir().unwrap();
7946 let base = dir.path().join("base.h5");
7947 let (n, target) = (50i32, 250i32);
7948 build_unit_chunked(&base, n);
7949
7950 for max_phase in 1u8..=4 {
7951 let p = dir.path().join(format!("crash_ea_{max_phase}.h5"));
7952 append_stopped_at(&base, &p, n..target, max_phase);
7953 let expected_len = if max_phase == 4 { target } else { n };
7954 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
7955 assert_eq!(
7956 f.dataset("d").unwrap().read_i32().unwrap(),
7957 (0..expected_len).collect::<Vec<_>>(),
7958 "inconsistent view after crash at phase {max_phase}"
7959 );
7960 }
7961 }
7962
7963 /// The same guarantee for an append that crosses the paged-data-block
7964 /// boundary (~131,060 chunks), where phase 1 allocates a paged super block,
7965 /// paged data blocks, the per-page checksums, and the page-init bitmap. This
7966 /// is the most intricate in-place growth the engine performs, and truncating
7967 /// it partway is exactly what a power loss does.
7968 ///
7969 /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202).
7970 /// Opening a paged persisting file must seed each free section into the
7971 /// manager its *slot* names, not one derived from its size.
7972 ///
7973 /// The three managers a paged file uses mean different things — SUPER (slot 0)
7974 /// is metadata, DRAW (slot 2) is small raw, and the generic-large manager
7975 /// (slot 6) holds large-raw fragments — and a large-raw fragment is itself
7976 /// smaller than a page, so size alone cannot recover the distinction. Getting
7977 /// this wrong is invisible from the outside: the total free space is
7978 /// unchanged, the reference library still opens the file, and only a later
7979 /// allocation drawn from the wrong manager would mix a page. So assert the
7980 /// routing directly.
7981 #[test]
7982 fn paged_open_seeds_each_manager_by_slot() {
7983 use crate::writer::FileBuilder;
7984 use tempfile::tempdir;
7985
7986 let dir = tempdir().unwrap();
7987 let path = dir.path().join("paged_seed.h5");
7988 let mut b = FileBuilder::new();
7989 b.create_dataset("d")
7990 .with_i32_data(&(0..1000).collect::<Vec<i32>>())
7991 .with_shape(&[1000]);
7992 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
7993 .with_file_space_page_size(4096);
7994 b.write(&path).unwrap();
7995
7996 // Read the file's recorded free space *before* opening the session: the
7997 // session holds an exclusive OS lock, and on Windows those locks are
7998 // mandatory, so a concurrent `File::open` would fail outright.
7999 let on_disk: u64 = crate::reader::File::open(&path)
8000 .unwrap()
8001 .persisted_free_space()
8002 .iter()
8003 .map(|&(_, l)| l)
8004 .sum();
8005
8006 let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8007 let pg = s.paged.as_ref().expect("a paged file installs paged state");
8008 assert_eq!(pg.page_size, 4096);
8009
8010 // The from-scratch writer leaves a page tail free in both the metadata and
8011 // the raw pages, so both per-type managers are populated. If every slot
8012 // were funnelled into one list, one of these would be empty.
8013 assert!(
8014 !pg.meta.sections().is_empty(),
8015 "SUPER (slot 0) sections seed the metadata list"
8016 );
8017 assert!(
8018 !pg.raw_small.sections().is_empty(),
8019 "DRAW (slot 2) sections seed the small-raw list, not the metadata list"
8020 );
8021
8022 // Nothing is double-counted or dropped: the three lists partition exactly
8023 // the free space the file records, and no two sections overlap.
8024 let mut all = pg.all_sections();
8025 let flat: u64 = all.iter().map(|&(_, l)| l).sum();
8026 assert_eq!(
8027 flat, on_disk,
8028 "the split lists hold exactly the file's free space"
8029 );
8030 all.sort_by_key(|&(a, _)| a);
8031 let mut prev_end = 0u64;
8032 for (addr, len) in all {
8033 assert!(addr >= prev_end, "the per-type lists do not overlap");
8034 prev_end = addr + len;
8035 }
8036 }
8037
8038 /// Deleting a chunked dataset from a paged file must not record its freed
8039 /// chunk index in the *metadata* manager.
8040 ///
8041 /// Every writer in this crate emits a chunk index in the same run as the chunk
8042 /// data it indexes, so the index sits in a raw page. Recording it as metadata
8043 /// would advertise a metadata-sized hole inside a page that still holds another
8044 /// dataset's live chunk data, and the reference library placing metadata there
8045 /// would mix the page — the one thing a paged file forbids. Page homogeneity is
8046 /// preserved on disk either way, so the mis-filing is invisible to a signature
8047 /// scan of the file and to the C library; the manager a section lands in has to
8048 /// be checked directly.
8049 #[test]
8050 fn deleted_chunk_index_is_freed_into_a_raw_manager() {
8051 use crate::writer::FileBuilder;
8052 use tempfile::tempdir;
8053
8054 let dir = tempdir().unwrap();
8055 let path = dir.path().join("paged_chunk_index_free.h5");
8056 let page = 4096u64;
8057 let mut b = FileBuilder::new();
8058 for name in ["drop", "keep"] {
8059 b.create_dataset(name)
8060 .with_i32_data(&(0..200).collect::<Vec<i32>>())
8061 .with_shape(&[200])
8062 .with_chunks(&[50]);
8063 }
8064 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
8065 .with_file_space_page_size(page);
8066 b.write(&path).unwrap();
8067
8068 {
8069 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8070 s.delete("/drop");
8071 s.commit().unwrap();
8072 }
8073
8074 // Pages still occupied by the surviving dataset's chunk data. Read with the
8075 // session closed: its lock is mandatory on Windows.
8076 let live_raw_pages: Vec<u64> = {
8077 let f = crate::reader::File::open(&path).unwrap();
8078 let ds = f.dataset("keep").unwrap();
8079 let mut pages: Vec<u64> = ds
8080 .chunks()
8081 .unwrap()
8082 .iter()
8083 .filter(|c| c.storage_size > 0)
8084 .flat_map(|c| (c.address / page)..=((c.address + c.storage_size - 1) / page))
8085 .collect();
8086 pages.sort_unstable();
8087 pages.dedup();
8088 pages
8089 };
8090 assert!(!live_raw_pages.is_empty(), "expected live raw pages");
8091
8092 let s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8093 let pg = s.paged.as_ref().expect("a paged file installs paged state");
8094 for (addr, len) in pg.meta.sections() {
8095 for p in (addr / page)..=((addr + len - 1) / page) {
8096 assert!(
8097 !live_raw_pages.contains(&p),
8098 "metadata free section ({addr}, {len}) sits in page {p}, which still \
8099 holds live raw chunk data"
8100 );
8101 }
8102 }
8103 // The index really was reclaimed somewhere, so this is not vacuous.
8104 let reclaimed: u64 = pg.all_sections().iter().map(|&(_, l)| l).sum();
8105 assert!(reclaimed > 0, "the delete reclaimed nothing");
8106 }
8107
8108 /// A paged commit that fails partway must leave the session's free lists
8109 /// exactly as it found them.
8110 ///
8111 /// Everything the commit gathers to free is still *live* until the superblock
8112 /// repoint: the objects occupying those regions are reachable from the old
8113 /// root, which a failed commit never replaces. A session that recorded them as
8114 /// free anyway would hand them out on the next commit, and the file would lose
8115 /// data with no error anywhere — in a release build, where the free list's
8116 /// double-free `debug_assert` is compiled out, silently.
8117 ///
8118 /// The failure is induced by pointing the superblock extension at a byte range
8119 /// that is not an object header, which fails the extension rewrite immediately
8120 /// after the regions are gathered.
8121 #[test]
8122 fn failed_paged_commit_leaves_the_free_lists_untouched() {
8123 use crate::writer::FileBuilder;
8124 use tempfile::tempdir;
8125
8126 let dir = tempdir().unwrap();
8127 let path = dir.path().join("paged_failed_commit.h5");
8128 let mut b = FileBuilder::new();
8129 b.create_dataset("keep")
8130 .with_i32_data(&(0..200).collect::<Vec<i32>>())
8131 .with_shape(&[200]);
8132 b.create_dataset("drop")
8133 .with_i32_data(&(0..200).collect::<Vec<i32>>())
8134 .with_shape(&[200]);
8135 b.with_file_space_strategy(FileSpaceStrategy::Page, true, 0)
8136 .with_file_space_page_size(4096);
8137 b.write(&path).unwrap();
8138
8139 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8140 let before = s.space_accounting().reusable_free_space;
8141
8142 // Break the extension so the commit fails *after* it has gathered the
8143 // regions `drop` vacates and *before* the superblock repoint.
8144 let good_ext = s.superblock.superblock_extension_address;
8145 s.superblock.superblock_extension_address = Some(0);
8146 s.delete("/drop");
8147 assert!(
8148 s.commit().is_err(),
8149 "a commit with an unreadable extension must fail"
8150 );
8151
8152 assert_eq!(
8153 s.space_accounting().reusable_free_space,
8154 before,
8155 "a failed commit must not record still-live regions as free"
8156 );
8157
8158 // The session stays usable: repair the extension and commit for real. If
8159 // the failed commit had folded its regions in, this second commit would
8160 // double-free them (a debug assertion) and publish `keep`'s live extent.
8161 s.superblock.superblock_extension_address = good_ext;
8162 s.delete("/drop");
8163 s.commit()
8164 .expect("the session is usable after a failed commit");
8165
8166 // Release the session's exclusive OS lock before reading the file back.
8167 // Those locks are mandatory on Windows, so a `File::open` overlapping the
8168 // session fails outright there (advisory locks elsewhere would allow it).
8169 drop(s);
8170
8171 let f = crate::reader::File::open(&path).unwrap();
8172 let kept = f.dataset("keep").unwrap().read_i32().unwrap();
8173 assert_eq!(kept, (0..200).collect::<Vec<i32>>(), "keep survives intact");
8174 let freed: u64 = f.persisted_free_space().iter().map(|&(_, l)| l).sum();
8175 let live_end = f.file_size();
8176 assert!(
8177 freed < live_end,
8178 "the recorded free space cannot cover the whole file"
8179 );
8180 }
8181
8182 #[test]
8183 fn append_inplace_crash_consistency_paged_prefix() {
8184 use crate::reader::File as PureFile;
8185 use tempfile::tempdir;
8186
8187 let dir = tempdir().unwrap();
8188 let base = dir.path().join("base.h5");
8189 let (start, target) = (131_000i32, 132_000i32);
8190 build_unit_chunked(&base, start);
8191
8192 for max_phase in 1u8..=4 {
8193 let p = dir.path().join(format!("crash_paged_{max_phase}.h5"));
8194 append_stopped_at(&base, &p, start..target, max_phase);
8195 let expected_len = if max_phase == 4 { target } else { start };
8196 let f = PureFile::from_bytes(std::fs::read(&p).unwrap()).unwrap();
8197 assert_eq!(
8198 f.dataset("d").unwrap().read_i32().unwrap(),
8199 (0..expected_len).collect::<Vec<_>>(),
8200 "inconsistent paged view after crash at phase {max_phase}"
8201 );
8202 }
8203 }
8204
8205 /// The consistent-prefix guarantee must hold for the *reference C library*,
8206 /// not only this crate's reader — and this crate's reader is the more lenient
8207 /// of the two, so it cannot stand in for it.
8208 ///
8209 /// The pure reader bounds chunk reads by `min(EA count, dimension)`, which
8210 /// makes it tolerate a phase-3 state where the element count has advanced
8211 /// past the dimension. The C library instead walks strictly by the dataspace
8212 /// dimension and re-validates block checksums, so a stale end-of-file, a
8213 /// half-grown index, or a mis-checksummed block could satisfy the reader here
8214 /// and still break C or h5py. That gap is the whole point of the test:
8215 /// crash-safety for the append path is an interop guarantee.
8216 ///
8217 /// Both starting layouts are covered — a partial trailing chunk and the
8218 /// EA-boundary growth above — since they exercise different index writes.
8219 ///
8220 /// Restores coverage lost with the deprecated `SwmrWriter` and `AppendWriter`
8221 /// (issue #202).
8222 #[test]
8223 // Reads back with the reference HDF5 C library (`hdf5-metno`), a
8224 // 64-bit-only dev-dependency; skip on 32-bit so the lib tests run there.
8225 #[cfg(not(target_pointer_width = "32"))]
8226 fn append_inplace_crash_consistency_c_library_reads_prefix() {
8227 use tempfile::tempdir;
8228
8229 // (initial length, chunk length, appended length): a partial trailing
8230 // chunk, a chunk-aligned start, and the EA-boundary crossing.
8231 for (n, chunk, add) in [(6i32, 4u64, 5i32), (8, 2, 6), (50, 1, 200)] {
8232 let dir = tempdir().unwrap();
8233 let base = dir.path().join("base.h5");
8234 {
8235 use crate::writer::FileBuilder;
8236 let mut b = FileBuilder::new();
8237 b.create_dataset("d")
8238 .with_i32_data(&(0..n).collect::<Vec<i32>>())
8239 .with_shape(&[n as u64])
8240 .with_maxshape(&[u64::MAX])
8241 .with_chunks(&[chunk]);
8242 b.write(&base).unwrap();
8243 }
8244
8245 for max_phase in 1u8..=4 {
8246 let p = dir
8247 .path()
8248 .join(format!("crash_c_{n}_{chunk}_{max_phase}.h5"));
8249 append_stopped_at(&base, &p, n..n + add, max_phase);
8250 let expected_len = if max_phase == 4 { n + add } else { n };
8251 let f = hdf5::File::open(&p).unwrap();
8252 assert_eq!(
8253 f.dataset("d").unwrap().read_raw::<i32>().unwrap(),
8254 (0..expected_len).collect::<Vec<_>>(),
8255 "C library saw an inconsistent view after crash at phase {max_phase} \
8256 (n={n}, chunk={chunk})"
8257 );
8258 f.close().unwrap();
8259 }
8260 }
8261 }
8262
8263 /// Crash recovery across the phase-3/phase-4 gap.
8264 ///
8265 /// A writer that crashes after publishing the Extensible-Array element count
8266 /// (phase 3) but before publishing the dataspace dimension (phase 4) leaves
8267 /// the on-disk count ahead of the committed dimension. A fresh writer must
8268 /// roll forward from the *committed dimension*, overwriting the uncommitted
8269 /// slots, rather than appending past them and leaving a gap.
8270 ///
8271 /// The crashed and recovering appends deliberately write different values at
8272 /// the overlapping positions, so a regression that seeds the chunk count from
8273 /// the stale EA header surfaces the crashed writer's values rather than
8274 /// merely producing plausible-looking data.
8275 ///
8276 /// Restores coverage lost with the deprecated `SwmrWriter` (issue #202); the
8277 /// surviving `recover_and_reappend_after_clean_phase4` covers only the clean
8278 /// case.
8279 #[test]
8280 // Reads back with the reference HDF5 C library (`hdf5-metno`), a
8281 // 64-bit-only dev-dependency; skip on 32-bit so the lib tests run there.
8282 #[cfg(not(target_pointer_width = "32"))]
8283 fn append_inplace_recover_and_reappend_after_phase3_crash() {
8284 use crate::reader::File as PureFile;
8285 use tempfile::tempdir;
8286
8287 let dir = tempdir().unwrap();
8288 let path = dir.path().join("phase3_recover.h5");
8289 let n = 50i32;
8290 build_unit_chunked(&path, n);
8291
8292 // Writer 1 crashes after phase 3: the element count advances but the
8293 // dimension stays at `n`. Its values are far from the correct
8294 // continuation, so a leak is unmistakable.
8295 {
8296 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8297 s.append_inplace_i32_phased("d", &(1000..1200).collect::<Vec<_>>(), 3)
8298 .unwrap();
8299 }
8300 let committed: Vec<i32> = (0..n).collect();
8301 let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
8302 assert_eq!(
8303 pf.dataset("d").unwrap().read_i32().unwrap(),
8304 committed,
8305 "phase-3 crash exposed uncommitted data to the pure reader"
8306 );
8307 {
8308 let f = hdf5::File::open(&path).unwrap();
8309 assert_eq!(
8310 f.dataset("d").unwrap().read_raw::<i32>().unwrap(),
8311 committed,
8312 "phase-3 crash exposed uncommitted data to the C library"
8313 );
8314 f.close().unwrap();
8315 }
8316
8317 // Writer 2 recovers: roll forward from the committed dimension,
8318 // overwriting the uncommitted slots with the real continuation.
8319 {
8320 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8321 s.append_inplace_i32_phased("d", &(n..150).collect::<Vec<_>>(), 4)
8322 .unwrap();
8323 }
8324
8325 let expected: Vec<i32> = (0..150).collect();
8326 let pf = PureFile::from_bytes(std::fs::read(&path).unwrap()).unwrap();
8327 assert_eq!(
8328 pf.dataset("d").unwrap().read_i32().unwrap(),
8329 expected,
8330 "recovery did not roll forward correctly (pure reader)"
8331 );
8332 let f = hdf5::File::open(&path).unwrap();
8333 assert_eq!(
8334 f.dataset("d").unwrap().read_raw::<i32>().unwrap(),
8335 expected,
8336 "recovery did not roll forward correctly (C library)"
8337 );
8338 f.close().unwrap();
8339 }
8340
8341 #[test]
8342 fn raw_appendable_recurses_into_aggregates() {
8343 use crate::datatype::{CompoundMember, DatatypeByteOrder};
8344
8345 let f64_with = |byte_order| Datatype::FloatingPoint {
8346 size: 8,
8347 byte_order,
8348 bit_offset: 0,
8349 bit_precision: 64,
8350 exponent_location: 52,
8351 exponent_size: 11,
8352 mantissa_location: 0,
8353 mantissa_size: 52,
8354 exponent_bias: 1023,
8355 };
8356 let le_f64 = f64_with(DatatypeByteOrder::LittleEndian);
8357 let be_f64 = f64_with(DatatypeByteOrder::BigEndian);
8358
8359 // Little-endian scalar: appendable. Big-endian scalar: not.
8360 assert!(datatype_is_raw_appendable(&le_f64));
8361 assert!(!datatype_is_raw_appendable(&be_f64));
8362
8363 // The confirmed bug: a compound / array whose leaf is big-endian must be
8364 // refused (it was wrongly accepted before recursion was added).
8365 let be_member = Datatype::Compound {
8366 size: 8,
8367 members: vec![CompoundMember {
8368 name: "x".into(),
8369 byte_offset: 0,
8370 datatype: be_f64.clone(),
8371 }],
8372 };
8373 assert!(!datatype_is_raw_appendable(&be_member));
8374 let le_member = Datatype::Compound {
8375 size: 8,
8376 members: vec![CompoundMember {
8377 name: "x".into(),
8378 byte_offset: 0,
8379 datatype: le_f64.clone(),
8380 }],
8381 };
8382 assert!(datatype_is_raw_appendable(&le_member));
8383 assert!(!datatype_is_raw_appendable(&Datatype::Array {
8384 base_type: Box::new(be_f64.clone()),
8385 dimensions: vec![4],
8386 }));
8387
8388 // Variable-length / reference leaves are never raw-appendable, even LE.
8389 assert!(!datatype_is_raw_appendable(&Datatype::VariableLength {
8390 is_string: false,
8391 padding: None,
8392 charset: None,
8393 base_type: Box::new(le_f64.clone()),
8394 }));
8395 assert!(!datatype_is_raw_appendable(&Datatype::Reference {
8396 size: 8,
8397 ref_type: crate::datatype::ReferenceType::Object,
8398 }));
8399 }
8400
8401 #[test]
8402 fn fresh_group_region_pairs_link_info_with_group_info() {
8403 // A new-style group must carry both a Link Info and a Group Info message
8404 // (the C library requires the pair before it will insert a link).
8405 let types = region_types(&fresh_group_region());
8406 assert_eq!(types, vec![MessageType::LinkInfo, MessageType::GroupInfo]);
8407 }
8408
8409 #[test]
8410 fn ensure_group_info_appends_when_missing() {
8411 // A region with a Link Info message but no Group Info message (how older
8412 // hdf5-pure releases wrote groups) gains exactly one Group Info message.
8413 let li_body = {
8414 let mut b = vec![0u8, 0];
8415 b.extend_from_slice(&u64::MAX.to_le_bytes());
8416 b.extend_from_slice(&u64::MAX.to_le_bytes());
8417 b
8418 };
8419 let mut region = region_message(MessageType::LinkInfo, &li_body);
8420 ensure_group_info(&mut region).unwrap();
8421 assert_eq!(
8422 region_types(®ion),
8423 vec![MessageType::LinkInfo, MessageType::GroupInfo]
8424 );
8425
8426 // The appended message decodes as a minimal Group Info body.
8427 let mut p = 0;
8428 while let Some((mt, body, end)) = next_message(®ion, p).unwrap() {
8429 if mt == MessageType::GroupInfo {
8430 assert_eq!(®ion[body..end], &GROUP_INFO_BODY);
8431 }
8432 p = end;
8433 }
8434 }
8435
8436 #[test]
8437 fn ensure_group_info_is_idempotent() {
8438 // A region that already has a Group Info message is left untouched, so
8439 // re-editing a healed (or C-written) group does not duplicate it.
8440 let mut region = fresh_group_region();
8441 let before = region.clone();
8442 ensure_group_info(&mut region).unwrap();
8443 assert_eq!(region, before);
8444 }
8445
8446 #[test]
8447 fn reject_foreign_addresses_refuses_any_shared_message() {
8448 // A shared (SOHM) message of *any* type — here a Dataspace — stores a
8449 // source-file reference in place of its body, so a verbatim cross-file
8450 // copy must refuse it, not only shared datatypes/attributes. (A plain,
8451 // non-shared dataspace embeds no foreign address and is accepted.)
8452 let mut shared = region_message(MessageType::Dataspace, &[0u8; 8]);
8453 shared[3] = MSG_FLAG_SHARED; // set the message's shared flag
8454 let err = reject_foreign_addresses(&shared).unwrap_err();
8455 assert!(err.to_string().contains("shared"), "got: {err}");
8456
8457 let plain = region_message(MessageType::Dataspace, &[0u8; 8]);
8458 reject_foreign_addresses(&plain).unwrap();
8459 }
8460
8461 /// Build a compact data-layout message body: version, class=0, 2-byte inline
8462 /// size, then the data.
8463 fn compact_layout_body(version: u8, data: &[u8]) -> Vec<u8> {
8464 let mut b = vec![version, 0];
8465 b.extend_from_slice(&(data.len() as u16).to_le_bytes());
8466 b.extend_from_slice(data);
8467 b
8468 }
8469
8470 #[test]
8471 fn rebuild_compact_layout_replaces_inline_data_only() {
8472 // A region with a Dataspace message, a compact Data Layout, and a trailing
8473 // Attribute message: rewriting the inline data must replace exactly the
8474 // layout's bytes and leave every other message verbatim.
8475 let mut region = region_message(MessageType::Dataspace, &[0xAB; 8]);
8476 region.extend_from_slice(®ion_message(
8477 MessageType::DataLayout,
8478 &compact_layout_body(3, &[1, 2, 3, 4]),
8479 ));
8480 region.extend_from_slice(®ion_message(MessageType::Attribute, &[0xCD; 5]));
8481
8482 let out = rebuild_compact_layout_region(®ion, &[9, 8, 7, 6]).unwrap();
8483
8484 // Same messages in the same order; only the layout's inline data changed.
8485 assert_eq!(
8486 region_types(&out),
8487 vec![
8488 MessageType::Dataspace,
8489 MessageType::DataLayout,
8490 MessageType::Attribute,
8491 ]
8492 );
8493 let mut p = 0;
8494 while let Some((mt, body, end)) = next_message(&out, p).unwrap() {
8495 match mt {
8496 MessageType::Dataspace => assert_eq!(&out[body..end], &[0xAB; 8]),
8497 MessageType::DataLayout => {
8498 assert_eq!(out[body], 3, "version preserved");
8499 assert_eq!(out[body + 1], 0, "still compact");
8500 let size = u16::from_le_bytes([out[body + 2], out[body + 3]]) as usize;
8501 assert_eq!(size, 4);
8502 assert_eq!(&out[body + 4..body + 4 + size], &[9, 8, 7, 6]);
8503 }
8504 MessageType::Attribute => assert_eq!(&out[body..end], &[0xCD; 5]),
8505 other => panic!("unexpected message {other:?}"),
8506 }
8507 p = end;
8508 }
8509 }
8510
8511 #[test]
8512 fn rebuild_compact_layout_refuses_non_compact() {
8513 // A contiguous (class 1) data layout is not compact, so the rebuild refuses
8514 // rather than corrupt it.
8515 let mut region = region_message(MessageType::DataLayout, &{
8516 let mut b = vec![3u8, 1]; // version 3, class 1 (contiguous)
8517 b.extend_from_slice(&0u64.to_le_bytes());
8518 b.extend_from_slice(&0u64.to_le_bytes());
8519 b
8520 });
8521 region.extend_from_slice(®ion_message(MessageType::Dataspace, &[0; 8]));
8522 let err = rebuild_compact_layout_region(®ion, &[1, 2]).unwrap_err();
8523 assert!(err.to_string().contains("non-compact"), "got: {err}");
8524 }
8525
8526 #[test]
8527 fn a_refused_open_never_builds_the_image() {
8528 // The image is what may cost `O(file size)` — the mirror reads the whole
8529 // file — so every refusal has to come first. Asserting on the build
8530 // closure states that directly; measuring memory or elapsed time would
8531 // only correlate with it.
8532 use crate::writer::FileBuilder;
8533
8534 let dir = tempfile::tempdir().unwrap();
8535
8536 // One refusal from each family: the superblock's status flags (issue
8537 // #245), and an unsupported superblock version, which has always been
8538 // refused after the read this reorders.
8539 let flagged = dir.path().join("flagged.h5");
8540 let mut b = FileBuilder::new();
8541 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
8542 b.write(&flagged).unwrap();
8543 let ancient = dir.path().join("ancient.h5");
8544 std::fs::copy(&flagged, &ancient).unwrap();
8545 for (path, version, flags) in [(&flagged, 3, SWMR_WRITE_FLAGS), (&ancient, 9, 0)] {
8546 let mut data = std::fs::read(path).unwrap();
8547 let off = signature::find_signature(&data).unwrap();
8548 let mut sb = Superblock::parse(&data, off).unwrap();
8549 sb.version = version;
8550 sb.consistency_flags = flags;
8551 let bytes = sb.serialize();
8552 data[off..off + bytes.len()].copy_from_slice(&bytes);
8553 std::fs::write(path, &data).unwrap();
8554 }
8555
8556 for path in [&flagged, &ancient] {
8557 let built = std::cell::Cell::new(false);
8558 let err = match WriteEngine::open_imaged(path, Some(FileLocking::Enabled), |h, len| {
8559 built.set(true);
8560 Ok(Box::new(HandleImage::new(
8561 h,
8562 len,
8563 MetadataCacheConfig::disabled(),
8564 )))
8565 }) {
8566 Err(e) => e,
8567 Ok(_) => panic!("{} must be refused", path.display()),
8568 };
8569 assert!(
8570 !built.get(),
8571 "{} was refused with {err:?}, but the image was built first",
8572 path.display()
8573 );
8574 }
8575 }
8576
8577 #[test]
8578 fn a_stale_consistency_flag_is_refused_then_cleared_by_a_commit() {
8579 // A v3 file a crashed SWMR writer left flagged is refused by the editor
8580 // (issue #245) rather than edited under a writer the file still records.
8581 // On a v2 file, where the check is gated off to match the C library, the
8582 // editor opens — and the commit clears the stale flag rather than
8583 // re-emitting it, so the file stays properly closed for the C library
8584 // (issue #73).
8585 use crate::writer::FileBuilder;
8586
8587 let dir = tempfile::tempdir().unwrap();
8588 let path = dir.path().join("stale_flag.h5");
8589
8590 let mut b = FileBuilder::new();
8591 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
8592 b.write(&path).unwrap();
8593
8594 // Simulate a crashed SWMR writer by stamping the on-disk write+SWMR flag
8595 // (0x05) into the superblock, recomputing its checksum.
8596 {
8597 let mut data = std::fs::read(&path).unwrap();
8598 let off = signature::find_signature(&data).unwrap();
8599 let mut sb = Superblock::parse(&data, off).unwrap();
8600 assert!(
8601 sb.version >= 2,
8602 "FileBuilder should emit a v2/v3 superblock"
8603 );
8604 sb.consistency_flags = 0x05;
8605 let bytes = sb.serialize();
8606 data[off..off + bytes.len()].copy_from_slice(&bytes);
8607 std::fs::write(&path, &data).unwrap();
8608 // Sanity: the stale flag is really set on disk now.
8609 assert_eq!(
8610 Superblock::parse(&data, off).unwrap().consistency_flags,
8611 0x05
8612 );
8613 }
8614
8615 // The editor refuses it while the flag stands.
8616 match WriteEngine::open_with_locking(&path, FileLocking::Enabled) {
8617 Err(Error::FileMarkedInUse(_)) => {}
8618 Err(e) => panic!("expected the flag refusal, got {e:?}"),
8619 Ok(_) => panic!("a flagged file must not be edited in place"),
8620 }
8621
8622 // The flag survives a *version-2* superblock, where the check is gated
8623 // off to match the C library — which is the one state that still carries
8624 // a stale flag into a commit, and so the one that keeps the healing below
8625 // load-bearing. (v2 and v3 superblocks share a byte layout, so restamping
8626 // the version is the whole difference.) A crashed C writer leaves plain
8627 // write access, without the SWMR bit.
8628 {
8629 let mut data = std::fs::read(&path).unwrap();
8630 let off = signature::find_signature(&data).unwrap();
8631 let mut sb = Superblock::parse(&data, off).unwrap();
8632 sb.version = 2;
8633 sb.consistency_flags = crate::file_lock::WRITE_ACCESS;
8634 let bytes = sb.serialize();
8635 data[off..off + bytes.len()].copy_from_slice(&bytes);
8636 std::fs::write(&path, &data).unwrap();
8637 }
8638
8639 // A clean edit-and-commit cycle heals it.
8640 {
8641 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
8642 .expect("the gate skips a v2 superblock, so this opens");
8643 let mut b = DatasetBuilder::new("e");
8644 b.with_i32_data(&[4, 5]);
8645 s.stage_created_dataset("e", b);
8646 s.commit().unwrap();
8647 }
8648
8649 let data = std::fs::read(&path).unwrap();
8650 let off = signature::find_signature(&data).unwrap();
8651 assert_eq!(
8652 Superblock::parse(&data, off).unwrap().consistency_flags,
8653 0,
8654 "commit must clear the stale consistency flag"
8655 );
8656 }
8657
8658 #[test]
8659 fn add_vlen_string_dataset_with_null_elements_via_edit_session() {
8660 // Regression test for a silent-corruption bug (issue #105): a
8661 // VL-string dataset added via the in-place edit engine used to commit `Ok(())`
8662 // without ever writing its global heap collection or patching its
8663 // placeholder references, so the dataset failed to read back. A null
8664 // element (no heap object at all, distinct from an empty string) must
8665 // stay untouched by the patch — only heap-backed elements'
8666 // placeholder addresses are resolved; exercising both keeps the mask
8667 // itself, not just the common all-`Bytes` case, under test.
8668 use crate::type_builders::VlStringElement;
8669 use crate::writer::FileBuilder;
8670
8671 let dir = tempfile::tempdir().unwrap();
8672 let path = dir.path().join("vlen_null.h5");
8673
8674 let mut b = FileBuilder::new();
8675 b.create_dataset("seed").with_i32_data(&[0]);
8676 b.write(&path).unwrap();
8677
8678 let datatype =
8679 crate::type_builders::make_vlen_string_type(crate::datatype::CharacterSet::Utf8);
8680 let elements = vec![
8681 VlStringElement::Bytes(b"alpha".to_vec()),
8682 VlStringElement::Null,
8683 VlStringElement::Bytes(b"gamma".to_vec()),
8684 ];
8685
8686 {
8687 let mut s = WriteEngine::open_with_locking(&path, FileLocking::Enabled).unwrap();
8688 let mut b = DatasetBuilder::new("labels");
8689 b.with_vlen_string_elements(datatype, &elements).unwrap();
8690 s.stage_created_dataset("labels", b);
8691 s.commit().unwrap();
8692 }
8693
8694 let file = crate::reader::File::open(&path).unwrap();
8695 let ds = file.dataset("labels").unwrap();
8696 assert_eq!(
8697 ds.read_string().unwrap(),
8698 vec!["alpha".to_string(), String::new(), "gamma".to_string()]
8699 );
8700 }
8701
8702 #[test]
8703 fn edit_session_root_group_base_address_overflow_is_rejected() {
8704 // The edit-path sibling of issue #137. A userblock file has a nonzero base
8705 // address that `WriteEngine::open` adds to the stored root-group address.
8706 // A crafted address of HADDR_UNDEF must be rejected rather than overflow
8707 // (panicking in debug, wrapping in release).
8708 use crate::writer::FileBuilder;
8709
8710 let dir = tempfile::tempdir().unwrap();
8711 let path = dir.path().join("edit_root_overflow.h5");
8712
8713 const UB: u64 = 512;
8714 let mut b = FileBuilder::new();
8715 b.with_userblock(UB);
8716 b.create_dataset("d").with_i32_data(&[1, 2, 3]);
8717 b.write(&path).unwrap();
8718
8719 // Rewrite the stored (base-relative) root-group address to HADDR_UNDEF,
8720 // recomputing the superblock checksum via `serialize`. The base address
8721 // still equals the superblock offset, so the file stays editable and the
8722 // editor reaches the `root_group_address + base` normalization.
8723 let mut data = std::fs::read(&path).unwrap();
8724 let off = signature::find_signature(&data).unwrap();
8725 let mut sb = Superblock::parse(&data, off).unwrap();
8726 assert_eq!(sb.base_address, UB, "userblock file must have base == UB");
8727 sb.root_group_address = u64::MAX;
8728 let bytes = sb.serialize();
8729 data[off..off + bytes.len()].copy_from_slice(&bytes);
8730 std::fs::write(&path, &data).unwrap();
8731
8732 let err = WriteEngine::open_with_locking(&path, FileLocking::Enabled)
8733 .err()
8734 .expect("open must fail");
8735 match err {
8736 Error::Format(FormatError::OffsetOverflow { offset, length }) => {
8737 assert_eq!(offset, u64::MAX);
8738 assert_eq!(length, UB);
8739 }
8740 other => panic!("expected root-group address overflow, got {other:?}"),
8741 }
8742 }
8743
8744 use tempfile::tempdir;
8745
8746 // -----------------------------------------------------------------------
8747 // Bounded sessions: the same engine over a `HandleImage`, which holds no
8748 // whole-file mirror. These came across from the standalone bounded engine
8749 // deleted in issue #198; what they cover is unchanged, but they now exercise
8750 // the shared code the mirror sessions use.
8751 // -----------------------------------------------------------------------
8752
8753 /// Build a rank-1 unlimited chunked i32 dataset `d` seeded with `0..n`.
8754 fn build_appendable(path: &Path, n: i32, chunk: u64) {
8755 let data: Vec<i32> = (0..n).collect();
8756 let mut b = crate::writer::FileBuilder::new();
8757 b.create_dataset("d")
8758 .with_i32_data(&data)
8759 .with_shape(&[n as u64])
8760 .with_maxshape(&[u64::MAX])
8761 .with_chunks(&[chunk]);
8762 b.write(path).unwrap();
8763 }
8764
8765 fn open_bounded_session(path: &Path) -> WriteEngine {
8766 WriteEngine::open_rw_with_strategy(
8767 path,
8768 crate::source::MetadataCacheConfig::disabled(),
8769 FileLocking::Enabled,
8770 MemoryStrategy::Bounded,
8771 )
8772 .unwrap()
8773 }
8774
8775 fn dataset_addr(engine: &WriteEngine) -> u64 {
8776 crate::group_v2::resolve_path_any_from_source(&engine.image(), engine.superblock(), "d")
8777 .unwrap()
8778 }
8779
8780 /// Crash consistency on a bounded session: stop the append after only the
8781 /// first `max_phase` durability phases (simulating a crash at that boundary)
8782 /// and assert the reopened file reads either the old length (phases 1-3) or
8783 /// the new one (phase 4), never a torn view. Layouts cover a partial trailing
8784 /// chunk (relocated tail) and a chunk-aligned start.
8785 #[test]
8786 fn bounded_append_crash_consistency_partial_tail_prefix() {
8787 let dir = tempdir().unwrap();
8788 for (case, (n, chunk, add)) in [(0usize, (6i32, 4u64, 5i32)), (1, (8, 2, 6))] {
8789 let base = dir.path().join(std::format!("base_{case}.h5"));
8790 build_appendable(&base, n, chunk);
8791 for max_phase in 1u8..=4 {
8792 let p = dir.path().join(std::format!("crash_{case}_{max_phase}.h5"));
8793 std::fs::copy(&base, &p).unwrap();
8794 {
8795 let mut engine = open_bounded_session(&p);
8796 let addr = dataset_addr(&engine);
8797 let mut b = AppendBuilder::new();
8798 b.append_i32(&(n..n + add).collect::<Vec<_>>());
8799 engine
8800 .append_inplace_gathered(AppendTarget::Header(addr), &b, max_phase)
8801 .unwrap();
8802 // Dropping the engine simulates the crash: no further phases,
8803 // no close barrier.
8804 }
8805 let expected_len = if max_phase == 4 { n + add } else { n };
8806 let got = crate::File::open(&p)
8807 .unwrap()
8808 .dataset("d")
8809 .unwrap()
8810 .read_i32()
8811 .unwrap();
8812 assert_eq!(
8813 got,
8814 (0..expected_len).collect::<Vec<_>>(),
8815 "case {case} phase {max_phase}"
8816 );
8817 }
8818 }
8819 }
8820
8821 /// The batching loop only honors `max_phase < 4` on its first batch, and a
8822 /// full multi-batch append leaves every batch fully committed: after a large
8823 /// append the file reads the complete sequence.
8824 #[test]
8825 fn bounded_multi_batch_append_commits_every_batch() {
8826 let dir = tempdir().unwrap();
8827 let p = dir.path().join("multibatch.h5");
8828 build_appendable(&p, 5, 512);
8829 let total = 700_000i32;
8830 {
8831 let mut engine = open_bounded_session(&p);
8832 let addr = dataset_addr(&engine);
8833 let mut b = AppendBuilder::new();
8834 b.append_i32(&(5..total).collect::<Vec<_>>());
8835 engine
8836 .append_inplace_gathered(AppendTarget::Header(addr), &b, 4)
8837 .unwrap();
8838 }
8839 let got = crate::File::open(&p)
8840 .unwrap()
8841 .dataset("d")
8842 .unwrap()
8843 .read_i32()
8844 .unwrap();
8845 assert_eq!(got.len(), total as usize);
8846 assert!(got.iter().enumerate().all(|(i, &v)| v == i as i32));
8847 }
8848
8849 /// A bounded session batches; a mirror session does not. The distinction is
8850 /// a deliberate trade — bounded peak memory against whole-call crash
8851 /// atomicity — so it is asserted rather than left to the batching code's
8852 /// arithmetic.
8853 #[test]
8854 fn only_a_bounded_session_batches_a_large_append() {
8855 let dir = tempdir().unwrap();
8856 let p = dir.path().join("batching.h5");
8857 build_appendable(&p, 8, 4);
8858
8859 let bounded_batch = {
8860 let mut engine = open_bounded_session(&p);
8861 engine
8862 .append_geometry(AppendTarget::Path("d"))
8863 .unwrap()
8864 .full_batch_elems
8865 };
8866 let mirror_batch = {
8867 let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
8868 engine
8869 .append_geometry(AppendTarget::Path("d"))
8870 .unwrap()
8871 .full_batch_elems
8872 };
8873
8874 assert_eq!(
8875 mirror_batch,
8876 u64::MAX,
8877 "a mirror session must take the whole append as one crash-atomic batch"
8878 );
8879 assert!(
8880 bounded_batch < u64::MAX,
8881 "a bounded session must cap a batch, got {bounded_batch}"
8882 );
8883 assert_eq!(
8884 bounded_batch % 4,
8885 0,
8886 "a batch must be a whole number of chunks"
8887 );
8888 }
8889
8890 /// A persisting file appended through a bounded session and dropped WITHOUT
8891 /// `finalize_persist` (the true-crash case) still reads back every durable
8892 /// append. Dropping the engine releases the exclusive lock, so the reopen is
8893 /// portable (no leaked lock). The finalize-at-close path is covered by the
8894 /// `tests/bounded_append.rs` integration tests.
8895 #[test]
8896 fn bounded_persist_append_without_finalize_is_readable() {
8897 let dir = tempdir().unwrap();
8898 let p = dir.path().join("persist_crash.h5");
8899 let mut b = crate::writer::FileBuilder::new();
8900 b.with_file_space_strategy(crate::FileSpaceStrategy::FsmAggr, true, 1);
8901 b.create_dataset("d")
8902 .with_i32_data(&(0..6).collect::<Vec<i32>>())
8903 .with_shape(&[6])
8904 .with_maxshape(&[u64::MAX])
8905 .with_chunks(&[4]);
8906 b.write(&p).unwrap();
8907 {
8908 let mut engine = open_bounded_session(&p);
8909 assert!(engine.persist.is_some(), "persist state is armed at open");
8910 let addr = dataset_addr(&engine);
8911 let mut ab = AppendBuilder::new();
8912 ab.append_i32(&(6..20).collect::<Vec<_>>());
8913 engine
8914 .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
8915 .unwrap();
8916 // Drop without finalizing: models a true crash and releases the lock.
8917 }
8918 let got = crate::File::open(&p)
8919 .unwrap()
8920 .dataset("d")
8921 .unwrap()
8922 .read_i32()
8923 .unwrap();
8924 assert_eq!(got, (0..20).collect::<Vec<_>>());
8925 }
8926
8927 /// A bounded session grows a PAGED persisting file and is killed before
8928 /// finalize (models a crash), leaving the file non-page-aligned. Reopening it
8929 /// must not panic, and the next append must re-align the crashed tail page
8930 /// before writing raw data (so no page mixes metadata and raw); a clean close
8931 /// then re-page-aligns the file and every row reads back.
8932 #[test]
8933 fn bounded_paged_reopen_after_crash_realigns_and_stays_readable() {
8934 let dir = tempdir().unwrap();
8935 let p = dir.path().join("paged_crash.h5");
8936 let mut b = crate::writer::FileBuilder::new();
8937 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
8938 .with_file_space_page_size(4096);
8939 b.create_dataset("d")
8940 .with_i32_data(&(0..64).collect::<Vec<i32>>())
8941 .with_shape(&[64])
8942 .with_maxshape(&[u64::MAX])
8943 .with_chunks(&[64]);
8944 b.write(&p).unwrap();
8945
8946 // Grow enough to force extensible-array index growth, so the last write of
8947 // the session is metadata and the tail page is a partial metadata page.
8948 {
8949 let mut engine = open_bounded_session(&p);
8950 let addr = dataset_addr(&engine);
8951 let mut ab = AppendBuilder::new();
8952 ab.append_i32(&(64..2000).collect::<Vec<_>>());
8953 engine
8954 .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
8955 .unwrap();
8956 // Drop without finalize: models a crash and releases the OS lock.
8957 }
8958 assert_ne!(
8959 std::fs::metadata(&p).unwrap().len() % 4096,
8960 0,
8961 "a crashed (un-finalized) paged session leaves the file non-page-aligned"
8962 );
8963
8964 // Reopen must not panic on the non-aligned file; the next append re-aligns
8965 // the crashed tail page, and finalize re-page-aligns the whole file.
8966 {
8967 let mut engine = open_bounded_session(&p);
8968 let addr = dataset_addr(&engine);
8969 let mut ab = AppendBuilder::new();
8970 ab.append_i32(&(2000..2500).collect::<Vec<_>>());
8971 engine
8972 .append_inplace_gathered(AppendTarget::Header(addr), &ab, 4)
8973 .unwrap();
8974 engine.finalize_persist().unwrap();
8975 engine.sync().unwrap();
8976 }
8977 assert_eq!(
8978 std::fs::metadata(&p).unwrap().len() % 4096,
8979 0,
8980 "reopen + append + finalize re-aligns the paged file"
8981 );
8982 let got = crate::File::open(&p)
8983 .unwrap()
8984 .dataset("d")
8985 .unwrap()
8986 .read_i32()
8987 .unwrap();
8988 assert_eq!(got, (0..2500).collect::<Vec<_>>());
8989 }
8990
8991 /// A staged commit on a bounded session must stay bounded: it may read the
8992 /// metadata it edits, but never the file's bulk. Measured rather than
8993 /// asserted from the design — the engine is shared with the mirror sessions
8994 /// now, and a single slice-taking read added anywhere on the commit path
8995 /// would silently make a bounded open cost as much as a mirrored one.
8996 #[test]
8997 fn a_bounded_commit_reads_far_less_than_the_file() {
8998 use std::sync::Arc;
8999 use std::sync::atomic::{AtomicU64, Ordering};
9000
9001 let dir = tempdir().unwrap();
9002 let p = dir.path().join("bulk.h5");
9003 // ~8 MiB of chunked data, so "reads the whole file" and "reads only the
9004 // metadata" differ by orders of magnitude rather than by a margin.
9005 let rows = 2_000_000i32;
9006 build_appendable(&p, rows, 8192);
9007 let file_len = std::fs::metadata(&p).unwrap().len();
9008 assert!(file_len > 4 << 20, "file is only {file_len} bytes");
9009
9010 let read_bytes = Arc::new(AtomicU64::new(0));
9011 {
9012 let mut engine =
9013 WriteEngine::open_bounded_counting(&p, Arc::clone(&read_bytes)).unwrap();
9014 engine.create_group("g");
9015 engine.commit().unwrap();
9016 }
9017 let read = read_bytes.load(Ordering::Relaxed);
9018
9019 assert!(
9020 read > 0,
9021 "the commit read nothing, so the test proves nothing"
9022 );
9023 // Measured at 310 bytes here. The bound is loose enough to survive a
9024 // changed header layout and still orders of magnitude below the file.
9025 assert!(
9026 read < 64 << 10,
9027 "a bounded commit read {read} bytes of a {file_len}-byte file"
9028 );
9029 }
9030
9031 /// An in-place append leaves a partially-filled **raw** page, so the next
9032 /// commit's metadata must pad it rather than pack into it.
9033 ///
9034 /// This is what keeps [`PagedEdit::begin`] reachable from [`EditStore`] now
9035 /// that an append allocates raw pages only: the append's job is to record that
9036 /// the tail page turned raw, and the commit's job is to act on it. It is also
9037 /// the interleaving that a single session-level page tracker makes possible —
9038 /// with a tracker per engine, the commit path could not see what the append
9039 /// path had done, which is why the whole-file editor refused an in-place append
9040 /// to a paged file at all (issue #198).
9041 #[test]
9042 fn a_commit_after_an_append_pads_the_raw_page_the_append_left() {
9043 const PAGE: u64 = 4096;
9044 let dir = tempdir().unwrap();
9045 let p = dir.path().join("paged_interleave.h5");
9046 let mut b = crate::writer::FileBuilder::new();
9047 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
9048 .with_file_space_page_size(PAGE);
9049 b.create_dataset("d")
9050 .with_i32_data(&(0..64).collect::<Vec<i32>>())
9051 .with_shape(&[64])
9052 .with_maxshape(&[u64::MAX])
9053 .with_chunks(&[64]);
9054 b.write(&p).unwrap();
9055
9056 let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
9057 let mut ab = AppendBuilder::new();
9058 ab.append_i32(&(64..2000).collect::<Vec<_>>());
9059 engine
9060 .append_inplace_gathered(AppendTarget::Path("d"), &ab, 4)
9061 .unwrap();
9062
9063 assert_eq!(
9064 engine.paged.as_ref().unwrap().last,
9065 Some(PageType::Raw),
9066 "the append must record that the tail page now holds raw data"
9067 );
9068 assert_ne!(
9069 engine.image.len() % PAGE,
9070 0,
9071 "the append must leave a partially-filled page for the commit to pad"
9072 );
9073
9074 engine.create_group("g");
9075 engine.commit().unwrap();
9076
9077 // The commit padded the raw tail before laying down metadata, and folded
9078 // that padding into the small-raw manager (`meta_pad`/`raw_pad` are cleared
9079 // into the free lists as part of the paged tail).
9080 let pg = engine.paged.as_ref().expect("the file is paged");
9081 let raw_free = pg.raw_small.sections();
9082 assert!(
9083 !raw_free.is_empty(),
9084 "the commit packed metadata into the raw page the append left open"
9085 );
9086 for (addr, len) in raw_free {
9087 assert_eq!(
9088 (addr + len) % PAGE,
9089 0,
9090 "padding {addr}+{len} does not reach a page boundary"
9091 );
9092 }
9093
9094 drop(engine);
9095 assert_eq!(
9096 crate::File::open(&p)
9097 .unwrap()
9098 .dataset("d")
9099 .unwrap()
9100 .read_i32()
9101 .unwrap(),
9102 (0..2000).collect::<Vec<_>>()
9103 );
9104 }
9105
9106 /// An in-place append to a paged file must allocate **only raw pages** — the
9107 /// chunk data and the extensible-array blocks indexing it alike.
9108 ///
9109 /// The reclaim path (`chunked_storage_spans`) reports both halves of a chunked
9110 /// dataset as raw free space, because that is where this crate places them. An
9111 /// append that put its index blocks in a metadata page instead would make the
9112 /// reclaim advertise metadata-page bytes for raw reuse, mixing the page a paged
9113 /// file exists to keep homogeneous. Measured here rather than through the
9114 /// reference C library, which reads a mixed-page file without complaint: an
9115 /// interop test proves interop and says nothing about segregation.
9116 #[test]
9117 fn an_inplace_append_to_a_paged_file_allocates_only_raw_pages() {
9118 const PAGE: u64 = 4096;
9119 let dir = tempdir().unwrap();
9120 let p = dir.path().join("paged_raw.h5");
9121 let mut b = crate::writer::FileBuilder::new();
9122 b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 0)
9123 .with_file_space_page_size(PAGE);
9124 b.create_dataset("d")
9125 .with_i32_data(&(0..64).collect::<Vec<i32>>())
9126 .with_shape(&[64])
9127 .with_maxshape(&[u64::MAX])
9128 .with_chunks(&[64]);
9129 b.write(&p).unwrap();
9130
9131 let mut engine = WriteEngine::open_with_locking(&p, FileLocking::Enabled).unwrap();
9132 let before = engine.image().len();
9133 // Two appends, each large enough to grow the extensible-array index, so the
9134 // run allocates index blocks as well as chunk data.
9135 for range in [64..2000, 2000..4000] {
9136 let mut ab = AppendBuilder::new();
9137 ab.append_i32(&range.collect::<Vec<_>>());
9138 engine
9139 .append_inplace_gathered(AppendTarget::Path("d"), &ab, 4)
9140 .unwrap();
9141 }
9142
9143 let pg = engine.paged.as_ref().expect("the file is paged");
9144 assert_eq!(
9145 pg.last,
9146 Some(PageType::Raw),
9147 "the append left the tail page holding something other than raw data"
9148 );
9149 assert!(
9150 pg.meta_pad.is_empty() && pg.raw_pad.is_empty(),
9151 "an in-place append switched page type: meta_pad={:?} raw_pad={:?}",
9152 pg.meta_pad,
9153 pg.raw_pad
9154 );
9155
9156 // Not vacuous: the append really did allocate index structure above the
9157 // pre-append end-of-file, which is what would have opened a metadata page.
9158 let addr = crate::group_v2::resolve_path_any_from_source(
9159 &engine.image(),
9160 engine.superblock(),
9161 "d",
9162 )
9163 .unwrap();
9164 let spans = engine
9165 .chunked_storage_spans(addr.to_usize().unwrap())
9166 .expect("a chunked dataset has reclaimable spans");
9167 let fresh = spans.iter().filter(|&&(a, _, _)| a >= before).count();
9168 assert!(
9169 fresh > 0,
9170 "the append allocated nothing above {before}, so the assertion above proves nothing"
9171 );
9172 assert!(
9173 spans.iter().all(|&(_, _, ty)| ty == PageType::Raw),
9174 "the reclaim tags every chunked span raw; a metadata tag here would need \
9175 the placement rule above to change with it"
9176 );
9177 }
9178}