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