Skip to main content

rust_hdf5/io/
writer.rs

1//! HDF5 file writer.
2//!
3//! Produces a valid HDF5 file with superblock v3, a root group object header,
4//! and datasets with contiguous or chunked storage. The output is readable by `h5dump`.
5
6use std::collections::{HashMap, HashSet};
7use std::path::{Path, PathBuf};
8
9use crate::dataset::DatasetAccess;
10use crate::format::btree_v1::{BTreeV1Config, ChunkBTreeV1Node, ChunkBTreeV1Tree, ChunkKey};
11use crate::format::chunk_index::btree_v2::Bt2ChunkIndex;
12use crate::format::chunk_index::extensible_array::{
13    compute_chunk_size_len, compute_ndblk_addrs, compute_nsblk_addrs, EaDblkPath, EaGeometry,
14    EaLoc, ExtensibleArrayDataBlock, ExtensibleArrayHeader, ExtensibleArrayIndexBlock,
15    ExtensibleArraySuperBlock, FilteredChunkEntry, FilteredDataBlock, FilteredIndexBlock,
16    EA_CLS_CHUNK, EA_CLS_FILT_CHUNK,
17};
18use crate::format::chunk_index::fixed_array::{
19    decode_filtered_page, decode_unfiltered_page, encode_filtered_page, encode_unfiltered_page,
20    FixedArrayDataBlock, FixedArrayFilteredChunkElement, FixedArrayHeader, FixedArrayPagedPrefix,
21    FA_CLIENT_FILT_CHUNK,
22};
23use crate::format::creation_order::CreationOrder;
24use crate::format::dense_attr::build_dense_attributes;
25use crate::format::dense_link::build_dense_links;
26use crate::format::free_space::{
27    self, FreeSection, FreeSpaceClass, FreeSpaceHeader, FreeSpaceManager,
28};
29use crate::format::local_heap::{
30    local_heap_header_size, LocalHeapHeader, LocalHeapImage, LOCAL_HEAP_FREE_NULL,
31};
32use crate::format::messages::attr_info::{next_creation_index, AttributeInfoMessage};
33use crate::format::messages::attribute::{
34    AttributeEntry, AttributeMessage, ATTR_FLAG_SPACE_SHARED, ATTR_FLAG_TYPE_SHARED,
35};
36use crate::format::messages::data_layout::{
37    DataLayoutMessage, EarrayParams, FixedArrayParams, LAYOUT_VERSION_DEFAULT,
38};
39use crate::format::messages::dataspace::{DataspaceClass, DataspaceMessage};
40use crate::format::messages::datatype::{ByteOrder, DatatypeMessage, ReferenceKind};
41use crate::format::messages::external_file_list::{ExternalFileListMessage, UNLIMITED};
42use crate::format::messages::fill_value::{
43    FillValueMessage, FILL_TIME_ALLOC, FILL_TIME_IFSET, FILL_TIME_NEVER,
44};
45use crate::format::messages::filter::{self, FilterPipeline};
46use crate::format::messages::group_info::GroupInfoMessage;
47use crate::format::messages::link::{CharacterSet, LinkMessage, LinkTarget};
48use crate::format::messages::link_info::LinkInfoMessage;
49use crate::format::messages::mod_time::ModificationTime;
50use crate::format::messages::superblock_ext::{
51    FileSpaceInfoMessage, FileSpaceStrategy, SharedMessageTableMessage,
52    DEFAULT_FILE_SPACE_PAGE_SIZE, FS_ADDR_COUNT_V1, PAGE_SIZE_MAX, PAGE_SIZE_MIN,
53};
54use crate::format::messages::virtual_mapping::{
55    parse_source_name, VirtualMapping, VirtualMappingList,
56};
57use crate::format::messages::*;
58use crate::format::object_header::{ObjectHeader, ObjectTimes, MAX_MESSAGE_SIZE};
59use crate::format::reference::{
60    encode_reference_element, encode_revised_blob, ReferenceElementImage, ReferenceTarget,
61    REVISED_BLOB_TOKEN_OFFSET,
62};
63use crate::format::selection::Selection;
64use crate::format::sohm::{
65    type_flag, SharedMessagePointer, MAX_SOHM_INDEXES, SOHM_HEAP_ID_LEN, SOHM_POINTER_HEAP_ID_AT,
66};
67use crate::format::sohm_write::{
68    build_shared_messages, NestedShare, SharedMessage, SohmIndexContent, SohmIndexSpec,
69};
70use crate::format::superblock::*;
71use crate::format::{FormatContext, LibverBound, ObjectFormat, UNDEF_ADDR};
72
73use crate::format::selection::check_hyperslab;
74use crate::io::allocator::{FileAllocator, FreeBlock};
75use crate::io::file_handle::FileHandle;
76use crate::io::hyperslab::{for_each_contiguous_run, for_each_dual_run};
77use crate::io::symbol_table_io::{free_stab, write_stab, Stab, StabExtents, StabLink, StabTarget};
78use crate::io::{FileMeta, IoResult};
79
80/// On-disk size in bytes of a fixed-array data block, for the layout (paged or
81/// flat) implied by `hdr`.
82///
83/// Mirrors `H5FA_DBLOCK_SIZE` (`H5FApkg.h`):
84///   - non-paged: `prefix + nelmts * raw_elmt_size + checksum`
85///   - paged: `prefix + page_init_bitmap + nelmts * raw_elmt_size
86///     + npages * checksum`, where the prefix checksum covers the bitmap.
87///
88/// `raw_elmt_size` is `sizeof_addr` for an unfiltered array, and
89/// `sizeof_addr + chunk_size_len + 4` (the filtered element: address +
90/// compressed size + filter mask) for a filtered array. libhdf5 carries this
91/// value as `hdr->cparam.raw_elmt_size`, i.e. exactly `hdr.element_size`.
92fn fixed_array_dblk_disk_size(ctx: &FormatContext, hdr: &FixedArrayHeader) -> u64 {
93    let elem_size = hdr.element_size as u64;
94    let sa = ctx.sizeof_addr as u64;
95    let nelmts = hdr.num_elmts;
96    // Common metadata prefix: signature(4) + version(1) + client_id(1) + header_addr(sa).
97    let meta_prefix = 4 + 1 + 1 + sa;
98    if hdr.is_paged() {
99        let npages = hdr.npages();
100        let bitmap_size = npages.div_ceil(8);
101        // prefix (incl. its own 4-byte checksum) + elements + per-page checksums.
102        (meta_prefix + bitmap_size + 4) + nelmts * elem_size + npages * 4
103    } else {
104        // prefix + elements + single 4-byte checksum.
105        meta_prefix + nelmts * elem_size + 4
106    }
107}
108
109/// A walk of a v2 B-tree: the file and node geometry the descent reads
110/// through, and the two collections it fills — every node's raw record
111/// bytes and every node block's address, the latter because `open_append`
112/// needs it so the reconstructed [`Bt2DatasetInfo::node_addrs`] pool owns
113/// the on-disk nodes (the next flush re-serializes the tree over them, and
114/// a delete frees them).
115///
116/// `record_size`, `node_size` and `geo` are constant for the whole walk, so
117/// [`descend`](Self::descend) takes only what changes per level: the node's
118/// address, its depth, and how many records it holds.
119struct Bt2Walk<'a> {
120    handle: &'a FileHandle,
121    ctx: &'a FormatContext,
122    record_size: u16,
123    node_size: u32,
124    geo: &'a crate::format::chunk_index::btree_v2::Bt2Geometry,
125    records: Vec<u8>,
126    node_addrs: Vec<u64>,
127}
128
129impl<'a> Bt2Walk<'a> {
130    fn new(
131        handle: &'a FileHandle,
132        ctx: &'a FormatContext,
133        record_size: u16,
134        node_size: u32,
135        geo: &'a crate::format::chunk_index::btree_v2::Bt2Geometry,
136    ) -> Self {
137        Self {
138            handle,
139            ctx,
140            record_size,
141            node_size,
142            geo,
143            records: Vec::new(),
144            node_addrs: Vec::new(),
145        }
146    }
147
148    /// Walk the subtree rooted at `addr`, at depth `depth` with `nrec`
149    /// records, collecting every node's raw record bytes and every node
150    /// block's address.
151    fn descend(&mut self, addr: u64, depth: u16, nrec: u16) -> IoResult<()> {
152        use crate::format::chunk_index::btree_v2::{Bt2InternalNode, Bt2LeafNode};
153
154        self.node_addrs.push(addr);
155        let buf = self.handle.read_at_most(addr, self.node_size as usize)?;
156        if depth == 0 {
157            let leaf = Bt2LeafNode::decode(&buf, nrec, self.record_size)?;
158            self.records.extend_from_slice(&leaf.record_data);
159        } else {
160            let node = Bt2InternalNode::decode(
161                &buf,
162                self.ctx,
163                depth,
164                nrec,
165                self.record_size,
166                self.geo.max_nrec_size,
167                self.geo.child_total_size(depth),
168            )?;
169            // In-order: an internal node's records separate its children, so each
170            // one belongs between the subtrees on either side of it.
171            let children: Vec<(u64, u16)> = node
172                .child_addrs
173                .iter()
174                .zip(node.child_nrecords.iter())
175                .map(|(&a, &n)| (a, n))
176                .collect();
177            let rec = self.record_size as usize;
178            for (i, (child_addr, child_nrec)) in children.into_iter().enumerate() {
179                self.descend(child_addr, depth - 1, child_nrec)?;
180                if let Some(record) = node.record_data.get(i * rec..(i + 1) * rec) {
181                    self.records.extend_from_slice(record);
182                }
183            }
184        }
185        Ok(())
186    }
187}
188
189/// A walk of a version-1 raw-data-chunk B-tree: the file and geometry the
190/// descent reads through, and the two collections it fills.
191///
192/// The v1 counterpart of [`Bt2Walk`], and for the same reason: the
193/// records are what [`BtreeV1DatasetInfo::build_tree`] bulk-loads on the next
194/// flush, and the addresses are the block pool that flush re-serializes over,
195/// so a reopened tree owns the nodes it found instead of leaking them and
196/// allocating a second set beside them.
197///
198/// One value rather than nine parameters threaded through the recursion: only
199/// `addr` and `depth` change between one level and the next, so they are what
200/// [`descend`](Self::descend) takes and everything else lives here.
201struct BtreeV1Walk<'a> {
202    handle: &'a FileHandle,
203    ctx: &'a FormatContext,
204    config: &'a BTreeV1Config,
205    /// The chunk edge lengths, *without* the trailing element-size dimension,
206    /// so `chunk_dims.len()` is the rank the node keys are decoded at.
207    chunk_dims: &'a [u64],
208    file_size: u64,
209    records: Vec<BtreeV1ChunkRecord>,
210    node_addrs: Vec<u64>,
211}
212
213impl<'a> BtreeV1Walk<'a> {
214    fn new(
215        handle: &'a FileHandle,
216        ctx: &'a FormatContext,
217        config: &'a BTreeV1Config,
218        chunk_dims: &'a [u64],
219        file_size: u64,
220    ) -> Self {
221        Self {
222            handle,
223            ctx,
224            config,
225            chunk_dims,
226            file_size,
227            records: Vec::new(),
228            node_addrs: Vec::new(),
229        }
230    }
231
232    /// Walk the subtree rooted at `addr`, collecting every leaf entry as a
233    /// [`BtreeV1ChunkRecord`] and every node block's address.
234    ///
235    /// Records come out in key order because a v1 B-tree's leaves are in key
236    /// order and this descends left to right, which is what
237    /// [`BtreeV1DatasetInfo::position`]'s binary search needs. The keys store
238    /// element offsets (`scaled * chunk_dim`, `H5D__btree_encode_key`), so the
239    /// grid position this records is the quotient.
240    fn descend(&mut self, addr: u64, depth: u32) -> IoResult<()> {
241        // The same bound the reader's walk uses: a node's level is one byte, so
242        // no honest tree is deeper than that, and a cyclic index stops here.
243        if depth > 256 {
244            return Err(crate::io::IoError::InvalidState(
245                "chunk B-tree v1 exceeds maximum depth".into(),
246            ));
247        }
248        if addr == UNDEF_ADDR || addr >= self.file_size {
249            return Ok(());
250        }
251        let rank = self.chunk_dims.len();
252        let sa = self.ctx.sizeof_addr as usize;
253        let node_size = self.config.chunk_btree_node_size(sa, rank);
254        let buf = self.handle.read_at_most(addr, node_size)?;
255        let node = ChunkBTreeV1Node::decode(&buf, sa, rank, self.config.chunk_max_entries())?;
256        self.node_addrs.push(addr);
257
258        if node.level == 0 {
259            for (i, &child_addr) in node.children.iter().enumerate() {
260                let key = &node.keys[i];
261                let scaled: Vec<u64> = key.offsets[..rank]
262                    .iter()
263                    .zip(self.chunk_dims)
264                    .map(|(&offset, &dim)| offset.checked_div(dim).unwrap_or(0))
265                    .collect();
266                self.records.push(BtreeV1ChunkRecord {
267                    scaled,
268                    address: child_addr,
269                    nbytes: key.chunk_size,
270                    filter_mask: key.filter_mask,
271                });
272            }
273        } else {
274            for &child_addr in &node.children {
275                self.descend(child_addr, depth + 1)?;
276            }
277        }
278        Ok(())
279    }
280}
281
282/// Encode a fixed-array data block for the layout implied by `hdr`, using the
283/// chunk addresses held in `dblk.elements` (unfiltered) or the filtered chunk
284/// entries in `dblk.filtered_elements` (filtered, `client_id == 1`).
285///
286/// For the paged layout (`hdr.is_paged()`), emits the `FADB` prefix with a
287/// page-init bitmap followed by `npages` checksummed element pages. A page is
288/// marked initialized iff at least one of its chunk addresses is defined,
289/// mirroring libhdf5's lazy `H5FA__dblk_page_create`. Uninitialized pages are
290/// still written (all `UNDEF_ADDR`, valid checksum) so the file contains no
291/// uninitialized bytes; the reader skips them via the bitmap.
292fn encode_fixed_array_dblk(
293    ctx: &FormatContext,
294    hdr: &FixedArrayHeader,
295    dblk: &FixedArrayDataBlock,
296) -> Vec<u8> {
297    let is_filtered = hdr.client_id == FA_CLIENT_FILT_CHUNK;
298    let sa = ctx.sizeof_addr as usize;
299    // chunk_size_len for filtered entries = element_size - sizeof_addr - 4.
300    // libhdf5 carries element_size = sizeof_addr + chunk_size_len + 4.
301    let chunk_size_len = (hdr.element_size as usize).saturating_sub(sa + 4);
302
303    if !hdr.is_paged() {
304        return if is_filtered {
305            dblk.encode_filtered(ctx, chunk_size_len)
306        } else {
307            dblk.encode_unfiltered(ctx)
308        };
309    }
310
311    let npages = hdr.npages() as usize;
312    let dblk_page_nelmts = hdr.dblk_page_nelmts() as usize;
313
314    // Build the page-init bitmap (MSB-first): a page is initialized iff any of
315    // its elements points at a defined address.
316    let mut bitmap = vec![0u8; npages.div_ceil(8)];
317    let nelmts = if is_filtered {
318        dblk.filtered_elements.len()
319    } else {
320        dblk.elements.len()
321    };
322    for p in 0..npages {
323        let start = p * dblk_page_nelmts;
324        let end = ((p + 1) * dblk_page_nelmts).min(nelmts);
325        let initialized = if is_filtered {
326            dblk.filtered_elements[start..end]
327                .iter()
328                .any(|e| e.address != UNDEF_ADDR)
329        } else {
330            dblk.elements[start..end].iter().any(|&a| a != UNDEF_ADDR)
331        };
332        if initialized {
333            bitmap[p / 8] |= 0x80u8 >> (p % 8);
334        }
335    }
336
337    let prefix = FixedArrayPagedPrefix {
338        client_id: hdr.client_id,
339        header_addr: dblk.header_addr,
340        page_init_bitmap: bitmap,
341        prefix_size: 4 + 1 + 1 + sa + npages.div_ceil(8) + 4,
342    };
343
344    let mut buf = prefix.encode(ctx);
345    debug_assert_eq!(buf.len(), prefix.prefix_size);
346
347    // Append each page: all pages use the full `dblk_page_nelmts` stride;
348    // only the last page holds fewer elements (libhdf5 H5FA.c).
349    for p in 0..npages {
350        let start = p * dblk_page_nelmts;
351        let end = ((p + 1) * dblk_page_nelmts).min(nelmts);
352        if is_filtered {
353            buf.extend_from_slice(&encode_filtered_page(
354                &dblk.filtered_elements[start..end],
355                ctx,
356                chunk_size_len,
357            ));
358        } else {
359            buf.extend_from_slice(&encode_unfiltered_page(&dblk.elements[start..end], ctx));
360        }
361    }
362    buf
363}
364
365/// Decode a fixed-array data block for the layout implied by `hdr` — the
366/// inverse of [`encode_fixed_array_dblk`], and the single decode dispatch
367/// over non-paged/paged × unfiltered/filtered.
368///
369/// For the paged layout, pages whose bitmap bit is clear are skipped, not
370/// decoded: libhdf5 never writes an uninitialized page, so its bytes are
371/// arbitrary and carry no valid checksum. Their elements stay at the
372/// undefined-address defaults, which is exactly what the bitmap means.
373fn decode_fixed_array_dblk(
374    ctx: &FormatContext,
375    hdr: &FixedArrayHeader,
376    buf: &[u8],
377    chunk_size_len: usize,
378) -> crate::format::FormatResult<FixedArrayDataBlock> {
379    let is_filtered = hdr.client_id == FA_CLIENT_FILT_CHUNK;
380    let num_elmts = hdr.num_elmts as usize;
381
382    if !hdr.is_paged() {
383        return if is_filtered {
384            FixedArrayDataBlock::decode_filtered(buf, ctx, num_elmts, chunk_size_len)
385        } else {
386            FixedArrayDataBlock::decode_unfiltered(buf, ctx, num_elmts)
387        };
388    }
389
390    let npages = hdr.npages() as usize;
391    let dblk_page_nelmts = hdr.dblk_page_nelmts() as usize;
392    let prefix = FixedArrayPagedPrefix::decode(buf, ctx, npages as u64)?;
393
394    let mut dblk = if is_filtered {
395        FixedArrayDataBlock::new_filtered(prefix.header_addr, num_elmts)
396    } else {
397        FixedArrayDataBlock::new_unfiltered(prefix.header_addr, num_elmts)
398    };
399    dblk.client_id = hdr.client_id;
400
401    // Pages follow the prefix back to back; every page spans the full
402    // `dblk_page_nelmts` stride except the last, which holds the remainder.
403    let mut pos = prefix.prefix_size;
404    for p in 0..npages {
405        let start = p * dblk_page_nelmts;
406        let end = ((p + 1) * dblk_page_nelmts).min(num_elmts);
407        let nelmts = end - start;
408        if prefix.page_initialized(p) {
409            let page_buf = buf.get(pos..).unwrap_or(&[]);
410            if is_filtered {
411                let elems = decode_filtered_page(page_buf, ctx, nelmts, chunk_size_len)?;
412                dblk.filtered_elements[start..end].clone_from_slice(&elems);
413            } else {
414                let addrs = decode_unfiltered_page(page_buf, ctx, nelmts)?;
415                dblk.elements[start..end].copy_from_slice(&addrs);
416            }
417        }
418        pos += nelmts * hdr.element_size as usize + 4;
419    }
420    Ok(dblk)
421}
422
423/// Interior-mutability cell for per-dataset write state, selected by feature.
424///
425/// This is the §5-B "cfg-selected interior types" from
426/// `docs/threadsafe-fine-grained-locking.md`: the single-threaded build uses a
427/// `RefCell` (zero overhead, no atomics), while the `threadsafe` build uses a
428/// `Mutex` so two threads can write *different* datasets concurrently while the
429/// same dataset's writes serialize. Call sites are identical across both via
430/// [`Slot::lock`].
431#[cfg(not(feature = "threadsafe"))]
432pub(crate) struct Slot<T>(std::cell::RefCell<T>);
433
434#[cfg(not(feature = "threadsafe"))]
435impl<T> Slot<T> {
436    pub(crate) fn new(value: T) -> Self {
437        Slot(std::cell::RefCell::new(value))
438    }
439    /// Borrow the contents mutably (an uncontended `RefCell` borrow).
440    pub(crate) fn lock(&self) -> std::cell::RefMut<'_, T> {
441        self.0.borrow_mut()
442    }
443}
444
445#[cfg(feature = "threadsafe")]
446pub(crate) struct Slot<T>(std::sync::Mutex<T>);
447
448#[cfg(feature = "threadsafe")]
449impl<T> Slot<T> {
450    pub(crate) fn new(value: T) -> Self {
451        Slot(std::sync::Mutex::new(value))
452    }
453    /// Lock the contents. Different datasets hold different slots, so this
454    /// only contends when two threads write the *same* dataset.
455    pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, T> {
456        self.0.lock().unwrap()
457    }
458}
459
460/// Proof that the create gate (`create_lock`) is held and the new dataset's
461/// name passed the uniqueness check. Only [`Hdf5Writer::begin_create`]
462/// constructs one and [`Hdf5Writer::push_dataset`] demands one, so a creator
463/// cannot reach the dataset registry while skipping either step. Carries
464/// the canonical (link-resolved) name the creator must store, so the
465/// registry only ever holds tree paths.
466pub(crate) struct CreateGuard<'a> {
467    #[cfg(not(feature = "threadsafe"))]
468    _gate: std::cell::RefMut<'a, ()>,
469    #[cfg(feature = "threadsafe")]
470    _gate: std::sync::MutexGuard<'a, ()>,
471    /// The dataset name with every group hard link in it resolved.
472    pub(crate) name: String,
473    /// The group that will hold the new dataset's link, resolved from the
474    /// path components of `name`; `None` is the root group. Carried here so
475    /// [`Hdf5Writer::push_dataset`] registers the child itself and no creator
476    /// can leave a dataset whose name says one thing and whose parent group
477    /// says another.
478    pub(crate) parent: Option<usize>,
479}
480
481/// Reference-counted shared pointer, feature-selected. The single-thread
482/// build uses `Rc` (no atomics); the `threadsafe` build uses `Arc` so a
483/// dataset/group slot can be cloned out of the registry and locked on its
484/// own — letting writes to *different* datasets proceed concurrently without
485/// holding the registry lock. See `docs/threadsafe-fine-grained-locking.md`
486/// (Stage 3).
487#[cfg(not(feature = "threadsafe"))]
488pub(crate) type Shared<T> = std::rc::Rc<T>;
489#[cfg(feature = "threadsafe")]
490pub(crate) type Shared<T> = std::sync::Arc<T>;
491
492/// One dataset's cell in the registry: its metadata slot plus the operation
493/// lock that serializes whole logical operations on it. Both live in one
494/// allocation so they cannot fall out of step — every dataset has its op
495/// lock by construction.
496pub(crate) struct DatasetCell {
497    /// Serializes one *whole* logical operation on this dataset.
498    ///
499    /// The metadata slot below serializes each individual acquisition, but a
500    /// multi-acquisition operation — take the append buffer → write chunks →
501    /// re-buffer the tail → extend, or flush-then-overwrite in a slice write
502    /// — would interleave with a concurrent same-dataset operation *between*
503    /// its acquisitions under `threadsafe`. Public write entries take this
504    /// lock and delegate to `_inner` variants; `_inner` variants and the
505    /// `pub(crate)` write helpers require the caller to hold it (or to hold
506    /// the writer exclusively via `&mut`, as close and the SWMR wrapper do).
507    ///
508    /// Not reentrant: the single-thread build's `RefCell` panics instantly
509    /// on a nested acquisition, so a missed entry/inner split fails loudly
510    /// in every test run rather than deadlocking only under `threadsafe`.
511    ///
512    /// Lock order: `create_lock → op → registry spine → metadata slot`. An
513    /// op lock is never held across another dataset's op lock, and no
514    /// op-lock holder takes `create_lock`, so the order is acyclic.
515    pub(crate) op: Slot<()>,
516    info: Slot<DatasetInfo>,
517}
518
519impl DatasetCell {
520    pub(crate) fn new(info: DatasetInfo) -> Self {
521        DatasetCell {
522            op: Slot::new(()),
523            info: Slot::new(info),
524        }
525    }
526
527    /// Borrow the metadata slot (a single acquisition; see [`Self::op`] for
528    /// whole-operation serialization).
529    #[cfg(not(feature = "threadsafe"))]
530    pub(crate) fn lock(&self) -> std::cell::RefMut<'_, DatasetInfo> {
531        self.info.lock()
532    }
533
534    /// Lock the metadata slot (a single acquisition; see [`Self::op`] for
535    /// whole-operation serialization).
536    #[cfg(feature = "threadsafe")]
537    pub(crate) fn lock(&self) -> std::sync::MutexGuard<'_, DatasetInfo> {
538        self.info.lock()
539    }
540}
541
542/// A single dataset's [`DatasetCell`], reference-counted so a writer can
543/// clone it out of the registry (releasing the registry lock) and then lock
544/// just this one dataset. Two threads writing different datasets take
545/// different `DatasetRef` locks and never contend; the same dataset's writes
546/// serialize, which is required because one chunk index is not concurrently
547/// mutable.
548pub(crate) type DatasetRef = Shared<DatasetCell>;
549
550/// A single group's metadata behind its own [`Slot`], reference-counted like
551/// [`DatasetRef`].
552pub(crate) type GroupRef = Shared<Slot<GroupInfo>>;
553
554/// Appended frames held back until they complete a chunk.
555///
556/// The buffer is the sole authority for rows `base .. base + frames`: the
557/// file's chunks do not hold them yet, and any operation that writes those
558/// rows must go through [`Hdf5Writer::flush_append_buffer`] first. `base` is
559/// recorded when the frames are buffered — never derived from the current
560/// extent, which an `extend_dataset` can move independently.
561pub struct AppendBuffer {
562    /// Absolute row of the first buffered frame.
563    pub base: u64,
564    /// Number of buffered frames.
565    pub frames: u64,
566    /// The frames' bytes, `frames` whole rows, row-major.
567    pub bytes: Vec<u8>,
568}
569
570/// One file a dataset's raw data lives in, as the writer holds it: the name
571/// the I/O path opens, together with the local-heap offset the External File
572/// List message stores that name as.
573///
574/// The two halves are one entry rather than two parallel lists because they
575/// describe one slot — the message encodes `name_offset`, and every read or
576/// write of the slot's bytes opens `name`; splitting them is what lets a
577/// rewrite pair a name with another slot's offset.
578#[derive(Debug, Clone, PartialEq, Eq)]
579pub struct ExternalFile {
580    /// The file name exactly as the heap stores it. Resolved against
581    /// `HDF5_EXTFILE_PREFIX` at I/O time, never here — the same rule the read
582    /// side follows.
583    pub name: String,
584    /// Where `name` sits in the local heap at [`ExternalStorage::heap_addr`].
585    pub name_offset: u64,
586    /// Byte offset within `name` where this slot's region begins.
587    pub offset: u64,
588    /// Bytes of the dataset's raw data this slot holds.
589    pub size: u64,
590}
591
592/// A dataset whose contiguous raw data lives outside this file — the External
593/// File List message (`H5O_EFL_ID`) and the local heap its names are in.
594///
595/// The data layout message of such a dataset still says `Contiguous`, with
596/// its address left undefined: it is this message's presence that makes
597/// libhdf5 route the dataset's I/O through `H5D_LOPS_EFL` (H5Dlayout.c).
598#[derive(Debug, Clone)]
599pub struct ExternalStorage {
600    /// Address of the local heap header holding every slot's name.
601    pub heap_addr: u64,
602    /// The files, in the order their regions concatenate into the dataset's
603    /// logical byte range.
604    pub files: Vec<ExternalFile>,
605    /// The prefix every one of those names is joined against, and the open
606    /// that settled it. Lives here rather than on [`DatasetInfo`] so a
607    /// dataset with no external storage cannot carry a prefix and a dataset
608    /// with external storage cannot lack one.
609    prefix: EfilePrefix,
610}
611
612/// The expanded external file prefix in force for one dataset, and the open
613/// that decided it — libhdf5's `dset->shared->extfile_prefix`.
614///
615/// `H5D__build_file_prefix` runs it once per open of the shared info, from
616/// the dapl of `H5D__create` (H5Dint.c:1318) or of the `H5D__open` that
617/// found no shared info yet (:1537), and both `H5D__efl_read` and
618/// `H5D__efl_write` then join against that one answer (H5Defl.c:315-317,
619/// :429-431). Measured under libhdf5 1.14.6 and 2.0.0: `H5Dcreate2` with a
620/// dapl naming a directory creates the raw data file there at `H5Dwrite`,
621/// and `HDF5_EXTFILE_PREFIX` shadows that property on the write path exactly
622/// as it does on the read path.
623#[derive(Debug, Clone, Default)]
624struct EfilePrefix {
625    /// The expansion itself; `None` is "no prefix", which leaves a stored
626    /// name to resolve against the process's current directory.
627    expanded: Option<PathBuf>,
628    /// The open that decided [`expanded`](Self::expanded). An expired handle
629    /// means no open is holding the answer any more, so the next one settles
630    /// it afresh — which is the state a dataset this session reopened starts
631    /// in, `H5Fopen` opening no dataset of its own.
632    open: std::sync::Weak<()>,
633}
634
635impl ExternalStorage {
636    /// The message this storage encodes to (`H5O_efl_t`).
637    fn message(&self) -> ExternalFileListMessage {
638        ExternalFileListMessage {
639            heap_addr: self.heap_addr,
640            slots: self
641                .files
642                .iter()
643                .map(
644                    |f| crate::format::messages::external_file_list::ExternalFileSlot {
645                        name_offset: f.name_offset,
646                        offset: f.offset,
647                        size: f.size,
648                    },
649                )
650                .collect(),
651        }
652    }
653
654    /// Bytes the slots reserve in total (`H5O_efl_total_size`), saturating
655    /// rather than wrapping so an overflowing list reads as "as large as it
656    /// gets" and passes any size check instead of failing one.
657    fn total_size(&self) -> u64 {
658        self.files
659            .iter()
660            .fold(0u64, |acc, f| acc.saturating_add(f.size))
661    }
662}
663
664/// A dataset whose elements are read out of other datasets — the virtual
665/// layout message (`H5D_VIRTUAL`) and the mapping list it points at.
666///
667/// The mappings live in one global heap object rather than in the header
668/// (`H5D__virtual_store_layout`), so the layout message carries only its
669/// address and index; the list itself is kept here so a rewrite of the header
670/// can re-emit the message pointing at the same object.
671#[derive(Debug, Clone, PartialEq, Eq)]
672pub struct VirtualStorage {
673    /// Address of the global heap collection holding the mapping list.
674    pub heap_addr: u64,
675    /// Index of the mapping-list object within that collection.
676    pub heap_index: u32,
677    /// The mappings themselves, in the order they were declared — which is
678    /// the order libhdf5 resolves overlapping ones in.
679    pub mappings: Vec<VirtualMapping>,
680}
681
682/// Where a contiguous dataset's raw bytes live, read off its registry entry
683/// so the write itself can run with the slot unlocked.
684///
685/// The one place the local-versus-external-versus-nowhere choice is made; see
686/// [`DatasetInfo::contiguous_target`].
687enum ContiguousTarget {
688    /// A block in this file, starting at this address.
689    Local(u64),
690    /// The files an External File List names, in dataset order, and the
691    /// prefix in force for the open doing the writing — carried together
692    /// because a slot name means nothing without it.
693    External {
694        files: Vec<ExternalFile>,
695        prefix: Option<PathBuf>,
696    },
697    /// Nowhere: the dataset is virtual, and every element of it is stored in
698    /// whichever source dataset its mappings send that element to.
699    Virtual,
700}
701
702/// What a writer-mode `H5Dataset` handle is built from — the shape and
703/// element width it answers questions with, the chunk index it writes
704/// through, and the open it holds.
705pub(crate) struct DatasetHandleParts {
706    pub(crate) shape: Vec<usize>,
707    pub(crate) element_size: usize,
708    /// `None` for storage that is not chunked.
709    pub(crate) chunk_index: Option<ChunkIndexKind>,
710    /// Keeps this open alive; see [`Hdf5Writer::bind_efile_prefix`].
711    pub(crate) open: Option<crate::io::reader::DatasetOpenToken>,
712}
713
714impl ContiguousTarget {
715    /// Whether this target is storage bytes can be written into at all —
716    /// false only for [`ContiguousTarget::Virtual`], which names sources
717    /// rather than storage.
718    fn is_storage(&self) -> bool {
719        !matches!(self, Self::Virtual)
720    }
721}
722
723/// The one refusal of a write into a virtual dataset, so the two paths that
724/// can reach one — [`Hdf5Writer::write_contiguous_bytes`] and the pre-insert
725/// gate of [`Hdf5Writer::write_vlen_strings_slice`] — say the same thing.
726///
727/// libhdf5 does take this write, pushing each element through the mapping
728/// that covers it into the source dataset holding it (`H5D__virtual_write`);
729/// this writer never opens a source file, so it refuses rather than dropping
730/// the bytes somewhere they cannot be read back from.
731/// The legality checks `H5Pset_virtual` runs over one mapping —
732/// `H5D_virtual_check_mapping_pre` and `H5D_virtual_check_mapping_post`
733/// (H5Dvirtual.c).
734///
735/// The two upstream checks that need the *source dataset's* own extent (the
736/// limited/limited element-count match, and a printf mapping's single-block
737/// match) are not run here for the same reason upstream skips them when the
738/// source space status is `H5O_VIRTUAL_STATUS_INVALID`: a mapping may name a
739/// source that does not exist yet, and nothing here opens one.
740fn check_virtual_mapping(dataset: &str, m: &VirtualMapping) -> IoResult<()> {
741    for (which, sel) in [
742        ("virtual", &m.virtual_selection),
743        ("source", &m.source_selection),
744    ] {
745        if matches!(sel, Selection::Points(_)) {
746            return Err(crate::io::IoError::Unsupported(format!(
747                "virtual dataset '{dataset}' has a point {which} selection, which \
748                 H5D_virtual_check_mapping_pre refuses for every virtual dataset mapping \
749                 (\"point selections not currently supported with virtual datasets\")"
750            )));
751        }
752    }
753
754    let unlim_virtual = m.virtual_selection.unlim_dim().is_some();
755    let unlim_source = m.source_selection.unlim_dim().is_some();
756
757    // Both sides unbounded: the mapping grows with its source, so the slices
758    // they exchange must be the same shape whatever either extent becomes.
759    if unlim_virtual && unlim_source {
760        if let (Some(v), Some(sr)) = (
761            regular_hyperslab(&m.virtual_selection),
762            regular_hyperslab(&m.source_selection),
763        ) {
764            let (nv, ns) = (v.num_elem_non_unlim(), sr.num_elem_non_unlim());
765            if nv != ns {
766                return Err(crate::io::IoError::InvalidState(format!(
767                    "virtual dataset '{dataset}' maps an unlimited source selection onto an \
768                     unlimited virtual selection, but a slice of the non-unlimited \
769                     dimensions holds {ns:?} source elements and {nv:?} virtual ones"
770                )));
771            }
772        }
773    }
774
775    // `H5D_virtual_check_mapping_post`: an unlimited virtual selection over a
776    // limited source selection is the printf shape, where each block of the
777    // virtual selection is filled by a *different* source dataset named by
778    // substituting that block's index. It needs a `%b` to name them, and a
779    // hyperslab virtual selection to have blocks at all; every other shape
780    // needs the opposite, since a substitution with only one block to fill
781    // has nothing to vary over.
782    let nsubs = parse_source_name(&m.source_file_name)
783        .and_then(|f| Ok(f.nsubs() + parse_source_name(&m.source_dset_name)?.nsubs()))
784        .map_err(|e| {
785            crate::io::IoError::InvalidState(format!(
786                "virtual dataset '{dataset}' source name: {e}"
787            ))
788        })?;
789    if unlim_virtual && !unlim_source {
790        if nsubs == 0 {
791            return Err(crate::io::IoError::InvalidState(format!(
792                "virtual dataset '{dataset}' has an unlimited virtual selection, a limited \
793                 source selection, and no printf specifiers in source names"
794            )));
795        }
796        if !matches!(m.virtual_selection, Selection::Hyperslab { .. }) {
797            return Err(crate::io::IoError::InvalidState(format!(
798                "virtual dataset '{dataset}' has a printf mapping whose virtual selection is \
799                 not a hyperslab; the substitution runs over the blocks of that hyperslab"
800            )));
801        }
802    } else if nsubs > 0 {
803        return Err(crate::io::IoError::InvalidState(format!(
804            "virtual dataset '{dataset}' has printf specifier(s) in source name(s) without \
805             an unlimited virtual selection and limited source selection"
806        )));
807    }
808    Ok(())
809}
810
811/// The regular (start, stride, count, block) form behind a selection, or
812/// `None` — the only form that can carry `H5S_UNLIMITED`, so every unlimited
813/// check goes through it.
814fn regular_hyperslab(sel: &Selection) -> Option<&crate::format::selection::RegularHyperslab> {
815    match sel {
816        Selection::Hyperslab {
817            form: crate::format::selection::Hyperslab::Regular(r),
818            ..
819        } => Some(r),
820        _ => None,
821    }
822}
823
824fn virtual_write_refused() -> crate::io::IoError {
825    crate::io::IoError::Unsupported(
826        "cannot write into a virtual dataset: its elements live in the source datasets \
827         its mappings name, and this writer does not write through to them — write the \
828         source datasets themselves"
829            .into(),
830    )
831}
832
833/// Metadata for a dataset being written.
834///
835/// The whole struct lives behind a per-dataset [`Slot`] (via [`DatasetRef`]).
836/// The streaming write path locks it only briefly — compression runs *outside*
837/// the lock — so writes to different datasets do not contend, and a structural
838/// op (create/delete) that scans names only momentarily touches a sibling
839/// slot.
840pub struct DatasetInfo {
841    /// Link name within the root group.
842    pub name: String,
843    /// Element datatype.
844    pub datatype: DatatypeMessage,
845    /// The committed datatype this dataset shares, when it was created from
846    /// one. The type itself stays in [`datatype`](Self::datatype) — the
847    /// dataspace, the element width and every payload check need it — and
848    /// this says the header must store a pointer to that object instead of a
849    /// datatype message of its own.
850    pub committed_type: Option<CommittedTypeRef>,
851    /// Dataspace (dimensionality).
852    pub dataspace: DataspaceMessage,
853    /// The object format the reopen found this dataset's messages written in,
854    /// `None` for a dataset this session created.
855    ///
856    /// A rewrite re-encodes the whole header — the shared-message table is
857    /// laid out whole, so every heap ID moves and every header naming one has
858    /// to be written again. Re-deriving the message format from the reopened
859    /// session's bounds would upgrade messages the file already has, which
860    /// libhdf5 never does: it grows a header in place and leaves every
861    /// message it did not touch alone. The same rule the reopen already
862    /// applies to a group it found in a symbol table
863    /// ([`uses_symbol_table`](Hdf5Writer::uses_symbol_table)) — what the file
864    /// says governs, not what this session's bound would have chosen.
865    pub read_format: Option<ObjectFormat>,
866    /// File offset of the dataset's object header (set during finalize).
867    pub obj_header_addr: u64,
868    /// File offset of the raw data block (contiguous only).
869    pub data_addr: u64,
870    /// Size of the raw data in bytes (contiguous only).
871    pub data_size: u64,
872    /// The raw data itself, for a compact dataset — the whole image, which
873    /// [`build_dataset_header`](Hdf5Writer::build_dataset_header) puts inside
874    /// the data layout message rather than in a block of its own. `Some` is
875    /// what makes a dataset compact, and the buffer is created at its final
876    /// length (filled, as `H5D__compact_fill` does, before any write), so it
877    /// is also the dataset's byte count; `data_addr`/`data_size` stay at the
878    /// "no block in the file" values a compact dataset shares with a NULL one.
879    pub compact: Option<Vec<u8>>,
880    /// The files this dataset's contiguous raw data lives in, when it lives
881    /// outside this HDF5 file. `Some` is what makes a contiguous dataset
882    /// externally stored: its `data_addr` stays [`UNDEF_ADDR`] and every byte
883    /// goes to the files named here instead of to a block of this file's own.
884    pub external: Option<ExternalStorage>,
885    /// The source datasets this dataset's elements are read from, when it is
886    /// virtual. `Some` is what makes it virtual, and it stores nothing of its
887    /// own: `data_addr`/`data_size` keep the "no block in this file" values a
888    /// compact dataset also has.
889    pub virtual_storage: Option<VirtualStorage>,
890    /// Chunked storage info (None for contiguous).
891    pub chunked: Option<ChunkedDatasetInfo>,
892    /// Fixed array chunked storage info.
893    pub fixed_array: Option<FixedArrayDatasetInfo>,
894    /// B-tree v2 chunked storage info.
895    pub btree_v2: Option<Bt2DatasetInfo>,
896    /// Implicit (no structure) chunked storage info.
897    pub implicit: Option<ImplicitDatasetInfo>,
898    /// Single-chunk chunked storage info: the whole (fixed) dataspace is
899    /// exactly one chunk.
900    pub single_chunk: Option<SingleChunkDatasetInfo>,
901    /// Version-1 B-tree chunked storage info — the classic-format index.
902    pub btree_v1: Option<BtreeV1DatasetInfo>,
903    /// Appended frames not yet written to chunks, `None` when empty.
904    pub append: Option<AppendBuffer>,
905    /// Attributes attached to this dataset.
906    pub attributes: Vec<AttributeEntry>,
907    /// File offset where the dataset object header was written (for SWMR in-place rewrites).
908    pub obj_header_written_addr: Option<u64>,
909    /// Encoded size of the dataset object header (for verifying in-place rewrites fit).
910    /// Every block the object's on-disk header occupies, chunk 0 first, or
911    /// empty when it has none yet. A rewrite keeps chunk 0's block — its
912    /// address is what every reference to the object holds — and frees the
913    /// rest, so a continuation block left behind is space no free-space
914    /// manager records.
915    pub obj_header_blocks: crate::io::object_header_io::HeaderBlocks,
916    /// Filter pipeline for compressed chunks.
917    pub filter_pipeline: Option<FilterPipeline>,
918    /// Soft-deleted: excluded from finalize output.
919    pub deleted: bool,
920    /// The dataspace extent changed this session (`extend_dataset` /
921    /// `set_dataset_extent`). On a reopened dataset the finalize gate
922    /// otherwise infers "modified" from `chunks_written` alone, and a
923    /// session that only changed the extent would keep the old on-disk
924    /// header — silently dropping the new shape.
925    pub extent_dirty: bool,
926    /// Something the object header encodes changed this session without
927    /// touching the dataset's storage — an attribute set or removed, a fill
928    /// value defined. See [`header_stale`](DatasetInfo::header_stale).
929    pub header_dirty: bool,
930    /// The hard link count the on-disk header was written with, so finalize
931    /// can tell that this session changed it.
932    ///
933    /// A count, not a flag, because the count is what the header records and
934    /// the ways to change it are many: creating a link, unlinking one,
935    /// deleting a link's parent group, promoting a link to a primary name.
936    /// Comparing the value closes all of them at once, where a dirty flag
937    /// would have to be set at each and would be forgotten at the next one
938    /// added.
939    pub nlink_written: u32,
940    /// When the link naming this dataset was created; see
941    /// [`GroupInfo::creation_seq`].
942    pub creation_seq: u64,
943    /// How this dataset records creation order for its attributes — the
944    /// file's creation-order policy captured when the dataset was created,
945    /// the way libhdf5 captures the DCPL. A dataset holds no links, so only
946    /// the attribute half of [`TrackOrder`] applies to it.
947    pub track_attr_order: CreationOrder,
948    /// User-defined fill value bytes (exactly one element wide). `None`
949    /// means default zero-fill; `Some` is emitted as a `fill_defined = 2`
950    /// fill-value message in the dataset object header.
951    pub fill_value: Option<Vec<u8>>,
952    /// Fill value write time (`H5Pset_fill_time`'s `H5D_fill_time_t`, one of
953    /// [`FILL_TIME_ALLOC`], [`FILL_TIME_NEVER`], [`FILL_TIME_IFSET`]),
954    /// emitted verbatim into the fill-value message's write-time field.
955    /// Defaults to `FILL_TIME_IFSET`, `H5D_CRT_FILL_TIME_DEF` — what a fresh
956    /// dataset creation property list carries until `set_dataset_fill_time`
957    /// says otherwise.
958    pub fill_time: u8,
959    /// Layout message version for chunked storage: 4, or 5 when the chunk
960    /// index encodes stored chunk sizes in a fixed `sizeof_size` field
961    /// (libhdf5 2.0). Chosen at create by `Hdf5Writer::chunk_layout_version`,
962    /// preserved from the file on reopen, and emitted verbatim at finalize.
963    /// Contiguous datasets ignore it.
964    pub layout_version: u8,
965    /// The times this object tracks: `Some` exactly when it was created with
966    /// `H5Pset_obj_track_times(true)`, `None` when it was not.
967    ///
968    /// One meaning on both header versions, which store them differently and
969    /// store different amounts of them: a version-2 header keeps all four in
970    /// its prefix, and a version-1 dataset keeps one, in an `H5O_MTIME_NEW`
971    /// message. [`touch_oh`] is the single place that turns this into either
972    /// of those, so the four fields are here whichever version the object
973    /// has, exactly as `H5O_t` carries `atime`/`mtime`/`ctime`/`btime` for a
974    /// version-1 header it never serialises them from.
975    pub times: Option<ObjectTimes>,
976}
977
978impl DatasetInfo {
979    /// Which chunk index this dataset uses, `None` for storage that is not
980    /// chunked — the one place the index-carrying fields are turned into an
981    /// answer.
982    ///
983    /// INVARIANT: a chunk index added to this struct is added here. A site
984    /// that spells the disjunction out itself is what classifies a new index
985    /// as contiguous storage, and contiguous storage is read and written at
986    /// [`data_addr`](Self::data_addr) — which a chunked dataset leaves
987    /// undefined, so the misclassification is a read or a write at
988    /// `UNDEF_ADDR` rather than an error.
989    pub(crate) fn chunk_index_kind(&self) -> Option<ChunkIndexKind> {
990        if self.chunked.is_some() {
991            Some(ChunkIndexKind::ExtensibleArray)
992        } else if self.fixed_array.is_some() {
993            Some(ChunkIndexKind::FixedArray)
994        } else if self.btree_v2.is_some() {
995            Some(ChunkIndexKind::BtreeV2)
996        } else if self.implicit.is_some() {
997            Some(ChunkIndexKind::Implicit)
998        } else if self.single_chunk.is_some() {
999            Some(ChunkIndexKind::SingleChunk)
1000        } else if self.btree_v1.is_some() {
1001            Some(ChunkIndexKind::BtreeV1)
1002        } else {
1003            None
1004        }
1005    }
1006
1007    /// Whether this dataset's raw data is stored in chunks — the question
1008    /// every storage-form test asks, asked in one place.
1009    pub(crate) fn is_chunked(&self) -> bool {
1010        self.chunk_index_kind().is_some()
1011    }
1012
1013    /// Where this dataset's contiguous raw bytes live, or `None` when it has
1014    /// no contiguous storage to write into at all — a chunked dataset, a
1015    /// compact one (whose bytes *are* the layout message), or one whose block
1016    /// was never allocated.
1017    ///
1018    /// INVARIANT: every write of a contiguous dataset's raw bytes picks its
1019    /// destination here and reaches it through
1020    /// [`Hdf5Writer::write_contiguous_bytes`]. A site that read `data_addr`
1021    /// itself would write an externally-stored dataset's data into this file
1022    /// — at [`UNDEF_ADDR`], the far end of the address space — instead of into
1023    /// the files its header names, and would do the same to a virtual one,
1024    /// whose bytes are not this file's to write at all.
1025    ///
1026    /// Chunked storage is excluded through
1027    /// [`chunk_index_kind`](Self::chunk_index_kind) rather than by naming the
1028    /// index-carrying fields, so an index added to this struct cannot arrive
1029    /// here as contiguous storage: an implicit-indexed dataset reads
1030    /// `data_addr` as the base of its chunk grid, which as a contiguous
1031    /// destination would take a raw write meant for one chunk and lay it over
1032    /// the whole grid.
1033    fn contiguous_target(&self) -> Option<ContiguousTarget> {
1034        if self.is_chunked() || self.compact.is_some() {
1035            return None;
1036        }
1037        if self.virtual_storage.is_some() {
1038            return Some(ContiguousTarget::Virtual);
1039        }
1040        match &self.external {
1041            Some(ext) => Some(ContiguousTarget::External {
1042                files: ext.files.clone(),
1043                prefix: ext.prefix.expanded.clone(),
1044            }),
1045            None => {
1046                (self.data_addr != UNDEF_ADDR).then_some(ContiguousTarget::Local(self.data_addr))
1047            }
1048        }
1049    }
1050
1051    /// The one run of file bytes an implicitly indexed dataset's chunk grid
1052    /// is — its start and its length — or `None` when the dataset is indexed
1053    /// some other way or its space is not allocated yet.
1054    ///
1055    /// That index has no per-chunk structure to hold an address in: every
1056    /// chunk sits at `data_addr + linear_index * chunk_bytes` and the grid is
1057    /// allocated whole at create (`H5D__none_idx_get_addr`, H5Dnone.c). So the
1058    /// run is file space this writer allocated, and it is the *only* storage a
1059    /// chunk of such a dataset can occupy — the builder refuses external and
1060    /// virtual storage together with chunked storage, which is why
1061    /// [`allocated_storage_run`](Self::allocated_storage_run) can name it
1062    /// [`ContiguousTarget::Local`] and no chunk write can reach the other two.
1063    fn implicit_grid(&self) -> Option<(u64, u64)> {
1064        let imp = self.implicit.as_ref()?;
1065        (imp.data_addr != UNDEF_ADDR).then_some((imp.data_addr, imp.data_size))
1066    }
1067
1068    /// The run of raw storage this writer *allocated* for the dataset — the
1069    /// target to initialise it through and its size — or `None` when it
1070    /// allocated none.
1071    ///
1072    /// The two storage forms that are one run of bytes: a contiguous
1073    /// dataset's data block, and an implicitly indexed dataset's chunk grid.
1074    /// A compact dataset is excluded (its bytes are its layout message) and so
1075    /// is every other chunk index, whose chunks are placed one at a time.
1076    ///
1077    /// External storage is excluded because this writer does not allocate it:
1078    /// `H5D__alloc_storage` skips its whole body — the space reservation and
1079    /// the `H5D__init_storage` that would tile the fill value into it — for a
1080    /// dataset with an external file list or an empty extent, "we assume that
1081    /// external storage is already allocated by the caller, or at least will
1082    /// be before I/O is performed" (H5Dint.c:2270-2274). Measured under
1083    /// libhdf5 1.14.6 and 2.0.0: a user fill value, `H5D_FILL_TIME_ALLOC` and
1084    /// `H5D_ALLOC_TIME_EARLY` together leave the raw data file uncreated at
1085    /// `H5Dcreate2`, and a read before any write fails with "unable to open
1086    /// external raw data file" rather than reporting the fill.
1087    ///
1088    /// INVARIANT: only storage whose bytes this file owns is initialised as
1089    /// one run, so the allocate-time fill cannot reach the files an external
1090    /// file list names or the sources a virtual dataset maps.
1091    fn allocated_storage_run(&self) -> Option<(ContiguousTarget, u64)> {
1092        match self.implicit_grid() {
1093            Some((addr, size)) => Some((ContiguousTarget::Local(addr), size)),
1094            // Not a fallthrough for an unallocated implicit grid:
1095            // `contiguous_target` answers `None` for every chunked dataset.
1096            None => match self.contiguous_target() {
1097                Some(t @ ContiguousTarget::Local(_)) => Some((t, self.data_size)),
1098                _ => None,
1099            },
1100        }
1101    }
1102
1103    /// Whether this session wrote chunk data or changed the extent, so the
1104    /// dataset's index structures have to be re-flushed.
1105    fn storage_dirty(&self) -> bool {
1106        self.chunked.as_ref().is_some_and(|c| c.chunks_written > 0)
1107            || self
1108                .fixed_array
1109                .as_ref()
1110                .is_some_and(|f| f.chunks_written > 0)
1111            || self.btree_v2.as_ref().is_some_and(|b| b.chunks_written > 0)
1112            || self.btree_v1.as_ref().is_some_and(|b| b.chunks_written > 0)
1113            || self
1114                .single_chunk
1115                .as_ref()
1116                .is_some_and(|s| s.chunks_written > 0)
1117            || self.extent_dirty
1118    }
1119
1120    /// Whether a reopened dataset's on-disk object header no longer describes
1121    /// it.
1122    ///
1123    /// INVARIANT: every mutation of something `build_dataset_header` encodes
1124    /// must show up here. Finalize keeps the original header when this is
1125    /// false, so a change this misses is not deferred — it is discarded, with
1126    /// no error to say so. Attributes were the case that proved it: they are
1127    /// invisible to the chunk-write counters, so an attribute set on a
1128    /// reopened dataset vanished at close.
1129    fn header_stale(&self) -> bool {
1130        self.storage_dirty() || self.header_dirty
1131    }
1132
1133    /// The same question for the one thing the dataset itself cannot see: how
1134    /// many hard links resolve to it. That count lives in the header — an
1135    /// Object Reference Count message in a version-2 header, the `nlink`
1136    /// prefix field of a version-1 one — but it is a property of the file's
1137    /// link graph, so the caller supplies today's value.
1138    fn header_stale_with(&self, nlink: u32) -> bool {
1139        self.header_stale() || nlink != self.nlink_written
1140    }
1141
1142    /// Record that this dataset's on-disk object header was just written with
1143    /// `nlink` in it.
1144    ///
1145    /// INVARIANT: every write of a dataset object header passes through here.
1146    /// [`header_stale_with`](Self::header_stale_with) is the one authority for
1147    /// "does what is on disk still describe this dataset?", and it answers by
1148    /// comparing against [`nlink_written`](Self::nlink_written) — so a site
1149    /// that writes a header without saying so leaves that answer describing an
1150    /// older write. There are three writers: `finalize`, `finalize_for_swmr`
1151    /// and `write_dataset_header_inplace`. The last recorded nothing; it could
1152    /// not drift today only because a count it could write is a count that
1153    /// makes the header outgrow its block, which it refuses. That is a
1154    /// property of the reference-count message's size, not a rule anything
1155    /// states, and it is not what the field's definition rests on.
1156    fn header_written(&mut self, nlink: u32) {
1157        self.nlink_written = nlink;
1158    }
1159}
1160
1161/// Runtime metadata for a chunked dataset.
1162pub struct ChunkedDatasetInfo {
1163    /// Chunk dimension sizes.
1164    pub chunk_dims: Vec<u64>,
1165    /// Extensible array parameters.
1166    pub earray_params: EarrayParams,
1167    /// File offset of the EA header.
1168    pub ea_header_addr: u64,
1169    /// File offset of the EA index block.
1170    pub ea_iblk_addr: u64,
1171    /// In-memory copy of the EA header (for updating statistics).
1172    pub ea_header: ExtensibleArrayHeader,
1173    /// In-memory copy of the EA index block (for unfiltered datasets).
1174    pub ea_iblk: ExtensibleArrayIndexBlock,
1175    /// Number of chunks written so far.
1176    pub chunks_written: u64,
1177    /// Filtered index block (for compressed datasets).
1178    pub filt_iblk: Option<FilteredIndexBlock>,
1179    /// chunk_size_len for filtered entries.
1180    pub chunk_size_len: u8,
1181}
1182
1183/// Where a newly-created EA data block's address must be recorded.
1184enum DblkParent {
1185    /// Slot `index_block.dblk_addrs[idx]`.
1186    IndexBlock(usize),
1187    /// Slot `super_block.dblk_addrs[local_dblk]` of the super block at `sblk_addr`.
1188    SuperBlock {
1189        sblk_addr: u64,
1190        ndblks_in_sblk: usize,
1191        local_dblk: usize,
1192    },
1193}
1194
1195/// Which attribute list an attribute operation targets: the root group's,
1196/// a group's (by full path), or a dataset's (by writer index).
1197#[derive(Clone, Copy)]
1198pub enum AttrTarget<'a> {
1199    /// The root group's (file-level) attributes.
1200    Root,
1201    /// A group's attributes, by full path.
1202    Group(&'a str),
1203    /// A dataset's attributes, by writer index.
1204    Dataset(usize),
1205}
1206
1207/// Which chunk index a dataset uses.
1208///
1209/// The five above the line are what `H5D__layout_set_latest_indexing`
1210/// (H5Dlayout.c) picks between once the file format allows a version-4 data
1211/// layout message, in this precedence: a v2 B-tree for two or more unlimited
1212/// dimensions, an extensible array for exactly one, and — for a fixed shape —
1213/// the single-chunk index whenever exactly one chunk covers the whole
1214/// dataspace (`dims == max_dims == chunk_dims`, checked before either
1215/// alternative below and taken regardless of filter or allocation-time), else
1216/// the implicit index when nothing has to be recorded per chunk (no filter,
1217/// early allocation), else a fixed array. [`BtreeV1`](Self::BtreeV1) is not
1218/// one of them: it belongs to the version-3 layout message, and a file whose
1219/// superblock is older than version 2 can carry no other.
1220#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1221pub(crate) enum ChunkIndexKind {
1222    ExtensibleArray,
1223    FixedArray,
1224    BtreeV2,
1225    Implicit,
1226    SingleChunk,
1227    BtreeV1,
1228}
1229
1230/// A chunked dataset's grid geometry, snapshotted out of its slot.
1231///
1232/// The single owner of chunk-grid arithmetic: how many chunks span each
1233/// dimension, where a coordinate sits in the row-major order the array
1234/// indices record, and how many bytes one chunk holds.
1235struct ChunkGeometry {
1236    kind: ChunkIndexKind,
1237    dims: Vec<u64>,
1238    max_dims: Option<Vec<u64>>,
1239    chunk_dims: Vec<u64>,
1240    element_size: u64,
1241}
1242
1243impl ChunkGeometry {
1244    /// Unfiltered byte size of one whole chunk.
1245    fn chunk_bytes(&self) -> u64 {
1246        self.chunk_dims.iter().product::<u64>() * self.element_size
1247    }
1248
1249    /// Row-major position of `coords` in the chunk grid — the linear index an
1250    /// extensible or fixed array records the chunk under, computed against
1251    /// the maximum-extent grid by [`crate::io::chunk_grid::linear_index`].
1252    fn linear_index(&self, coords: &[u64]) -> IoResult<u64> {
1253        crate::io::chunk_grid::linear_index(
1254            &self.dims,
1255            self.max_dims.as_deref(),
1256            &self.chunk_dims,
1257            coords,
1258        )
1259    }
1260}
1261
1262/// Where an attribute arriving at [`Hdf5Writer::insert_attribute`] came from.
1263///
1264/// The variable-length setters have to evict before they allocate — the
1265/// free-before-alloc order — so by the time the replacement is inserted the
1266/// list no longer holds the entry it replaces, and the ordinary "already
1267/// present, so keep its index" test cannot see it. `H5A__attr_write` does not
1268/// create the attribute again, so the index travels with the eviction rather
1269/// than being stamped afresh; without it a rewritten attribute takes the set's
1270/// running maximum and moves to the end of the creation order.
1271#[derive(Debug, Clone, Copy)]
1272enum AttrOrigin {
1273    /// A new attribute, which takes the set's next creation index.
1274    Created,
1275    /// A value written over an attribute this writer has just evicted, which
1276    /// keeps that attribute's creation index — `None` when the object tracks
1277    /// no order, and so records none. An eviction that found nothing to remove
1278    /// answers `Created`: what follows it is a create like any other.
1279    Rewritten(Option<u16>),
1280}
1281use AttrOrigin::{Created, Rewritten};
1282
1283/// Take an object's attributes into the append session, or refuse the reopen.
1284///
1285/// Append mode rebuilds every object header it touches out of the attributes
1286/// read from it, so what this returns is what the object will still have when
1287/// the session finalizes. An attribute set that could not be read whole —
1288/// `ObjectAttributes::into_complete` refuses it — would come back as the part
1289/// that did read, silently deleting the rest.
1290///
1291/// Left to surface at `finalize`, that failure would land after this session's
1292/// chunk data and indices had already been written past the allocation point
1293/// the superblock still records, leaving a file libhdf5 reads as truncated.
1294/// Refusing the open leaves it untouched.
1295///
1296/// Size is no longer a reason to refuse: an attribute too large for a header
1297/// message goes back out through dense storage, the form libhdf5 read it from.
1298///
1299/// The set comes back in creation-index order, which is the order the registry
1300/// holds attributes in for an object made in this session too. A dense set is
1301/// read through the name index, so the order it arrives in is the order a hash
1302/// walk took; sorting here is what makes "the list is in creation order" true
1303/// of a reopened object as well, without any later stage having to know which
1304/// storage form the attributes came out of. Attributes of an untracked object
1305/// carry no index and keep the order they were read in.
1306fn take_reopened_attributes(
1307    attrs: crate::io::reader::ObjectAttributes,
1308    owner: &str,
1309) -> IoResult<Vec<AttributeEntry>> {
1310    let mut attrs = attrs.into_complete(owner)?;
1311    attrs.sort_by_key(|a| a.creation_index());
1312    Ok(attrs)
1313}
1314
1315/// The creation-order policy an on-disk object header declares — the single
1316/// owner of the recovery rule, used for the root group, every reopened group
1317/// and (through its attribute half) every reopened dataset.
1318///
1319/// The two halves come from two different places, and reading one for both is
1320/// how a file that sets only one of them came back with both or neither:
1321///
1322///   * links — the `Link Info` message's flag bits, which is what
1323///     `H5Pget_link_creation_order` reads (`H5G__get_create_plist`). A group
1324///     with no such message (or one this crate cannot decode) tracks nothing;
1325///     so does every dataset, which has no links to order.
1326///   * attributes — the object header's own flag bits, which is what
1327///     `H5Pget_attr_creation_order` reads (`H5Pocpl.c`). The `Attribute Info`
1328///     message carries the same two bits, but the header is the authority
1329///     libhdf5 consults, and it is present even when the object has no
1330///     attributes yet.
1331fn recover_track_order(
1332    header: &crate::format::object_header::ObjectHeader,
1333    ctx: &FormatContext,
1334) -> TrackOrder {
1335    let links = header
1336        .messages
1337        .iter()
1338        .find(|m| m.msg_type == crate::format::messages::MSG_LINK_INFO)
1339        .and_then(|m| LinkInfoMessage::decode(&m.data, ctx).ok())
1340        .map(|(info, _)| info.creation_order())
1341        .unwrap_or_default();
1342    TrackOrder {
1343        links,
1344        attrs: header.attribute_creation_order(),
1345    }
1346}
1347
1348/// `H5O_touch_oh` (H5Oint.c:1273): put an object's tracked times where its
1349/// header version keeps them.
1350///
1351/// INVARIANT: every object header this writer builds passes its times through
1352/// here. The version decides the storage and nothing else does — a caller that
1353/// set `ObjectHeader::times` itself would hand a version-1 encode a prefix
1354/// field that version has no room for, and one that added the message itself
1355/// would put a second copy in a version-2 header.
1356///
1357/// `force` is upstream's own parameter, and it is what splits datasets from
1358/// everything else: it creates the version-1 `H5O_MTIME_NEW` message when the
1359/// header has none, and only `H5D__update_oh_info` passes it true
1360/// (H5Dint.c:1022-1026). Every other caller passes false and so creates no
1361/// message at all, which is why a version-1 group or committed datatype
1362/// records no time even when it is tracking them. A version-2 header keeps all
1363/// four times in its prefix whatever `force` says.
1364fn touch_oh(
1365    header: &mut ObjectHeader,
1366    format: ObjectFormat,
1367    times: Option<ObjectTimes>,
1368    force: bool,
1369) {
1370    let Some(times) = touched_times(times) else {
1371        return;
1372    };
1373    match format {
1374        ObjectFormat::Modern => header.times = Some(times),
1375        ObjectFormat::Legacy if force => header.add_message(
1376            crate::format::messages::MSG_MOD_TIME,
1377            0x00,
1378            ModificationTime(times.change).encode(),
1379        ),
1380        ObjectFormat::Legacy => {}
1381    }
1382}
1383
1384/// The times a header being (re)written carries, given what the object had.
1385///
1386/// Every object header this writer emits is one it is writing *now*, which is
1387/// what `H5O_touch_oh` is called for: an object that stores times gets its
1388/// access and change time moved to now, and one that does not store them stays
1389/// that way — the flag belongs to the object's creation property list, and a
1390/// rewrite is not a creation.
1391fn touched_times(times: Option<ObjectTimes>) -> Option<ObjectTimes> {
1392    times.map(|t| t.touched(now_seconds()))
1393}
1394
1395/// Seconds since the epoch, as an object header stores them (`H5_now`).
1396///
1397/// Saturates rather than wrapping: the field is a 32-bit count, and a clock
1398/// past 2106 is better reported as the largest time the format can express
1399/// than as a time in 1970. A clock before the epoch yields 0, which is what
1400/// libhdf5 writes for "no time recorded".
1401fn now_seconds() -> u32 {
1402    std::time::SystemTime::now()
1403        .duration_since(std::time::UNIX_EPOCH)
1404        .map_or(0, |d| u32::try_from(d.as_secs()).unwrap_or(u32::MAX))
1405}
1406
1407/// The dense storage an on-disk object header names: the fractal heap and the
1408/// indices its `Attribute Info` and `Link Info` messages point at.
1409///
1410/// A rewrite of that header lays fresh storage out and stops naming this, so
1411/// what this returns is exactly what the rewrite supersedes and must free.
1412/// Compact storage names no heap and yields `None` — there is nothing to free
1413/// and nothing that could be freed twice.
1414fn superseded_dense(
1415    header: &crate::format::object_header::ObjectHeader,
1416    ctx: &FormatContext,
1417) -> (Option<AttributeInfoMessage>, Option<LinkInfoMessage>) {
1418    let decode = |msg_type: u8| {
1419        header
1420            .messages
1421            .iter()
1422            .find(|m| m.msg_type == msg_type)
1423            .map(|m| m.data.as_slice())
1424    };
1425    let attrs = decode(crate::format::messages::MSG_ATTR_INFO)
1426        .and_then(|d| AttributeInfoMessage::decode(d, ctx).ok())
1427        .map(|(info, _)| info)
1428        .filter(|info| info.is_dense());
1429    let links = decode(crate::format::messages::MSG_LINK_INFO)
1430        .and_then(|d| LinkInfoMessage::decode(d, ctx).ok())
1431        .map(|(info, _)| info)
1432        .filter(|info| info.is_dense());
1433    (attrs, links)
1434}
1435
1436/// One collection block with free space that a later vlen insert may
1437/// fill — an entry in the writer's CWFS list (libhdf5 `f->shared->cwfs`).
1438struct CwfsEntry {
1439    /// Block address of the collection.
1440    addr: u64,
1441    /// Declared block size; never changes after allocation.
1442    size: usize,
1443    /// Bytes its free-space marker owns, per
1444    /// [`GlobalHeapCollection::free_space_at`](crate::format::global_heap::GlobalHeapCollection::free_space_at).
1445    free: usize,
1446}
1447
1448/// Maximum CWFS entries tracked — libhdf5's `H5HG_NCWFS` (H5HGpkg.h).
1449const H5HG_NCWFS: usize = 16;
1450
1451/// Record a collection with `free` bytes in the CWFS list: update its
1452/// entry if present, append while the list is short, and otherwise
1453/// replace the entry with the least free space when this one has more —
1454/// the retention rule of libhdf5's `H5HG_insert`.
1455fn cwfs_note(cwfs: &mut Vec<CwfsEntry>, addr: u64, size: usize, free: usize) {
1456    if let Some(p) = cwfs.iter().position(|e| e.addr == addr) {
1457        cwfs[p].free = free;
1458        return;
1459    }
1460    if cwfs.len() < H5HG_NCWFS {
1461        cwfs.insert(0, CwfsEntry { addr, size, free });
1462        return;
1463    }
1464    if let Some(p) = (0..cwfs.len()).min_by_key(|&p| cwfs[p].free) {
1465        if free > cwfs[p].free {
1466            cwfs[p] = CwfsEntry { addr, size, free };
1467        }
1468    }
1469}
1470
1471/// The uniform rejection for `delete_dataset` / `delete_group` while SWMR
1472/// streaming is active: deleting frees the object's blocks, and a live
1473/// reader may hold any of their addresses.
1474fn swmr_delete_error(name: &str) -> crate::io::IoError {
1475    crate::io::IoError::InvalidState(format!(
1476        "cannot delete '{name}' during SWMR streaming: a reader may hold the \
1477         object's header and storage addresses (libhdf5 forbids link deletion \
1478         during SWMR writes too)"
1479    ))
1480}
1481
1482/// Whether the chunk at grid `coords` lies entirely at or beyond `extent` in
1483/// some dimension — no element of it would survive a shrink to that extent.
1484fn chunk_outside_extent(coords: &[u64], chunk_dims: &[u64], extent: &[u64]) -> bool {
1485    coords
1486        .iter()
1487        .zip(chunk_dims)
1488        .zip(extent)
1489        .any(|((&c, &cd), &e)| c.saturating_mul(cd) >= e)
1490}
1491
1492/// Whether the chunk at grid `coords` keeps elements under `extent` but
1493/// extends past it in some dimension — a shrink must refill its
1494/// out-of-extent region with the fill value.
1495fn chunk_straddles_extent(coords: &[u64], chunk_dims: &[u64], extent: &[u64]) -> bool {
1496    !chunk_outside_extent(coords, chunk_dims, extent)
1497        && coords
1498            .iter()
1499            .zip(chunk_dims)
1500            .zip(extent)
1501            .any(|((&c, &cd), &e)| (c + 1).saturating_mul(cd) > e)
1502}
1503
1504/// Overwrite, in `data` (one whole chunk, unfiltered, row-major), every
1505/// element at or beyond `extent` with the matching bytes of `fill` — a
1506/// same-sized buffer tiled with the fill value. The caller guarantees the
1507/// chunk at `coords` straddles `extent`, so every dimension keeps at least
1508/// one element. Returns the replaced bytes, so a vlen dataset's dead
1509/// heap references can be released rather than stranded.
1510fn refill_chunk_beyond_extent(
1511    data: &mut [u8],
1512    fill: &[u8],
1513    coords: &[u64],
1514    chunk_dims: &[u64],
1515    extent: &[u64],
1516    element_size: usize,
1517) -> Vec<u8> {
1518    let ndims = chunk_dims.len();
1519    let keep: Vec<usize> = (0..ndims)
1520        .map(|d| {
1521            let origin = coords[d] * chunk_dims[d];
1522            chunk_dims[d].min(extent[d].saturating_sub(origin)) as usize
1523        })
1524        .collect();
1525    // Row-major walk: for every row (all dimensions but the last),
1526    // overwrite the whole row when its prefix is outside the keep box,
1527    // else only the row's out-of-extent tail.
1528    let row_elems = chunk_dims[ndims - 1] as usize;
1529    let keep_last = keep[ndims - 1];
1530    let nrows: u64 = chunk_dims[..ndims - 1].iter().product();
1531    let mut replaced = Vec::new();
1532    for r in 0..nrows {
1533        let mut rem = r;
1534        let mut in_keep = true;
1535        for d in (0..ndims - 1).rev() {
1536            let c = rem % chunk_dims[d];
1537            rem /= chunk_dims[d];
1538            if c as usize >= keep[d] {
1539                in_keep = false;
1540            }
1541        }
1542        let start = if in_keep { keep_last } else { 0 };
1543        if start == row_elems {
1544            continue;
1545        }
1546        let a = (r as usize * row_elems + start) * element_size;
1547        let b = (r as usize + 1) * row_elems * element_size;
1548        replaced.extend_from_slice(&data[a..b]);
1549        data[a..b].copy_from_slice(&fill[a..b]);
1550    }
1551    replaced
1552}
1553
1554/// Validate caller-supplied chunk geometry at dataset definition, the rule
1555/// libhdf5 applies in `H5Pset_chunk` (H5Pdcpl.c) and `H5D__chunk_construct`
1556/// (H5Dchunk.c): the chunk rank must be positive and match the dataspace
1557/// rank, no chunk dimension may be zero, and a chunk dimension may not
1558/// exceed a fixed maximum dimension — except in a dimension whose current
1559/// size is zero, which libhdf5 exempts.
1560fn validate_chunk_geometry(dims: &[u64], max_dims: &[u64], chunk_dims: &[u64]) -> IoResult<()> {
1561    if chunk_dims.is_empty() {
1562        return Err(crate::io::IoError::InvalidState(
1563            "chunk dimensionality must be positive".into(),
1564        ));
1565    }
1566    let ndims = dims.len();
1567    if chunk_dims.len() != ndims {
1568        return Err(crate::io::IoError::InvalidState(format!(
1569            "chunk shape has {} dimensions but the dataspace has {}",
1570            chunk_dims.len(),
1571            ndims
1572        )));
1573    }
1574    if max_dims.len() != ndims {
1575        return Err(crate::io::IoError::InvalidState(format!(
1576            "maximum shape has {} dimensions but the dataspace has {}",
1577            max_dims.len(),
1578            ndims
1579        )));
1580    }
1581    for d in 0..ndims {
1582        if chunk_dims[d] == 0 {
1583            return Err(crate::io::IoError::InvalidState(format!(
1584                "chunk dimension {d} is zero"
1585            )));
1586        }
1587        if dims[d] != 0 && max_dims[d] != u64::MAX && max_dims[d] < chunk_dims[d] {
1588            return Err(crate::io::IoError::InvalidState(format!(
1589                "chunk dimension {} is {} but the maximum dimension size is {}",
1590                d, chunk_dims[d], max_dims[d]
1591            )));
1592        }
1593    }
1594    Ok(())
1595}
1596
1597/// An extensible-array index requires at most one unlimited dimension —
1598/// `H5D__chunk_construct` (H5Dchunk.c) only selects this index for exactly
1599/// one — at any position: `chunk_grid::linear_index` seeds the unlimited
1600/// dimension into the slot no down-chunks multiplier touches, the same
1601/// address libhdf5 reaches by swizzling it to the slowest position
1602/// (`H5VM_swizzle_coords`, H5Dearray.c). Two or more unlimited dimensions
1603/// have no finite grid at all; that shape needs a v2 B-tree index instead.
1604fn ensure_at_most_one_unlimited(max_dims: &[u64]) -> IoResult<()> {
1605    let unlimited: Vec<usize> = max_dims
1606        .iter()
1607        .enumerate()
1608        .filter(|&(_, &m)| m == u64::MAX)
1609        .map(|(d, _)| d)
1610        .collect();
1611    if unlimited.len() > 1 {
1612        return Err(crate::io::IoError::InvalidState(format!(
1613            "an extensible-array index supports at most one unlimited dimension, \
1614             but dimensions {unlimited:?} are all unlimited; a v2 B-tree index \
1615             handles two or more"
1616        )));
1617    }
1618    Ok(())
1619}
1620
1621/// Reject strings the dataset's declared character set cannot label.
1622///
1623/// A Rust `&str` is always UTF-8, so only an ASCII declaration (charset 0)
1624/// can be violated. libhdf5 stores the bytes unvalidated — its vlen write
1625/// path has no cset check anywhere — which mislabels them for every reader
1626/// that trusts the declaration (h5py raises on the same mismatch).
1627fn ensure_vlen_charset(charset: u8, strings: &[&str]) -> IoResult<()> {
1628    if charset == 0 {
1629        if let Some((i, s)) = strings.iter().enumerate().find(|(_, s)| !s.is_ascii()) {
1630            return Err(crate::io::IoError::InvalidState(format!(
1631                "string {i} ({s:?}) is not ASCII, but the dataset's character set is"
1632            )));
1633        }
1634    }
1635    Ok(())
1636}
1637
1638/// Runtime metadata for a fixed-array-indexed chunked dataset.
1639pub struct FixedArrayDatasetInfo {
1640    /// Chunk dimension sizes.
1641    pub chunk_dims: Vec<u64>,
1642    /// File offset of the FA header.
1643    pub fa_header_addr: u64,
1644    /// File offset of the FA data block.
1645    pub fa_dblk_addr: u64,
1646    /// In-memory copy of the FA header.
1647    pub fa_header: FixedArrayHeader,
1648    /// In-memory copy of the FA data block.
1649    pub fa_dblk: FixedArrayDataBlock,
1650    /// Number of chunks written so far.
1651    pub chunks_written: u64,
1652}
1653
1654/// Runtime metadata for an implicitly indexed chunked dataset — the index
1655/// that is no structure at all (`H5Dnone.c`).
1656///
1657/// Every chunk of the maximum-extent grid is allocated at create in one
1658/// contiguous run, in the row-major order [`crate::io::chunk_grid`] defines,
1659/// so a chunk's address is `data_addr + linear_index * chunk_bytes` and
1660/// nothing has to be recorded when one is written. libhdf5 picks this index
1661/// only when that arithmetic is total: no filter (every chunk is exactly
1662/// `chunk_bytes` long), no unlimited dimension (the run has a finite length),
1663/// and early allocation (the run exists before any write).
1664pub struct ImplicitDatasetInfo {
1665    /// Chunk dimension sizes.
1666    pub chunk_dims: Vec<u64>,
1667    /// File offset of the first chunk — the layout message's index address.
1668    pub data_addr: u64,
1669    /// Byte length of the whole chunk run: `nchunks * chunk_bytes`.
1670    pub data_size: u64,
1671}
1672
1673/// Runtime metadata for a single-chunk indexed dataset (`H5Dsingle.c`): a
1674/// fixed dataspace exactly one chunk wide in every dimension
1675/// (`dims == max_dims == chunk_dims`), so there is exactly one chunk and its
1676/// address — and, when filtered, its stored size and filter mask — are held
1677/// directly in the layout message rather than in any index structure.
1678///
1679/// libhdf5 selects this index ahead of the implicit and fixed-array indexes
1680/// whenever the shape qualifies, whether or not the dataset is filtered or
1681/// early-allocated (`H5D__layout_set_latest_indexing`, H5Dlayout.c).
1682pub struct SingleChunkDatasetInfo {
1683    /// Chunk dimension sizes (equal to the dataspace's `dims`).
1684    pub chunk_dims: Vec<u64>,
1685    /// File offset of the chunk, [`UNDEF_ADDR`] until the chunk is written
1686    /// (or immediately, for an unfiltered dataset created with early
1687    /// allocation).
1688    pub data_addr: u64,
1689    /// The chunk's full unfiltered byte length — `chunk_dims.product() *
1690    /// element_size`, fixed for the dataset's lifetime.
1691    pub data_size: u64,
1692    /// Stored (on-disk) byte length: equal to `data_size` when the dataset
1693    /// carries no filter pipeline; the filtered length once the chunk has
1694    /// been written, 0 before then.
1695    pub nbytes: u64,
1696    /// Filter mask recorded for the stored chunk (bit *i* set means filter
1697    /// *i* was skipped); meaningful only when the dataset is filtered.
1698    pub filter_mask: u32,
1699    /// Chunks written this session (0 or 1) — `storage_dirty`'s signal that
1700    /// the layout message's address/size/mask fields must be re-flushed.
1701    pub chunks_written: u64,
1702    /// Whether this dataset was created with early allocation
1703    /// (`H5D_ALLOC_TIME_EARLY`) — distinct from `data_addr` being defined,
1704    /// which also becomes true the moment an incrementally allocated
1705    /// dataset's one chunk is written; `build_dataset_header` needs this to
1706    /// tell the two apart when it reports the fill-value message's
1707    /// allocation time. Only ever set for an unfiltered dataset: a filtered
1708    /// chunk's stored length is not known until it is compressed, so there
1709    /// is nothing to allocate ahead of that write regardless of alloc time
1710    /// (the same gap `create_fixed_array_dataset_with_pipeline` has).
1711    pub early_alloc: bool,
1712}
1713
1714/// One chunk as the version-1 B-tree records it — the key libhdf5 stores
1715/// (`H5D_btree_key_t`) plus the address it keys.
1716pub struct BtreeV1ChunkRecord {
1717    /// Grid position of the chunk. The key's element offsets are derived from
1718    /// it at encode time (`scaled * chunk_dim`), so this is the one place the
1719    /// position is stored and the sort order is over these coordinates.
1720    pub scaled: Vec<u64>,
1721    /// File offset of the chunk's bytes.
1722    pub address: u64,
1723    /// Stored byte length — the filtered length when the dataset is filtered,
1724    /// the full chunk otherwise. `u32` because the key's field is.
1725    pub nbytes: u32,
1726    /// Filter mask: bit `i` set means filter `i` was skipped for this chunk.
1727    pub filter_mask: u32,
1728}
1729
1730/// Runtime metadata for a chunked dataset indexed by a version-1 B-tree —
1731/// the classic-format chunk index (`H5Dbtree.c`), and the only one a
1732/// version-0/1 superblock file can carry.
1733pub struct BtreeV1DatasetInfo {
1734    /// Chunk dimension sizes.
1735    pub chunk_dims: Vec<u64>,
1736    /// Maximum dimensions (u64::MAX = unlimited).
1737    pub max_dims: Vec<u64>,
1738    /// The file's v1-B-tree "K" ranks. Every node's width is derived from
1739    /// them, and they are recorded only in the superblock this file was
1740    /// opened with — so they are carried rather than re-derived.
1741    pub config: BTreeV1Config,
1742    /// The chunks, in key order (`scaled` ascending, lexicographically).
1743    pub records: Vec<BtreeV1ChunkRecord>,
1744    /// Pool of node-size blocks holding the tree's nodes, on the same terms
1745    /// as [`Bt2DatasetInfo::node_addrs`]: a flush re-serializes the whole
1746    /// bulk-loaded tree over them and allocates only the shortfall, so no
1747    /// flush can orphan a block it replaced.
1748    pub node_addrs: Vec<u64>,
1749    /// Address of the tree's root node — what the version-3 data layout
1750    /// message carries. `UNDEF_ADDR` until a flush puts a node in the file,
1751    /// which is the state libhdf5 leaves a chunked dataset in until its first
1752    /// chunk is written.
1753    pub root_addr: u64,
1754    /// Number of chunks written so far.
1755    pub chunks_written: u64,
1756}
1757
1758impl BtreeV1DatasetInfo {
1759    /// The chunk shape a key's offsets are scaled by: the chunk dimensions
1760    /// with the element size appended, which is also what the layout message
1761    /// stores.
1762    fn key_dims(&self, element_size: u64) -> Vec<u64> {
1763        let mut dims = self.chunk_dims.clone();
1764        dims.push(element_size);
1765        dims
1766    }
1767
1768    /// Bulk-load the tree this index's records describe.
1769    fn build_tree(&self, element_size: u64, sizeof_addr: usize) -> ChunkBTreeV1Tree {
1770        let dims = self.key_dims(element_size);
1771        let entries: Vec<(ChunkKey, u64)> = self
1772            .records
1773            .iter()
1774            .map(|r| {
1775                (
1776                    ChunkKey::for_chunk(&r.scaled, &dims, r.nbytes, r.filter_mask),
1777                    r.address,
1778                )
1779            })
1780            .collect();
1781        // The right boundary closes the tree past its greatest key, which is
1782        // the last record's — the records are kept in key order.
1783        let last = self
1784            .records
1785            .last()
1786            .map_or_else(|| vec![0; self.chunk_dims.len()], |r| r.scaled.clone());
1787        ChunkBTreeV1Tree::build(
1788            &entries,
1789            ChunkKey::right_bound(&last, &dims),
1790            &self.config,
1791            sizeof_addr,
1792        )
1793    }
1794
1795    /// Where `scaled` sits in [`records`](Self::records): `Ok` at its record,
1796    /// `Err` at the position one would be inserted at.
1797    fn position(&self, scaled: &[u64]) -> Result<usize, usize> {
1798        self.records
1799            .binary_search_by(|r| r.scaled.as_slice().cmp(scaled))
1800    }
1801}
1802
1803/// Runtime metadata for a B-tree v2 indexed chunked dataset.
1804pub struct Bt2DatasetInfo {
1805    /// Chunk dimension sizes.
1806    pub chunk_dims: Vec<u64>,
1807    /// File offset of the BT2 header.
1808    pub bt2_header_addr: u64,
1809    /// Pool of node-size blocks (the index's
1810    /// [`node_size`](Bt2ChunkIndex::node_size) bytes each) holding the tree's
1811    /// nodes, in the order [`Bt2Tree::encode`] emits them.
1812    ///
1813    /// The single owner of the tree's node addresses: a flush re-serializes the
1814    /// whole tree over these blocks and allocates only the shortfall, so no
1815    /// flush can orphan a block it replaced. Every node is the same size, so a
1816    /// block stays usable however the tree reshapes.
1817    ///
1818    /// The pool holds exactly one block per node after every flush, in both
1819    /// directions: a taller tree allocates the shortfall, a smaller one frees
1820    /// the surplus. Nothing here depends on the record count only ever rising,
1821    /// so a record-removal path can be added to [`Bt2ChunkIndex`] without the
1822    /// blocks it drops going unreachable.
1823    pub node_addrs: Vec<u64>,
1824    /// In-memory chunk index.
1825    pub index: Bt2ChunkIndex,
1826    /// Number of chunks written so far.
1827    pub chunks_written: u64,
1828}
1829
1830/// Metadata for a group being written.
1831pub struct GroupInfo {
1832    /// Full path of this group (e.g. "/detector" or "/detector/raw").
1833    pub name: String,
1834    /// Index of the parent group in the groups vec, or None for root-level groups.
1835    pub parent: Option<usize>,
1836    /// Indices of child datasets (into `datasets` vec).
1837    pub child_datasets: Vec<usize>,
1838    /// Indices of child groups (into `groups` vec).
1839    pub child_groups: Vec<usize>,
1840    /// File offset of this group's object header (set during finalize).
1841    pub obj_header_addr: u64,
1842    /// File offset of the on-disk header a reopen found for this group, so
1843    /// finalize can free the block it supersedes.
1844    pub obj_header_written_addr: Option<u64>,
1845    /// Encoded size of that on-disk header (first block).
1846    /// Every block the object's on-disk header occupies, chunk 0 first, or
1847    /// empty when it has none yet. A rewrite keeps chunk 0's block — its
1848    /// address is what every reference to the object holds — and frees the
1849    /// rest, so a continuation block left behind is space no free-space
1850    /// manager records.
1851    pub obj_header_blocks: crate::io::object_header_io::HeaderBlocks,
1852    /// Soft-deleted: excluded from finalize output.
1853    pub deleted: bool,
1854    /// Attributes attached to this group (e.g. NeXus `NX_class`).
1855    pub attributes: Vec<AttributeEntry>,
1856    /// When the link naming this group was created, on the writer's single
1857    /// monotonic sequence. Groups, datasets and hard links share it, so a
1858    /// parent can order its links the way they were actually made.
1859    pub creation_seq: u64,
1860    /// How this group records creation order for its links and, separately,
1861    /// for its attributes. Creation-order tracking is a property of the
1862    /// object's creation property list in libhdf5, so it is captured here
1863    /// when the group is created rather than read from the writer at
1864    /// finalize: a later change of policy must not rewrite an object already
1865    /// made.
1866    pub track_order: TrackOrder,
1867    /// The times this group tracks, on the same terms as
1868    /// [`DatasetInfo::times`]. A version-1 group header records none of them:
1869    /// nothing calls `H5O_touch_oh` with `force` for a group, so the message a
1870    /// version-1 dataset gets is never created for one.
1871    pub times: Option<ObjectTimes>,
1872}
1873
1874/// One object's creation-order policy, with the two subsystems libhdf5 keeps
1875/// apart kept apart here too.
1876///
1877/// `H5Pset_link_creation_order` and `H5Pset_attr_creation_order` are separate
1878/// calls reading back out of separate places on disk — the Link Info message
1879/// and the object header's own flag bits — and a file may set either alone.
1880/// Carrying them as one flag made a reopen give a one-of-two file both or
1881/// neither.
1882#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1883pub struct TrackOrder {
1884    /// Creation order of the links this group holds. Meaningless for a
1885    /// dataset, which is why `DatasetInfo` keeps only the attribute half.
1886    pub links: CreationOrder,
1887    /// Creation order of the attributes attached to this object.
1888    pub attrs: CreationOrder,
1889}
1890
1891impl TrackOrder {
1892    /// The policy the crate's single `track_order` knob selects: both
1893    /// subsystems tracked *and* indexed, or neither — the pair h5py's
1894    /// `File(track_order=True)` writes.
1895    pub fn uniform(track: bool) -> Self {
1896        let order = if track {
1897            CreationOrder::Indexed
1898        } else {
1899            CreationOrder::Untracked
1900        };
1901        Self {
1902            links: order,
1903            attrs: order,
1904        }
1905    }
1906}
1907
1908/// The object a [`HardLink`] resolves to.
1909#[derive(Clone, Copy)]
1910pub enum HardLinkTarget {
1911    /// Index into the writer's `datasets` vec.
1912    Dataset(usize),
1913    /// Index into the writer's `groups` vec.
1914    Group(usize),
1915}
1916
1917/// A user-created hard link: an additional name, in some group, for an
1918/// object that already exists under its own name.
1919///
1920/// The HDF5 file format makes every group entry a `name -> object header
1921/// address` mapping, so a hard link is just a second such entry pointing at
1922/// an already-written object. No data is copied.
1923#[derive(Clone)]
1924pub struct HardLink {
1925    /// Parent group index (`None` = the root group).
1926    pub parent: Option<usize>,
1927    /// Leaf name of the link within the parent group.
1928    pub name: String,
1929    /// Object this link resolves to.
1930    pub target: HardLinkTarget,
1931    /// When this link was created; see [`GroupInfo::creation_seq`].
1932    pub creation_seq: u64,
1933}
1934
1935/// A user-created symbolic link: a name in a group whose value is a path
1936/// rather than an object header address.
1937///
1938/// A soft link holds a path within this file; an external link holds a file
1939/// name and a path within that file. Neither names an object this writer
1940/// owns, so — unlike [`HardLink`] — nothing about it is resolved: the link is
1941/// stored as written and answered at traversal time, exactly as `H5Lcreate_soft`
1942/// and `H5Lcreate_external` store theirs.
1943#[derive(Clone)]
1944pub struct SymbolicLink {
1945    /// Parent group index (`None` = the root group).
1946    pub parent: Option<usize>,
1947    /// Leaf name of the link within the parent group.
1948    pub name: String,
1949    /// The path (and, for an external link, the file) this link names.
1950    pub target: LinkTarget,
1951    /// When this link was created; see [`GroupInfo::creation_seq`].
1952    pub creation_seq: u64,
1953}
1954
1955/// A committed (named) datatype: an object header holding one datatype
1956/// message and nothing else, reached by a link like any other object.
1957///
1958/// `H5Tcommit2` makes the type an object in its own right so several datasets
1959/// can declare they share it; each of those datasets then stores a pointer to
1960/// this object header in place of its own datatype message. The object's
1961/// reference count is therefore the links naming it *plus* the datasets
1962/// sharing it — `H5O__shared_link_adj` counts a share as a link — and an
1963/// object no link and no dataset reaches is not written at all.
1964#[derive(Clone)]
1965pub struct CommittedDatatype {
1966    /// Full path with no leading `/`, the form dataset names take.
1967    pub name: String,
1968    /// Parent group index (`None` = the root group).
1969    pub parent: Option<usize>,
1970    /// The committed type.
1971    pub datatype: DatatypeMessage,
1972    /// When the link naming it was created; see [`GroupInfo::creation_seq`].
1973    pub creation_seq: u64,
1974    /// The times it tracks, on the same terms as [`DatasetInfo::times`]. A
1975    /// version-1 committed datatype header records none of them, for the same
1976    /// reason a version-1 group's does not.
1977    pub times: Option<ObjectTimes>,
1978    /// File offset of its object header (set during finalize).
1979    pub obj_header_addr: u64,
1980}
1981
1982/// Where the object header a dataset's shared datatype pointer must name
1983/// comes from.
1984///
1985/// A dataset built on a committed type stores no datatype message: it stores
1986/// the address of the type's object header. Only the address matters at
1987/// encode time, but it is knowable at two different moments — a type this
1988/// session commits has no address until finalize lays the file out, while one
1989/// a reopen found is already at an address this session will not move. Naming
1990/// both here keeps [`build_dataset_header`](Hdf5Writer::build_dataset_header)
1991/// the one place that turns a share into a pointer, whichever way the share
1992/// arrived.
1993#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1994pub enum CommittedTypeRef {
1995    /// A type committed in this session, by its index in
1996    /// [`committed_datatypes`](Hdf5Writer::committed_datatypes); its address
1997    /// is read from that registry once finalize has stamped one.
1998    Session(usize),
1999    /// A committed datatype a reopen kept by its bytes, at the object header
2000    /// address it already occupies.
2001    Preserved(u64),
2002}
2003
2004/// A link a reopened file already held that this writer cannot express.
2005///
2006/// Soft, external and user-defined links have no creation, retarget or delete
2007/// operation here — only hard links do — so a header rewrite that emits what
2008/// the registry models would erase them. Their encoded `Link` message rides
2009/// along instead and is written back byte for byte, which preserves every
2010/// field (name character set, creation order, the link value) without this
2011/// writer having to model any of them.
2012///
2013/// A *hard* link is preserved the same way when the object it names is one
2014/// the reopen could not model: writing the link back unchanged leaves that
2015/// object's header exactly where it is, which is the only way the rewrite can
2016/// keep what it cannot rebuild.
2017#[derive(Clone)]
2018pub struct PreservedLink {
2019    /// Parent group index (`None` = the root group).
2020    pub parent: Option<usize>,
2021    /// Leaf name of the link within the parent group.
2022    pub name: String,
2023    /// The link's class, decoded once at collection so listings can report
2024    /// it. Never the source of what gets written — `encoded` is.
2025    pub class: crate::io::reader::LinkClass,
2026    /// The decoded message, for the questions the emitters ask of every link
2027    /// (does it fit a symbol table entry, what does it name). Never the source
2028    /// of what gets written — `encoded` is.
2029    pub link: LinkMessage,
2030    /// The encoded `Link` message body, exactly as read from the file. The
2031    /// one byte range the rewrite touches is the creation order, which
2032    /// `group_links` renumbers together with the modelled links.
2033    pub encoded: Vec<u8>,
2034    /// When this link was created; see [`GroupInfo::creation_seq`].
2035    pub creation_seq: u64,
2036    /// Why the object this link names could not be modelled, for the callers
2037    /// that ask for it by name. `None` when the link's own class — not its
2038    /// target — is what this writer cannot express.
2039    pub reason: Option<String>,
2040    /// What the object this link names is, when the walk could tell. A
2041    /// listing asks this; `reason` is prose for the caller that asks why.
2042    pub kind: PreservedKind,
2043}
2044
2045/// One link of a group as the close emits it, in both forms the emitters
2046/// ask for: the message for what it says, the bytes for what gets written.
2047/// A modelled link's bytes are its encoding; a preserved link's are the ones
2048/// the reopen read, with only the creation order restamped.
2049pub(crate) struct GroupLink {
2050    pub(crate) link: LinkMessage,
2051    pub(crate) encoded: Vec<u8>,
2052    /// Carried through by its bytes from a reopen, so it pins the group to
2053    /// compact storage.
2054    pub(crate) preserved: bool,
2055}
2056
2057/// Every link a reopen walk met, split by what the writer can do with it.
2058/// A header rewrite emits both halves, so a link in neither half is a link
2059/// the close would destroy.
2060#[derive(Default)]
2061struct CollectedLinks {
2062    /// Hard links whose target the reopen modelled, with the plan that says
2063    /// how to rebuild it.
2064    hard: Vec<(HardEntry, CollectedObject)>,
2065    /// Links written back unchanged: the class this writer cannot express,
2066    /// and the hard links whose object it cannot model.
2067    preserved: Vec<PreservedEntry>,
2068    /// The creation order each link carried on disk (`H5O_LINK_STORE_CORDER`),
2069    /// by full link path without a leading `/`. Present only for groups that
2070    /// track link creation order, as every NetCDF-4 group does.
2071    link_corder: std::collections::HashMap<String, i64>,
2072}
2073
2074/// One hard link the reopen walk met: what it names, and the exact message
2075/// that names it.
2076#[derive(Clone)]
2077struct HardEntry {
2078    /// Full link path, in the no-leading-`/` form the registry uses.
2079    path: String,
2080    /// Object header address the link names.
2081    address: u64,
2082    /// The encoded `Link` message body, exactly as read from the file.
2083    encoded: Vec<u8>,
2084}
2085
2086/// A link the rewrite writes back exactly as it read it.
2087struct PreservedEntry {
2088    path: String,
2089    class: crate::io::reader::LinkClass,
2090    encoded: Vec<u8>,
2091    /// Why the object it names could not be modelled; `None` when the link's
2092    /// own class is what this writer cannot express.
2093    reason: Option<String>,
2094    /// What the object is, when the walk could tell.
2095    kind: PreservedKind,
2096}
2097
2098/// What a reopen can do with one object it reached.
2099///
2100/// A header rewrite emits a modelled object out of the registry, so the
2101/// registry may hold an object only when *every* message the model consumes
2102/// decoded. A partial read is not a smaller object, it is a different one:
2103/// before this rule a dataset whose datatype message did not decode was
2104/// registered as a group, and the close rewrote its header as one.
2105enum ObjectPlan {
2106    /// A dataset the rewrite can rebuild.
2107    Dataset(Box<DatasetParts>),
2108    /// A group the rewrite can rebuild, and the links it holds.
2109    Group(GroupParts),
2110    /// An object this writer cannot model, and why. Its header is never
2111    /// rewritten and never freed; the link naming it is written back byte for
2112    /// byte, so the object stays exactly as the file already had it — what
2113    /// libhdf5 does with the parts of a file it does not understand.
2114    ///
2115    /// `kind` is what the walk could still tell about the object it is
2116    /// keeping. Not modelling an object is not the same as not knowing what
2117    /// it is, and answering the second question with the first is what made
2118    /// `named_datatype_names` deny, in write mode, a datatype the same file
2119    /// reports in read mode.
2120    Preserve { why: String, kind: PreservedKind },
2121}
2122
2123/// What a preserved object is, as far as the reopen walk could tell.
2124///
2125/// Deliberately not a copy of the reader's `ObjectKind`: that one carries the
2126/// decoded object, and a preserved object is precisely the one whose contents
2127/// the writer does not decode. This says only what a listing needs.
2128#[derive(Clone, Copy, PartialEq, Eq, Debug)]
2129pub enum PreservedKind {
2130    /// The walk did not classify it — or the link's own class, not its
2131    /// target, is what could not be expressed.
2132    Unclassified,
2133    /// A committed (named) datatype, by
2134    /// [`header_is_committed_datatype`](crate::io::reader::header_is_committed_datatype).
2135    NamedDatatype,
2136}
2137
2138impl ObjectPlan {
2139    /// An object kept by its bytes, of a kind the walk did not classify.
2140    ///
2141    /// Every reason that is a *failure* to read reaches this: a message that
2142    /// did not decode says nothing about what the object was.
2143    fn preserve(why: impl Into<String>) -> Self {
2144        ObjectPlan::Preserve {
2145            why: why.into(),
2146            kind: PreservedKind::Unclassified,
2147        }
2148    }
2149}
2150
2151/// The messages a dataset's rewrite is built from, all decoded.
2152struct DatasetParts {
2153    /// Every block the header chain occupies, chunk 0 first. All of them are
2154    /// superseded: the rewrite re-encodes the whole chain into one fresh
2155    /// chunk, so a continuation left unfreed is space nothing claims.
2156    header_blocks: crate::io::object_header_io::HeaderBlocks,
2157    datatype: DatatypeMessage,
2158    /// The committed datatype object header `datatype` was read *through*,
2159    /// when the header stores a pointer instead of a message of its own.
2160    ///
2161    /// The literal type is in `datatype` either way, because the read resolves
2162    /// the pointer before anything decodes it; this is what a rewrite needs to
2163    /// put the pointer back rather than inline a copy of the named type and
2164    /// leave `H5Tcommitted` false.
2165    committed_type: Option<u64>,
2166    dataspace: crate::format::messages::dataspace::DataspaceMessage,
2167    /// The object format the reopen found this dataset's messages written in,
2168    /// read from the dataspace message's own version byte.
2169    ///
2170    /// A version-2 superblock does not settle it: `H5F__super_init` raises the
2171    /// superblock for a shared-message table or non-default file-space
2172    /// properties without touching `H5F_LOW_BOUND` (H5Fsuper.c:1135, :1144), so
2173    /// a file created at the earliest bound with either can hold version-1
2174    /// messages under a version-2 superblock — which is what
2175    /// `tests/fixtures/sohm_*.h5` are.
2176    read_format: ObjectFormat,
2177    layout: crate::format::messages::data_layout::DataLayoutMessage,
2178    filter_pipeline: Option<FilterPipeline>,
2179    fill_value: Option<Vec<u8>>,
2180    /// The fill-value message's write-time byte, preserved across a
2181    /// rewrite the same way `fill_value` is — an appended-to dataset must
2182    /// keep the policy libhdf5 (or this writer) declared for it, not fall
2183    /// back to the `H5D_CRT_FILL_TIME_DEF` a fresh dataset gets.
2184    fill_write_time: u8,
2185    attributes: Vec<AttributeEntry>,
2186    /// The creation-order policy the on-disk header declares; a rewrite that
2187    /// read it from the writer instead would stamp this session's policy onto
2188    /// an object libhdf5 created under another.
2189    track_order: TrackOrder,
2190    /// The times the on-disk header records, for the same reason: whether an
2191    /// object tracks them is settled when it is created, not when it is
2192    /// rewritten. Recovered by [`ObjectHeader::recorded_times`].
2193    times: Option<ObjectTimes>,
2194    /// The dense storage the rewrite supersedes and must free.
2195    dense: DenseCarry,
2196    /// The External File List the header carries, with each slot's name
2197    /// already read back out of the local heap the message points at. `None`
2198    /// for a dataset whose raw data is in this file.
2199    ///
2200    /// Carried rather than re-derived because the rewrite has to re-emit the
2201    /// message: a contiguous layout with an undefined address and no EFL
2202    /// beside it is a dataset with no data at all, so dropping this on a
2203    /// header rewrite would silently unlink every external byte.
2204    external: Option<ExternalStorage>,
2205}
2206
2207/// The same for a group, plus the links it holds — decoded once, with the
2208/// bytes they came from, so the walk and the rewrite agree on its contents.
2209struct GroupParts {
2210    header_blocks: crate::io::object_header_io::HeaderBlocks,
2211    attributes: Vec<AttributeEntry>,
2212    links: Vec<(crate::format::messages::link::LinkMessage, Vec<u8>)>,
2213    track_order: TrackOrder,
2214    times: Option<ObjectTimes>,
2215    dense: DenseCarry,
2216    /// The symbol-table storage a classic group's header names — the blocks
2217    /// the rewrite supersedes. `None` for a link-message group, which has
2218    /// none. Its links are already in `links`: the walk turns each symbol
2219    /// table entry into the link message it stands for, so nothing downstream
2220    /// has to know which of the two forms the group was in.
2221    stab: Option<StabExtents>,
2222}
2223
2224/// The dense storage one reopened object's header names, which the rewrite of
2225/// that header stops naming and therefore has to free. Both halves are read
2226/// back before this is built — a heap that could not be read makes the object
2227/// [`ObjectPlan::Preserve`], so nothing here describes storage whose contents
2228/// were lost.
2229#[derive(Default)]
2230struct DenseCarry {
2231    attrs: Option<AttributeInfoMessage>,
2232    links: Option<LinkInfoMessage>,
2233}
2234
2235/// A modelled object, as the walk hands it to the registry rebuild. A group's
2236/// links are not here: the walk followed them, and each child is an entry of
2237/// its own.
2238enum CollectedObject {
2239    Dataset(Box<DatasetParts>),
2240    Group {
2241        header_blocks: crate::io::object_header_io::HeaderBlocks,
2242        attributes: Vec<AttributeEntry>,
2243        track_order: TrackOrder,
2244        times: Option<ObjectTimes>,
2245        dense: DenseCarry,
2246        stab: Option<StabExtents>,
2247    },
2248}
2249
2250/// The reopen's discovery pass: one walk that classifies every object it
2251/// reaches and descends into the groups among them.
2252///
2253/// Every object the close will touch is decided here and nowhere else, so
2254/// "modelled or preserved" is a property of the walk rather than of whatever
2255/// each later stage happened to be able to decode.
2256struct ReopenWalk<'a> {
2257    handle: &'a mut FileHandle,
2258    meta: &'a crate::io::FileMeta,
2259    out: CollectedLinks,
2260    /// Object headers already descended into, so hard-link cycles end.
2261    visited: std::collections::HashSet<u64>,
2262}
2263
2264impl<'a> ReopenWalk<'a> {
2265    fn new(handle: &'a mut FileHandle, meta: &'a crate::io::FileMeta) -> Self {
2266        Self {
2267            handle,
2268            meta,
2269            out: CollectedLinks::default(),
2270            visited: std::collections::HashSet::new(),
2271        }
2272    }
2273
2274    /// Everything the walk found.
2275    fn finish(self) -> CollectedLinks {
2276        self.out
2277    }
2278
2279    /// Decide what the reopen can do with the object at `addr`.
2280    ///
2281    /// The single gate: every object the rewrite touches is classified here,
2282    /// and an object is modelled only when each message the model consumes
2283    /// decoded. See [`ObjectPlan`] for why anything else must keep its bytes.
2284    fn plan(&mut self, addr: u64) -> IoResult<ObjectPlan> {
2285        let (handle, meta) = (&mut *self.handle, self.meta);
2286        let ctx = &meta.ctx;
2287        use crate::format::messages::data_layout::DataLayoutMessage;
2288        use crate::format::messages::dataspace::DataspaceMessage;
2289        use crate::format::messages::link::{CharacterSet, LinkMessage};
2290        use crate::format::messages::link_info::LinkInfoMessage;
2291        use crate::format::messages::shared::MSG_FLAG_SHARED;
2292        use crate::format::messages::{
2293            MSG_ATTRIBUTE, MSG_DATASPACE, MSG_DATATYPE, MSG_DATA_LAYOUT, MSG_EXTERNAL_FILE_LIST,
2294            MSG_FILL_VALUE, MSG_FILTER_PIPELINE, MSG_LINK, MSG_LINK_INFO, MSG_SYMBOL_TABLE,
2295        };
2296
2297        // The whole chain, messages and blocks alike: a filter pipeline or an
2298        // attribute that spilled into a continuation is one the rewrite would
2299        // otherwise drop, and a continuation block it does not know about is
2300        // one the rewrite would orphan.
2301        let (header, header_blocks) =
2302            match crate::io::object_header_io::read_object_header_with_blocks(handle, meta, addr) {
2303                Ok(h) => h,
2304                Err(e) => {
2305                    return Ok(ObjectPlan::preserve(format!(
2306                        "its object header chain does not read: {e}"
2307                    )))
2308                }
2309            };
2310
2311        // The policy, the times and the storage the header declares, read once
2312        // from the whole chain: all three are properties of the object, not of
2313        // any one message the loop below happens to reach.
2314        let track_order = recover_track_order(&header, ctx);
2315        let times = header.recorded_times();
2316        let (dense_attrs, dense_links) = superseded_dense(&header, ctx);
2317
2318        // Attributes come from the reader's collector rather than from the
2319        // loop below, so compact, dense and shared attributes all reach the
2320        // rewrite by the one path that knows how to read each of them. An
2321        // object whose set did not read whole is preserved: a short set here
2322        // would be a rewrite deleting the attributes it could not read.
2323        let attributes = match take_reopened_attributes(
2324            crate::io::reader::collect_object_attributes(handle, ctx, &header),
2325            &format!("the object at {addr:#x}"),
2326        ) {
2327            Ok(a) => a,
2328            Err(e) => {
2329                return Ok(ObjectPlan::preserve(format!(
2330                    "its attributes do not read back whole: {e}"
2331                )))
2332            }
2333        };
2334
2335        let mut datatype = None;
2336        let mut dataspace = None;
2337        let mut layout = None;
2338        let mut filter_pipeline = None;
2339        let mut fill_value = None;
2340        // No fill-value message at all is the library default, the same
2341        // convention the reader-side decode (`Hdf5Reader::dataset_info`)
2342        // uses for `fill_defined`.
2343        let mut fill_write_time: u8 = FILL_TIME_IFSET;
2344        let mut external = None;
2345        let mut links = Vec::new();
2346        let mut stab = None;
2347        // A datatype, dataspace or layout message says the object is not a
2348        // group, whether or not the three a dataset needs are all there.
2349        let mut dataset_shaped = false;
2350
2351        for msg in &header.messages {
2352            let consumed = matches!(
2353                msg.msg_type,
2354                MSG_DATATYPE
2355                    | MSG_DATASPACE
2356                    | MSG_DATA_LAYOUT
2357                    | MSG_FILTER_PIPELINE
2358                    | MSG_FILL_VALUE
2359                    | MSG_EXTERNAL_FILE_LIST
2360                    | MSG_ATTRIBUTE
2361                    | MSG_LINK
2362                    | MSG_LINK_INFO
2363                    | MSG_SYMBOL_TABLE
2364            );
2365            // A shared message holds a reference to where its body lives, not
2366            // the body. Decoding those bytes as one does not fail loudly — the
2367            // reference's version byte reads as a version and a class of its
2368            // own — so the guard is the only thing between a shared datatype
2369            // and a rewrite that invents a type for it.
2370            if consumed && msg.flags & MSG_FLAG_SHARED != 0 {
2371                return Ok(ObjectPlan::preserve(format!(
2372                    "its message of type {:#04x} is a shared-message reference, which this \
2373                     writer does not resolve",
2374                    msg.msg_type
2375                )));
2376            }
2377            macro_rules! consume {
2378                ($decode:expr, $what:literal) => {
2379                    match $decode {
2380                        Ok(v) => v,
2381                        Err(e) => {
2382                            return Ok(ObjectPlan::preserve(format!(
2383                                "its {} message does not decode: {e}",
2384                                $what
2385                            )))
2386                        }
2387                    }
2388                };
2389            }
2390            match msg.msg_type {
2391                // The pre-1.6 modification time, a formatted date string
2392                // (`H5O_MTIME`, type 0x0E). `recorded_times` reads only the
2393                // modern form, and a rewrite emits only that, so an object
2394                // carrying this one would come back out with the time it
2395                // recorded gone. Keeping its bytes is the same answer an
2396                // undecodable message already gets.
2397                crate::format::messages::MSG_MOD_TIME_OLD => {
2398                    return Ok(ObjectPlan::preserve(
2399                        "it carries a pre-1.6 modification time message, which this writer \
2400                         reads but does not write",
2401                    ))
2402                }
2403                MSG_DATATYPE => {
2404                    dataset_shaped = true;
2405                    let (dt, _) = consume!(DatatypeMessage::decode(&msg.data, ctx), "datatype");
2406                    datatype = Some(dt);
2407                }
2408                MSG_DATASPACE => {
2409                    dataset_shaped = true;
2410                    let version = msg.data.first().copied().unwrap_or(1);
2411                    let (ds, _) = consume!(DataspaceMessage::decode(&msg.data, ctx), "dataspace");
2412                    dataspace = Some((ds, version));
2413                }
2414                MSG_DATA_LAYOUT => {
2415                    dataset_shaped = true;
2416                    let (dl, _) =
2417                        consume!(DataLayoutMessage::decode(&msg.data, ctx), "data layout");
2418                    layout = Some(dl);
2419                }
2420                MSG_FILTER_PIPELINE => {
2421                    let (p, _) = consume!(FilterPipeline::decode(&msg.data), "filter pipeline");
2422                    if !p.filters.is_empty() {
2423                        filter_pipeline = Some(p);
2424                    }
2425                }
2426                MSG_FILL_VALUE => {
2427                    let (fv, _) = consume!(FillValueMessage::decode(&msg.data), "fill value");
2428                    if fv.fill_defined == 2 {
2429                        fill_value = fv.fill_value;
2430                    }
2431                    fill_write_time = fv.fill_write_time;
2432                }
2433                MSG_EXTERNAL_FILE_LIST => {
2434                    dataset_shaped = true;
2435                    let (efl, _) = consume!(
2436                        ExternalFileListMessage::decode(&msg.data, ctx),
2437                        "external file list"
2438                    );
2439                    // The names live in a local heap of their own, so the
2440                    // rewrite cannot re-emit the message from its bytes alone
2441                    // — it has to be able to point at the same strings. A heap
2442                    // that does not read back leaves the object preserved,
2443                    // which is what keeps its data reachable.
2444                    let resolved = match crate::io::reader::Hdf5Reader::resolve_external_file_slots(
2445                        handle, ctx, &efl,
2446                    ) {
2447                        Ok(r) => r,
2448                        Err(e) => {
2449                            return Ok(ObjectPlan::preserve(format!(
2450                                "its external file list names do not read back: {e}"
2451                            )))
2452                        }
2453                    };
2454                    external = Some(ExternalStorage {
2455                        heap_addr: efl.heap_addr,
2456                        // `H5Fopen` opens no dataset, so nothing has read a
2457                        // dapl for this one yet; the first handle it hands
2458                        // out settles the prefix.
2459                        prefix: EfilePrefix::default(),
2460                        files: efl
2461                            .slots
2462                            .iter()
2463                            .zip(resolved)
2464                            .map(|(slot, seg)| ExternalFile {
2465                                name: seg.name,
2466                                name_offset: slot.name_offset,
2467                                offset: slot.offset,
2468                                size: slot.size,
2469                            })
2470                            .collect(),
2471                    });
2472                }
2473                MSG_LINK => {
2474                    let (l, _) = consume!(LinkMessage::decode(&msg.data, ctx), "link");
2475                    links.push((l, msg.data.clone()));
2476                }
2477                MSG_LINK_INFO => {
2478                    let (li, _) = consume!(LinkInfoMessage::decode(&msg.data, ctx), "link info");
2479                    // Once a group holds enough links libhdf5 moves them into
2480                    // the fractal heap this message names and writes no `Link`
2481                    // messages at all. Reading them back is what makes the
2482                    // rewrite emit the group with its children; a rewrite from
2483                    // the header messages alone emitted it empty, orphaning
2484                    // every object below it.
2485                    if li.fractal_heap_address != UNDEF_ADDR {
2486                        let dense = match crate::io::reader::Hdf5Reader::read_dense_links(
2487                            handle,
2488                            ctx,
2489                            li.fractal_heap_address,
2490                        ) {
2491                            Ok(l) => l,
2492                            Err(e) => {
2493                                return Ok(ObjectPlan::preserve(format!(
2494                                    "its dense link storage does not read: {e}"
2495                                )))
2496                            }
2497                        };
2498                        // Re-encoded rather than carried as bytes: a heap
2499                        // object is not a header message, so there are no
2500                        // message bytes to carry. The encoding round-trips
2501                        // through the same decoder that just read it.
2502                        links.extend(dense.into_iter().map(|l| {
2503                            let bytes = l.encode(ctx);
2504                            (l, bytes)
2505                        }));
2506                    }
2507                }
2508                MSG_SYMBOL_TABLE => {
2509                    // A classic group keeps no link message at all: its links
2510                    // are symbol table entries in the B-tree this message
2511                    // names. Turning each into the link message it stands for
2512                    // is what lets the rest of the reopen — the walk, the
2513                    // registry, the preserve path — work on one link model
2514                    // whichever form the group is in.
2515                    let Some(s) = Stab::decode(&msg.data, ctx) else {
2516                        return Ok(ObjectPlan::preserve(
2517                            "its symbol table message is shorter than the two addresses it \
2518                             must carry",
2519                        ));
2520                    };
2521                    let contents = match crate::io::symbol_table_io::read_stab(handle, meta, s) {
2522                        Ok(c) => c,
2523                        Err(e) => {
2524                            return Ok(ObjectPlan::preserve(format!(
2525                                "its symbol table does not read: {e}"
2526                            )))
2527                        }
2528                    };
2529                    stab = Some(contents.extents);
2530                    links.extend(contents.links.into_iter().map(|l| {
2531                        let msg = match l.target {
2532                            StabTarget::Hard { addr, .. } => LinkMessage::hard(&l.name, addr),
2533                            StabTarget::Soft { value } => LinkMessage::soft(&l.name, &value),
2534                        };
2535                        // An entry carries no character set field, so the link
2536                        // it stands for has the file default whatever its name
2537                        // looks like (`H5G__ent_to_link`, H5Gent.c:372).
2538                        // Deriving one from the name would take a group
2539                        // libhdf5 wrote with a high-byte ASCII name out of its
2540                        // symbol table on the rewrite.
2541                        let msg = msg.with_cset(CharacterSet::Ascii);
2542                        let bytes = msg.encode(ctx);
2543                        (msg, bytes)
2544                    }));
2545                }
2546                _ => {}
2547            }
2548        }
2549
2550        // libhdf5 refuses a layout that disagrees with its sibling dataspace
2551        // and datatype as the dataset opens (`H5O__layout_decode` for the
2552        // chunk rank, `H5D__compact_init` for the compact size); modelled
2553        // anyway, the disagreement would be read at the wrong rank or past
2554        // the compact payload, so the dataset keeps its bytes, exactly as
2555        // unreadable as the file already had it.
2556        if let (Some((ds, _)), Some(dt), Some(dl)) = (&dataspace, &datatype, &layout) {
2557            if let Err(e) = dl.check_against_dataset(ds, dt, ctx) {
2558                return Ok(ObjectPlan::preserve(format!(
2559                    "its layout doesn't fit its dataspace and datatype: {e}"
2560                )));
2561            }
2562        }
2563
2564        match (datatype, dataspace, layout) {
2565            // A layout `rebuild_dataset` has no arm for leaves the registry
2566            // entry with an undefined data address, and the close then rewrites
2567            // the header as a contiguous, unallocated dataset — every element
2568            // gone, silently. Only the layouts that rebuild are modelled; the
2569            // rest keep their bytes, as an undecodable message already does.
2570            // The virtual layout is this.
2571            (Some(_), Some(_), Some(layout)) if !layout_rebuilds(&layout) => {
2572                Ok(ObjectPlan::preserve(format!(
2573                    "its data layout is {}, which this writer reads but does not build",
2574                    layout.describe()
2575                )))
2576            }
2577            (Some(datatype), Some((dataspace, dataspace_version)), Some(layout)) => {
2578                // Asked of the raw chain, not of `header`: the read above has
2579                // already put the named type's message in place of the pointer.
2580                let committed_type = match crate::io::object_header_io::committed_datatype_address(
2581                    handle, meta, addr,
2582                ) {
2583                    Ok(c) => c,
2584                    Err(e) => {
2585                        return Ok(ObjectPlan::preserve(format!(
2586                            "its shared datatype pointer does not decode: {e}"
2587                        )))
2588                    }
2589                };
2590                Ok(ObjectPlan::Dataset(Box::new(DatasetParts {
2591                    header_blocks,
2592                    datatype,
2593                    committed_type,
2594                    dataspace,
2595                    read_format: if dataspace_version <= 1 {
2596                        ObjectFormat::Legacy
2597                    } else {
2598                        ObjectFormat::Modern
2599                    },
2600                    layout,
2601                    filter_pipeline,
2602                    fill_value,
2603                    fill_write_time,
2604                    attributes,
2605                    track_order,
2606                    times,
2607                    dense: DenseCarry {
2608                        attrs: dense_attrs,
2609                        links: dense_links,
2610                    },
2611                    external,
2612                })))
2613            }
2614            // A committed (named) datatype has a datatype message and neither
2615            // of the other two; so does a dataset whose header this crate only
2616            // half understands. Neither is a group, and modelling either as
2617            // one is what rewrote them into empty groups. They part company
2618            // here and nowhere else: the datatype is kept by its bytes like
2619            // the other, but a listing can still name it.
2620            _ if crate::io::reader::header_is_committed_datatype(&header) => {
2621                Ok(ObjectPlan::Preserve {
2622                    why: "it is a committed (named) datatype, which this writer carries by \
2623                          its bytes rather than re-encoding"
2624                        .into(),
2625                    kind: PreservedKind::NamedDatatype,
2626                })
2627            }
2628            _ if dataset_shaped => Ok(ObjectPlan::preserve(
2629                "it carries a datatype, dataspace or layout message but not the three a \
2630                 dataset is built from; this writer models only groups and datasets",
2631            )),
2632            _ => Ok(ObjectPlan::Group(GroupParts {
2633                header_blocks,
2634                attributes,
2635                links,
2636                track_order,
2637                times,
2638                dense: DenseCarry {
2639                    attrs: dense_attrs,
2640                    links: dense_links,
2641                },
2642                stab,
2643            })),
2644        }
2645    }
2646
2647    /// Walk `links` (one group's, already decoded), classifying every object
2648    /// they name and descending into the groups among them.
2649    fn group(
2650        &mut self,
2651        links: &[(crate::format::messages::link::LinkMessage, Vec<u8>)],
2652        prefix: &str,
2653        depth: usize,
2654    ) -> IoResult<()> {
2655        // Bound nesting depth so a pathologically deep group chain cannot
2656        // overflow the stack (the `visited` set bounds total work but not
2657        // recursion depth).
2658        if depth > 256 {
2659            return Ok(());
2660        }
2661        use crate::format::messages::link::LinkTarget;
2662        for (link, encoded) in links {
2663            let full_name = if prefix.is_empty() {
2664                link.name.clone()
2665            } else {
2666                format!("{}/{}", prefix, link.name)
2667            };
2668
2669            if let Some(corder) = link.creation_order {
2670                self.out.link_corder.insert(full_name.clone(), corder);
2671            }
2672            // Only a hard link names an object this writer can rebuild. Every
2673            // other class is kept by its bytes, because a close that emitted
2674            // only what the registry models would drop it from the file.
2675            let LinkTarget::Hard { address } = &link.target else {
2676                self.out.preserved.push(PreservedEntry {
2677                    path: full_name,
2678                    class: crate::io::reader::LinkClass::from_target(&link.target),
2679                    encoded: encoded.clone(),
2680                    reason: None,
2681                    kind: PreservedKind::Unclassified,
2682                });
2683                continue;
2684            };
2685            let entry = HardEntry {
2686                path: full_name.clone(),
2687                address: *address,
2688                encoded: encoded.clone(),
2689            };
2690
2691            match self.plan(*address)? {
2692                // Kept by its bytes, exactly as a link class this writer
2693                // cannot express is: writing the link back unchanged is what
2694                // leaves the object's header where the file already has it.
2695                ObjectPlan::Preserve { why, kind } => self.out.preserved.push(PreservedEntry {
2696                    path: full_name,
2697                    class: crate::io::reader::LinkClass::Hard,
2698                    encoded: entry.encoded,
2699                    reason: Some(why),
2700                    kind,
2701                }),
2702                ObjectPlan::Dataset(parts) => {
2703                    self.out.hard.push((entry, CollectedObject::Dataset(parts)));
2704                }
2705                ObjectPlan::Group(parts) => {
2706                    self.out.hard.push((
2707                        entry,
2708                        CollectedObject::Group {
2709                            header_blocks: parts.header_blocks,
2710                            attributes: parts.attributes,
2711                            track_order: parts.track_order,
2712                            times: parts.times,
2713                            dense: parts.dense,
2714                            stab: parts.stab,
2715                        },
2716                    ));
2717                    // Recurse only into a group's header we have not entered
2718                    // before — breaks hard-link cycles.
2719                    if self.visited.insert(*address) {
2720                        self.group(&parts.links, &full_name, depth + 1)?;
2721                    }
2722                }
2723            }
2724        }
2725        Ok(())
2726    }
2727}
2728
2729/// Rebuild one reopened dataset's in-memory registry entry, storage and
2730/// all, from the header messages the walk decoded.
2731///
2732/// Fails when the chunk index the file names does not read back. The
2733/// caller answers that by preserving the object rather than registering
2734/// a dataset whose index has forgotten where its chunks are: the close
2735/// rewrites what the registry holds, so an index rebuilt from the part of
2736/// it that decoded would strand every chunk it could not read.
2737fn rebuild_dataset(
2738    handle: &mut FileHandle,
2739    meta: &FileMeta,
2740    file_size: u64,
2741    name: String,
2742    obj_addr: u64,
2743    parts: DatasetParts,
2744) -> IoResult<DatasetInfo> {
2745    let ctx = &meta.ctx;
2746    let DatasetParts {
2747        header_blocks,
2748        datatype: dt,
2749        committed_type,
2750        dataspace: ds,
2751        read_format,
2752        layout: dl,
2753        filter_pipeline: fp,
2754        fill_value,
2755        fill_write_time,
2756        attributes: attrs,
2757        track_order,
2758        times,
2759        dense: _,
2760        external,
2761    } = parts;
2762
2763    let mut info = DatasetInfo {
2764        name,
2765        datatype: dt,
2766        // The named type's own object is preserved by its bytes, so the
2767        // address the walk read the pointer from is the address it will still
2768        // be at when this header is written back.
2769        committed_type: committed_type.map(CommittedTypeRef::Preserved),
2770        read_format: Some(read_format),
2771        external,
2772        virtual_storage: None,
2773        dataspace: ds,
2774        obj_header_addr: obj_addr,
2775        data_addr: UNDEF_ADDR,
2776        data_size: 0,
2777        compact: None,
2778        chunked: None,
2779        fixed_array: None,
2780        implicit: None,
2781        single_chunk: None,
2782        btree_v1: None,
2783        btree_v2: None,
2784        append: None,
2785        attributes: attrs,
2786        obj_header_written_addr: Some(obj_addr),
2787        obj_header_blocks: header_blocks,
2788        filter_pipeline: fp,
2789        deleted: false,
2790        extent_dirty: false,
2791        header_dirty: false,
2792        // Stamped by the caller once the whole link graph is registered: it
2793        // is the count of links reaching this object, which one dataset's
2794        // parts cannot see.
2795        nlink_written: 1,
2796        // Stamped by the caller, which knows the order the walk met each
2797        // object; the rebuild sees one dataset at a time.
2798        creation_seq: 0,
2799        track_attr_order: track_order.attrs,
2800        fill_value,
2801        fill_time: fill_write_time,
2802        // Preserve the on-disk layout version so finalize re-encodes
2803        // what it read: a v5 file reopened and appended to must not be
2804        // silently downgraded to v4 (the filtered indexes keep their
2805        // 8-byte size fields, which v4 readers would mis-derive).
2806        layout_version: match &dl {
2807            DataLayoutMessage::ChunkedV4 { version, .. } => *version,
2808            // The classic index has no version above its own: a version-3
2809            // message is the whole of `H5D__chunk_set_info`'s MAX below the
2810            // version-4 gate, and re-encoding it any higher would name an
2811            // index the message cannot carry.
2812            DataLayoutMessage::ChunkedV3 { .. } => LAYOUT_VERSION_DEFAULT,
2813            _ => 4,
2814        },
2815        times,
2816    };
2817
2818    // Reconstruct storage-specific metadata
2819    debug_assert!(
2820        layout_rebuilds(&dl),
2821        "ReopenWalk::plan must preserve a layout this has no arm for"
2822    );
2823    match &dl {
2824        DataLayoutMessage::Contiguous { address, size } => {
2825            info.data_addr = *address;
2826            info.data_size = *size;
2827        }
2828        // The image is the layout message, so the rebuild carries it out of
2829        // the header it came from: anything that makes this dataset's header
2830        // stale rewrites the layout message from `compact`, and a rebuild
2831        // that left it empty would rewrite the dataset as an unallocated
2832        // contiguous one — dropping every byte.
2833        DataLayoutMessage::Compact { data } => {
2834            info.compact = Some(data.clone());
2835        }
2836        // The classic chunk index, reconstructed into the same
2837        // `BtreeV1DatasetInfo` a chunked dataset *created* in this format
2838        // gets, so the one set of machinery — `build_tree`, the flush's block
2839        // pool, `write_chunk`, `extend_dataset`, the prune a delete runs —
2840        // drives a reopened dataset and a fresh one alike. `root_addr` is what
2841        // the layout message carries and stays undefined for a dataset whose
2842        // chunks were never written, exactly as libhdf5 leaves it.
2843        DataLayoutMessage::ChunkedV3 {
2844            chunk_dims,
2845            b_tree_address,
2846        } => {
2847            let real_chunk_dims: Vec<u64> = chunk_dims[..chunk_dims.len() - 1].to_vec();
2848            let mut walk = BtreeV1Walk::new(handle, ctx, &meta.btree, &real_chunk_dims, file_size);
2849            walk.descend(*b_tree_address, 0)?;
2850            let BtreeV1Walk {
2851                records,
2852                node_addrs,
2853                ..
2854            } = walk;
2855            let max_dims = info
2856                .dataspace
2857                .max_dims
2858                .clone()
2859                .unwrap_or_else(|| info.dataspace.dims.clone());
2860            info.btree_v1 = Some(BtreeV1DatasetInfo {
2861                chunk_dims: real_chunk_dims,
2862                max_dims,
2863                // The file's own "K" ranks, not this session's defaults: they
2864                // set every node's width, so a tree bulk-loaded under the
2865                // wrong ones would re-serialize over blocks of the wrong size.
2866                config: meta.btree,
2867                records,
2868                node_addrs,
2869                root_addr: *b_tree_address,
2870                chunks_written: 0,
2871            });
2872        }
2873        DataLayoutMessage::ChunkedV4 {
2874            chunk_dims,
2875            index_address,
2876            index_type,
2877            earray_params,
2878            single_chunk_filter,
2879            ..
2880        } => {
2881            let real_chunk_dims: Vec<u64> = chunk_dims[..chunk_dims.len() - 1].to_vec();
2882
2883            if *index_type == crate::format::messages::data_layout::ChunkIndexType::ExtensibleArray
2884            {
2885                if let Some(params) = earray_params {
2886                    let ep = EarrayParams {
2887                        max_nelmts_bits: params.max_nelmts_bits,
2888                        idx_blk_elmts: params.idx_blk_elmts,
2889                        sup_blk_min_data_ptrs: params.sup_blk_min_data_ptrs,
2890                        data_blk_min_elmts: params.data_blk_min_elmts,
2891                        max_dblk_page_nelmts_bits: params.max_dblk_page_nelmts_bits,
2892                    };
2893                    let ndblk_addrs = compute_ndblk_addrs(ep.sup_blk_min_data_ptrs)?;
2894                    let nsblk_addrs = compute_nsblk_addrs(
2895                        ep.idx_blk_elmts,
2896                        ep.data_blk_min_elmts,
2897                        ep.sup_blk_min_data_ptrs,
2898                        ep.max_nelmts_bits,
2899                    )?;
2900
2901                    // Read EA header
2902                    let hdr_buf = handle.read_at_most(*index_address, 256)?;
2903                    let ea_header = ExtensibleArrayHeader::decode(&hdr_buf, ctx)?;
2904
2905                    let is_filtered = ea_header.class_id
2906                        == crate::format::chunk_index::extensible_array::EA_CLS_FILT_CHUNK;
2907                    let chunk_size_len = if is_filtered {
2908                        ea_header.raw_elmt_size - ctx.sizeof_addr - 4
2909                    } else {
2910                        0
2911                    };
2912
2913                    // Read the EA index block. Filtered datasets
2914                    // store a `FilteredIndexBlock`; unfiltered ones a
2915                    // plain `ExtensibleArrayIndexBlock`. Both must be
2916                    // reconstructed so a reopened dataset can append
2917                    // (write_chunk consults whichever applies).
2918                    let ea_iblk_addr = ea_header.idx_blk_addr;
2919                    let (ea_iblk, filt_iblk) = if is_filtered {
2920                        let placeholder = ExtensibleArrayIndexBlock::new(
2921                            *index_address,
2922                            ep.idx_blk_elmts,
2923                            ndblk_addrs,
2924                            nsblk_addrs,
2925                        );
2926                        let fib = if ea_iblk_addr != UNDEF_ADDR {
2927                            let iblk_buf = handle.read_at_most(ea_iblk_addr, 65536)?;
2928                            FilteredIndexBlock::decode(
2929                                &iblk_buf,
2930                                ctx,
2931                                ep.idx_blk_elmts as usize,
2932                                ndblk_addrs,
2933                                nsblk_addrs,
2934                                chunk_size_len,
2935                            )?
2936                        } else {
2937                            FilteredIndexBlock::new(
2938                                *index_address,
2939                                ep.idx_blk_elmts,
2940                                ndblk_addrs,
2941                                nsblk_addrs,
2942                            )
2943                        };
2944                        (placeholder, Some(fib))
2945                    } else {
2946                        let eib = if ea_iblk_addr != UNDEF_ADDR {
2947                            let iblk_buf = handle.read_at_most(ea_iblk_addr, 65536)?;
2948                            ExtensibleArrayIndexBlock::decode(
2949                                &iblk_buf,
2950                                ctx,
2951                                ep.idx_blk_elmts as usize,
2952                                ndblk_addrs,
2953                                nsblk_addrs,
2954                            )?
2955                        } else {
2956                            ExtensibleArrayIndexBlock::new(
2957                                *index_address,
2958                                ep.idx_blk_elmts,
2959                                ndblk_addrs,
2960                                nsblk_addrs,
2961                            )
2962                        };
2963                        (eib, None)
2964                    };
2965
2966                    info.chunked = Some(ChunkedDatasetInfo {
2967                        chunk_dims: real_chunk_dims,
2968                        earray_params: ep,
2969                        ea_header_addr: *index_address,
2970                        ea_iblk_addr,
2971                        ea_header,
2972                        ea_iblk,
2973                        chunks_written: 0,
2974                        filt_iblk,
2975                        chunk_size_len,
2976                    });
2977                }
2978            } else if *index_type
2979                == crate::format::messages::data_layout::ChunkIndexType::FixedArray
2980            {
2981                // Read the FA header and data block back so a
2982                // reopened dataset is writable and deletable, not
2983                // re-link only — a placeholder made a delete free
2984                // just the header and leak every chunk plus the
2985                // index. Paged data blocks (any FA with more than
2986                // dblk_page_nelmts chunks, libhdf5 default 1024)
2987                // reconstruct through the same decode owner; only
2988                // pages the bitmap marks initialized are decoded.
2989                let hdr_buf = handle.read_at_most(*index_address, 256)?;
2990                let fa_header = FixedArrayHeader::decode(&hdr_buf, ctx)?;
2991                let is_filtered = fa_header.client_id == FA_CLIENT_FILT_CHUNK;
2992                let chunk_size_len = if is_filtered {
2993                    (fa_header.element_size as usize)
2994                        .checked_sub(ctx.sizeof_addr as usize + 4)
2995                        .ok_or_else(|| {
2996                            crate::io::IoError::InvalidState(
2997                                "fixed array filtered element_size too small".into(),
2998                            )
2999                        })?
3000                } else {
3001                    0
3002                };
3003                if fa_header.data_blk_addr != UNDEF_ADDR && chunk_size_len <= 8 {
3004                    let dblk_size = fixed_array_dblk_disk_size(ctx, &fa_header) as usize;
3005                    let dblk_buf = handle.read_at_most(fa_header.data_blk_addr, dblk_size)?;
3006                    let fa_dblk =
3007                        decode_fixed_array_dblk(ctx, &fa_header, &dblk_buf, chunk_size_len)?;
3008                    info.fixed_array = Some(FixedArrayDatasetInfo {
3009                        chunk_dims: real_chunk_dims,
3010                        fa_header_addr: *index_address,
3011                        fa_dblk_addr: fa_header.data_blk_addr,
3012                        fa_header,
3013                        fa_dblk,
3014                        // Chunks written this session, matching the
3015                        // EA reconstruction above.
3016                        chunks_written: 0,
3017                    });
3018                }
3019            } else if *index_type == crate::format::messages::data_layout::ChunkIndexType::BTreeV2 {
3020                use crate::format::chunk_index::btree_v2::{
3021                    Bt2Geometry, Bt2Header, BT2_TYPE_CHUNK_FILT, BT2_TYPE_CHUNK_UNFILT,
3022                };
3023
3024                // Walk the tree back into the in-memory index and
3025                // adopt its node blocks as the flush pool. The pool
3026                // re-serializes at the header's node_size, whatever
3027                // it is — libhdf5 sizes every node from
3028                // hdr->node_size (H5B2leaf.c, H5B2internal.c) — so
3029                // a foreign size reopens too. Only a record type
3030                // that is not a chunk record, or a node size below
3031                // the bulk loader's few-records-per-node floor
3032                // (the same bound creation enforces), stays
3033                // re-link only.
3034                let hdr_buf = handle.read_at_most(*index_address, 256)?;
3035                let bt2_hdr = Bt2Header::decode(&hdr_buf, ctx)?;
3036                let ndims = real_chunk_dims.len();
3037                let is_filt = match bt2_hdr.record_type {
3038                    BT2_TYPE_CHUNK_UNFILT => Some(false),
3039                    BT2_TYPE_CHUNK_FILT => Some(true),
3040                    _ => None,
3041                };
3042                if let (Some(is_filt), true) = (
3043                    is_filt,
3044                    bt2_hdr.node_size as usize >= 10 + 3 * bt2_hdr.record_size as usize,
3045                ) {
3046                    let mut index = if is_filt {
3047                        let csl = (bt2_hdr.record_size as usize)
3048                            .checked_sub(ctx.sizeof_addr as usize + 4 + ndims * 8)
3049                            .filter(|&c| c <= 8)
3050                            .ok_or_else(|| {
3051                                crate::io::IoError::InvalidState(
3052                                    "v2 B-tree filtered record size does not fit \
3053                                     its rank and address width"
3054                                        .into(),
3055                                )
3056                            })?;
3057                        Bt2ChunkIndex::new_filtered(ndims, csl as u8)
3058                    } else {
3059                        Bt2ChunkIndex::new_unfiltered(ndims)
3060                    };
3061                    // Re-serialize with the creator's parameters:
3062                    // node blocks keep their size and the rewritten
3063                    // header keeps its declared split/merge.
3064                    index.node_size = bt2_hdr.node_size;
3065                    index.split_percent = bt2_hdr.split_percent;
3066                    index.merge_percent = bt2_hdr.merge_percent;
3067                    let mut node_addrs = Vec::new();
3068                    if bt2_hdr.root_node_addr != UNDEF_ADDR && bt2_hdr.total_num_records > 0 {
3069                        let geo = Bt2Geometry::new(
3070                            bt2_hdr.node_size,
3071                            bt2_hdr.record_size,
3072                            bt2_hdr.depth,
3073                            ctx.sizeof_addr,
3074                        );
3075                        let mut walk =
3076                            Bt2Walk::new(handle, ctx, bt2_hdr.record_size, bt2_hdr.node_size, &geo);
3077                        walk.descend(
3078                            bt2_hdr.root_node_addr,
3079                            bt2_hdr.depth,
3080                            bt2_hdr.num_records_in_root,
3081                        )?;
3082                        node_addrs = walk.node_addrs;
3083                        let record_bytes = walk.records;
3084                        let total = if bt2_hdr.record_size > 0 {
3085                            record_bytes.len() / bt2_hdr.record_size as usize
3086                        } else {
3087                            0
3088                        };
3089                        if is_filt {
3090                            for r in Bt2ChunkIndex::decode_filtered_records(
3091                                &record_bytes,
3092                                total,
3093                                ndims,
3094                                bt2_hdr.record_size,
3095                                ctx,
3096                            )? {
3097                                index.insert_filtered(
3098                                    r.scaled_offsets,
3099                                    r.chunk_address,
3100                                    r.chunk_size,
3101                                    r.filter_mask,
3102                                );
3103                            }
3104                        } else {
3105                            for r in Bt2ChunkIndex::decode_unfiltered_records(
3106                                &record_bytes,
3107                                total,
3108                                ndims,
3109                                ctx,
3110                            )? {
3111                                index.insert(r.scaled_offsets, r.chunk_address);
3112                            }
3113                        }
3114                    }
3115                    info.btree_v2 = Some(Bt2DatasetInfo {
3116                        chunk_dims: real_chunk_dims,
3117                        bt2_header_addr: *index_address,
3118                        node_addrs,
3119                        index,
3120                        chunks_written: 0,
3121                    });
3122                }
3123            } else if *index_type == crate::format::messages::data_layout::ChunkIndexType::Implicit
3124            {
3125                // Nothing to read back: the index *is* the run of chunk space
3126                // at `index_address`, and its length is the chunk grid times
3127                // the chunk size. Reconstructing that length is what lets a
3128                // delete free the storage and a write address it — a rebuild
3129                // that left this empty would rewrite the dataset as an
3130                // unallocated contiguous one, dropping every byte.
3131                let mut nchunks: u64 = 1;
3132                for g in crate::io::chunk_grid::index_grid(
3133                    &info.dataspace.dims,
3134                    info.dataspace.max_dims.as_deref(),
3135                    &real_chunk_dims,
3136                )? {
3137                    nchunks = nchunks.checked_mul(g).ok_or_else(|| {
3138                        crate::io::IoError::InvalidState("chunk count overflows u64".into())
3139                    })?;
3140                }
3141                let data_size = nchunks
3142                    .checked_mul(chunk_dims.iter().product::<u64>())
3143                    .ok_or_else(|| {
3144                        crate::io::IoError::InvalidState(
3145                            "implicit chunk storage overflows u64".into(),
3146                        )
3147                    })?;
3148                info.implicit = Some(ImplicitDatasetInfo {
3149                    chunk_dims: real_chunk_dims,
3150                    data_addr: *index_address,
3151                    data_size,
3152                });
3153            } else if *index_type
3154                == crate::format::messages::data_layout::ChunkIndexType::SingleChunk
3155            {
3156                // No index structure to read back either: the one chunk's
3157                // address, and its stored size and filter mask if the
3158                // layout's filtered flag is set, are the whole of the
3159                // layout message. `chunk_dims` already includes the
3160                // trailing element-size dimension, so its product is the
3161                // chunk's unfiltered byte length directly (see `data_size`
3162                // in the Implicit arm above).
3163                let data_size = chunk_dims.iter().product::<u64>();
3164                let (nbytes, filter_mask) = match single_chunk_filter {
3165                    Some(scf) => (scf.nbytes, scf.filter_mask),
3166                    None => (data_size, 0),
3167                };
3168                info.single_chunk = Some(SingleChunkDatasetInfo {
3169                    chunk_dims: real_chunk_dims,
3170                    data_addr: *index_address,
3171                    data_size,
3172                    nbytes,
3173                    filter_mask,
3174                    chunks_written: 0,
3175                    // Whether this was created with early allocation isn't
3176                    // recoverable here: `fill_value` above is only the
3177                    // decoded fill bytes, not the fill-value message's
3178                    // `alloc_time` byte the layout was chosen under. A
3179                    // reopened dataset that later gets a header rewrite
3180                    // therefore reports incremental allocation regardless
3181                    // of how it was actually created — the same
3182                    // imprecision a reopened `fixed_array`/`btree_v2`
3183                    // dataset already has, for the same reason.
3184                    early_alloc: false,
3185                });
3186            }
3187        }
3188        // Unreachable by `layout_rebuilds`, which is the gate
3189        // `ReopenWalk::plan` consults before it ever calls this.
3190        _ => {}
3191    }
3192
3193    Ok(info)
3194}
3195
3196/// Write `data` at *dataset-relative* byte offset `skip` into an external file
3197/// list, walking slots by cumulative declared size exactly like
3198/// `H5D__efl_write` (H5Defl.c).
3199///
3200/// Each slot's file is opened create-if-missing and never truncated, so a
3201/// write touches only the byte range that slot owns. A write past the *total*
3202/// declared size of the list is an error, matching upstream's "write past
3203/// logical end of file" check.
3204fn write_external_file_bytes(
3205    files: &[ExternalFile],
3206    extfile_prefix: Option<&Path>,
3207    mut skip: u64,
3208    data: &[u8],
3209) -> IoResult<()> {
3210    // `H5D__efl_write`'s slot walk: an `H5O_EFL_UNLIMITED` slot matches every
3211    // remaining offset (`skip >= u64::MAX` is never true), so the search stops
3212    // there and the write below takes the whole rest of the data.
3213    let mut slot_idx = 0usize;
3214    while slot_idx < files.len() && skip >= files[slot_idx].size {
3215        skip -= files[slot_idx].size;
3216        slot_idx += 1;
3217    }
3218
3219    let mut written = 0usize;
3220    while written < data.len() {
3221        let Some(slot) = files.get(slot_idx) else {
3222            return Err(crate::io::IoError::InvalidState(
3223                "write past the logical end of the external file list".into(),
3224            ));
3225        };
3226        let full_path = crate::io::reader::combine_prefixed_path(extfile_prefix, &slot.name);
3227        let ext_handle = FileHandle::open_or_create_readwrite_with_locking(
3228            &full_path,
3229            crate::io::locking::FileLocking::Disabled,
3230        )
3231        .map_err(|e| {
3232            crate::io::IoError::InvalidState(format!(
3233                "unable to open external raw data file {} for writing: {e}",
3234                full_path.display()
3235            ))
3236        })?;
3237        let this_write = (slot.size - skip).min((data.len() - written) as u64) as usize;
3238        let at = slot.offset.checked_add(skip).ok_or_else(|| {
3239            crate::io::IoError::InvalidState(format!(
3240                "external file '{}' slot offset {} overflows {skip} bytes into the slot",
3241                slot.name, slot.offset
3242            ))
3243        })?;
3244        ext_handle.write_at(at, &data[written..written + this_write])?;
3245        // This handle is dropped at the end of the iteration, and `Drop` can
3246        // only print a flush failure. Empty the accumulator here instead, so a
3247        // full disk on an external raw-data file reaches the caller.
3248        ext_handle.flush()?;
3249
3250        written += this_write;
3251        skip = 0;
3252        slot_idx += 1;
3253    }
3254    Ok(())
3255}
3256
3257/// The directory the HDF5 file at `path` sits in — libhdf5's `H5F_t::extpath`,
3258/// which `H5D__build_file_prefix` expands `${ORIGIN}` to.
3259///
3260/// Canonicalized, so the value survives the process changing directory and so
3261/// a writer and a reader of the same file agree on it. Called once per open,
3262/// never per I/O, for exactly that reason.
3263fn source_dir_of(path: &Path) -> IoResult<PathBuf> {
3264    let canonical = std::fs::canonicalize(path)?;
3265    Ok(canonical
3266        .parent()
3267        .map(Path::to_path_buf)
3268        .unwrap_or_default())
3269}
3270
3271/// Whether [`rebuild_dataset`] has an arm that reconstructs this layout.
3272///
3273/// The single list: `ReopenWalk::plan` preserves an object whose layout this
3274/// says no to, so a layout added to one side and not the other cannot happen.
3275/// Keeping two lists is what would rewrite a modelled dataset as unallocated
3276/// contiguous storage, or preserve one the writer can now build.
3277fn layout_rebuilds(layout: &DataLayoutMessage) -> bool {
3278    matches!(
3279        layout,
3280        DataLayoutMessage::Contiguous { .. }
3281            | DataLayoutMessage::Compact { .. }
3282            | DataLayoutMessage::ChunkedV3 { .. }
3283            | DataLayoutMessage::ChunkedV4 { .. }
3284    )
3285}
3286
3287/// Encode an Object Reference Count message (type 0x16) body: a version
3288/// byte (`H5O_REFCOUNT_VERSION` = 0) followed by the little-endian u32
3289/// count. Emitted on objects reached by more than one hard link.
3290fn encode_refcount(refcount: u32) -> Vec<u8> {
3291    let mut v = Vec::with_capacity(5);
3292    v.push(0u8);
3293    v.extend_from_slice(&refcount.to_le_bytes());
3294    v
3295}
3296
3297/// The symbol-table storage of every group that has one, and the single owner
3298/// of which groups those are.
3299///
3300/// A group stores its links in a symbol table because the file was *made* that
3301/// way — `H5F_LIBVER_EARLIEST` is the one bound `H5G__obj_create_real`
3302/// (H5Gobj.c:179) writes them at — or because it already had one when the file
3303/// was reopened. The second is not the first: `H5G_obj_insert` inserts into
3304/// whatever storage the group is in and converts only when a link will not fit
3305/// an entry (H5Gobj.c:512), so a symbol table survives a reopen at any bound.
3306/// A file with shared messages is where the two come apart, because its
3307/// superblock extension forces a version-2 superblock over symbol-table groups
3308/// (H5Fsuper.c:1135) — a group the session adds is made as the session's
3309/// bound says while the groups already there stay symbol tables.
3310struct SymbolTables {
3311    /// The scopes the reopen found a Symbol Table message on. Fixed for the
3312    /// session: a group already in that storage stays in it, whatever bound
3313    /// the objects added beside it are written at.
3314    found: HashSet<LinkScope>,
3315    /// The symbol-table storage each group's header already names, by the
3316    /// scope whose rewrite supersedes it.
3317    ///
3318    /// INVARIANT: every entry is freed exactly once, by
3319    /// [`Hdf5Writer::prepare_symbol_tables`], which removes it as it frees.
3320    superseded: Slot<HashMap<LinkScope, StabExtents>>,
3321    /// The storage that same pass laid out, read by the header builders.
3322    ///
3323    /// INVARIANT: an entry exists here only after every block of that group's
3324    /// heap and B-tree is on disk. `build_group_header` reads it and never
3325    /// builds — a header is sized and then written by two separate calls, so a
3326    /// build that allocated would allocate twice.
3327    written: Slot<HashMap<LinkScope, Stab>>,
3328}
3329
3330impl SymbolTables {
3331    /// What a file being created starts from: no group found in a symbol table
3332    /// because none was read, and nothing on disk to free.
3333    fn none_found() -> Self {
3334        Self {
3335            found: HashSet::new(),
3336            superseded: Slot::new(HashMap::new()),
3337            written: Slot::new(HashMap::new()),
3338        }
3339    }
3340}
3341
3342/// Everything a version-0/1 (symbol-table) file carries that a version-2/3 one
3343/// does not.
3344///
3345/// Its presence *is* the generation switch — [`Hdf5Writer::message_format`]
3346/// reads nothing else: libhdf5 at `H5F_LIBVER_EARLIEST` writes a version-0/1
3347/// superblock over version-1 object headers over symbol-table groups. Which
3348/// groups are symbol tables is the separate question [`SymbolTables`] answers,
3349/// because a reopen at a newer bound keeps the ones it finds.
3350///
3351/// Two things put one here, and only two: reopening a file that already is in
3352/// that format, and creating one at that bound
3353/// ([`LegacyFile::created`]). Neither is distinguished afterwards — a file is
3354/// classic or it is not, and every encoder asks only that.
3355struct LegacyFile {
3356    /// The superblock as it was read, or as [`LegacyFile::created`] built it.
3357    /// The close re-emits it with only the end of file and the root symbol
3358    /// table entry recomputed: the "K" ranks in particular are recorded
3359    /// nowhere else, and every node width in the file is derived from them.
3360    superblock: SuperblockV0V1,
3361}
3362
3363impl LegacyFile {
3364    /// The classic-format state a file created at `H5F_LIBVER_EARLIEST`
3365    /// starts from.
3366    ///
3367    /// A new file has no symbol table on disk to free and none laid out, so
3368    /// its [`SymbolTables`] starts empty and every group it makes takes that
3369    /// storage from the bound rather than from what was found.
3370    ///
3371    /// The superblock is the one `H5F__super_init` writes at that bound: the
3372    /// library-default "K" ranks (`H5F_CRT_SYM_LEAF_DEF`,
3373    /// `HDF5_BTREE_SNODE_IK_DEF`), no free-space info and no driver info. The
3374    /// root entry's object header address and cached symbol table are stamped
3375    /// in by [`Hdf5Writer::write_superblock`] once the root group has one;
3376    /// its name offset is the empty string at the front of every local heap.
3377    ///
3378    /// Version 0, not 1: a version-1 superblock exists only to carry a
3379    /// non-default chunked-storage "K" value (H5Fsuper.c:1150), and this
3380    /// writer has no property to set one.
3381    fn created(ctx: FormatContext, base_address: u64) -> Self {
3382        let btree = BTreeV1Config::default();
3383        Self {
3384            superblock: SuperblockV0V1 {
3385                version: SUPERBLOCK_V0,
3386                sizeof_offsets: ctx.sizeof_addr,
3387                sizeof_lengths: ctx.sizeof_size,
3388                file_consistency_flags: 0,
3389                sym_leaf_k: btree.sym_leaf_k,
3390                btree_internal_k: btree.snode_internal_k,
3391                indexed_storage_k: None,
3392                base_address,
3393                superblock_extension_address: UNDEF_ADDR,
3394                end_of_file_address: 0,
3395                driver_info_address: UNDEF_ADDR,
3396                root_symbol_table_entry: SymbolTableEntry {
3397                    name_offset: 0,
3398                    obj_header_addr: UNDEF_ADDR,
3399                    cache: SymbolTableCache::Nothing,
3400                },
3401            },
3402        }
3403    }
3404}
3405
3406/// The superblock extension a reopen found, and the single owner of the one
3407/// this file's close writes back.
3408///
3409/// The extension is external truth: it is where a file records the things its
3410/// superblock has no field for — non-default v1 B-tree "K" ranks, a driver's
3411/// settings, the file space strategy and its persisted free-space managers,
3412/// and the shared object header message table. `H5F__super_ext_write_msg`
3413/// modifies one message of it and leaves the rest alone, so a close that lays
3414/// a fresh extension out from what *this writer* models drops everything it
3415/// does not — and the K ranks are not decoration: a chunked dataset's version-1
3416/// B-tree nodes are sized from `chunk_internal_k`, so a reader that has lost
3417/// the message reads the tree at the default rank and fails outright.
3418///
3419/// INVARIANT: every message of the extension read is re-emitted by
3420/// [`Hdf5Writer::write_superblock_extension`], byte for byte, except the
3421/// shared-message table — the one message naming storage this session lays out
3422/// afresh, which [`SohmState`] recomputes. Nothing else here is interpreted,
3423/// so a message this crate does not model survives exactly as a modelled one
3424/// does.
3425struct CarriedExtension {
3426    /// Every block the extension header occupied — chunk 0 and each
3427    /// continuation it named — freed once the replacement is laid out. Empty
3428    /// for a file with no extension, and for one whose extension this session
3429    /// is the first to write. A rewrite re-encodes the whole chain into one
3430    /// chunk, so freeing only the first would leave the rest as space no
3431    /// free-space manager records and no object claims.
3432    superseded: crate::io::object_header_io::HeaderBlocks,
3433    /// Every message that header held — the shared-message table,
3434    /// continuations and null padding excepted. The first two are structure
3435    /// rather than content; the third is free space.
3436    carried: Vec<crate::io::object_header_io::ExtensionMessage>,
3437    /// Where [`Hdf5Writer::write_superblock_extension`] put the replacement,
3438    /// and the only value the superblock's extension address is read from.
3439    /// `None` until that pass runs, and for a file that needs no extension.
3440    addr: Slot<Option<u64>>,
3441}
3442
3443/// What a reopen learns from a file's free-space managers, split by who owns
3444/// it: the sections go to the allocator and the rest stays with the writer.
3445struct ReopenedFreeSpace {
3446    /// `None` for a file this writer records no free space for.
3447    state: Option<Box<FileSpaceState>>,
3448    /// Every section the managers held, each tagged with the manager it came
3449    /// out of and merged only within it, address-ordered. Empty whenever
3450    /// `state` is `None`.
3451    sections: Vec<FreeBlock>,
3452}
3453
3454/// The file-space info message this session is responsible for, and the
3455/// manager blocks it supersedes.
3456///
3457/// A file whose message says `persist` records the space its own edits
3458/// released in one free-space manager per allocation type: a header block
3459/// (`FSHD`) naming a sections block (`FSSE`) that lists every free region.
3460/// Nothing else in the file says those regions are free, so a session that
3461/// rewrites the file without reading them either leaks the space it frees or
3462/// hands out space a manager still claims.
3463///
3464/// Present for a file this writer *created* with non-default file-space
3465/// properties as well, where there is nothing to read and the message is this
3466/// session's to write. `None` — the field, not this struct — is the third
3467/// case: a reopened file whose message this session must not touch, which the
3468/// carried extension re-emits byte for byte.
3469///
3470/// INVARIANT: the sections read are handed to [`FileAllocator`] and tracked
3471/// there alone, so there is one account of the file's free space and not two.
3472/// What stays here is only what the allocator has no place for: the message to
3473/// write, and the managers' own blocks, which are not free space until the
3474/// close that replaces them frees them.
3475struct FileSpaceState {
3476    /// The message, as read or as the creation options declared it. It is the
3477    /// only place the manager addresses are recorded, so the close that moves
3478    /// them rewrites this message.
3479    info: FileSpaceInfoMessage,
3480    /// The manager blocks themselves — one header, and one sections block per
3481    /// manager that had any sections. Freed by the close that lays their
3482    /// replacements out, the rule every other superseded structure follows.
3483    /// Empty for a created file, which supersedes nothing.
3484    superseded: Vec<(u64, u64)>,
3485}
3486
3487impl FileSpaceState {
3488    /// Whether this file keeps free-space managers on disk. Both strategies
3489    /// that have managers do — paged aggregation has the same managers plus a
3490    /// large one — while the two aggregator-only strategies and
3491    /// `persist: false` still carry the message with nothing to write into it.
3492    fn records_free_space(&self) -> bool {
3493        self.info.persist
3494            && matches!(
3495                self.info.strategy,
3496                FileSpaceStrategy::FsmAggr | FileSpaceStrategy::Page
3497            )
3498    }
3499}
3500
3501/// One free-space manager that has been given its own two blocks, and the
3502/// sections it will write into them.
3503///
3504/// Produced by
3505/// [`settle_free_space_managers`](Hdf5Writer::settle_free_space_managers).
3506/// Both blocks are ordinary allocations out of the same [`FileAllocator`] the
3507/// rest of the file uses, because upstream's are too:
3508/// `H5FS_vfd_alloc_hdr_and_section_info_if_needed` calls `H5MF_alloc`
3509/// (H5FSsection.c:2352, 2406).
3510struct PlacedManager {
3511    /// Which of the file's managers this is; its message slot names it in the
3512    /// file-space info message.
3513    manager: FreeSpaceManager,
3514    /// Header block address.
3515    hdr_addr: u64,
3516    /// Sections block address.
3517    sect_addr: u64,
3518    /// Bytes the sections block occupies. What the header records as both
3519    /// `sect_size` and `alloc_sect_size`, so an image shorter than the block
3520    /// is padded rather than reported short.
3521    sect_size: u64,
3522    /// The sections this manager records, in serialization order. Filled on
3523    /// the settling round, once no allocation can change them.
3524    sections: Vec<FreeSection>,
3525}
3526
3527/// The manager header for `sections`, before its own blocks have addresses.
3528///
3529/// Every width the section encoding uses comes from here, and the only one
3530/// that varies with the content is `serial_sections` — it decides how many
3531/// bytes a per-size run count takes — so sizing a layout and encoding it must
3532/// go through this one function or the two disagree.
3533fn manager_header(sections: &[FreeSection]) -> FreeSpaceHeader {
3534    FreeSpaceHeader {
3535        client: free_space::CLIENT_FILE,
3536        total_space: sections.iter().map(|s| s.len).sum(),
3537        total_sections: sections.len() as u64,
3538        // Every class the file client registers is serializable; only a
3539        // fractal heap's manager has ghost sections.
3540        serial_sections: sections.len() as u64,
3541        ghost_sections: 0,
3542        nclasses: free_space::FILE_SECT_CLASSES,
3543        shrink_percent: free_space::SHRINK_PERCENT,
3544        expand_percent: free_space::EXPAND_PERCENT,
3545        max_sect_addr: free_space::SEC2_MAX_SECT_ADDR,
3546        max_sect_size: free_space::SEC2_MAXADDR,
3547        sect_addr: UNDEF_ADDR,
3548        sect_size: 0,
3549        alloc_sect_size: 0,
3550    }
3551}
3552
3553impl Default for CarriedExtension {
3554    /// What a file with no extension carries: nothing to free, nothing to
3555    /// re-emit, and no address until a shared-message table gives it one.
3556    fn default() -> Self {
3557        Self {
3558            superseded: Vec::new(),
3559            carried: Vec::new(),
3560            addr: Slot::new(None),
3561        }
3562    }
3563}
3564
3565/// Where a file's superblock version comes from — the two cases libhdf5 keeps
3566/// strictly apart, and this writer's single source for the version it writes
3567/// back.
3568///
3569/// INVARIANT: reopening a file never changes its superblock version.
3570///
3571/// libhdf5 splits the same way. `H5F__super_init` is the only place a version
3572/// is *decided* — content first, then `MAX(super_vers,
3573/// HDF5_superblock_ver_bounds[low_bound])` (H5Fsuper.c:1128-1154).
3574/// `H5F__super_read` never recomputes one. Nor does it bound anything by it:
3575/// the structures a session appends are written at the bound the caller
3576/// named, or the writer's default, whatever version the superblock has.
3577/// libhdf5 1.14 raised a reopened file's low bound to the row its superblock
3578/// version belongs to; libhdf5 2.0 dropped that (HDFGroup/hdf5#4939), and the
3579/// one raise left is SWMR write access, to `H5F_LIBVER_V110`
3580/// (H5Fsuper.c:453), which [`reject_swmr`](Hdf5Writer::reject_swmr) asks of
3581/// the caller instead. One direction only: the bound decides a created
3582/// file's version, the version never decides the bound.
3583///
3584/// Two variants rather than one number with a rule attached, because the
3585/// number means different things on the two paths — a floor to raise on the
3586/// create path, a fixed value on the reopen path — and a single field would
3587/// have every reader re-derive which.
3588#[derive(Debug, Clone, Copy)]
3589enum SuperblockVersion {
3590    /// A file this writer created. The version its creation options start
3591    /// from, which [`superblock_version_for`](Hdf5Writer::superblock_version_for)
3592    /// raises to what the content and the named bound need.
3593    Chosen(u8),
3594    /// A file this writer reopened: the version already in the file, written
3595    /// back unchanged. `Existing(0..=1)` and `Hdf5Writer::legacy` say the same
3596    /// thing from two directions and cannot disagree: `open_append_with_locking`
3597    /// builds the `LegacyFile` from exactly those versions.
3598    Existing(u8),
3599}
3600
3601/// A registry entry that has held some name.
3602///
3603/// Datasets, groups and committed datatypes keep stable indices — their
3604/// registries only grow, deletion being a flag — so the index can name the
3605/// exact entry. The link registries shrink as links are unlinked, and a
3606/// link's path is derived from its parent group's current name, so for those
3607/// the index records only that the kind once claimed the name and the (short)
3608/// list itself answers.
3609#[derive(Clone, Copy, PartialEq, Eq)]
3610enum NameHit {
3611    Dataset(usize),
3612    Group(usize),
3613    Datatype(usize),
3614    HardLink,
3615    SymbolicLink,
3616    PreservedLink,
3617}
3618
3619/// Which names the file model already holds, so creating an object does not
3620/// have to walk every registry to find out.
3621///
3622/// INVARIANT: while `map` is `Some`, every name a registry entry currently
3623/// holds has an entry in `map` covering that entry. The converse is not
3624/// required: a hit whose object was since deleted, or whose name has since
3625/// changed, stays in the map and is filtered out by
3626/// [`Hdf5Writer::name_holder`], which re-runs the very predicates the linear
3627/// scan used. The index may therefore answer "maybe", never "free" for a name
3628/// that is taken.
3629///
3630/// MUST NOT: no code may give a registry entry a name, or move the path a
3631/// link is emitted under, without either registering the new name through
3632/// [`Hdf5Writer::register_name`] or dropping the index through
3633/// [`Hdf5Writer::forget_name_index`]. State a constructor puts straight into
3634/// the registries needs neither — `map` starts `None`, and the first query
3635/// builds it from the registries as they then stand.
3636struct NameIndex {
3637    map: Option<HashMap<String, Vec<NameHit>>>,
3638    /// Bumped whenever the registries move under a build in flight, so that
3639    /// build's result is discarded instead of being installed stale.
3640    epoch: u64,
3641}
3642
3643impl NameIndex {
3644    fn new() -> Self {
3645        NameIndex {
3646            map: None,
3647            epoch: 0,
3648        }
3649    }
3650
3651    /// Record that `hit` holds `name`. With no map built there is nothing to
3652    /// record, but the registries have moved, so any build in flight is
3653    /// invalidated rather than trusted.
3654    fn insert(&mut self, name: &str, hit: NameHit) {
3655        match self.map.as_mut() {
3656            None => self.epoch += 1,
3657            Some(map) => {
3658                let hits = map.entry(name.to_string()).or_default();
3659                if !hits.contains(&hit) {
3660                    hits.push(hit);
3661                }
3662            }
3663        }
3664    }
3665
3666    /// Throw the index away: the next query rebuilds it from the registries.
3667    fn forget(&mut self) {
3668        self.map = None;
3669        self.epoch += 1;
3670    }
3671}
3672
3673/// HDF5 file writer.
3674///
3675/// Usage:
3676/// 1. `Hdf5Writer::create(path)` to create a new file.
3677/// 2. `create_dataset(name, datatype, dims)` to define datasets.
3678/// 3. `write_dataset_raw(index, data)` to write raw data.
3679/// 4. `close()` to finalize the file (writes superblock, headers, etc.).
3680pub struct Hdf5Writer {
3681    handle: FileHandle,
3682    allocator: FileAllocator,
3683    ctx: FormatContext,
3684    /// Dataset registry. The outer [`Slot`] guards the spine (push on create,
3685    /// index/clone on access) and is held only briefly; each [`DatasetRef`]
3686    /// carries one dataset's metadata behind its own lock. A writer clones
3687    /// the `DatasetRef` out (releasing this lock) before doing the long
3688    /// per-dataset work, so a create never blocks an in-flight write.
3689    pub(crate) datasets: Slot<Vec<DatasetRef>>,
3690    /// Group registry, same shape as [`Self::datasets`].
3691    pub(crate) groups: Slot<Vec<GroupRef>>,
3692    /// User-created hard links (additional names for existing objects),
3693    /// resolved and emitted during finalize.
3694    pub(crate) hard_links: Slot<Vec<HardLink>>,
3695    /// User-created soft and external links. Held apart from
3696    /// [`Self::hard_links`] because they name a path rather than an object:
3697    /// nothing resolves them, and no object's reference count counts them.
3698    pub(crate) symbolic_links: Slot<Vec<SymbolicLink>>,
3699    /// Datatypes committed this session, each an object of its own; see
3700    /// [`CommittedDatatype`].
3701    pub(crate) committed_datatypes: Slot<Vec<CommittedDatatype>>,
3702    /// Links a reopened file held that this writer cannot express, carried
3703    /// through every header rewrite by their encoded bytes. Always empty for
3704    /// a freshly created file; see [`PreservedLink`].
3705    pub(crate) preserved_links: Slot<Vec<PreservedLink>>,
3706    /// Which names the registries above already hold; see [`NameIndex`].
3707    /// Boxed so this side table costs the writer one pointer: inline, its
3708    /// map shifted every field after it and cost the attribute path ~5%.
3709    name_index: Slot<Box<NameIndex>>,
3710    /// Attributes attached to the root group (file-level attributes).
3711    pub(crate) root_attributes: Slot<Vec<crate::format::messages::attribute::AttributeEntry>>,
3712    /// Serializes object creation so name-uniqueness check and registry insert
3713    /// happen atomically.
3714    ///
3715    /// INVARIANT: no two emitted links share a full-path name. Under
3716    /// `threadsafe`, create methods run on the shared read guard, so without
3717    /// this gate two threads could both pass the duplicate-name check (which
3718    /// snapshots a registry and drops its lock) and both push, writing an
3719    /// invalid HDF5 file with two same-named links. A create holds this lock
3720    /// across its check *and* its push; the streaming write path never takes
3721    /// it, so writes to existing datasets stay fully concurrent. It is the
3722    /// outermost lock a create acquires (create_lock → spine → slot), and no
3723    /// write path takes it, so it cannot deadlock with the registry locks.
3724    pub(crate) create_lock: Slot<()>,
3725    /// The low `H5Pset_libver_bounds` bound the *caller named*, or `None`
3726    /// when none was: the oldest libhdf5 the objects this writer creates must
3727    /// stay readable by. It is the one switch the version-bearing messages
3728    /// read — the datatype message version (`H5O_dtype_ver_bounds`), the data
3729    /// layout message version (`H5O_layout_ver_bounds`) and with it the chunk
3730    /// index, and the superblock floor (`HDF5_superblock_ver_bounds`) of a
3731    /// file this writer creates.
3732    ///
3733    /// `None` is not `Some(Earliest)`. No single libhdf5 bound describes this
3734    /// crate's default file: it takes the earliest row of the datatype and
3735    /// superblock tables (version-1 datatypes, a version-2 superblock raised
3736    /// to 3 only by what the content needs) over the v1.10 chunk indexes,
3737    /// which is the `H5F_LIBVER_V110` row of the layout table. Naming a bound
3738    /// asks for one whole libhdf5 generation instead, so the two cannot share
3739    /// a field.
3740    ///
3741    /// Nothing reads this directly:
3742    /// [`session_libver`](Hdf5Writer::session_libver) is the only reader, and
3743    /// it is where `None` becomes the default of the family asking, the same
3744    /// on a created file and a reopened one: the superblock a reopened file
3745    /// already has says nothing about the bound (see [`SuperblockVersion`]).
3746    libver: Option<LibverBound>,
3747    closed: bool,
3748    /// Set once `finalize_for_swmr` has published a readable file.
3749    ///
3750    /// A SWMR reader may hold a chunk index that still points at a block this
3751    /// writer has since replaced, so from that point on a relocated chunk's
3752    /// old block is kept rather than released for reuse — the same rule as
3753    /// libhdf5's `H5D__chunk_file_alloc`, which skips `H5MF_xfree` under
3754    /// `H5F_ACC_SWMR_WRITE`.
3755    swmr_active: bool,
3756    /// Collections with free space — libhdf5's `f->shared->cwfs` list. A
3757    /// vlen insert fills these partially-filled collection blocks before
3758    /// creating a new one, so many small writes share 4096-byte blocks
3759    /// instead of each taking their own. Entries hold `(addr, block size,
3760    /// free bytes)` hints; the block on disk stays the single truth for
3761    /// contents, and only the two functions that rewrite collection blocks
3762    /// ([`insert_vlen_objects`](Self::insert_vlen_objects) and
3763    /// [`release_vlen_references`](Self::release_vlen_references)) may
3764    /// update this list. In-memory only, like the allocator's free list:
3765    /// a reopened file's free space is rediscovered as releases touch its
3766    /// collections. Capped at [`H5HG_NCWFS`] entries.
3767    cwfs: Slot<Vec<CwfsEntry>>,
3768    /// Address of the root group object header (set after first finalize).
3769    root_group_addr: Option<u64>,
3770    /// Size of the encoded root group object header (for in-place rewrites).
3771    /// The on-disk root header block a reopen found, `(addr, len)`, so
3772    /// finalize can free the block its rewrite supersedes.
3773    superseded_root_header: crate::io::object_header_io::HeaderBlocks,
3774    /// Where this file's superblock version comes from. The single owner of
3775    /// the reopen invariant — see [`SuperblockVersion`] and
3776    /// [`superblock_version_for`](Self::superblock_version_for).
3777    superblock_version: SuperblockVersion,
3778    /// Objects whose attributes this finalize spilled to dense storage, and
3779    /// the `Attribute Info` message naming what was written for each.
3780    ///
3781    /// INVARIANT: an entry exists here only after every block of that
3782    /// object's heap and name index is on disk, and only
3783    /// [`prepare_dense_attributes`](Self::prepare_dense_attributes) may add
3784    /// one. `emit_attributes` reads it and never builds — a header is sized
3785    /// and then written by two separate `build_*_header` calls, so a build
3786    /// that allocated would allocate twice and leave the sized-for blocks
3787    /// stranded.
3788    dense_attributes: Slot<HashMap<AttrScope, AttributeInfoMessage>>,
3789    /// Each object's attribute set as `finalize_for_swmr` published it,
3790    /// references resolved. A change after `start_swmr` is committed at
3791    /// close, so until then a header rewrite carries this set, not the
3792    /// current one, and the dense storage built for it is the storage that
3793    /// header names.
3794    swmr_published_attrs: Slot<HashMap<AttrScope, Vec<AttributeEntry>>>,
3795    /// Groups whose links this finalize spilled to dense storage, and the
3796    /// `Link Info` message naming what was written for each.
3797    ///
3798    /// INVARIANT: an entry exists here only after every block of that group's
3799    /// heap and name index is on disk, and only
3800    /// [`prepare_dense_links`](Self::prepare_dense_links) may add one.
3801    dense_links: Slot<HashMap<LinkScope, LinkInfoMessage>>,
3802    /// The dense storage the reopened object headers already name — the heaps
3803    /// and indices this session's rewrites and deletes supersede.
3804    ///
3805    /// `None` for a file this session created: every block such a file will
3806    /// hold was allocated here, so there is nothing on disk to supersede and
3807    /// nothing to allocate for the bookkeeping either.
3808    ///
3809    /// INVARIANT: every entry is freed exactly once, by
3810    /// [`release_superseded_dense_attrs`](Self::release_superseded_dense_attrs)
3811    /// or [`release_superseded_dense_links`](Self::release_superseded_dense_links),
3812    /// which remove it as they free. Nothing else may remove one: an entry
3813    /// that leaves without reaching the allocator is a leaked heap, and one
3814    /// that reaches it twice hands the same blocks to two objects.
3815    superseded_dense: Slot<Option<Box<SupersededDense>>>,
3816    /// The creation-order policy in force: whether an object created from
3817    /// now on records creation order for its links and its attributes. The
3818    /// h5py `track_order` analogue; see
3819    /// [`set_track_order`](Self::set_track_order). Each object captures this
3820    /// at creation, so changing it never rewrites an object already made.
3821    track_order: TrackOrder,
3822    /// Whether an object created from now on records the times its header can
3823    /// hold — `H5Pset_obj_track_times`, whose default is on
3824    /// (`H5O_CRT_OHDR_FLAGS_DEF` is `H5O_HDR_STORE_TIMES`, H5Opkg.h:74).
3825    /// Captured by each object at creation for the same reason
3826    /// [`track_order`](Self::track_order) is: it belongs to the creation
3827    /// property list, so a later change must not rewrite an object already
3828    /// made.
3829    track_times: bool,
3830    /// The root group's own captured policy. The root is created with the
3831    /// file, so its value comes from
3832    /// [`create_with_options`](Self::create_with_options) — or, on reopen,
3833    /// from the header already on disk.
3834    root_track_order: TrackOrder,
3835    /// The root group's stored times, on the same terms as
3836    /// [`GroupInfo::times`]: whatever a reopened file's root header had, and
3837    /// `None` for a file this writer created.
3838    root_times: Option<ObjectTimes>,
3839    /// Hands out the creation sequence numbers that order a group's links.
3840    next_creation_seq: Slot<u64>,
3841    /// Object-reference elements waiting for their target's object header
3842    /// address, which only exists once finalize has placed every header.
3843    pending_object_references: Slot<Vec<PendingObjectReference>>,
3844    /// Heap-backed reference objects waiting for the same address — the
3845    /// pre-1.12 region form and every 1.12 form whose element is a blob id.
3846    pending_heap_references: Slot<Vec<PendingHeapReference>>,
3847    /// What each object-reference attribute's value *means*, so
3848    /// [`object_attributes`](Hdf5Writer::object_attributes) can say it in
3849    /// addresses every time an object header is built.
3850    attribute_references: Slot<Vec<AttributeReferenceValue>>,
3851    /// Set when this file is in the classic (version-0/1 superblock) format,
3852    /// whether it was reopened in it or created at `H5F_LIBVER_EARLIEST`.
3853    /// See [`LegacyFile`]; [`is_legacy`](Self::is_legacy) is the only reader
3854    /// of whether it is there.
3855    legacy: Option<Box<LegacyFile>>,
3856    /// Which groups keep their links in a symbol table, and the storage each
3857    /// of them has. Empty for a file whose groups all store links in messages;
3858    /// see [`SymbolTables`], which owns the question.
3859    symbol_tables: SymbolTables,
3860    /// The v1 B-tree "K" ranks every node width in this file is derived from,
3861    /// after the superblock extension has had its say. A property of the file
3862    /// rather than of its generation: a version-2 superblock records no ranks
3863    /// of its own but its extension may, and a rewrite that used the library
3864    /// defaults there would write nodes of the wrong width.
3865    /// [`btree_v1_config`](Hdf5Writer::btree_v1_config) is the only reader.
3866    btree: BTreeV1Config,
3867    /// The superblock extension this file carries, and where the replacement
3868    /// went; see [`CarriedExtension`].
3869    extension: Box<CarriedExtension>,
3870    /// The free-space managers a reopened `persist: true` file carries; see
3871    /// [`FileSpaceState`]. `None` for every other file — one with no
3872    /// file-space info message, one that does not persist, one under paged
3873    /// aggregation, and every file this session created — and those files get
3874    /// no free-space manager written either.
3875    free_space: Option<Box<FileSpaceState>>,
3876    /// The file's shared-message indexes, when it was created with any.
3877    /// `None` — the default — is a file with no shared-message table, where
3878    /// [`share_message`](Self::share_message) is the identity.
3879    sohm: Option<Box<SohmState>>,
3880    /// The directory holding this HDF5 file, resolved once when it was opened
3881    /// — libhdf5's `H5F_t::extpath`, and the same value the read side keeps.
3882    /// External raw-data file names are joined against it when
3883    /// `HDF5_EXTFILE_PREFIX` names `${ORIGIN}`, so a write and a later read of
3884    /// the same dataset must resolve a relative name identically; capturing it
3885    /// at open time rather than reading the process's current directory per
3886    /// write is what makes that hold.
3887    source_dir: PathBuf,
3888}
3889
3890/// A file's shared object header messages, from creation to the table on disk.
3891///
3892/// INVARIANT: a message body reaches the file either literally or as a pointer
3893/// to exactly one heap object, never both, and the reference count of that
3894/// object is the number of headers that hold the pointer.
3895/// [`share_message`](Hdf5Writer::share_message) is the only place a body is
3896/// offered to an index, and
3897/// [`prepare_shared_messages`](Hdf5Writer::prepare_shared_messages) is the
3898/// only place the phase changes — so counting and substituting are two passes
3899/// over the same call site rather than two pieces of logic that must agree.
3900struct SohmState {
3901    /// The indexes the file was created with, in table order.
3902    indexes: Vec<SohmIndexSpec>,
3903    /// What `share_message` does to an eligible message right now.
3904    phase: Slot<SohmPhase>,
3905    /// Address of the master table this session laid out, once it has one.
3906    /// Also the once-only latch on the layout: a second finalize keeps the
3907    /// table the first one published, and
3908    /// [`Hdf5Writer::write_superblock_extension`] reads it to name that table
3909    /// in the extension.
3910    table_addr: Slot<Option<u64>>,
3911    /// The blocks the table a reopen found occupies — the master table and
3912    /// each index's heap and index structure — taken by the finalize that
3913    /// replaces them. Empty for a file this session created.
3914    ///
3915    /// The table is laid out whole from the whole message set, so a reopen
3916    /// replaces it rather than inserting into it, and every header holding a
3917    /// pointer into the old one is rewritten in the same finalize
3918    /// ([`Hdf5Writer::rebuilds_shared_messages`]).
3919    superseded: Slot<Vec<(u64, u64)>>,
3920}
3921
3922/// The passes `share_message` runs in, and the state between them.
3923enum SohmPhase {
3924    /// Outside a finalize: every message stays literal.
3925    Idle,
3926    /// Measuring headers, before the bodies they will hold are final. A
3927    /// shareable message answers at the width of a heap pointer over a heap
3928    /// object that does not exist yet, which is the width the one it ends up
3929    /// pointing at has: a `H5O_shared_t` in heap form is the same size
3930    /// whatever it names. Nothing this pass produces is written — it exists so
3931    /// [`allocate_object_headers`](Hdf5Writer::allocate_object_headers) can
3932    /// reserve a block for a header whose messages are shared before the
3933    /// content phase has decided which heap object each one shares.
3934    ///
3935    /// The set is [`FirstCopies`], and it is why this pass has state at all:
3936    /// a message left literal is *wider* than a pointer, so a header can only
3937    /// be measured by making the same first-copy decision the substituting
3938    /// pass will make.
3939    Predict(FirstCopies),
3940    /// Counting the bodies the file will share. Messages still go in
3941    /// literally, so nothing this pass builds is written.
3942    Collect(SohmCollector),
3943    /// Substituting. A body the collect pass never saw stays literal, which
3944    /// is a valid file: the record it would have shared simply keeps a
3945    /// reference count one higher than the pointers that reach it.
3946    Resolve {
3947        /// Heap ID per body, from the table this finalize laid out.
3948        ids: HashMap<(u8, Vec<u8>), [u8; SOHM_HEAP_ID_LEN]>,
3949        /// The first copies this pass has already handed out; see
3950        /// [`FirstCopies`].
3951        first: FirstCopies,
3952    },
3953}
3954
3955/// The bodies a pass has already left literal in the header that offered them
3956/// first (`H5SM_IN_OH`, H5SM.c:1400-1417).
3957///
3958/// INVARIANT: the three passes walk the same object headers in the same order
3959/// — [`allocate_object_headers`](Hdf5Writer::allocate_object_headers),
3960/// [`prepare_shared_messages`](Hdf5Writer::prepare_shared_messages) and
3961/// [`write_object_headers`](Hdf5Writer::write_object_headers) each build every
3962/// dataset in `datasets` order, then every group, then the root — so "the
3963/// header that offered this body first" is the same header in all three. Each
3964/// pass keeps its own set rather than sharing one, so a pass that does not run
3965/// cannot leave a stale decision behind for the next one. A divergence would
3966/// make a header wider than the block reserved for it, which
3967/// [`check_header_size`] refuses rather than writing.
3968type FirstCopies = std::collections::HashSet<(u8, Vec<u8>)>;
3969
3970/// The object header a message is being written into — `H5SM_try_share`'s
3971/// `open_oh` argument, which is what decides whether a first copy has a header
3972/// to stay literal in at all.
3973#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3974enum ShareOwner {
3975    /// `H5SM_try_share(f, NULL, ...)`: the message belongs to no object header
3976    /// of its own. An attribute's datatype and dataspace are offered this way
3977    /// (H5Aint.c:375-377) — they live inside the attribute's body, so there is
3978    /// no header message for a record to name and the body goes to the heap on
3979    /// first use however shareable its class is.
3980    Detached,
3981    /// `H5SM_try_share(f, oh, ...)`: the message is a message of the object
3982    /// header at this address (`H5O__msg_alloc`, H5Omessage.c:1735).
3983    Header(u64),
3984}
3985
3986impl SohmState {
3987    /// A file's indexes, plus the blocks of the table they were read out of
3988    /// when the file was reopened (empty when it was created this session).
3989    fn new(indexes: Vec<SohmIndexSpec>, superseded: Vec<(u64, u64)>) -> Self {
3990        Self {
3991            indexes,
3992            phase: Slot::new(SohmPhase::Idle),
3993            table_addr: Slot::new(None),
3994            superseded: Slot::new(superseded),
3995        }
3996    }
3997
3998    /// The index that would take a `msg_type` message of `body_len` bytes,
3999    /// as `H5SM_try_share` resolves one: the first index whose type mask
4000    /// covers the class, and then only if the message reaches that index's
4001    /// minimum. A message too small for its index is not offered to another —
4002    /// `H5SM__get_index` picks by type alone and the size check comes after.
4003    fn index_for(&self, msg_type: u8, body_len: usize) -> Option<usize> {
4004        let flag = type_flag(msg_type)?;
4005        let (at, spec) = self
4006            .indexes
4007            .iter()
4008            .enumerate()
4009            .find(|(_, spec)| spec.mesg_types & flag != 0)?;
4010        (body_len as u64 >= u64::from(spec.min_mesg_size)).then_some(at)
4011    }
4012
4013    /// Whether any index takes attribute messages, which is what makes the
4014    /// file record message creation indices — `H5SM_init` sets
4015    /// `store_msg_crt_idx` on exactly this condition (H5SM.c:220).
4016    fn shares_attributes(&self) -> bool {
4017        let Some(flag) = type_flag(MSG_ATTRIBUTE) else {
4018            return false;
4019        };
4020        self.indexes.iter().any(|spec| spec.mesg_types & flag != 0)
4021    }
4022}
4023
4024/// What decides whether two offers are the same shared message: the class,
4025/// the bytes, and the messages the bytes will end up pointing at.
4026type CollectedKey = (u8, Vec<u8>, Vec<NestedShare>);
4027
4028/// The shareable message bodies of one collect pass, in first-seen order.
4029struct SohmCollector {
4030    /// Per index, its bodies with the number of headers holding each.
4031    messages: Vec<Vec<SharedMessage>>,
4032    /// Where a body sits: `(index, position in that index's messages)`, keyed
4033    /// by everything that decides what will be stored — the class, the bytes,
4034    /// and the messages the bytes will end up pointing at.
4035    seen: HashMap<CollectedKey, (usize, usize)>,
4036}
4037
4038impl SohmCollector {
4039    fn new(nindexes: usize) -> Self {
4040        Self {
4041            messages: vec![Vec::new(); nindexes],
4042            seen: HashMap::new(),
4043        }
4044    }
4045
4046    /// Count one message against `index`, adding the body the first time it
4047    /// is seen, and say whether that body is new.
4048    ///
4049    /// `ohdr` is the header this offer would leave the body literal in when it
4050    /// is the first — `None` when the class cannot be shared in an object
4051    /// header or the offer names none. It is recorded only for a first copy:
4052    /// once a body is in the heap, later offers of it are pointers whatever
4053    /// header they come from.
4054    ///
4055    /// Two bodies are the same message only if their nesting agrees as well:
4056    /// the heap IDs a nesting body will hold are still zero here, so two
4057    /// attributes that differ only in their datatype are the same bytes at
4058    /// this point and different bytes on disk.
4059    fn record(
4060        &mut self,
4061        index: usize,
4062        msg_type: u8,
4063        body: &[u8],
4064        nested: &[NestedShare],
4065        ohdr: Option<u64>,
4066    ) -> bool {
4067        let key = (msg_type, body.to_vec(), nested.to_vec());
4068        match self.seen.get(&key) {
4069            Some(&(at, pos)) => {
4070                self.messages[at][pos].ref_count += 1;
4071                false
4072            }
4073            None => {
4074                let pos = self.messages[index].len();
4075                self.messages[index].push(SharedMessage {
4076                    msg_type,
4077                    body: body.to_vec(),
4078                    nested: nested.to_vec(),
4079                    ref_count: 1,
4080                    ohdr_addr: ohdr,
4081                });
4082                self.seen.insert(key, (index, pos));
4083                true
4084            }
4085        }
4086    }
4087
4088    /// Give back the reference [`record`](Self::record) took for a body whose
4089    /// container turned out to be a copy of one already here.
4090    ///
4091    /// A body reached only through a shared container is referenced once per
4092    /// container *record*, not once per object that has one: the pointer to
4093    /// it lives in the container's heap object, which exists once however
4094    /// many headers name it. `H5O__attr_create` reaches the same count from
4095    /// the other side, by building each attribute's components shared and
4096    /// then calling `H5O__attr_delete` — which decrements exactly the
4097    /// datatype and dataspace (H5Oattr.c:568-585) — whenever the attribute it
4098    /// built was not the first copy (H5Oattribute.c:331-366).
4099    fn release(&mut self, msg_type: u8, body: &[u8]) {
4100        if let Some(&(at, pos)) = self.seen.get(&(msg_type, body.to_vec(), Vec::new())) {
4101            let count = &mut self.messages[at][pos].ref_count;
4102            *count = count.saturating_sub(1);
4103        }
4104    }
4105}
4106
4107/// The file-creation properties a brand-new file is made with.
4108///
4109/// libhdf5 splits these across the file creation and file access property
4110/// lists (`H5Pset_userblock`, `H5Pset_link_creation_order`,
4111/// `H5Pset_libver_bounds`, the locking property); what they have in common is
4112/// that they are read once, when the file is created, and cannot be changed
4113/// afterwards without rewriting it. Options that *can* change mid-session —
4114/// the bound for objects created later, the creation-order policy for later
4115/// objects — have their own setters.
4116#[derive(Debug, Clone, Copy, Default)]
4117pub struct FileCreateOptions {
4118    /// OS-level locking policy for the new file.
4119    pub locking: crate::io::locking::FileLocking,
4120    /// Creation-order policy for the root group, and the default for every
4121    /// object created afterwards; see [`Hdf5Writer::set_track_order`].
4122    pub track_order: bool,
4123    /// Time-tracking policy for the root group, and the default for every
4124    /// object created afterwards; see [`Hdf5Writer::set_track_times`].
4125    pub track_times: bool,
4126    /// The file's low library-version bound (`H5Pset_libver_bounds`'s `low`),
4127    /// or `None` when the caller named none.
4128    ///
4129    /// The distinction is not decoration. `Some(LibverBound::Earliest)` is a
4130    /// request for the format libhdf5 writes at `H5F_LIBVER_EARLIEST` — a
4131    /// version-0 superblock over symbol-table groups and version-1 object
4132    /// headers, which is what [`ObjectFormat::Legacy`] encodes. `None` keeps
4133    /// what this crate has always written for a file whose creator said
4134    /// nothing: the version-2 superblock and link-message groups of the v1.8
4135    /// format, with the earliest bound's message versions where they can
4136    /// express the content. That combination is this crate's own, not one
4137    /// libhdf5 writes, so it cannot be spelled as a bound.
4138    pub libver: Option<LibverBound>,
4139    /// Bytes reserved in front of the superblock for the application's own
4140    /// use (`H5Pset_userblock`). Zero, the default, places the superblock at
4141    /// offset 0; otherwise a power of two of at least
4142    /// [`MIN_USERBLOCK`] bytes, since a reader finds the
4143    /// superblock by doubling its search offset from there.
4144    pub userblock: u64,
4145    /// Shared object header message indexes; see [`SharedMessageConfig`].
4146    pub shared_messages: SharedMessageConfig,
4147    /// How the file manages its own space; see [`FileSpaceConfig`].
4148    pub file_space: FileSpaceConfig,
4149}
4150
4151/// The file-space handling properties a new file is created with — the three
4152/// arguments of `H5Pset_file_space_strategy` and the one of
4153/// `H5Pset_file_space_page_size`.
4154///
4155/// The four together are what `H5F__super_init` compares against the library
4156/// defaults to decide whether the file needs a file-space info message at all
4157/// (H5Fsuper.c:1092-1097), which is why the page size belongs here even though
4158/// only paged aggregation allocates by it: a file that names a page size and
4159/// nothing else still carries the message.
4160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4161pub struct FileSpaceConfig {
4162    /// `H5F_fspace_strategy_t`.
4163    pub strategy: FileSpaceStrategy,
4164    /// Whether the free-space managers are written to the file on close.
4165    pub persist: bool,
4166    /// The smallest section a manager records; a block freed below it is
4167    /// space the file leaks rather than tracks.
4168    pub threshold: u64,
4169    /// `H5Pset_file_space_page_size`: the file-space page every allocation of
4170    /// a paged file is shaped by, and the value the message carries whatever
4171    /// the strategy.
4172    pub page_size: u64,
4173}
4174
4175impl Default for FileSpaceConfig {
4176    /// `H5F_FILE_SPACE_STRATEGY_DEF`, `H5F_FREE_SPACE_PERSIST_DEF`,
4177    /// `H5F_FREE_SPACE_THRESHOLD_DEF` and `H5F_FILE_SPACE_PAGE_SIZE_DEF`
4178    /// (H5Fprivate.h:326-336).
4179    fn default() -> Self {
4180        Self {
4181            strategy: FileSpaceStrategy::FsmAggr,
4182            persist: false,
4183            threshold: 1,
4184            page_size: DEFAULT_FILE_SPACE_PAGE_SIZE,
4185        }
4186    }
4187}
4188
4189impl FileSpaceConfig {
4190    /// The properties as `H5P__set_file_space_strategy` (H5Pfcpl.c:1176)
4191    /// stores them: `persist` and `threshold` are set only for the two
4192    /// strategies that have free-space managers to persist, and keep their
4193    /// defaults for the two that do not.
4194    pub fn new(strategy: FileSpaceStrategy, persist: bool, threshold: u64) -> Self {
4195        let uses_managers = matches!(
4196            strategy,
4197            FileSpaceStrategy::FsmAggr | FileSpaceStrategy::Page
4198        );
4199        Self {
4200            strategy,
4201            persist: uses_managers && persist,
4202            threshold: if uses_managers {
4203                threshold
4204            } else {
4205                Self::default().threshold
4206            },
4207            ..Self::default()
4208        }
4209    }
4210
4211    /// `H5Pset_file_space_page_size`, the fourth file-space property and the
4212    /// one libhdf5 sets on its own call.
4213    ///
4214    /// Independent of the strategy, as upstream is: the value reaches the
4215    /// file-space info message whatever the strategy is, and only paged
4216    /// aggregation allocates by it. Out-of-range sizes are refused where the
4217    /// file is created ([`validate`](Self::validate)) rather than here, so a
4218    /// builder chain stays a builder chain.
4219    pub fn with_page_size(mut self, page_size: u64) -> Self {
4220        self.page_size = page_size;
4221        self
4222    }
4223
4224    /// Whether the file has to say any of this on disk. `H5F__super_init`
4225    /// writes the file-space info message only for a file that differs from
4226    /// the library defaults in one of the four properties (H5Fsuper.c:1092),
4227    /// and raises such a file's superblock to version 2 so it has an
4228    /// extension to write it into (H5Fsuper.c:1144).
4229    pub fn is_default(&self) -> bool {
4230        *self == Self::default()
4231    }
4232
4233    /// Refuse what this writer cannot make. `H5Pset_file_space_strategy`
4234    /// itself only refuses a strategy outside the enum (H5Pfcpl.c:1223), and
4235    /// `H5Pset_file_space_page_size` a page size outside `[512, 1 GiB]`
4236    /// (H5Pfcpl.c:1389-1393) — no power of two required, only the bounds.
4237    fn validate(&self) -> IoResult<()> {
4238        if !(PAGE_SIZE_MIN..=PAGE_SIZE_MAX).contains(&self.page_size) {
4239            return Err(crate::io::IoError::InvalidState(format!(
4240                "a file-space page size is between {PAGE_SIZE_MIN} bytes and \
4241                 {PAGE_SIZE_MAX}, not {}",
4242                self.page_size
4243            )));
4244        }
4245        match self.strategy {
4246            FileSpaceStrategy::FsmAggr
4247            | FileSpaceStrategy::Aggr
4248            | FileSpaceStrategy::None
4249            | FileSpaceStrategy::Page => Ok(()),
4250            FileSpaceStrategy::Unknown(b) => Err(crate::io::IoError::InvalidState(format!(
4251                "invalid file-space strategy {b}"
4252            ))),
4253        }
4254    }
4255
4256    /// The message a created file carries, before anything is allocated:
4257    /// every manager address undefined and no end-of-allocation recorded,
4258    /// which is what `H5F__super_init` writes (H5Fsuper.c:1369-1382).
4259    fn message(&self) -> FileSpaceInfoMessage {
4260        FileSpaceInfoMessage {
4261            // `H5O_fsinfo_set_version` starts at version 1 and only ever
4262            // raises it, so a created file never carries the version-0 form
4263            // however low its version bounds are.
4264            version: 1,
4265            strategy: self.strategy,
4266            persist: self.persist,
4267            threshold: self.threshold,
4268            page_size: self.page_size,
4269            pgend_meta_thres: 0,
4270            eoa_pre_fsm_fsalloc: UNDEF_ADDR,
4271            fs_addr: vec![UNDEF_ADDR; FS_ADDR_COUNT_V1],
4272        }
4273    }
4274}
4275
4276/// The shared object header message indexes a new file is created with.
4277///
4278/// libhdf5 sets these with three calls on the file creation property list:
4279/// `H5Pset_shared_mesg_nindexes` fixes how many indexes there are,
4280/// `H5Pset_shared_mesg_index` gives each one the message types it covers and
4281/// the smallest message it will take, and `H5Pset_shared_mesg_phase_change`
4282/// sets the list/B-tree thresholds for all of them at once. The default —
4283/// no indexes — is a file with no shared-message table, which is what every
4284/// file this crate wrote before the option existed.
4285#[derive(Debug, Clone, Copy, PartialEq)]
4286pub struct SharedMessageConfig {
4287    /// Indexes in table order; only the first `count` are in use.
4288    indexes: [SohmIndexSpec; MAX_SOHM_INDEXES],
4289    /// How many indexes the caller asked for. Kept even when it is more than
4290    /// the array holds, so file creation can refuse the count the way
4291    /// `H5Pset_shared_mesg_nindexes` does rather than silently drop indexes.
4292    count: usize,
4293}
4294
4295impl Default for SharedMessageConfig {
4296    fn default() -> Self {
4297        Self {
4298            indexes: [SohmIndexSpec {
4299                mesg_types: 0,
4300                min_mesg_size: 0,
4301                list_max: DEFAULT_SOHM_LIST_MAX,
4302                btree_min: DEFAULT_SOHM_BTREE_MIN,
4303            }; MAX_SOHM_INDEXES],
4304            count: 0,
4305        }
4306    }
4307}
4308
4309impl SharedMessageConfig {
4310    /// One index per `(mesg_types, min_mesg_size)` pair — the arguments
4311    /// `H5Pset_shared_mesg_index` takes, where `mesg_types` is the bit mask
4312    /// [`type_flag`](crate::format::sohm::type_flag) builds — with the
4313    /// file-wide phase change `H5Pset_shared_mesg_phase_change` sets: above
4314    /// `list_max` an index is a v2 B-tree, below `btree_min` it is a list
4315    /// again, and `list_max == 0` makes it a B-tree from its first message.
4316    ///
4317    /// Nothing is validated here; [`Hdf5Writer::create_with_options`] refuses
4318    /// a configuration libhdf5 would refuse, so an invalid one is reported
4319    /// where the file is made rather than where the value is typed.
4320    pub fn new(indexes: &[(u16, u32)], list_max: u16, btree_min: u16) -> Self {
4321        let mut config = Self {
4322            count: indexes.len(),
4323            ..Self::default()
4324        };
4325        for (slot, &(mesg_types, min_mesg_size)) in config.indexes.iter_mut().zip(indexes) {
4326            *slot = SohmIndexSpec {
4327                mesg_types,
4328                min_mesg_size,
4329                list_max,
4330                btree_min,
4331            };
4332        }
4333        config
4334    }
4335
4336    /// The indexes in use, in table order.
4337    pub(crate) fn specs(&self) -> &[SohmIndexSpec] {
4338        &self.indexes[..self.count.min(MAX_SOHM_INDEXES)]
4339    }
4340
4341    /// Refuse a configuration `H5Pset_shared_mesg_nindexes` or
4342    /// `H5Pset_shared_mesg_phase_change` would refuse.
4343    fn validate(&self) -> IoResult<()> {
4344        if self.count > MAX_SOHM_INDEXES {
4345            return Err(crate::io::IoError::InvalidState(format!(
4346                "a file may declare at most {MAX_SOHM_INDEXES} shared-message \
4347                 indexes, not {}",
4348                self.count
4349            )));
4350        }
4351        for spec in self.specs() {
4352            // The two thresholds must not overlap, or an index would convert
4353            // back and forth on every insert.
4354            if u32::from(spec.btree_min) > u32::from(spec.list_max) + 1 {
4355                return Err(crate::io::IoError::InvalidState(format!(
4356                    "shared-message phase change needs btree_min ({}) at most one \
4357                     past list_max ({}), or an index converts on every insert",
4358                    spec.btree_min, spec.list_max
4359                )));
4360            }
4361            if spec.mesg_types == 0 {
4362                return Err(crate::io::IoError::InvalidState(
4363                    "a shared-message index covering no message type would never \
4364                     be used; give it a type mask or drop it"
4365                        .into(),
4366                ));
4367            }
4368        }
4369        Ok(())
4370    }
4371}
4372
4373/// One object-reference element written before its value could be known.
4374///
4375/// An `H5R_OBJECT1` element is the target's object header address, and
4376/// addresses are assigned during finalize, so a write records the target by
4377/// path here and [`Hdf5Writer::write_object_reference_values`] puts the address
4378/// down once every header has one.
4379pub(crate) struct PendingObjectReference {
4380    /// Dataset holding the element.
4381    dataset: usize,
4382    /// Element index within that dataset.
4383    element: u64,
4384    /// Path of the object the element names; `/` is the root group.
4385    target: String,
4386}
4387
4388/// One heap-backed reference object written before its target's address could
4389/// be known.
4390///
4391/// The *element* of a `H5R_DATASET_REGION1`, and of every 1.12 reference whose
4392/// encoding does not fit inline, is final at write time — it is the global-heap
4393/// id of the object the write inserted. What waits is the `sizeof_addr` bytes
4394/// of that heap object holding the target's object header address, which
4395/// [`Hdf5Writer::write_heap_reference_values`] stamps in.
4396pub(crate) struct PendingHeapReference {
4397    /// Address of the global-heap collection holding the object.
4398    collection: u64,
4399    /// The object's index within that collection.
4400    index: u16,
4401    /// Where the target's token sits inside that object. The pre-1.12 region
4402    /// form leads with it (`H5R__encode_token_region_compat`); every 1.12 form
4403    /// puts the token's length byte first (`H5R__encode_obj_token`).
4404    token_offset: usize,
4405    /// What the reference names, and how strictly its path must resolve.
4406    target: PendingHeapTarget,
4407}
4408
4409/// What the path of a heap-backed reference must resolve to.
4410///
4411/// The two rules `H5R` applies: a region reference names a *dataset*, since
4412/// `H5Rcreate_region` takes one dataset's dataspace and every reader
4413/// dereferences it as one, while an attribute reference names the attribute's
4414/// owner, which `H5Rcreate_attr` lets be any object.
4415#[derive(Debug, Clone)]
4416pub(crate) enum PendingHeapTarget {
4417    Dataset(String),
4418    Object(String),
4419}
4420
4421/// The value of an attribute whose elements are object references, kept as
4422/// what it means rather than as what it encodes to.
4423///
4424/// An attribute's value is part of its object header message, so it cannot be
4425/// stamped after the fact the way a dataset element can — the header is one
4426/// block, written once. What is stored instead is the paths, and
4427/// [`Hdf5Writer::object_attributes`] turns them into addresses every time the
4428/// attribute set is built: the measuring pass reads the zeros of objects that
4429/// have no address yet, the content pass reads the addresses the file will
4430/// have, and the two agree in length because an address is a fixed-width
4431/// field. The entry in the object's attribute list carries an image with
4432/// zeros where the addresses go and is never itself written.
4433///
4434/// The address of `targets[i]` lands at byte `i * stride` of that image: the
4435/// whole element when the attribute is an array of references, the leading
4436/// member when each element is a compound that carries other fields beside
4437/// the reference (`REFERENCE_LIST`'s `dimension`), which the stored image
4438/// already holds.
4439pub(crate) struct AttributeReferenceValue {
4440    /// The object the attribute hangs on.
4441    scope: AttrScope,
4442    /// The attribute's name within that object.
4443    name: String,
4444    /// Paths of the objects the elements name, in element order; `/` is the
4445    /// root group.
4446    targets: Vec<String>,
4447    /// Bytes from one element's address to the next: the element size.
4448    stride: usize,
4449}
4450
4451/// The attribute naming the scales attached to each axis of a dataset.
4452pub(crate) const DIMENSION_LIST: &str = "DIMENSION_LIST";
4453/// The attribute naming every (dataset, axis) a dimension scale is attached to.
4454pub(crate) const REFERENCE_LIST: &str = "REFERENCE_LIST";
4455/// The `CLASS` a dimension scale carries.
4456const DIMENSION_SCALE_CLASS: &str = "DIMENSION_SCALE";
4457
4458/// A dataset's `CLASS` attribute as `H5DS` reads it.
4459enum ClassAttr {
4460    /// A fixed-length string, with what `H5DSis_scale` checks beside the text.
4461    Fixed {
4462        size: u32,
4463        null_terminated: bool,
4464        text: String,
4465    },
4466    /// A variable-length string.
4467    VarLen(String),
4468    /// Not a string at all.
4469    NotString,
4470}
4471
4472/// `bytes` read as a C string: everything before the first NUL.
4473fn c_string(bytes: &[u8]) -> String {
4474    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
4475    String::from_utf8_lossy(&bytes[..end]).into_owned()
4476}
4477
4478/// Refuse an object header body that is not the length its block was reserved
4479/// at.
4480///
4481/// The one check standing behind
4482/// [`HeaderLayout`]'s premise that measuring a header before its content is
4483/// final gives the same length as encoding it after. `what` names the object
4484/// only when the check fails, so the caller pays for the lookup only then.
4485fn check_header_size(
4486    encoded: &[u8],
4487    reserved: usize,
4488    what: impl FnOnce() -> String,
4489) -> IoResult<()> {
4490    if encoded.len() == reserved {
4491        return Ok(());
4492    }
4493    Err(crate::io::IoError::InvalidState(format!(
4494        "the object header of {} encodes to {} bytes but was measured at {}; \
4495         a message in it changed length once the addresses it names were known",
4496        what(),
4497        encoded.len(),
4498        reserved
4499    )))
4500}
4501
4502/// Where one object header goes: chunk 0's block and, when the header does
4503/// not fit it, a continuation block of its own.
4504///
4505/// Produced by [`Hdf5Writer::place_header`] and consumed by
4506/// [`Hdf5Writer::encode_header_in`]; between the two, everything the header
4507/// names is built against the address it records. The sizes travel with the
4508/// addresses because they are what the blocks were reserved at: the writing
4509/// pass checks each image against them rather than trusting that the two
4510/// passes agreed.
4511#[derive(Debug, Clone, Copy)]
4512struct HeaderPlacement {
4513    /// Chunk 0's address.
4514    addr: u64,
4515    /// Bytes reserved at `addr`. For a fresh header that is the whole image,
4516    /// a continuation chunk included, since one is laid directly behind
4517    /// chunk 0 in the same block.
4518    size: usize,
4519    /// Whether the block is one the object's existing header already
4520    /// occupied, which chunk 0 is then held to the size of; a fresh block is
4521    /// an exact fit.
4522    kept: bool,
4523    /// A continuation block of its own, `(address, size)`: what a kept block
4524    /// too small for every message spills into.
4525    continuation: Option<(u64, usize)>,
4526}
4527
4528impl HeaderPlacement {
4529    /// A block of `size` bytes at `addr` holding the whole header.
4530    fn fresh(addr: u64, size: usize) -> Self {
4531        Self {
4532            addr,
4533            size,
4534            kept: false,
4535            continuation: None,
4536        }
4537    }
4538
4539    /// The placement as the registry records a written header: chunk 0's
4540    /// block, then the continuation block when there is one.
4541    fn blocks(&self) -> crate::io::object_header_io::HeaderBlocks {
4542        std::iter::once((self.addr, self.size as u64))
4543            .chain(self.continuation.map(|(a, s)| (a, s as u64)))
4544            .collect()
4545    }
4546
4547    /// The placement a written header's recorded blocks describe, to write
4548    /// it back over: chunk 0 held to its block, and the continuation chunk,
4549    /// if it has one, to its own.
4550    fn over(blocks: &[(u64, u64)]) -> Option<Self> {
4551        match blocks {
4552            [(addr, size)] => Some(Self {
4553                addr: *addr,
4554                size: *size as usize,
4555                kept: true,
4556                continuation: None,
4557            }),
4558            [(addr, size), (cont, cont_size)] => Some(Self {
4559                addr: *addr,
4560                size: *size as usize,
4561                kept: true,
4562                continuation: Some((*cont, *cont_size as usize)),
4563            }),
4564            _ => None,
4565        }
4566    }
4567}
4568
4569/// Where every object header this finalize writes goes.
4570///
4571/// Produced by [`Hdf5Writer::allocate_object_headers`] and consumed by
4572/// [`Hdf5Writer::write_object_headers`].
4573struct HeaderLayout {
4574    /// `(dataset index, placement)`, in write order.
4575    datasets: Vec<(usize, HeaderPlacement)>,
4576    /// `(group index, placement)`, in write order.
4577    groups: Vec<(usize, HeaderPlacement)>,
4578    /// The root group's placement.
4579    root: HeaderPlacement,
4580}
4581
4582/// The chunk-0 blocks existing object headers keep across a rewrite, by
4583/// object: `(address, length)` of each, as the open-time walk read it.
4584///
4585/// Filled by [`Hdf5Writer::supersede_headers`] from the registry's
4586/// `obj_header_blocks` and consumed by
4587/// [`Hdf5Writer::allocate_object_headers`].
4588#[derive(Default)]
4589struct KeptChunks {
4590    datasets: std::collections::HashMap<usize, (u64, u64)>,
4591    groups: std::collections::HashMap<usize, (u64, u64)>,
4592    root: Option<(u64, u64)>,
4593}
4594
4595/// Refuse a region-reference selection the target dataset's extent does not
4596/// admit — libhdf5's `H5S_select_valid`, which `H5Rcreate` applies before it
4597/// serializes anything.
4598///
4599/// The rank check comes from [`Selection::to_boxes`], which also refuses a
4600/// regular hyperslab with an unlimited count or block; a region reference has
4601/// no growable extent to resolve one against.
4602fn validate_region_selection(selection: &Selection, dims: &[u64], path: &str) -> IoResult<()> {
4603    let boxes = selection.to_boxes(dims).map_err(|e| {
4604        crate::io::IoError::InvalidState(format!("region reference over '{path}': {e}"))
4605    })?;
4606    for (start, count) in boxes {
4607        for (d, (&s, &c)) in start.iter().zip(&count).enumerate() {
4608            if s.checked_add(c).is_none_or(|end| end > dims[d]) {
4609                return Err(crate::io::IoError::InvalidState(format!(
4610                    "region reference over '{path}' selects {s}..{} in dimension {d}, \
4611                     outside the dataset's extent of {}",
4612                    s.saturating_add(c),
4613                    dims[d]
4614                )));
4615            }
4616        }
4617    }
4618    Ok(())
4619}
4620
4621/// What a reopen found already on disk in dense form, by the scope whose
4622/// header names it.
4623///
4624/// Both halves together because they are found together — one walk of the
4625/// reopened headers fills both — and released together only in the delete
4626/// path; a finalize supersedes attribute storage before it lays object
4627/// headers out and link storage after, so each half has its own owner.
4628#[derive(Debug, Default)]
4629struct SupersededDense {
4630    attrs: HashMap<AttrScope, AttributeInfoMessage>,
4631    links: HashMap<LinkScope, LinkInfoMessage>,
4632}
4633
4634/// Which object's attribute list a prepared dense layout belongs to.
4635#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4636pub(crate) enum AttrScope {
4637    Root,
4638    Group(usize),
4639    Dataset(usize),
4640}
4641
4642/// Which group's link list a prepared dense layout belongs to.
4643#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
4644pub(crate) enum LinkScope {
4645    Root,
4646    Group(usize),
4647}
4648
4649/// Attributes an object header keeps before libhdf5 spills the whole set to
4650/// dense storage (`H5O_CRT_ATTR_MAX_COMPACT_DEF`).
4651const MAX_COMPACT_ATTRS: usize = 8;
4652
4653/// Links `H5G__obj_create_real` sizes a new group's object header for
4654/// (`H5G_CRT_GINFO_EST_NUM_ENTRIES`), and the name length it assumes for each
4655/// (`H5G_CRT_GINFO_EST_NAME_LEN`). Together with the link info and group info
4656/// messages they are the whole of chunk 0 — see
4657/// [`chunk0_capacity`](Hdf5Writer::chunk0_capacity).
4658const EST_LINK_COUNT: usize = 4;
4659/// See [`EST_LINK_COUNT`].
4660const EST_LINK_NAME_LEN: usize = 8;
4661
4662/// Messages a shared-message index keeps in list form before it becomes a v2
4663/// B-tree (`H5F_CRT_SHMSG_LIST_MAX_DEF`).
4664const DEFAULT_SOHM_LIST_MAX: u16 = 50;
4665
4666/// Messages a shared-message B-tree index drops to before it reverts to a
4667/// list (`H5F_CRT_SHMSG_BTREE_MIN_DEF`).
4668const DEFAULT_SOHM_BTREE_MIN: u16 = 40;
4669
4670/// Links a group header keeps before libhdf5 spills the whole set to dense
4671/// storage (`H5G_CRT_GINFO_MAX_COMPACT`). This writer emits no phase-change
4672/// values in the Group Info message, so the default is what applies.
4673const MAX_COMPACT_LINKS: usize = 8;
4674
4675/// Bytes a compact dataset's raw image may occupy.
4676///
4677/// `H5D__compact_construct` bounds it by `H5O_MESG_MAX_SIZE` less the layout
4678/// message's own four bytes (version, class, and the 2-byte data length).
4679/// The constant it subtracts from is 65536, one past what the object header's
4680/// 2-byte message size field can express, so the ceiling here is taken from
4681/// [`MAX_MESSAGE_SIZE`] — the largest message that actually encodes — and is
4682/// one byte below libhdf5's.
4683pub const MAX_COMPACT_DATA: usize = MAX_MESSAGE_SIZE - 4;
4684
4685/// Smallest userblock a file can be created with, and the granularity of
4686/// every larger one: `H5Pset_userblock` takes 0 or a power of two from here
4687/// up, because `H5FD_locate_signature` looks for the superblock at 0 and then
4688/// at this offset doubled repeatedly.
4689pub const MIN_USERBLOCK: u64 = 512;
4690
4691impl Hdf5Writer {
4692    /// Create a new HDF5 file at `path` using the env-var-derived locking
4693    /// policy (controlled by `HDF5_USE_FILE_LOCKING`).
4694    ///
4695    /// The superblock (48 bytes for v3 with 8-byte offsets) is reserved at
4696    /// offset 0 and written during `close()`.
4697    pub fn create(path: &Path) -> IoResult<Self> {
4698        Self::create_with_locking(
4699            path,
4700            crate::io::locking::FileLocking::from_env_or(Default::default()),
4701        )
4702    }
4703
4704    /// Create a new HDF5 file at `path` with an explicit locking policy.
4705    pub fn create_with_locking(
4706        path: &Path,
4707        locking: crate::io::locking::FileLocking,
4708    ) -> IoResult<Self> {
4709        Self::create_with_options(
4710            path,
4711            FileCreateOptions {
4712                locking,
4713                ..Default::default()
4714            },
4715        )
4716    }
4717
4718    /// Create a new HDF5 file at `path` with explicit file-creation options.
4719    pub fn create_with_options(path: &Path, options: FileCreateOptions) -> IoResult<Self> {
4720        let FileCreateOptions {
4721            locking,
4722            track_order,
4723            track_times,
4724            libver,
4725            userblock,
4726            shared_messages,
4727            file_space,
4728        } = options;
4729        shared_messages.validate()?;
4730        file_space.validate()?;
4731        if userblock != 0 && (userblock < MIN_USERBLOCK || !userblock.is_power_of_two()) {
4732            return Err(crate::io::IoError::InvalidState(format!(
4733                "a userblock is {MIN_USERBLOCK} bytes or a power of two above it, \
4734                 not {userblock}: a reader locates the superblock by doubling its \
4735                 search offset from {MIN_USERBLOCK}, so no other size can hold one"
4736            )));
4737        }
4738        let policy = free_space::SpacePolicy::for_message(&file_space.message());
4739        // `H5F__super_init` (H5Fsuper.c:1182-1192) refuses a userblock that is
4740        // not a whole number of allocation units, which for a paged file is
4741        // the file-space page: everything after the userblock is addressed
4742        // from its end, so a userblock that is not a page multiple would put
4743        // every page boundary off the file's own grid.
4744        if let Some(page) = policy.page() {
4745            if userblock != 0 && userblock % page != 0 {
4746                return Err(crate::io::IoError::InvalidState(format!(
4747                    "a paged file's userblock is a multiple of its {page}-byte \
4748                     file-space page, not {userblock}"
4749                )));
4750            }
4751        }
4752        let mut handle = FileHandle::create_with_locking(path, locking)?;
4753        if userblock != 0 {
4754            // Written while the handle is still unbased, so offset 0 is the
4755            // start of the file: the block belongs to the application, not to
4756            // the HDF5 address space that begins where it ends. libhdf5 zeroes
4757            // it the same way (`H5F__super_init`), leaving a file whose first
4758            // `userblock` bytes are the application's to overwrite.
4759            handle.write_at(0, &vec![0u8; userblock as usize])?;
4760            handle.set_base(userblock);
4761        }
4762        let ctx = FormatContext::default_v3();
4763
4764        // `H5F_LIBVER_EARLIEST` is the one bound under which libhdf5 writes
4765        // the classic generation — the version-1 rows of every
4766        // message-version table, the symbol-table group form
4767        // (`H5G__obj_create_real`, H5Gobj.c:179) and the version-0 superblock
4768        // row of `HDF5_superblock_ver_bounds`.
4769        //
4770        // Shared object header messages move the last of those three and
4771        // nothing else. Their master table lives in a superblock extension,
4772        // which only a version-2 superblock has, so `H5F__super_init` raises
4773        // the superblock to version 2 whatever the low bound says
4774        // (H5Fsuper.c:1135) — but it does not touch `H5F_LOW_BOUND`, which is
4775        // what every other rule reads. So such a file is a version-2
4776        // superblock over symbol-table groups and version-1 messages, which
4777        // is what the `tests/fixtures/sohm_*.h5` files libhdf5 itself wrote
4778        // are.
4779        let classic = libver == Some(LibverBound::Earliest);
4780        let legacy = classic.then(|| Box::new(LegacyFile::created(ctx, userblock)));
4781        // Non-default file-space properties raise the superblock the same way
4782        // a shared-message table does, and for the same reason: the message
4783        // that declares them lives in an extension, and only a version-2
4784        // superblock has one (H5Fsuper.c:1144).
4785        let superblock_version = SuperblockVersion::Chosen(
4786            if classic && shared_messages.specs().is_empty() && file_space.is_default() {
4787                SUPERBLOCK_V0
4788            } else {
4789                SUPERBLOCK_V2
4790            },
4791        );
4792
4793        // Reserve the superblock at offset 0. Which version it gets is only
4794        // known once the file's content is (see `superblock_version_for`),
4795        // but the two a version-2 file can reach — 2 and 3 — encode to the
4796        // same size, so the reservation follows the base version alone.
4797        let superblock_size = match legacy.as_deref() {
4798            Some(l) if matches!(superblock_version, SuperblockVersion::Chosen(v) if v < SUPERBLOCK_V2) => {
4799                l.superblock.encoded_size()
4800            }
4801            _ => SuperblockV2V3::size_for(ctx.sizeof_addr),
4802        };
4803        // The superblock is an ordinary allocation, not a reservation: under
4804        // paged aggregation it takes the whole of page zero and leaves the
4805        // rest of that page as a section of the metadata manager, which is
4806        // what `H5F__super_init` gets from `H5MF_alloc(f, H5FD_MEM_SUPER, ...)`
4807        // going through `H5MF__alloc_pagefs`. Unpaged it returns offset zero
4808        // and moves the end of the file to `superblock_size`, which is what
4809        // reserving it did.
4810        let allocator = FileAllocator::with_policy(0, policy);
4811        allocator.allocate(superblock_size as u64, FreeSpaceClass::Metadata);
4812
4813        Ok(Self {
4814            handle,
4815            allocator,
4816            ctx,
4817            datasets: Slot::new(Vec::new()),
4818            groups: Slot::new(Vec::new()),
4819            hard_links: Slot::new(Vec::new()),
4820            symbolic_links: Slot::new(Vec::new()),
4821            committed_datatypes: Slot::new(Vec::new()),
4822            preserved_links: Slot::new(Vec::new()),
4823            name_index: Slot::new(Box::new(NameIndex::new())),
4824            root_attributes: Slot::new(Vec::new()),
4825            create_lock: Slot::new(()),
4826            libver,
4827            closed: false,
4828            swmr_active: false,
4829            cwfs: Slot::new(Vec::new()),
4830            root_group_addr: None,
4831            superseded_root_header: Vec::new(),
4832            // A new file starts at the oldest superblock the generation it was
4833            // created in allows, and finalize raises it if the content needs a
4834            // newer one.
4835            superblock_version,
4836            dense_attributes: Slot::new(HashMap::new()),
4837            swmr_published_attrs: Slot::new(HashMap::new()),
4838            dense_links: Slot::new(HashMap::new()),
4839            superseded_dense: Slot::new(None),
4840            track_order: TrackOrder::uniform(track_order),
4841            track_times,
4842            root_track_order: TrackOrder::uniform(track_order),
4843            // The root group is created with the file, so it captures the
4844            // policy the same instant every other field of it is settled.
4845            root_times: track_times.then(|| ObjectTimes::created_at(now_seconds())),
4846            next_creation_seq: Slot::new(0),
4847            pending_object_references: Slot::new(Vec::new()),
4848            pending_heap_references: Slot::new(Vec::new()),
4849            attribute_references: Slot::new(Vec::new()),
4850            legacy,
4851            symbol_tables: SymbolTables::none_found(),
4852            // A created file has no extension to carry and no ranks but the
4853            // library defaults: `H5Pset_sym_k`/`H5Pset_istore_k` have no
4854            // equivalent on this writer's creation path.
4855            btree: BTreeV1Config::default(),
4856            extension: Box::default(),
4857            // A file created at the library defaults declares no file-space
4858            // strategy, so it has no message to write and no manager to keep;
4859            // one created with any other properties owns both.
4860            free_space: (!file_space.is_default()).then(|| {
4861                Box::new(FileSpaceState {
4862                    info: file_space.message(),
4863                    superseded: Vec::new(),
4864                })
4865            }),
4866            sohm: (!shared_messages.specs().is_empty())
4867                .then(|| Box::new(SohmState::new(shared_messages.specs().to_vec(), Vec::new()))),
4868            source_dir: source_dir_of(path)?,
4869        })
4870    }
4871
4872    /// Target the libhdf5 2.0 file format for datasets created after this
4873    /// call: filtered chunked datasets get layout message version 5, whose
4874    /// chunk indexes store chunk sizes in a fixed `sizeof_size`-byte field
4875    /// with no overflow limit (see [`Self::chunk_layout_version`]). Off by
4876    /// default, because readers older than libhdf5 2.0 — including the
4877    /// 1.14-based h5py wheels — reject version 5.
4878    ///
4879    /// `false` names `H5F_LIBVER_EARLIEST`, the far end of the same table,
4880    /// rather than un-naming the bound: it is `set_libver_bound`'s contract
4881    /// that applies, chunk index included.
4882    pub fn set_libver_latest(&mut self, latest: bool) -> IoResult<()> {
4883        self.set_libver_bound(if latest {
4884            LibverBound::V200
4885        } else {
4886            LibverBound::Earliest
4887        })
4888    }
4889
4890    /// Bytes this file reserves in front of its superblock
4891    /// (`H5Pget_userblock`).
4892    ///
4893    /// The same value for a file created with one and for a file reopened
4894    /// through [`open_append_with_locking`](Self::open_append_with_locking),
4895    /// which takes it from where the signature turned up: it is the base of
4896    /// the handle's address space either way.
4897    pub fn userblock_size(&self) -> u64 {
4898        self.handle.base()
4899    }
4900
4901    /// Set the file's low libver bound, the equivalent of
4902    /// `H5Pset_libver_bounds`'s `low` argument. Objects created after this
4903    /// call encode their messages at the versions that bound calls for.
4904    ///
4905    /// On a reopened file the bound is taken as named, above or below the row
4906    /// the file's superblock version belongs to, as `H5Fopen` takes a fapl's
4907    /// since libhdf5 2.0 (HDFGroup/hdf5#4939): a version-3 superblock opened
4908    /// at `Earliest` gets version-1 B-tree chunk indexes appended, as h5py on
4909    /// libhdf5 2.x appends them. Only a bound the file's format cannot express
4910    /// at all is refused.
4911    pub fn set_libver_bound(&mut self, libver: LibverBound) -> IoResult<()> {
4912        // A classic file cannot honour a newer bound: every encoder in it
4913        // reads `H5F_LOW_BOUND`, and raising that is what makes libhdf5 write
4914        // the version-2/3 superblock this file does not have. Refused rather
4915        // than pinned silently, so the caller learns the bound did not take.
4916        if libver != LibverBound::Earliest && self.is_legacy() {
4917            return Err(crate::io::IoError::Unsupported(format!(
4918                "cannot set the library-version bound to {libver:?} on this file: it is                  in the classic (version-0/1 superblock) format, which libhdf5 writes                  only at H5F_LIBVER_EARLIEST"
4919            )));
4920        }
4921        self.libver = Some(libver);
4922        Ok(())
4923    }
4924
4925    /// The generation the *message* encoders follow — dataspace, datatype,
4926    /// fill value, attribute.
4927    ///
4928    /// A property of the file, not of the object: `H5S__set_version`,
4929    /// `H5O__fill_set_version`, `H5A__set_version` and `H5T_set_version` all
4930    /// read `H5F_LOW_BOUND(f)` and nothing about the object they are encoding
4931    /// for. So a creation-order-tracking group in a classic file still gets
4932    /// version-1 dataspaces and version-1 attribute messages, even though its
4933    /// own header is version 2.
4934    fn message_format(&self) -> ObjectFormat {
4935        match self.legacy {
4936            Some(_) => ObjectFormat::Legacy,
4937            None => ObjectFormat::Modern,
4938        }
4939    }
4940
4941    /// The object header version an object with this creation-order policy
4942    /// gets — `H5O__set_version` (H5Oint.c:251).
4943    ///
4944    /// Version 1 is the floor a classic file's low bound sets, but tracking
4945    /// creation order of *either* kind raises the object past it: the link
4946    /// creation index lives in the message envelope and the attribute tracking
4947    /// flags live in the header prefix, and version 1 has neither. This is a
4948    /// per-object question in a classic file, which is why the format is not
4949    /// one switch for the whole file — libhdf5 writes version-2 headers inside
4950    /// a version-0 superblock whenever the creation property list asks for
4951    /// creation order.
4952    fn header_format(&self, track: TrackOrder) -> ObjectFormat {
4953        let attrs = self.header_attr_order(track.attrs);
4954        if self.legacy.is_some() && !track.links.is_tracked() && !attrs.is_tracked() {
4955            ObjectFormat::Legacy
4956        } else {
4957            ObjectFormat::Modern
4958        }
4959    }
4960
4961    /// The attribute creation-order policy an object header records, given
4962    /// what the object's creation property list asked for.
4963    ///
4964    /// A file whose shared-message configuration covers attributes records a
4965    /// creation index on every object header message: a shared attribute is
4966    /// found again through it, so `H5SM_init` sets `store_msg_crt_idx`
4967    /// (H5SM.c:220) and `H5O__create_ohdr` then raises every header it creates
4968    /// to version 2 and ORs `H5O_HDR_ATTR_CRT_ORDER_TRACKED` into its flags
4969    /// (H5Oint.c:364, H5Oint.c:442) whatever the property list says. So on
4970    /// such a file the floor is `Tracked` — this is the only place that floor
4971    /// is applied, and both the header version and the header flags come
4972    /// through here.
4973    fn header_attr_order(&self, requested: CreationOrder) -> CreationOrder {
4974        if requested.is_tracked() || !self.tracks_message_creation_index() {
4975            return requested;
4976        }
4977        CreationOrder::Tracked
4978    }
4979
4980    /// Whether this finalize replaces the file's shared-message table.
4981    ///
4982    /// It does whenever the file has indexes and no table has been published
4983    /// this session — every finalize of a file created with them, and the
4984    /// first finalize after a reopen. `build_shared_messages` lays a table out
4985    /// whole from the whole message set rather than inserting into an existing
4986    /// one, so a reopen's table is a *replacement*: every heap ID in the file
4987    /// is reassigned, which makes every object header that holds one stale
4988    /// however little else about it changed. A second finalize (a SWMR close)
4989    /// keeps the table the first published and answers `false`.
4990    fn rebuilds_shared_messages(&self) -> bool {
4991        self.sohm
4992            .as_deref()
4993            .is_some_and(|s| s.table_addr.lock().is_none())
4994    }
4995
4996    /// Whether every object header this writer emits records message creation
4997    /// indices.
4998    fn tracks_message_creation_index(&self) -> bool {
4999        self.sohm
5000            .as_deref()
5001            .is_some_and(SohmState::shares_attributes)
5002    }
5003
5004    /// Whether the group at `scope` stores its links in a symbol table —
5005    /// `H5G__obj_create_real` (H5Gobj.c:129) and the conversion
5006    /// `H5G_obj_insert` performs (H5Gobj.c:512).
5007    ///
5008    /// The new group format is used unconditionally from `H5F_LIBVER_V18` up
5009    /// *for a group being created*, and below it only when the group tracks
5010    /// link creation order: a symbol table entry has no room for a creation
5011    /// index. The two axes are independent — a group that tracks only
5012    /// *attribute* creation order gets a version-2 header over a symbol table,
5013    /// which is what libhdf5 writes for it.
5014    ///
5015    /// A group the reopen found in a symbol table is not being created, and
5016    /// `H5G_obj_insert` never moves an existing group to the new format for
5017    /// the bound's sake. So [`SymbolTables::found`] answers for it whatever
5018    /// generation the rest of this session writes at.
5019    ///
5020    /// The content of the group is the third axis. A symbol table entry has
5021    /// three cache types and no room for a fourth, so an external or
5022    /// user-defined link cannot go in one; libhdf5 answers by converting that
5023    /// one group to link messages the moment such a link is inserted, leaving
5024    /// the superblock version, the object header version and every other group
5025    /// in the file alone. This writer builds each group's storage once at
5026    /// finalize rather than link by link, so the same rule reads as a question
5027    /// about the finished set.
5028    fn uses_symbol_table(&self, scope: LinkScope, links: CreationOrder) -> bool {
5029        (self.legacy.is_some() || self.symbol_tables.found.contains(&scope))
5030            && !links.is_tracked()
5031            && self.links_fit_symbol_table(scope, links)
5032    }
5033
5034    /// Whether every link `scope` holds is one a symbol table entry can
5035    /// express — `H5G_obj_insert`'s `obj_lnk->cset != H5T_CSET_ASCII ||
5036    /// obj_lnk->type > H5L_TYPE_BUILTIN_MAX` test (H5Gobj.c:514), asked of the
5037    /// whole set.
5038    ///
5039    /// A link a reopen carried through verbatim counts too, by its decoded
5040    /// form: the entry would have to be built from that.
5041    fn links_fit_symbol_table(&self, scope: LinkScope, order: CreationOrder) -> bool {
5042        self.group_links(scope, order)
5043            .iter()
5044            .all(|l| l.link.fits_symbol_table())
5045    }
5046
5047    /// The header format of the registered dataset at `index`.
5048    ///
5049    /// A dataset has no links, so only the attribute half of the policy can
5050    /// raise it past version 1.
5051    fn dataset_header_format(&self, index: usize) -> ObjectFormat {
5052        let ds = self.ds(index);
5053        let attrs = ds.lock().track_attr_order;
5054        self.header_format(TrackOrder {
5055            links: CreationOrder::default(),
5056            attrs,
5057        })
5058    }
5059
5060    /// The header format of the registered group at `index`.
5061    fn group_header_format(&self, index: usize) -> ObjectFormat {
5062        let grp = self.grp(index);
5063        let track = grp.lock().track_order;
5064        self.header_format(track)
5065    }
5066
5067    /// The bound one family of encoders is written at, and the single reader
5068    /// of the `libver` field.
5069    ///
5070    /// A bound the caller named is the fapl's `low`, as it was named: since
5071    /// libhdf5 2.0 `H5F__super_read` raises nothing but an SWMR-write open
5072    /// (HDFGroup/hdf5#4939), so a reopened file's superblock version says
5073    /// which generation the file *is* and nothing about the generation this
5074    /// session appends in. With no bound named the answer is `create_default`,
5075    /// which differs per family because this crate's default file is two rows
5076    /// rather than one bound (see the `libver` field, [`encoding_libver`] and
5077    /// [`layout_version_bound`]) — the same default on a created file, whose
5078    /// superblock is then written to match, and on a reopened one, whose
5079    /// superblock is written back as found.
5080    ///
5081    /// [`encoding_libver`]: Self::encoding_libver
5082    /// [`layout_version_bound`]: Self::layout_version_bound
5083    fn session_libver(&self, create_default: LibverBound) -> LibverBound {
5084        let bound = self.libver.unwrap_or(create_default);
5085        match self.message_format() {
5086            // `H5F_LIBVER_EARLIEST` is the only low bound under which libhdf5
5087            // writes a version-0/1 superblock at all, so a newer structure
5088            // inside one is a combination no libhdf5 produces. Refused where
5089            // the caller asks for it (`set_libver_bound`) rather than silently
5090            // dropped; capping here is what keeps the encoders honest if a
5091            // path ever misses that gate.
5092            ObjectFormat::Legacy => bound.min(LibverBound::Earliest),
5093            ObjectFormat::Modern => bound,
5094        }
5095    }
5096
5097    /// The bound the message encoders see — dataspace, datatype, fill value,
5098    /// attribute.
5099    fn encoding_libver(&self) -> LibverBound {
5100        self.session_libver(LibverBound::Earliest)
5101    }
5102
5103    /// The data layout message version this file's bound calls for —
5104    /// `H5O_layout_ver_bounds[H5F_LOW_BOUND(f)]` (H5Dlayout.c:44), the term
5105    /// `H5D__chunk_set_info` weighs against the version a chunk *requires*
5106    /// (H5Dchunk.c:936, :1046).
5107    ///
5108    /// With no bound named the row is `H5F_LIBVER_V110`'s: this crate's
5109    /// default file uses the v1.10 chunk indexes, which is exactly what that
5110    /// row says and what no other row does (see the `libver` field for why the
5111    /// default is not `Earliest` here even though the datatype and superblock
5112    /// tables read it that way). A reopened file takes the same default: a
5113    /// version-2 superblock gets v1.10 indexes appended unless a bound below
5114    /// `V110` is named, whose layout version of 3 has no index-type field at
5115    /// all and puts the appended chunks on the version-1 B-tree.
5116    fn layout_version_bound(&self) -> u8 {
5117        self.session_libver(LibverBound::V110).layout_version()
5118    }
5119
5120    /// The data layout version a chunk of `chunk_bytes` *requires* whatever
5121    /// the bound says — `version_req` in `H5D__chunk_set_info` (H5Dchunk.c:909).
5122    ///
5123    /// Only one thing raises it: a chunk over 4 GiB does not fit the version-4
5124    /// message's 32-bit stored-size field. The floor is the default the
5125    /// creation property list carries, `H5O_LAYOUT_VERSION_DEFAULT`
5126    /// (H5Oprivate.h:451), which is why a classic file's chunked dataset is a
5127    /// version-3 message rather than the version-1 its bound's row names.
5128    fn required_chunk_layout_version(chunk_bytes: u64) -> u8 {
5129        if chunk_bytes > u32::MAX as u64 {
5130            5
5131        } else {
5132            LAYOUT_VERSION_DEFAULT
5133        }
5134    }
5135
5136    /// Whether a new chunked dataset of this chunk size is indexed by one of
5137    /// the v1.10 indexes — extensible array, fixed array, v2 B-tree, single
5138    /// chunk or implicit — rather than by the version-1 B-tree.
5139    ///
5140    /// The gate `H5D__chunk_set_info` puts in front of the whole
5141    /// index-selection block (H5Dchunk.c:936): the bound's layout version
5142    /// reaches 4, or the chunk requires a version that does. Only inside it
5143    /// does the dataspace get to pick between the five; below it the layout
5144    /// message has no index-type field and the chunks go on the version-1
5145    /// B-tree. So the format decides before the shape does — a fixed shape
5146    /// covered by exactly one chunk takes the single-chunk index only on the
5147    /// near side of this gate.
5148    pub(crate) fn uses_v110_chunk_indexing(&self, chunk_bytes: u64) -> bool {
5149        self.layout_version_bound() >= 4 || Self::required_chunk_layout_version(chunk_bytes) >= 4
5150    }
5151
5152    /// Refuse an SWMR session this file's format cannot record.
5153    ///
5154    /// The two checks `H5F__start_swmr_write` opens with: the superblock must
5155    /// be at least version 3 (H5Fint.c:3814, hdf5_1.14.6 H5Fint.c:3751) — the
5156    /// only version with the status-flags field that says a writer is attached
5157    /// — and the low bound must be at least `H5F_LIBVER_V110` (H5Fint.c:3818),
5158    /// the oldest bound whose `HDF5_superblock_ver_bounds` row reaches version
5159    /// 3.
5160    ///
5161    /// The first is the [`SuperblockVersion`] question: a reopened file
5162    /// already has its version and reopening never rewrites one, so it is
5163    /// checked as found; a file this writer created has no version on disk
5164    /// yet, and nothing in it says the superblock may not be version 3 — SWMR
5165    /// is what makes it one. The second is asked of the bound the caller
5166    /// named on either path, since a reopened file's superblock no longer
5167    /// raises it (HDFGroup/hdf5#4939); a caller who named none passes,
5168    /// because the default's layout row is `V110`'s, the row the v1.10 chunk
5169    /// indexes an SWMR reader follows belong to.
5170    ///
5171    /// Named, not silently upgraded. libhdf5 upgrades in the one case where
5172    /// SWMR is asked for at *create* time (`H5F_ACC_SWMR_WRITE` raises the
5173    /// bound to V110 in `H5F__super_init`, H5Fsuper.c:1131); on the reopen
5174    /// path it refuses instead, and so does this.
5175    fn reject_swmr(&self) -> IoResult<()> {
5176        let below_v110 = self.libver.is_some_and(|b| b < LibverBound::V110);
5177        let why = match self.superblock_version {
5178            SuperblockVersion::Existing(version) if version < SUPERBLOCK_V3 => format!(
5179                "its superblock is version {version}, and reopening a file never \
5180                 rewrites that"
5181            ),
5182            SuperblockVersion::Chosen(_) if self.is_legacy() => {
5183                "it is in the classic (version-0/1 superblock) format that \
5184                 H5F_LIBVER_EARLIEST selects"
5185                    .to_string()
5186            }
5187            _ if below_v110 => "it was asked for at a library-version bound below \
5188                 H5F_LIBVER_V110, whose superblock row is version 2"
5189                .to_string(),
5190            _ => return Ok(()),
5191        };
5192        Err(crate::io::IoError::Unsupported(format!(
5193            "cannot start an SWMR session on this file: {why}, and SWMR needs a \
5194             version-3 superblock to record that a writer is attached; create the \
5195             file at H5F_LIBVER_V110 or newer"
5196        )))
5197    }
5198
5199    /// Whether this file is in the classic (version-0/1 superblock) format,
5200    /// whose groups store their links in symbol tables — either because it
5201    /// was reopened in it or because it was created at
5202    /// `H5F_LIBVER_EARLIEST`.
5203    pub(crate) fn is_legacy(&self) -> bool {
5204        self.legacy.is_some()
5205    }
5206
5207    /// The v1-B-tree "K" ranks in force for this file, from which every v1
5208    /// node's width is derived.
5209    ///
5210    /// A version-0/1 superblock records them in a field of its own and a
5211    /// version-2/3 one in a B-tree-K message in its superblock extension, so
5212    /// the file's generation says nothing about whether they are the defaults
5213    /// — `H5F__super_read` reads both into the same `H5F_shared_t`, and so
5214    /// does the reopen, into `btree`.
5215    fn btree_v1_config(&self) -> BTreeV1Config {
5216        self.btree
5217    }
5218
5219    /// Track and index creation order for the links and the attributes of
5220    /// every object created after this call — the equivalent of setting
5221    /// `H5Pset_link_creation_order` and `H5Pset_attr_creation_order` to
5222    /// `H5P_CRT_ORDER_TRACKED | H5P_CRT_ORDER_INDEXED` on the creation
5223    /// property lists those objects are made with.
5224    ///
5225    /// Objects already created keep the policy they were made under, exactly
5226    /// as libhdf5 keeps what their creation property list said. The root
5227    /// group is created with the file, so its policy comes from
5228    /// [`create_with_options`](Self::create_with_options) instead.
5229    pub fn set_track_order(&mut self, track: bool) {
5230        self.track_order = TrackOrder::uniform(track);
5231    }
5232
5233    /// Record the times of every object created after this call —
5234    /// `H5Pset_obj_track_times` on the creation property lists those objects
5235    /// are made with.
5236    ///
5237    /// Off by default, which is h5py's default and not libhdf5's: h5py's
5238    /// high-level API sets `track_times=False` on every object it makes
5239    /// (`_hl/files.py:189`, `_hl/dataset.py:39`, `_hl/group.py:42`), while a
5240    /// bare creation property list leaves it on (`H5O_CRT_OHDR_FLAGS_DEF` is
5241    /// `H5O_HDR_STORE_TIMES`, H5Opkg.h:74). A caller after libhdf5's own
5242    /// bytes turns it on here.
5243    ///
5244    /// Objects already created keep the policy they were made under, and the
5245    /// root group takes its own from
5246    /// [`create_with_options`](Self::create_with_options) — the same split
5247    /// [`set_track_order`](Self::set_track_order) has, and for the same
5248    /// reason: this is a creation property, not a file-wide setting.
5249    pub fn set_track_times(&mut self, track: bool) {
5250        self.track_times = track;
5251    }
5252
5253    /// The times an object created right now records — all four set to the
5254    /// current time, as `H5O_apply_ohdr` initialises them (H5Oint.c:411-414),
5255    /// or `None` when this session is not tracking times.
5256    ///
5257    /// INVARIANT: every object this writer registers takes its `times` from
5258    /// here. The policy belongs to the creation property list, so reading
5259    /// [`track_times`](Self::track_times) at any later moment — a finalize, a
5260    /// header rewrite — would stamp a policy the object was not made under.
5261    fn created_object_times(&self) -> Option<ObjectTimes> {
5262        self.track_times
5263            .then(|| ObjectTimes::created_at(now_seconds()))
5264    }
5265
5266    /// Layout message version for a new chunked dataset on one of the v1.10
5267    /// indexes — `H5D__chunk_set_info`'s closing
5268    /// `MAX3(layout->version, version_req, MIN(bound, version_perf))`
5269    /// (H5Dchunk.c:1046).
5270    ///
5271    /// Version 5 is *required* for a chunk over 4 GiB (pre-2.0 readers cannot
5272    /// handle one even though the v4 wire format could express it) and
5273    /// *preferred* for filtered chunks, which is why it takes the file's
5274    /// bound to get there: the preference is capped by the bound's own row,
5275    /// so only the 2.0 format lets it through. Everything else stays at
5276    /// version 4, which every 1.10+ reader accepts.
5277    fn chunk_layout_version(&self, filtered: bool, chunk_bytes: u64) -> u8 {
5278        // `version_perf`: 4 for the v1.10 indexes as such, 5 when a filter
5279        // can make a chunk expand past what version 4 can record.
5280        let preferred = if filtered { 5 } else { 4 };
5281        Self::required_chunk_layout_version(chunk_bytes)
5282            .max(self.layout_version_bound().min(preferred))
5283            .max(LAYOUT_VERSION_DEFAULT)
5284    }
5285
5286    /// Width of the stored-chunk-size field in a filtered chunk index:
5287    /// version 5 uses the fixed `sizeof_size`; version 4 derives it from the
5288    /// uncompressed chunk byte count (one spare byte included), the
5289    /// `H5D_*_COMPUTE_CHUNK_SIZE_LEN` rule shared by the extensible-array,
5290    /// fixed-array and v2-B-tree indexes.
5291    fn chunk_size_len_for(&self, layout_version: u8, chunk_bytes: u64) -> u8 {
5292        if layout_version >= 5 {
5293            self.ctx.sizeof_size
5294        } else {
5295            compute_chunk_size_len(chunk_bytes)
5296        }
5297    }
5298
5299    /// Provide public access to the format context.
5300    pub fn ctx(&self) -> &FormatContext {
5301        &self.ctx
5302    }
5303
5304    /// Number of dataset slots in the registry (including soft-deleted ones).
5305    pub(crate) fn dataset_count(&self) -> usize {
5306        self.datasets.lock().len()
5307    }
5308
5309    /// Clone out the [`DatasetRef`] for `index`, releasing the registry lock
5310    /// immediately. Lock the returned ref to read or mutate that one dataset.
5311    ///
5312    /// Panics on an out-of-range index, exactly like the `Vec` indexing it
5313    /// replaces; bounds-checking callers consult [`Self::dataset_count`] first.
5314    ///
5315    /// MUST NOT be called while the registry [`Slot`] is already locked (it
5316    /// would deadlock the `threadsafe` mutex / panic the single-thread
5317    /// `RefCell`): collect the refs you need, drop the registry guard, then work.
5318    pub(crate) fn ds(&self, index: usize) -> DatasetRef {
5319        Shared::clone(&self.datasets.lock()[index])
5320    }
5321
5322    /// Number of group slots in the registry (including soft-deleted ones).
5323    pub(crate) fn group_count(&self) -> usize {
5324        self.groups.lock().len()
5325    }
5326
5327    /// Clone out the [`GroupRef`] for `index`. Same contract as [`Self::ds`].
5328    pub(crate) fn grp(&self, index: usize) -> GroupRef {
5329        Shared::clone(&self.groups.lock()[index])
5330    }
5331
5332    /// Enter the create gate: take `create_lock` and check that `name` is not
5333    /// already taken. The returned witness is what [`Self::push_dataset`]
5334    /// requires, so the uniqueness check and the registry push are atomic
5335    /// (see `create_lock`) at every creator by construction.
5336    pub(crate) fn begin_create(&self, name: &str) -> IoResult<CreateGuard<'_>> {
5337        let gate = self.create_lock.lock();
5338        // A creation path through hard links lands in the link's target
5339        // group, as HDF5 traversal does. Canonicalizing here — the one
5340        // entry every creator passes — keeps alias forms out of the
5341        // registry.
5342        let name = self.canonical_dataset_path(name);
5343        // A path that leaves this file, or that runs into an object the
5344        // reopen kept verbatim, is refused here rather than at each creator:
5345        // this is the one gate every creation passes, so a creator added
5346        // later cannot forget the check. Both run before the parent lookup,
5347        // which would otherwise report the group such a path names as absent
5348        // instead of naming what stops the path. Uniqueness comes first among
5349        // them: a name already in the file is taken whatever holds it.
5350        self.reject_external_traversal(&name)?;
5351        self.ensure_name_free(&name)?;
5352        self.reject_preserved_object(&name)?;
5353        let (parent, _leaf) = self.split_parent(&name)?;
5354        Ok(CreateGuard {
5355            _gate: gate,
5356            name,
5357            parent,
5358        })
5359    }
5360
5361    /// Split an object path into the group that will hold its link and the
5362    /// leaf link name, resolving every component through the group registry.
5363    ///
5364    /// `path` is the registry form — no leading `/`, e.g. `"grp/sub/late"`.
5365    /// This is what keeps a `/` out of a link name: HDF5 link names are
5366    /// single path components (`H5G_traverse` splits on `/` before it ever
5367    /// reaches `H5L_link`), so a name that carries a path must name a group
5368    /// that exists, or be refused.
5369    ///
5370    /// A missing component is an error rather than an implicit group: the
5371    /// default link creation property list has `H5Pset_create_intermediate_group`
5372    /// off, and this writer exposes no property list to turn it on with.
5373    fn split_parent(&self, path: &str) -> IoResult<(Option<usize>, String)> {
5374        let (parent_path, leaf) = path.rsplit_once('/').unwrap_or(("", path));
5375        if leaf.is_empty() {
5376            return Err(crate::io::IoError::InvalidState(format!(
5377                "'{path}' does not end in a link name"
5378            )));
5379        }
5380        if parent_path.is_empty() {
5381            return Ok((None, leaf.to_string()));
5382        }
5383        let abs = format!("/{parent_path}");
5384        let groups = self.group_refs();
5385        let idx = groups
5386            .iter()
5387            .position(|g| {
5388                let gg = g.lock();
5389                gg.name == abs && !gg.deleted
5390            })
5391            .ok_or_else(|| {
5392                crate::io::IoError::NotFound(format!(
5393                    "cannot create '{path}': group '{abs}' does not exist"
5394                ))
5395            })?;
5396        Ok((Some(idx), leaf.to_string()))
5397    }
5398
5399    /// Push a freshly-built dataset into the registry and return its index.
5400    /// Takes the registry lock only for the push, so it does not block an
5401    /// in-flight write that already cloned its own [`DatasetRef`] out.
5402    /// The [`CreateGuard`] proves the caller entered through
5403    /// [`Self::begin_create`] and still holds the gate.
5404    pub(crate) fn push_dataset(&self, create: &CreateGuard<'_>, info: DatasetInfo) -> usize {
5405        let name = info.name.clone();
5406        let idx = {
5407            let mut reg = self.datasets.lock();
5408            let idx = reg.len();
5409            reg.push(Shared::new(DatasetCell::new(info)));
5410            idx
5411        };
5412        self.register_name(&name, NameHit::Dataset(idx));
5413        // The spine guard is dropped before the group slot is taken: the lock
5414        // order is spine -> slot and never the reverse.
5415        if let Some(pidx) = create.parent {
5416            self.grp(pidx).lock().child_datasets.push(idx);
5417        }
5418        idx
5419    }
5420
5421    /// Push a freshly-built group into the registry and return its index.
5422    pub(crate) fn push_group(&self, info: GroupInfo) -> usize {
5423        let name = info.name.trim_start_matches('/').to_string();
5424        let idx = {
5425            let mut reg = self.groups.lock();
5426            let idx = reg.len();
5427            reg.push(Shared::new(Slot::new(info)));
5428            idx
5429        };
5430        self.register_name(&name, NameHit::Group(idx));
5431        idx
5432    }
5433
5434    /// Snapshot every [`DatasetRef`] (spine lock held only for the clone).
5435    /// Iterate the snapshot to lock each dataset one at a time — this keeps
5436    /// the lock order *spine → slot* and never reacquires the spine while a
5437    /// slot is held, which is what makes the registry deadlock-free.
5438    pub(crate) fn dataset_refs(&self) -> Vec<DatasetRef> {
5439        self.datasets.lock().iter().map(Shared::clone).collect()
5440    }
5441
5442    /// Snapshot every [`GroupRef`]; see [`Self::dataset_refs`].
5443    pub(crate) fn group_refs(&self) -> Vec<GroupRef> {
5444        self.groups.lock().iter().map(Shared::clone).collect()
5445    }
5446
5447    /// Snapshot the hard-link list (the lock is held only for the clone), so
5448    /// callers can resolve each link's target/parent — which locks dataset and
5449    /// group slots — without holding the hard-link lock.
5450    /// The next creation sequence number.
5451    ///
5452    /// One monotonic counter for datasets, groups and hard links alike: a
5453    /// group orders its links by it, so an interleaved run of `create_group`
5454    /// and `create_dataset` comes back out in the order it was made rather
5455    /// than grouped by kind.
5456    fn take_creation_seq(&self) -> u64 {
5457        let mut next = self.next_creation_seq.lock();
5458        let seq = *next;
5459        *next += 1;
5460        seq
5461    }
5462
5463    pub(crate) fn hard_links_vec(&self) -> Vec<HardLink> {
5464        self.hard_links.lock().clone()
5465    }
5466
5467    /// Snapshot the symbolic-link list; see [`Self::hard_links_vec`].
5468    pub(crate) fn symbolic_links_vec(&self) -> Vec<SymbolicLink> {
5469        self.symbolic_links.lock().clone()
5470    }
5471
5472    /// Open an existing HDF5 file for appending new datasets, using the
5473    /// env-var-derived locking policy.
5474    ///
5475    /// Reads existing dataset object headers fully, reconstructing metadata
5476    /// for chunked datasets so that `write_chunk` and `extend_dataset` work
5477    /// on reopened datasets.
5478    pub fn open_append(path: &Path) -> IoResult<Self> {
5479        Self::open_append_with_locking(
5480            path,
5481            crate::io::locking::FileLocking::from_env_or(Default::default()),
5482        )
5483    }
5484
5485    /// Carry a reopened file's shared-message table into the writer's model:
5486    /// the index specifications the file was created with, and every block the
5487    /// table occupies so the finalize that replaces it can give them back.
5488    ///
5489    /// `H5SM_init` fixes the index count, each index's type mask, its minimum
5490    /// message size and the file-wide phase-change pair when the file is
5491    /// created, and nothing afterwards changes any of them — they are file
5492    /// creation properties. So the master table on disk *is* the
5493    /// [`SharedMessageConfig`] the file was made with, read back.
5494    ///
5495    /// Returns `None` for a file with no shared-message table, which is every
5496    /// file libhdf5 writes without `H5Pset_shared_mesg_nindexes`.
5497    /// Read the free-space managers a reopened file persists, if it does.
5498    ///
5499    /// `H5F__super_read` copies the file-space info message's addresses into
5500    /// `f->shared->fs_addr[]` and the library opens each manager lazily; this
5501    /// reads them all at once, because the writer needs the whole section set
5502    /// before it allocates anything.
5503    ///
5504    /// Returns `None` — nothing read, nothing to write back — for a file with
5505    /// no file-space info message, one that does not persist, and one whose
5506    /// strategy keeps no managers at all.
5507    fn reopen_free_space(
5508        handle: &mut FileHandle,
5509        meta: &crate::io::FileMeta,
5510        ext: &crate::io::reader::SuperblockExtension,
5511    ) -> IoResult<ReopenedFreeSpace> {
5512        let none = || ReopenedFreeSpace {
5513            state: None,
5514            sections: Vec::new(),
5515        };
5516        let Some(info) = ext.file_space_info.as_ref().filter(|i| i.persist) else {
5517            return Ok(none());
5518        };
5519        if !matches!(
5520            info.strategy,
5521            FileSpaceStrategy::FsmAggr | FileSpaceStrategy::Page
5522        ) {
5523            return Ok(none());
5524        }
5525        let found = crate::io::free_space_io::read_managers(handle, &meta.ctx, info)?;
5526        Ok(ReopenedFreeSpace {
5527            state: Some(Box::new(FileSpaceState {
5528                info: info.clone(),
5529                superseded: found.blocks,
5530            })),
5531            sections: found.sections,
5532        })
5533    }
5534
5535    fn reopen_shared_messages(
5536        handle: &mut FileHandle,
5537        meta: &crate::io::FileMeta,
5538        ext: &crate::io::reader::SuperblockExtension,
5539    ) -> IoResult<Option<Box<SohmState>>> {
5540        use crate::format::chunk_index::btree_v2::collect_btree_v2_extents;
5541        use crate::format::fractal_heap::collect_heap_extents;
5542        use crate::format::sohm::{list_size, SohmMasterTable, SOHM_INDEX_LIST};
5543
5544        let (Some(table), Some(smt)) = (
5545            meta.sohm.as_ref().filter(|t| !t.indexes.is_empty()),
5546            ext.shared_message_table.as_ref(),
5547        ) else {
5548            return Ok(None);
5549        };
5550        let ctx = &meta.ctx;
5551
5552        // The extension header itself is superseded by `CarriedExtension`,
5553        // which owns it whether or not the file has shared messages; what is
5554        // superseded here is only the storage the table message names.
5555        let mut superseded = Vec::new();
5556        superseded.push((
5557            smt.table_address,
5558            SohmMasterTable::encoded_size(ctx, smt.nindexes) as u64,
5559        ));
5560
5561        let mut specs = Vec::with_capacity(table.indexes.len());
5562        for index in &table.indexes {
5563            specs.push(SohmIndexSpec {
5564                mesg_types: index.mesg_types,
5565                min_mesg_size: index.min_mesg_size,
5566                list_max: index.list_max,
5567                btree_min: index.btree_min,
5568            });
5569            let mut reader = crate::io::reader::HandleBlockReader { handle };
5570            if index.heap_addr != UNDEF_ADDR {
5571                superseded.extend(collect_heap_extents(index.heap_addr, ctx, &mut reader)?);
5572            }
5573            if index.index_addr != UNDEF_ADDR {
5574                if index.index_type == SOHM_INDEX_LIST {
5575                    // `H5SM_LIST_SIZE`: the block is sized for `list_max`
5576                    // records however few are in it.
5577                    superseded.push((index.index_addr, list_size(ctx, index.list_max) as u64));
5578                } else {
5579                    superseded.extend(collect_btree_v2_extents(
5580                        index.index_addr,
5581                        ctx,
5582                        &mut reader,
5583                    )?);
5584                }
5585            }
5586        }
5587        Ok(Some(Box::new(SohmState::new(specs, superseded))))
5588    }
5589
5590    /// Open an existing HDF5 file for appending with an explicit locking
5591    /// policy.
5592    pub fn open_append_with_locking(
5593        path: &Path,
5594        locking: crate::io::locking::FileLocking,
5595    ) -> IoResult<Self> {
5596        let mut handle = FileHandle::open_readwrite_with_locking(path, locking)?;
5597        // The same `H5FD_locate_signature` search the read path makes, through
5598        // the same handle mechanism: the offset it finds is the file's base
5599        // address, so the allocator's end-of-file, every write and the
5600        // superblock rewrite all work in the HDF5 address space, and the
5601        // userblock in `[0, base)` is not addressable from this writer at all.
5602        let super_addr = handle
5603            .locate_signature()?
5604            .ok_or(crate::format::FormatError::InvalidSignature)?;
5605        handle.set_base(super_addr);
5606        let file_size = handle.file_size()?;
5607
5608        let sb_buf = handle.read_at_most(0, 256)?;
5609        // Which generation the file is decides everything the close then
5610        // writes back: version-1 object headers and symbol-table groups over a
5611        // version-0/1 superblock, or version-2 headers and link-message groups
5612        // over a version-2/3 one. libhdf5 writes those two combinations and no
5613        // mixture of them, so the branch is taken once, here, and carried as
5614        // `legacy`.
5615        let version = crate::format::superblock::detect_superblock_version(&sb_buf)?;
5616        let (ctx, sb_btree, root_addr, ext_addr, legacy) = if version <= 1 {
5617            let sb = SuperblockV0V1::decode(&sb_buf)?;
5618            let ctx = FormatContext {
5619                sizeof_addr: sb.sizeof_offsets,
5620                sizeof_size: sb.sizeof_lengths,
5621            };
5622            // Unlike a v2/v3 superblock, a classic one carries the "K" ranks
5623            // itself; every v1-B-tree and symbol-table node width in the file
5624            // comes from them.
5625            let btree = crate::format::btree_v1::BTreeV1Config {
5626                sym_leaf_k: sb.sym_leaf_k,
5627                snode_internal_k: sb.btree_internal_k,
5628                chunk_internal_k: sb.indexed_storage_k.unwrap_or(32),
5629            };
5630            let root = sb.root_symbol_table_entry.obj_header_addr;
5631            let ext = sb.superblock_extension_address;
5632            (ctx, btree, root, ext, Some(sb))
5633        } else {
5634            let sb = SuperblockV2V3::decode(&sb_buf)?;
5635            let ctx = FormatContext {
5636                sizeof_addr: sb.sizeof_offsets,
5637                sizeof_size: sb.sizeof_lengths,
5638            };
5639            (
5640                ctx,
5641                crate::format::btree_v1::BTreeV1Config::default(),
5642                sb.root_group_object_header_address,
5643                sb.superblock_extension_address,
5644                None,
5645            )
5646        };
5647
5648        // The reopen reads object headers exactly as the reader does, so it
5649        // needs the same file-level parameters: a v2/v3 superblock carries no
5650        // B-tree K values, and only the extension can override the defaults.
5651        let (meta, ext) = crate::io::reader::Hdf5Reader::read_extension_and_meta(
5652            &mut handle,
5653            ctx,
5654            sb_btree,
5655            ext_addr,
5656        )?;
5657
5658        // A file with shared object header messages keeps datatypes,
5659        // dataspaces and attributes in a fractal heap per index, and each
5660        // object header holds a heap ID pointing at one. The table is laid out
5661        // whole from the whole message set (`build_shared_messages`), never
5662        // grown insert by insert, so a reopen carries the indexes and the
5663        // bodies forward and the next finalize lays a new table out over the
5664        // old one's blocks — which is sound exactly while no header keeping
5665        // its bytes still points into the old heap. The walk below is what
5666        // settles that.
5667        let sohm = Self::reopen_shared_messages(&mut handle, &meta, &ext)?;
5668
5669        // The extension is external truth this close rewrites, so what it held
5670        // is captured whole here — before anything else reads the file — and
5671        // re-emitted by `write_superblock_extension`. Read from the raw chain
5672        // rather than from `ext`, which keeps only the messages this crate
5673        // models.
5674        let extension = if ext_addr == UNDEF_ADDR || ext_addr == 0 {
5675            Box::<CarriedExtension>::default()
5676        } else {
5677            let (carried, blocks) = crate::io::object_header_io::superblock_extension_messages(
5678                &mut handle,
5679                &meta,
5680                ext_addr,
5681            )?;
5682            Box::new(CarriedExtension {
5683                superseded: blocks,
5684                carried,
5685                addr: Slot::new(None),
5686            })
5687        };
5688
5689        // The managers that extension's file-space info message names, read
5690        // before anything allocates: the sections they hold are file space
5691        // this session may hand out, and the close rewrites them.
5692        let reopened_free_space = Self::reopen_free_space(&mut handle, &meta, &ext)?;
5693
5694        // Discover links from root group (and subgroups recursively). Every
5695        // object is classified before it is registered, and the root is the
5696        // one object with no alternative: its header must be rewritten to
5697        // hold anything new, so an unmodellable root is refused here rather
5698        // than rewritten into whatever this writer could read of it.
5699        let mut walk = ReopenWalk::new(&mut handle, &meta);
5700        let root = match walk.plan(root_addr)? {
5701            ObjectPlan::Group(parts) => parts,
5702            ObjectPlan::Dataset(_) => {
5703                return Err(crate::io::IoError::InvalidState(
5704                    "cannot open this file for appending: its root object is a dataset, \
5705                     not a group"
5706                        .into(),
5707                ))
5708            }
5709            ObjectPlan::Preserve { why, .. } => {
5710                return Err(crate::io::IoError::Unsupported(format!(
5711                    "cannot open this file for appending: {why}. Every append rewrites the \
5712                     root group's header, and this writer will not rewrite it from the part \
5713                     of it that it can read"
5714                )));
5715            }
5716        };
5717        let root_header_blocks = root.header_blocks;
5718        let root_attributes = root.attributes;
5719        let root_track_order = root.track_order;
5720        let root_times = root.times;
5721        let root_dense = root.dense;
5722        let root_stab = root.stab;
5723
5724        walk.group(&root.links, "", 0)?;
5725        let collected = walk.finish();
5726        let mut link_entries = collected.hard;
5727        let mut preserved = collected.preserved;
5728        let link_corder = collected.link_corder;
5729        // Objects the loop below could not rebuild, by header address, so the
5730        // other links to one are preserved with it rather than left pointing
5731        // at a registry entry that is no longer there.
5732        let mut unrebuilt: std::collections::HashMap<u64, String> = Default::default();
5733
5734        // Two link entries can share one object header — hard links. Only
5735        // the first-walked path becomes the object; the rest are rebuilt
5736        // as hard-link registry entries further down. Without this split
5737        // every alias came back as its own DatasetInfo carrying the same
5738        // storage addresses, so deleting (or finalizing) one freed blocks
5739        // the others still referenced.
5740        let mut seen_header_addrs = std::collections::HashSet::new();
5741        let mut alias_entries: Vec<HardEntry> = Vec::new();
5742        link_entries.retain(|(entry, _)| {
5743            if seen_header_addrs.insert(entry.address) {
5744                true
5745            } else {
5746                alias_entries.push(entry.clone());
5747                false
5748            }
5749        });
5750
5751        // The order the walk met each object, kept before the loop below
5752        // consumes the entries: `ensure_groups_for` needs parents to precede
5753        // children.
5754        let walk_order: Vec<String> = link_entries.iter().map(|(e, _)| e.path.clone()).collect();
5755
5756        let mut existing_datasets = Vec::new();
5757        // Non-dataset link targets (groups): the header's chunk-0 address and
5758        // every block its chain occupies, by link path — so finalize can free
5759        // the blocks its rewrite supersedes — plus the attributes the header
5760        // carries, which the group registry below must keep or finalize
5761        // rewrites the group without them.
5762        type GroupHeaderInfo = (
5763            u64,
5764            crate::io::object_header_io::HeaderBlocks,
5765            Vec<AttributeEntry>,
5766            TrackOrder,
5767            Option<ObjectTimes>,
5768        );
5769        let mut group_headers: std::collections::HashMap<String, GroupHeaderInfo> =
5770            Default::default();
5771        // The dense storage each rebuilt dataset's header named, by registry
5772        // index, so finalize frees exactly what its rewrite supersedes. Keyed
5773        // after the rebuild succeeded: a preserved dataset keeps its header,
5774        // and freeing the heap that header still names would strand it.
5775        let mut dataset_dense: Vec<(usize, AttributeInfoMessage)> = Vec::new();
5776        let mut group_dense: Vec<(String, DenseCarry)> = Vec::new();
5777        // The same, for the symbol-table storage a classic group's header
5778        // names: keyed by path here, by registry index once every group has
5779        // one.
5780        let mut group_stabs: Vec<(String, StabExtents)> = Vec::new();
5781        for (entry, object) in link_entries {
5782            let HardEntry {
5783                path: name,
5784                address: obj_addr,
5785                encoded,
5786            } = entry;
5787            let parts = match object {
5788                CollectedObject::Group {
5789                    header_blocks,
5790                    attributes,
5791                    track_order,
5792                    times,
5793                    dense,
5794                    stab,
5795                } => {
5796                    group_dense.push((name.clone(), dense));
5797                    if let Some(stab) = stab {
5798                        group_stabs.push((name.clone(), stab));
5799                    }
5800                    group_headers.insert(
5801                        name,
5802                        (obj_addr, header_blocks, attributes, track_order, times),
5803                    );
5804                    continue;
5805                }
5806                CollectedObject::Dataset(parts) => *parts,
5807            };
5808            let dense_attrs = parts.dense.attrs.clone();
5809            match rebuild_dataset(&mut handle, &meta, file_size, name.clone(), obj_addr, parts) {
5810                Ok(info) => {
5811                    if let Some(ainfo) = dense_attrs {
5812                        dataset_dense.push((existing_datasets.len(), ainfo));
5813                    }
5814                    existing_datasets.push(info);
5815                }
5816                // Kept by its bytes for the same reason a header this walk
5817                // could not decode is: the rewrite would otherwise emit an
5818                // object whose chunk index no longer names its chunks.
5819                Err(e) => {
5820                    let why = format!("this writer could not rebuild its chunk index: {e}");
5821                    unrebuilt.insert(obj_addr, why.clone());
5822                    preserved.push(PreservedEntry {
5823                        path: name,
5824                        class: crate::io::reader::LinkClass::Hard,
5825                        encoded,
5826                        reason: Some(why),
5827                        // A dataset whose chunk index would not rebuild: the
5828                        // walk classified it, and it is not a datatype.
5829                        kind: PreservedKind::Unclassified,
5830                    });
5831                }
5832            }
5833        }
5834
5835        // Reconstruct the group registry. Every group is a link entry of its
5836        // own, whether or not a dataset lives under it, so the registry is
5837        // built from the discovered links — rebuilding it from dataset paths
5838        // alone made attribute-only and empty groups vanish at close, and
5839        // dropped the attributes of the groups that survived.
5840        let mut groups: Vec<GroupInfo> = Vec::new();
5841        let mut group_index_map: std::collections::HashMap<String, usize> =
5842            std::collections::HashMap::new();
5843
5844        // Register the chain of groups "/a", "/a/b", … for the link-style
5845        // path `link_path` ("a/b"), taking each one's on-disk header block
5846        // and attributes out of `group_headers` when the link walk saw it.
5847        fn ensure_groups_for(
5848            link_path: &str,
5849            groups: &mut Vec<GroupInfo>,
5850            group_index_map: &mut std::collections::HashMap<String, usize>,
5851            group_headers: &mut std::collections::HashMap<String, GroupHeaderInfo>,
5852        ) {
5853            let mut path = String::new();
5854            for part in link_path.split('/') {
5855                let parent_path = if path.is_empty() {
5856                    "/".to_string()
5857                } else {
5858                    path.clone()
5859                };
5860                if path.is_empty() {
5861                    path = format!("/{}", part);
5862                } else {
5863                    path = format!("{}/{}", path, part);
5864                }
5865                if group_index_map.contains_key(&path) {
5866                    continue;
5867                }
5868                let parent = if parent_path == "/" {
5869                    None
5870                } else {
5871                    group_index_map.get(&parent_path).copied()
5872                };
5873                let gidx = groups.len();
5874                let (obj_header_written_addr, obj_header_blocks, attributes, track_order, times) =
5875                    group_headers.remove(path.trim_start_matches('/')).map_or(
5876                        (None, Vec::new(), Vec::new(), TrackOrder::default(), None),
5877                        |(addr, blocks, attrs, track, times)| {
5878                            (Some(addr), blocks, attrs, track, times)
5879                        },
5880                    );
5881                groups.push(GroupInfo {
5882                    name: path.clone(),
5883                    parent,
5884                    creation_seq: 0,
5885                    track_order,
5886                    times,
5887                    child_datasets: Vec::new(),
5888                    child_groups: Vec::new(),
5889                    obj_header_addr: 0,
5890                    obj_header_written_addr,
5891                    obj_header_blocks,
5892                    deleted: false,
5893                    attributes,
5894                });
5895                if let Some(pidx) = parent {
5896                    groups[pidx].child_groups.push(gidx);
5897                }
5898                group_index_map.insert(path.clone(), gidx);
5899            }
5900        }
5901
5902        // Every linked group, in link-walk order (parents precede children).
5903        for name in &walk_order {
5904            if group_headers.contains_key(name.as_str()) {
5905                ensure_groups_for(name, &mut groups, &mut group_index_map, &mut group_headers);
5906            }
5907        }
5908
5909        // Assign each dataset to its immediate parent group, creating any
5910        // group the link walk could not decode (its chain stays placeholder).
5911        for (di, ds) in existing_datasets.iter().enumerate() {
5912            let parts: Vec<&str> = ds.name.split('/').collect();
5913            if parts.len() <= 1 {
5914                continue; // root-level dataset, no group
5915            }
5916            let parent_link_path = parts[..parts.len() - 1].join("/");
5917            ensure_groups_for(
5918                &parent_link_path,
5919                &mut groups,
5920                &mut group_index_map,
5921                &mut group_headers,
5922            );
5923            let gidx = group_index_map[&format!("/{}", parent_link_path)];
5924            groups[gidx].child_datasets.push(di);
5925        }
5926
5927        // An object the rebuild above gave up on is preserved by its bytes,
5928        // so the other links to it are preserved too: there is no registry
5929        // entry for them to name.
5930        alias_entries.retain(|entry| match unrebuilt.get(&entry.address) {
5931            None => true,
5932            Some(why) => {
5933                preserved.push(PreservedEntry {
5934                    path: entry.path.clone(),
5935                    class: crate::io::reader::LinkClass::Hard,
5936                    encoded: entry.encoded.clone(),
5937                    reason: Some(why.clone()),
5938                    kind: PreservedKind::Unclassified,
5939                });
5940                false
5941            }
5942        });
5943
5944        // The one thing a rebuilt shared-message table can break: an object
5945        // kept by its bytes keeps the heap IDs its header holds, and the
5946        // finalize gives the heap those IDs name back to the allocator. Every
5947        // object the registry holds is rewritten instead
5948        // ([`rebuilds_shared_messages`](Self::rebuilds_shared_messages)), so
5949        // this asks only the preserved ones, and names the object rather than
5950        // the feature — the file is appendable the moment nothing preserved
5951        // holds a heap ID or hides a subtree that might.
5952        if sohm.is_some() {
5953            for entry in &preserved {
5954                if !matches!(entry.class, crate::io::reader::LinkClass::Hard) {
5955                    continue;
5956                }
5957                let Ok((link, _)) = LinkMessage::decode(&entry.encoded, &meta.ctx) else {
5958                    continue;
5959                };
5960                let LinkTarget::Hard { address } = link.target else {
5961                    continue;
5962                };
5963                if let Some(blocks) = crate::io::object_header_io::blocks_shared_message_rebuild(
5964                    &mut handle,
5965                    &meta,
5966                    address,
5967                )? {
5968                    let why = entry
5969                        .reason
5970                        .as_deref()
5971                        .unwrap_or("this writer cannot model it");
5972                    return Err(crate::io::IoError::Unsupported(format!(
5973                        "cannot open this file for appending: '{}' {blocks}, but {why}, so \
5974                         its header keeps the bytes it has while the append lays the \
5975                         shared-message table out afresh",
5976                        entry.path
5977                    )));
5978                }
5979            }
5980        }
5981
5982        // Rebuild the hard-link registry from the alias entries set aside
5983        // above, so the H5Ldelete semantics survive a reopen. An alias whose
5984        // target the walk could not model is not here at all: it was
5985        // preserved by its own bytes, exactly as the first link to that
5986        // object was.
5987        let mut hard_links: Vec<HardLink> = Vec::new();
5988        for HardEntry {
5989            path,
5990            address: addr,
5991            ..
5992        } in alias_entries
5993        {
5994            let target = if let Some(di) = existing_datasets
5995                .iter()
5996                .position(|d| d.obj_header_addr == addr)
5997            {
5998                HardLinkTarget::Dataset(di)
5999            } else if let Some(gi) = groups
6000                .iter()
6001                .position(|g| g.obj_header_written_addr == Some(addr))
6002            {
6003                HardLinkTarget::Group(gi)
6004            } else {
6005                continue;
6006            };
6007            let (parent, link_name) = match path.rsplit_once('/') {
6008                None => (None, path),
6009                Some((dir, leaf)) => {
6010                    ensure_groups_for(dir, &mut groups, &mut group_index_map, &mut group_headers);
6011                    (
6012                        group_index_map.get(&format!("/{dir}")).copied(),
6013                        leaf.to_string(),
6014                    )
6015                }
6016            };
6017            hard_links.push(HardLink {
6018                parent,
6019                name: link_name,
6020                target,
6021                creation_seq: 0,
6022            });
6023        }
6024
6025        // Attach every link the writer cannot express to the group that
6026        // holds it, so the rewrite of that group's header emits it again.
6027        // `ensure_groups_for` registers the parent chain, which matters for
6028        // a group whose only content is such a link: nothing else would put
6029        // it in the registry, and the close would drop group and link alike.
6030        let mut preserved_links: Vec<PreservedLink> = Vec::new();
6031        for PreservedEntry {
6032            path,
6033            class,
6034            encoded,
6035            reason,
6036            kind,
6037        } in preserved
6038        {
6039            let (parent, link_name) = match path.rsplit_once('/') {
6040                None => (None, path),
6041                Some((dir, leaf)) => {
6042                    ensure_groups_for(dir, &mut groups, &mut group_index_map, &mut group_headers);
6043                    (
6044                        group_index_map.get(&format!("/{dir}")).copied(),
6045                        leaf.to_string(),
6046                    )
6047                }
6048            };
6049            let (link, _) = LinkMessage::decode(&encoded, &meta.ctx)?;
6050            preserved_links.push(PreservedLink {
6051                parent,
6052                name: link_name,
6053                class,
6054                link,
6055                encoded,
6056                reason,
6057                kind,
6058                creation_seq: 0,
6059            });
6060        }
6061
6062        // Stamp the creation sequence of every reopened link. A group that
6063        // tracks creation order (every NetCDF-4 group) recorded it on each
6064        // link, and `group_links` sorts by this sequence before renumbering,
6065        // so the stored order is what keeps an append session from reordering
6066        // the group — netcdf-c lists variables, and numbers them, in creation
6067        // order. Links with no stored order (untracked groups) fall back to
6068        // discovery order: datasets, then groups, then the hard links found
6069        // beside them, then the links kept by their bytes, all after every
6070        // stored order so new links stay last.
6071        let mut creation_seq = link_corder
6072            .values()
6073            .copied()
6074            .filter(|c| *c >= 0)
6075            .max()
6076            .map_or(0, |max| max as u64 + 1);
6077        let mut stamp = |path: &str| -> u64 {
6078            match link_corder.get(path.trim_start_matches('/')) {
6079                Some(&corder) if corder >= 0 => corder as u64,
6080                _ => {
6081                    let seq = creation_seq;
6082                    creation_seq += 1;
6083                    seq
6084                }
6085            }
6086        };
6087        for d in &mut existing_datasets {
6088            d.creation_seq = stamp(&d.name);
6089        }
6090        for g in &mut groups {
6091            g.creation_seq = stamp(&g.name);
6092        }
6093        let link_path = |parent: Option<usize>, name: &str| match parent {
6094            Some(p) => format!("{}/{}", groups[p].name.trim_start_matches('/'), name),
6095            None => name.to_string(),
6096        };
6097        for l in &mut hard_links {
6098            l.creation_seq = stamp(&link_path(l.parent, &l.name));
6099        }
6100        for l in &mut preserved_links {
6101            l.creation_seq = stamp(&link_path(l.parent, &l.name));
6102        }
6103
6104        // The strategy is the file's, not this session's: a paged file
6105        // allocates on its own page grid however it was opened, `persist`
6106        // deciding only whether the managers survive the close.
6107        let allocator = FileAllocator::with_policy(
6108            file_size,
6109            ext.file_space_info
6110                .as_ref()
6111                .map_or(free_space::SpacePolicy::Aggr, |info| {
6112                    free_space::SpacePolicy::for_message(info)
6113                }),
6114        );
6115        // The sections the file's own managers recorded are free space, so
6116        // they are what this session allocates from first — `H5MF_alloc` asks
6117        // the free-space manager before it bumps the end of the file, and a
6118        // reopen that skipped this would grow a file that had room.
6119        allocator.reset_free_list(&reopened_free_space.sections);
6120
6121        // Now that every object has its registry index, key the dense storage
6122        // found on disk by the scope that will supersede it. A group the link
6123        // walk saw but never registered is not rewritten either, so leaving it
6124        // out is what keeps its storage referenced.
6125        let mut superseded = SupersededDense {
6126            attrs: dataset_dense
6127                .into_iter()
6128                .map(|(di, ainfo)| (AttrScope::Dataset(di), ainfo))
6129                .collect(),
6130            links: HashMap::new(),
6131        };
6132        superseded
6133            .attrs
6134            .extend(root_dense.attrs.map(|a| (AttrScope::Root, a)));
6135        superseded
6136            .links
6137            .extend(root_dense.links.map(|l| (LinkScope::Root, l)));
6138        for (name, dense) in group_dense {
6139            let Some(&gidx) = group_index_map.get(&format!("/{name}")) else {
6140                continue;
6141            };
6142            superseded
6143                .attrs
6144                .extend(dense.attrs.map(|a| (AttrScope::Group(gidx), a)));
6145            superseded
6146                .links
6147                .extend(dense.links.map(|l| (LinkScope::Group(gidx), l)));
6148        }
6149        let superseded = (!superseded.attrs.is_empty() || !superseded.links.is_empty())
6150            .then(|| Box::new(superseded));
6151
6152        // The same keying for the symbol-table storage. Built from the headers
6153        // alone, not from the superblock version: a group whose header carried
6154        // no Symbol Table message contributes nothing — what happens to a group
6155        // libhdf5 wrote at a newer bound inside an otherwise classic file — and
6156        // one that carried it keeps its storage even where the superblock is
6157        // version 2, which is what a file with shared messages is.
6158        let mut stabs: HashMap<LinkScope, StabExtents> = HashMap::new();
6159        stabs.extend(root_stab.map(|s| (LinkScope::Root, s)));
6160        for (name, extents) in group_stabs {
6161            if let Some(&gidx) = group_index_map.get(&format!("/{name}")) {
6162                stabs.insert(LinkScope::Group(gidx), extents);
6163            }
6164        }
6165        let symbol_tables = SymbolTables {
6166            found: stabs.keys().copied().collect(),
6167            superseded: Slot::new(stabs),
6168            written: Slot::new(HashMap::new()),
6169        };
6170
6171        // The superblock the close re-emits, and the generation every message
6172        // this session encodes belongs to.
6173        let legacy = legacy.map(|superblock| Box::new(LegacyFile { superblock }));
6174
6175        // Wrap the reconstructed plain vecs into the per-slot registry. The
6176        // reconstruction logic above runs single-threaded on local `Vec`s;
6177        // only the final hand-off needs the `Shared<Slot<_>>` shape.
6178        let datasets = existing_datasets
6179            .into_iter()
6180            .map(|i| Shared::new(DatasetCell::new(i)))
6181            .collect();
6182        let groups = groups
6183            .into_iter()
6184            .map(|g| Shared::new(Slot::new(g)))
6185            .collect();
6186
6187        let writer = Self {
6188            handle,
6189            allocator,
6190            ctx,
6191            datasets: Slot::new(datasets),
6192            groups: Slot::new(groups),
6193            hard_links: Slot::new(hard_links),
6194            // A reopen carries the soft and external links it found as
6195            // `preserved_links`, byte for byte; this list holds only the ones
6196            // created in this session.
6197            symbolic_links: Slot::new(Vec::new()),
6198            committed_datatypes: Slot::new(Vec::new()),
6199            preserved_links: Slot::new(preserved_links),
6200            name_index: Slot::new(Box::new(NameIndex::new())),
6201            root_attributes: Slot::new(root_attributes),
6202            create_lock: Slot::new(()),
6203            // A reopen names no bound, so the session appends at the same
6204            // default a create uses. `set_libver_bound` is where a caller
6205            // names one, exactly as `H5Fopen` takes a fapl.
6206            libver: None,
6207            closed: false,
6208            swmr_active: false,
6209            cwfs: Slot::new(Vec::new()),
6210            root_group_addr: None,
6211            superseded_root_header: root_header_blocks,
6212            // The version the file already has, written back unchanged.
6213            superblock_version: SuperblockVersion::Existing(version),
6214            // The reopened file's own policy, so objects added in this
6215            // session are made the way the file already declares.
6216            root_track_order,
6217            root_times,
6218            dense_attributes: Slot::new(HashMap::new()),
6219            swmr_published_attrs: Slot::new(HashMap::new()),
6220            dense_links: Slot::new(HashMap::new()),
6221            superseded_dense: Slot::new(superseded),
6222            track_order: root_track_order,
6223            // Not recovered from the file the way the creation-order policy
6224            // is: a version-1 header leaves no trace of whether the object was
6225            // tracking times, so there is nothing on disk to read the policy
6226            // back from. An object added to a reopened file gets this writer's
6227            // own default, the same one a created file starts at.
6228            track_times: false,
6229            next_creation_seq: Slot::new(creation_seq),
6230            pending_object_references: Slot::new(Vec::new()),
6231            pending_heap_references: Slot::new(Vec::new()),
6232            attribute_references: Slot::new(Vec::new()),
6233            legacy,
6234            symbol_tables,
6235            // The ranks the superblock or its extension declared, which every
6236            // v1-B-tree and symbol-table node this session writes is sized by.
6237            btree: meta.btree,
6238            extension,
6239            free_space: reopened_free_space.state,
6240            // The indexes the file was created with, and the blocks its
6241            // current table occupies; the next finalize lays a new table out
6242            // over them from the whole message set.
6243            sohm,
6244            source_dir: source_dir_of(path)?,
6245        };
6246        // The link graph is complete only now, so this is the first point the
6247        // count each on-disk header was written with can be read off it: in a
6248        // well-formed file the links the walk found reaching an object *are*
6249        // that count, so nothing has to be decoded out of the headers.
6250        for i in 0..writer.dataset_count() {
6251            let nlink = writer.object_link_count(HardLinkTarget::Dataset(i));
6252            writer.ds(i).lock().nlink_written = nlink;
6253        }
6254        Ok(writer)
6255    }
6256
6257    /// Return the names of all datasets created so far.
6258    pub fn dataset_names(&self) -> Vec<String> {
6259        self.dataset_refs()
6260            .iter()
6261            .filter_map(|d| {
6262                let g = d.lock();
6263                (!g.deleted).then(|| g.name.clone())
6264            })
6265            .collect()
6266    }
6267
6268    /// Find a dataset index by name. Like `H5Dopen`, the name may be any
6269    /// link path to the dataset: a user hard link's path — or a path
6270    /// whose group components pass through such links — resolves to its
6271    /// target.
6272    pub fn dataset_index(&self, name: &str) -> Option<usize> {
6273        let name = self.canonical_dataset_path(name);
6274        self.dataset_refs()
6275            .iter()
6276            .position(|d| {
6277                let g = d.lock();
6278                g.name == name && !g.deleted
6279            })
6280            .or_else(|| {
6281                self.hard_links_vec().iter().find_map(|l| match l.target {
6282                    HardLinkTarget::Dataset(i)
6283                        if self.hard_link_emitted(l) && self.hard_link_full_path(l) == name =>
6284                    {
6285                        Some(i)
6286                    }
6287                    _ => None,
6288                })
6289            })
6290    }
6291
6292    /// Reconstruct the fields a writer-mode `H5Dataset` handle needs for the
6293    /// dataset at `index`, and open it under `access`. Single owner of this
6294    /// mapping so `H5File::dataset_writer`, `H5Group::dataset_writer`, and
6295    /// the vlen-string helpers all agree — including on
6296    /// [`bind_efile_prefix`](Self::bind_efile_prefix), which no handle site
6297    /// can then forget to run.
6298    pub(crate) fn dataset_handle_parts(
6299        &self,
6300        index: usize,
6301        access: &DatasetAccess,
6302    ) -> IoResult<DatasetHandleParts> {
6303        let open = self.bind_efile_prefix(index, access)?;
6304        let ds = self.ds(index);
6305        let g = ds.lock();
6306        Ok(DatasetHandleParts {
6307            shape: g.dataspace.dims.iter().map(|&d| d as usize).collect(),
6308            element_size: g.datatype.element_size() as usize,
6309            chunk_index: g.chunk_index_kind(),
6310            open,
6311        })
6312    }
6313
6314    /// Put `access`'s external file prefix in force for the dataset at
6315    /// `index`, or join the open that already settled one.
6316    ///
6317    /// INVARIANT: every write of an externally stored dataset's raw bytes
6318    /// joins its slot names against the prefix an *open* settled, and this is
6319    /// the only place that settles one. `write_contiguous_bytes` reads it and
6320    /// nothing else writes it, so a write cannot resolve a prefix of its own
6321    /// and land bytes where a read under the same properties would not look
6322    /// for them.
6323    ///
6324    /// First open wins, and a joining open may not disagree: `H5D__open_name`
6325    /// compares its own expanded prefix against the open dataset's and fails
6326    /// when they differ (H5Dint.c:1533-1545). Measured under libhdf5 1.14.6
6327    /// and 2.0.0, with a dataset created through a dapl naming a directory
6328    /// and its handle still alive: a second open naming another directory is
6329    /// refused, one naming the same directory joins, one naming none is
6330    /// refused too, and with `HDF5_EXTFILE_PREFIX` set — which shadows every
6331    /// property, so all three expand alike — none of them is. Dropping every
6332    /// handle releases the answer and the next open settles it afresh, which
6333    /// the same measurement confirms.
6334    ///
6335    /// Returns the token that keeps the open alive, `None` for a dataset
6336    /// whose raw data is in this file and which therefore has no prefix to
6337    /// agree about.
6338    pub(crate) fn bind_efile_prefix(
6339        &self,
6340        index: usize,
6341        access: &DatasetAccess,
6342    ) -> IoResult<Option<crate::io::reader::DatasetOpenToken>> {
6343        let ds = self.ds(index);
6344        let mut g = ds.lock();
6345        let source_dir = &self.source_dir;
6346        let Some(ext) = g.external.as_mut() else {
6347            return Ok(None);
6348        };
6349        let want =
6350            crate::io::reader::resolve_extfile_prefix(access.efile_prefix_value(), source_dir);
6351        if let Some(open) = ext.prefix.open.upgrade() {
6352            if ext.prefix.expanded != want {
6353                let name = g.name.clone();
6354                return Err(crate::io::IoError::InvalidState(format!(
6355                    "dataset {name:?} is already open under a different external file                      prefix, and libhdf5 refuses to join an open that disagrees about one"
6356                )));
6357            }
6358            return Ok(Some(open));
6359        }
6360        let token: crate::io::reader::DatasetOpenToken = std::sync::Arc::new(());
6361        ext.prefix = EfilePrefix {
6362            expanded: want,
6363            open: std::sync::Arc::downgrade(&token),
6364        };
6365        Ok(Some(token))
6366    }
6367
6368    /// Reject a name some other link in the file already occupies.
6369    ///
6370    /// `name` is the registry's full-path form, with no leading `/`. HDF5
6371    /// requires link names to be unique within their group, and every kind of
6372    /// link this writer can emit competes for the same name: a dataset's own
6373    /// link, a group's, a user hard link, a soft or external link, and a link
6374    /// a reopen is carrying through verbatim. This is the one place that list
6375    /// is written down, so a creator cannot be blind to a kind it does not
6376    /// itself make — nor a kind added after it.
6377    fn ensure_name_free(&self, name: &str) -> IoResult<()> {
6378        let holder = self.name_holder(name);
6379        // The index is a filter over the registries, not a second copy of
6380        // them, so a debug build re-derives the answer on every create: a
6381        // name it failed to record surfaces as a failing assertion in the
6382        // suite rather than as two links of one name in somebody's file.
6383        #[cfg(debug_assertions)]
6384        assert_eq!(
6385            holder,
6386            self.scan_name_holder(name),
6387            "the name index disagrees with the registries for '{name}'"
6388        );
6389        match holder {
6390            None => Ok(()),
6391            Some(kind) => Err(crate::io::IoError::InvalidState(format!(
6392                "a {kind} named '{name}' already exists"
6393            ))),
6394        }
6395    }
6396
6397    /// What already holds `name`, or `None` if it is free.
6398    ///
6399    /// The kinds answer in a fixed order — dataset, group, committed
6400    /// datatype, hard link, symbolic link, preserved link — because the
6401    /// refusal names the first one that holds it. [`NameIndex`] narrows each
6402    /// kind to the entries that ever took this name; every candidate is then
6403    /// put through the same predicate the full scan used, so a hit left
6404    /// behind by a delete or a rename answers exactly as an absent one does.
6405    fn name_holder(&self, name: &str) -> Option<&'static str> {
6406        self.build_name_index();
6407        let hits: Vec<NameHit> = {
6408            let index = self.name_index.lock();
6409            index.map.as_ref().and_then(|m| m.get(name))?.clone()
6410        };
6411        for hit in &hits {
6412            if let NameHit::Dataset(i) = *hit {
6413                let ds = self.ds(i);
6414                let d = ds.lock();
6415                if !d.deleted && d.name == name {
6416                    return Some("dataset");
6417                }
6418            }
6419        }
6420        for hit in &hits {
6421            if let NameHit::Group(i) = *hit {
6422                let grp = self.grp(i);
6423                let g = grp.lock();
6424                if !g.deleted && g.name.trim_start_matches('/') == name {
6425                    return Some("group");
6426                }
6427            }
6428        }
6429        for hit in &hits {
6430            if let NameHit::Datatype(i) = *hit {
6431                // The registry lock goes before `parent_alive` takes a group
6432                // slot, never across it.
6433                let (parent, held) = {
6434                    let reg = self.committed_datatypes.lock();
6435                    (reg[i].parent, reg[i].name == name)
6436                };
6437                if held && self.parent_alive(parent) {
6438                    return Some("committed datatype");
6439                }
6440            }
6441        }
6442        if hits.contains(&NameHit::HardLink)
6443            && self
6444                .hard_links_vec()
6445                .iter()
6446                .any(|l| self.hard_link_emitted(l) && self.hard_link_full_path(l) == name)
6447        {
6448            return Some("hard link");
6449        }
6450        if hits.contains(&NameHit::SymbolicLink)
6451            && self
6452                .symbolic_links_vec()
6453                .iter()
6454                .any(|l| self.symbolic_link_emitted(l) && self.symbolic_link_full_path(l) == name)
6455        {
6456            return Some("link");
6457        }
6458        // A preserved link occupies its name in the group just as a modelled
6459        // one does; both are emitted, and two link messages of one name in a
6460        // group is an invalid file.
6461        if hits.contains(&NameHit::PreservedLink)
6462            && self.preserved_link_paths().iter().any(|(p, _)| *p == name)
6463        {
6464            return Some("link");
6465        }
6466        None
6467    }
6468
6469    /// The same answer read straight off the registries, which is what the
6470    /// index is checked against in a debug build.
6471    #[cfg(debug_assertions)]
6472    fn scan_name_holder(&self, name: &str) -> Option<&'static str> {
6473        if self.dataset_refs().iter().any(|d| {
6474            let g = d.lock();
6475            !g.deleted && g.name == name
6476        }) {
6477            return Some("dataset");
6478        }
6479        if self.group_refs().iter().any(|g| {
6480            let gg = g.lock();
6481            !gg.deleted && gg.name.trim_start_matches('/') == name
6482        }) {
6483            return Some("group");
6484        }
6485        if self
6486            .committed_datatypes_vec()
6487            .iter()
6488            .any(|c| self.parent_alive(c.parent) && c.name == name)
6489        {
6490            return Some("committed datatype");
6491        }
6492        if self
6493            .hard_links_vec()
6494            .iter()
6495            .any(|l| self.hard_link_emitted(l) && self.hard_link_full_path(l) == name)
6496        {
6497            return Some("hard link");
6498        }
6499        if self
6500            .symbolic_links_vec()
6501            .iter()
6502            .any(|l| self.symbolic_link_emitted(l) && self.symbolic_link_full_path(l) == name)
6503        {
6504            return Some("link");
6505        }
6506        if self.preserved_link_paths().iter().any(|(p, _)| *p == name) {
6507            return Some("link");
6508        }
6509        None
6510    }
6511
6512    /// Build the name index unless it is already built.
6513    ///
6514    /// The walk takes the registry spines and their slots, so it runs with no
6515    /// index lock held — the writer never holds one lock across another — and
6516    /// the result is kept only if nothing renamed, created or unlinked
6517    /// anything while it ran.
6518    fn build_name_index(&self) {
6519        let epoch = {
6520            let index = self.name_index.lock();
6521            if index.map.is_some() {
6522                return;
6523            }
6524            index.epoch
6525        };
6526        let mut map: HashMap<String, Vec<NameHit>> = HashMap::new();
6527        for (i, ds) in self.dataset_refs().iter().enumerate() {
6528            let d = ds.lock();
6529            if !d.deleted {
6530                map.entry(d.name.clone())
6531                    .or_default()
6532                    .push(NameHit::Dataset(i));
6533            }
6534        }
6535        for (i, grp) in self.group_refs().iter().enumerate() {
6536            let g = grp.lock();
6537            if !g.deleted {
6538                map.entry(g.name.trim_start_matches('/').to_string())
6539                    .or_default()
6540                    .push(NameHit::Group(i));
6541            }
6542        }
6543        for (i, c) in self.committed_datatypes_vec().iter().enumerate() {
6544            map.entry(c.name.clone())
6545                .or_default()
6546                .push(NameHit::Datatype(i));
6547        }
6548        for l in self.hard_links_vec().iter() {
6549            map.entry(self.hard_link_full_path(l))
6550                .or_default()
6551                .push(NameHit::HardLink);
6552        }
6553        for l in self.symbolic_links_vec().iter() {
6554            map.entry(self.symbolic_link_full_path(l))
6555                .or_default()
6556                .push(NameHit::SymbolicLink);
6557        }
6558        for (path, _) in self.preserved_link_paths() {
6559            map.entry(path).or_default().push(NameHit::PreservedLink);
6560        }
6561        let mut index = self.name_index.lock();
6562        if index.map.is_none() && index.epoch == epoch {
6563            index.map = Some(map);
6564        }
6565    }
6566
6567    /// Record that `hit` now holds `name` — the one way a new name enters the
6568    /// index, called from every push that gives a registry entry a name.
6569    fn register_name(&self, name: &str, hit: NameHit) {
6570        self.name_index.lock().insert(name, hit);
6571    }
6572
6573    /// Drop the index because something moved names wholesale (a group
6574    /// rename carries its subtree and every link path under it).
6575    fn forget_name_index(&self) {
6576        self.name_index.lock().forget();
6577    }
6578
6579    /// Delete a dataset name, with libhdf5's `H5Ldelete` semantics: a name
6580    /// is only a link. If `name` is a user hard link's path, just that
6581    /// link is removed and the object is untouched. If it is the tree name
6582    /// and a user hard link still names the object, the object survives
6583    /// under it — the link becomes the primary name and nothing is freed.
6584    /// Only deleting the *last* name soft-deletes the object and frees the
6585    /// file space it owned: its chunk blocks and chunk-index structures
6586    /// (or contiguous data block), the global-heap objects of its
6587    /// variable-length data and attributes, and — on a reopened file — the
6588    /// on-disk object header block. The freed space is reused by later
6589    /// allocations in this session; the file does not shrink.
6590    ///
6591    /// Refused while SWMR streaming is active: a live reader may hold any
6592    /// of those addresses (libhdf5 forbids link deletion during SWMR
6593    /// writes too).
6594    pub fn delete_dataset(&self, name: &str) -> IoResult<()> {
6595        if self.swmr_active {
6596            return Err(swmr_delete_error(name));
6597        }
6598        self.reject_external_traversal(name)?;
6599        // The gate keeps the link list and child lists still while this
6600        // delete reads and rewrites them (create_lock → op → slot order,
6601        // the same as every creator).
6602        let _create = self.create_lock.lock();
6603        // `H5Ldelete` resolves the path through links only *up to* the
6604        // leaf — the leaf is what gets deleted, so a leaf naming a user
6605        // link must stay literal and be unlinked, not its target.
6606        let name = match name.rsplit_once('/') {
6607            None => name.to_string(),
6608            Some((dir, leaf)) => format!(
6609                "{}/{leaf}",
6610                self.canonical_group_path(&format!("/{dir}"))
6611                    .trim_start_matches('/')
6612            ),
6613        };
6614        let refs = self.dataset_refs();
6615        let idx = match refs.iter().position(|d| {
6616            let g = d.lock();
6617            g.name == name && !g.deleted
6618        }) {
6619            Some(i) => i,
6620            None => {
6621                // Not a tree name — the path may name a user hard link,
6622                // and deleting a link path unlinks just that link (the
6623                // creation collision checks keep the two namespaces
6624                // disjoint, so the order of the lookups cannot matter).
6625                let link = self.hard_links_vec().iter().position(|l| {
6626                    self.hard_link_emitted(l)
6627                        && matches!(l.target, HardLinkTarget::Dataset(_))
6628                        && self.hard_link_full_path(l) == name
6629                });
6630                let Some(pos) = link else {
6631                    return Err(crate::io::IoError::NotFound(name));
6632                };
6633                self.hard_links.lock().remove(pos);
6634                return Ok(());
6635            }
6636        };
6637        // A surviving hard link keeps the object: promote the first one to
6638        // the primary name and delete nothing.
6639        let promote = self.hard_links_vec().iter().position(|l| {
6640            self.hard_link_emitted(l) && matches!(l.target, HardLinkTarget::Dataset(i) if i == idx)
6641        });
6642        if let Some(pos) = promote {
6643            self.promote_dataset_to_link(idx, pos);
6644            return Ok(());
6645        }
6646        refs[idx].lock().deleted = true;
6647        // Remove from parent group's child_datasets
6648        for grp in self.group_refs() {
6649            grp.lock().child_datasets.retain(|&di| di != idx);
6650        }
6651        self.purge_dead_links();
6652        let ds = self.ds(idx);
6653        let _op = ds.op.lock();
6654        self.release_dataset_storage(idx)
6655    }
6656
6657    /// Soft-delete a group and all its child datasets and sub-groups,
6658    /// freeing every deleted object's file space the way
6659    /// [`delete_dataset`](Self::delete_dataset) does — with the same
6660    /// `H5Ldelete` semantics: a `name` that is a user hard link's path
6661    /// unlinks just that link, and hard links from *outside* the subtree
6662    /// keep their targets. A dataset or group such a link names survives,
6663    /// re-homed under the link (a group brings its whole subtree with
6664    /// it); a link naming the deleted group itself turns the call into a
6665    /// pure rename and nothing is freed. Refused while SWMR streaming is
6666    /// active, same rule as `delete_dataset`.
6667    pub fn delete_group(&self, name: &str) -> IoResult<()> {
6668        if self.swmr_active {
6669            return Err(swmr_delete_error(name));
6670        }
6671        self.reject_external_traversal(name)?;
6672        // Same gate as `delete_dataset`: the pre-scan below and the
6673        // promotions must see a still link list and child lists.
6674        let _create = self.create_lock.lock();
6675        let name = if name.starts_with('/') {
6676            name.to_string()
6677        } else {
6678            format!("/{}", name)
6679        };
6680        // Leaf stays literal, directory resolves through links — the
6681        // same `H5Ldelete` rule as `delete_dataset`.
6682        let name = match name.rsplit_once('/') {
6683            Some((dir, leaf)) if !dir.is_empty() => {
6684                format!("{}/{leaf}", self.canonical_group_path(dir))
6685            }
6686            _ => name,
6687        };
6688        let groups = self.group_refs();
6689        let gidx = match groups.iter().position(|g| {
6690            let gg = g.lock();
6691            gg.name == name && !gg.deleted
6692        }) {
6693            Some(i) => i,
6694            None => {
6695                // Same `H5Ldelete` rule as `delete_dataset`: a path naming
6696                // a user hard link to a group unlinks just that link.
6697                let trimmed = name.trim_start_matches('/');
6698                let link = self.hard_links_vec().iter().position(|l| {
6699                    self.hard_link_emitted(l)
6700                        && matches!(l.target, HardLinkTarget::Group(_))
6701                        && self.hard_link_full_path(l) == trimmed
6702                });
6703                let Some(pos) = link else {
6704                    return Err(crate::io::IoError::NotFound(name.clone()));
6705                };
6706                self.hard_links.lock().remove(pos);
6707                return Ok(());
6708            }
6709        };
6710
6711        // A link is "outside" when its parent group does not die with the
6712        // subtree; only outside links can keep their targets alive.
6713        fn outside(parent: Option<usize>, doomed_gs: &[usize]) -> bool {
6714            match parent {
6715                None => true,
6716                Some(pi) => !doomed_gs.contains(&pi),
6717            }
6718        }
6719        // A group an outside link names survives, re-homed with its whole
6720        // subtree under the link. Each promotion moves that subtree out of
6721        // the doomed set — and can turn a link inside it into an outside
6722        // one — so rescan from scratch until no promotable group is left.
6723        // Promoting `gidx` itself makes the delete a pure rename: return.
6724        let mut doomed_ds = Vec::new();
6725        let mut doomed_gs = Vec::new();
6726        loop {
6727            doomed_ds.clear();
6728            doomed_gs.clear();
6729            self.collect_live_subtree(gidx, &mut doomed_ds, &mut doomed_gs);
6730            let promote = self
6731                .hard_links_vec()
6732                .iter()
6733                .enumerate()
6734                .find_map(|(pos, l)| match l.target {
6735                    HardLinkTarget::Group(gi)
6736                        if self.hard_link_emitted(l)
6737                            && outside(l.parent, &doomed_gs)
6738                            && doomed_gs.contains(&gi) =>
6739                    {
6740                        Some((pos, gi))
6741                    }
6742                    _ => None,
6743                });
6744            let Some((pos, gi)) = promote else { break };
6745            self.promote_group_to_link(gi, pos);
6746            if gi == gidx {
6747                return Ok(());
6748            }
6749        }
6750        // A dataset an outside link names survives its container: re-home
6751        // it under the link now, so the marking pass below never sees it.
6752        for di in doomed_ds {
6753            let promote = self.hard_links_vec().iter().position(|l| {
6754                self.hard_link_emitted(l)
6755                    && outside(l.parent, &doomed_gs)
6756                    && matches!(l.target, HardLinkTarget::Dataset(i) if i == di)
6757            });
6758            if let Some(pos) = promote {
6759                self.promote_dataset_to_link(di, pos);
6760            }
6761        }
6762
6763        let mut ds_deleted = Vec::new();
6764        let mut gs_deleted = Vec::new();
6765        self.delete_group_recursive(gidx, &mut ds_deleted, &mut gs_deleted);
6766        // Remove from parent's child_groups
6767        let parent = groups[gidx].lock().parent;
6768        if let Some(pidx) = parent {
6769            groups[pidx].lock().child_groups.retain(|&gi| gi != gidx);
6770        }
6771        self.purge_dead_links();
6772        // Free storage only after the whole subtree is marked: the lists
6773        // hold each object exactly once (the marking pass skips anything
6774        // already deleted), so nothing is freed twice.
6775        for di in ds_deleted {
6776            let ds = self.ds(di);
6777            let _op = ds.op.lock();
6778            self.release_dataset_storage(di)?;
6779        }
6780        for gi in gs_deleted {
6781            self.release_group_storage(gi)?;
6782        }
6783        Ok(())
6784    }
6785
6786    /// Collect the live (not soft-deleted) members of `gidx`'s subtree,
6787    /// each exactly once, without changing anything — the read-only twin
6788    /// of [`delete_group_recursive`](Self::delete_group_recursive), for
6789    /// the pre-scan that must run before any marking.
6790    fn collect_live_subtree(&self, gidx: usize, ds_out: &mut Vec<usize>, gs_out: &mut Vec<usize>) {
6791        if gs_out.contains(&gidx) {
6792            return;
6793        }
6794        let (child_ds, child_gs) = {
6795            let grp = self.grp(gidx);
6796            let g = grp.lock();
6797            if g.deleted {
6798                return;
6799            }
6800            (g.child_datasets.clone(), g.child_groups.clone())
6801        };
6802        gs_out.push(gidx);
6803        for di in child_ds {
6804            if !self.ds(di).lock().deleted && !ds_out.contains(&di) {
6805                ds_out.push(di);
6806            }
6807        }
6808        for gi in child_gs {
6809            self.collect_live_subtree(gi, ds_out, gs_out);
6810        }
6811    }
6812
6813    /// Re-home dataset `idx` under the hard link at `pos` in the link
6814    /// list — the surviving half of `H5Ldelete`: the link leaves the user
6815    /// list and becomes the dataset's primary (tree) name, in the link's
6816    /// parent group. Storage is untouched; any further links to the
6817    /// dataset stay in the list and keep resolving.
6818    fn promote_dataset_to_link(&self, idx: usize, pos: usize) {
6819        let link = self.hard_links.lock().remove(pos);
6820        let new_name = self.hard_link_full_path(&link);
6821        for grp in self.group_refs() {
6822            grp.lock().child_datasets.retain(|&di| di != idx);
6823        }
6824        if let Some(pi) = link.parent {
6825            self.grp(pi).lock().child_datasets.push(idx);
6826        }
6827        self.ds(idx).lock().name = new_name.clone();
6828        self.register_name(&new_name, NameHit::Dataset(idx));
6829    }
6830
6831    /// The group counterpart of
6832    /// [`promote_dataset_to_link`](Self::promote_dataset_to_link): re-home
6833    /// group `gidx` under the hard link at `pos`, bringing its whole
6834    /// subtree with it. Names are stored as full paths, so every live
6835    /// descendant is renamed by prefix.
6836    fn promote_group_to_link(&self, gidx: usize, pos: usize) {
6837        let link = self.hard_links.lock().remove(pos);
6838        let new_name = format!("/{}", self.hard_link_full_path(&link));
6839        let old_name = self.grp(gidx).lock().name.clone();
6840        for grp in self.group_refs() {
6841            grp.lock().child_groups.retain(|&g| g != gidx);
6842        }
6843        {
6844            let grp = self.grp(gidx);
6845            let mut g = grp.lock();
6846            g.parent = link.parent;
6847            g.name = new_name.clone();
6848        }
6849        if let Some(pi) = link.parent {
6850            self.grp(pi).lock().child_groups.push(gidx);
6851        }
6852
6853        let mut ds_in = Vec::new();
6854        let mut gs_in = Vec::new();
6855        self.collect_live_subtree(gidx, &mut ds_in, &mut gs_in);
6856        // Group names carry a leading '/' ("/a/b"), dataset names none
6857        // ("a/b/ds") — two prefix forms of the same rename.
6858        let old_grp_prefix = format!("{old_name}/");
6859        let new_grp_prefix = format!("{new_name}/");
6860        let old_ds_prefix = old_grp_prefix.trim_start_matches('/').to_string();
6861        let new_ds_prefix = new_grp_prefix.trim_start_matches('/').to_string();
6862        for gi in gs_in {
6863            if gi == gidx {
6864                continue;
6865            }
6866            let grp = self.grp(gi);
6867            let mut g = grp.lock();
6868            let renamed = g
6869                .name
6870                .strip_prefix(&old_grp_prefix)
6871                .map(|rest| format!("{new_grp_prefix}{rest}"));
6872            if let Some(n) = renamed {
6873                g.name = n;
6874            }
6875        }
6876        for di in ds_in {
6877            let ds = self.ds(di);
6878            let mut d = ds.lock();
6879            let renamed = d
6880                .name
6881                .strip_prefix(&old_ds_prefix)
6882                .map(|rest| format!("{new_ds_prefix}{rest}"));
6883            if let Some(n) = renamed {
6884                d.name = n;
6885            }
6886        }
6887        // A group carries its subtree and every link path under it, so far
6888        // more names moved than this function can enumerate: start over.
6889        self.forget_name_index();
6890    }
6891
6892    /// Drop link entries that can no longer be emitted — their parent group
6893    /// or, for a hard link, their target object was just deleted — so the
6894    /// lists mirror what the file will hold instead of carrying suppressed
6895    /// zombies. Both kinds are purged here so a delete cannot clear one list
6896    /// and leave the other holding a name in a group that is gone.
6897    fn purge_dead_links(&self) {
6898        let dead: Vec<usize> = self
6899            .hard_links_vec()
6900            .iter()
6901            .enumerate()
6902            .filter(|(_, l)| !self.hard_link_emitted(l))
6903            .map(|(p, _)| p)
6904            .collect();
6905        let mut links = self.hard_links.lock();
6906        for p in dead.into_iter().rev() {
6907            links.remove(p);
6908        }
6909        drop(links);
6910
6911        let dead: Vec<usize> = self
6912            .symbolic_links_vec()
6913            .iter()
6914            .enumerate()
6915            .filter(|(_, l)| !self.symbolic_link_emitted(l))
6916            .map(|(p, _)| p)
6917            .collect();
6918        let mut links = self.symbolic_links.lock();
6919        for p in dead.into_iter().rev() {
6920            links.remove(p);
6921        }
6922    }
6923
6924    /// Mark `gidx` and its subtree deleted, appending each newly-deleted
6925    /// object's index to `ds_out` / `gs_out` exactly once — the caller
6926    /// frees their storage, and an object reachable twice (or a subtree
6927    /// already deleted) must not be freed twice.
6928    fn delete_group_recursive(
6929        &self,
6930        gidx: usize,
6931        ds_out: &mut Vec<usize>,
6932        gs_out: &mut Vec<usize>,
6933    ) {
6934        // Mark deleted and snapshot the child lists, releasing the group lock
6935        // before locking any dataset/child-group slot (spine → slot order).
6936        let (child_ds, child_gs) = {
6937            let grp = self.grp(gidx);
6938            let mut g = grp.lock();
6939            if g.deleted {
6940                return;
6941            }
6942            g.deleted = true;
6943            (g.child_datasets.clone(), g.child_groups.clone())
6944        };
6945        gs_out.push(gidx);
6946        for di in child_ds {
6947            let ds = self.ds(di);
6948            let mut d = ds.lock();
6949            if !d.deleted {
6950                d.deleted = true;
6951                ds_out.push(di);
6952            }
6953        }
6954        for gi in child_gs {
6955            self.delete_group_recursive(gi, ds_out, gs_out);
6956        }
6957    }
6958
6959    /// Free everything a soft-deleted dataset owned. The single owner of
6960    /// delete-time reclamation, called only from the two delete paths with
6961    /// the dataset already marked deleted and its op lock held.
6962    ///
6963    /// A deleted dataset contributes nothing to finalize (the header,
6964    /// index-flush and append-flush loops all skip it), so nothing in the
6965    /// finalized file can reference the blocks freed here. Never runs under
6966    /// SWMR — the delete entry points refuse first.
6967    fn release_dataset_storage(&self, index: usize) -> IoResult<()> {
6968        use crate::format::messages::datatype::DatatypeMessage;
6969        let (indexed, ndims, contiguous, is_vlen, attrs, header_blocks, mapping_list) = {
6970            let ds = self.ds(index);
6971            let mut m = ds.lock();
6972            // Buffered rows were never written to a chunk; they die with
6973            // the dataset instead of being flushed at close.
6974            m.append = None;
6975            let indexed = m.is_chunked();
6976            let contiguous = (!indexed && m.data_addr != UNDEF_ADDR && m.data_size > 0)
6977                .then_some((m.data_addr, m.data_size));
6978            m.data_addr = UNDEF_ADDR;
6979            m.data_size = 0;
6980            // The external files themselves are the application's, not this
6981            // file's, and neither is the name heap freed: `H5O_MSG_EFL`
6982            // installs no file-delete method, so libhdf5 leaves the heap block
6983            // behind too. Dropping the list is what stops a deleted dataset
6984            // still claiming storage.
6985            m.external = None;
6986            // The mapping list is this file's own metadata, so unlike the
6987            // external files above it *is* freed — `H5D__virtual_delete`
6988            // removes the heap object. The source datasets it named are
6989            // another file's and are left alone.
6990            let mapping_list = m
6991                .virtual_storage
6992                .take()
6993                .and_then(|v| u16::try_from(v.heap_index).ok().map(|i| (v.heap_addr, i)));
6994            let is_vlen = matches!(
6995                m.datatype,
6996                DatatypeMessage::VarLenString { .. } | DatatypeMessage::VarLenSequence { .. }
6997            );
6998            let attrs = std::mem::take(&mut m.attributes);
6999            m.obj_header_written_addr = None;
7000            let header_blocks = std::mem::take(&mut m.obj_header_blocks);
7001            (
7002                indexed,
7003                m.dataspace.dims.len(),
7004                contiguous,
7005                is_vlen,
7006                attrs,
7007                header_blocks,
7008                mapping_list,
7009            )
7010        };
7011        if let Some((addr, idx)) = mapping_list {
7012            self.remove_heap_objects([(addr, vec![idx])].into_iter().collect())?;
7013        }
7014        if indexed {
7015            // Prune to a zero extent: every stored chunk is entirely beyond
7016            // it, so the walk frees each chunk block and collects the vlen
7017            // references its bytes held (released inside).
7018            self.prune_chunks_beyond(index, &vec![0; ndims])?;
7019            self.free_chunk_index(index)?;
7020        } else if let Some((addr, size)) = contiguous {
7021            if is_vlen {
7022                let data = self.handle.read_at(addr, size as usize)?;
7023                self.release_vlen_references(&data)?;
7024            }
7025            self.allocator.free(addr, size, FreeSpaceClass::RawData);
7026        }
7027        for attr in &attrs {
7028            self.release_attr_vlen(attr)?;
7029        }
7030        self.release_superseded_dense_attrs(AttrScope::Dataset(index))?;
7031        for (addr, size) in header_blocks {
7032            self.allocator.free(addr, size, FreeSpaceClass::Metadata);
7033        }
7034        Ok(())
7035    }
7036
7037    /// Free a deleted group's file space: its attributes' global-heap
7038    /// objects and, on a reopened file, the on-disk header block. The
7039    /// group counterpart of
7040    /// [`release_dataset_storage`](Self::release_dataset_storage).
7041    fn release_group_storage(&self, gidx: usize) -> IoResult<()> {
7042        let (attrs, header_blocks) = {
7043            let grp = self.grp(gidx);
7044            let mut g = grp.lock();
7045            let attrs = std::mem::take(&mut g.attributes);
7046            g.obj_header_written_addr = None;
7047            (attrs, std::mem::take(&mut g.obj_header_blocks))
7048        };
7049        for attr in &attrs {
7050            self.release_attr_vlen(attr)?;
7051        }
7052        self.release_superseded_dense_attrs(AttrScope::Group(gidx))?;
7053        self.release_superseded_dense_links(LinkScope::Group(gidx))?;
7054        for (addr, size) in header_blocks {
7055            self.allocator.free(addr, size, FreeSpaceClass::Metadata);
7056        }
7057        Ok(())
7058    }
7059
7060    /// Free the dense attribute storage a reopened header names, once, when
7061    /// this session stops naming it — because the header is being rewritten
7062    /// around fresh storage, or because the object was deleted.
7063    ///
7064    /// The single owner of that transition: nothing else removes an attribute
7065    /// entry from [`superseded_dense`](Self::superseded_dense), and this
7066    /// removes it as it frees, so no heap is freed twice or left half freed.
7067    /// An object whose storage was compact, or whose header this session
7068    /// keeps, has no entry and nothing happens.
7069    ///
7070    /// Never under SWMR: a live reader may still be walking the storage the
7071    /// published headers name, the same rule the superseded-header and
7072    /// relocated-chunk paths follow. The entry stays in place, unfreed.
7073    fn release_superseded_dense_attrs(&self, scope: AttrScope) -> IoResult<()> {
7074        if self.swmr_active {
7075            return Ok(());
7076        }
7077        let taken = self
7078            .superseded_dense
7079            .lock()
7080            .as_mut()
7081            .and_then(|s| s.attrs.remove(&scope));
7082        let Some(ainfo) = taken else {
7083            return Ok(());
7084        };
7085        self.release_dense_storage(
7086            ainfo.fractal_heap_address,
7087            ainfo.name_btree_address,
7088            ainfo.creation_order_btree_address,
7089        )
7090    }
7091
7092    /// The link counterpart of
7093    /// [`release_superseded_dense_attrs`](Self::release_superseded_dense_attrs),
7094    /// under the same invariant and the same SWMR rule. Split from it because
7095    /// the two are superseded at different points of a finalize: attribute
7096    /// storage before the object headers are laid out, link storage after
7097    /// every one of them has an address.
7098    fn release_superseded_dense_links(&self, scope: LinkScope) -> IoResult<()> {
7099        if self.swmr_active {
7100            return Ok(());
7101        }
7102        let taken = self
7103            .superseded_dense
7104            .lock()
7105            .as_mut()
7106            .and_then(|s| s.links.remove(&scope));
7107        let Some(linfo) = taken else {
7108            return Ok(());
7109        };
7110        self.release_dense_storage(
7111            linfo.fractal_heap_address,
7112            linfo.name_btree_address,
7113            linfo.creation_order_btree_address,
7114        )
7115    }
7116
7117    /// Return one dense storage's file space to the allocator: the fractal
7118    /// heap in full, its name index, and the creation-order index when the
7119    /// object had one.
7120    ///
7121    /// The extents come from walking the structures themselves rather than
7122    /// from re-deriving what a writer would have allocated, so storage
7123    /// libhdf5 laid out is freed as accurately as storage this crate wrote.
7124    /// Every walk here already ran once this session — the reopen read every
7125    /// attribute out of this heap through the same index — so a failure means
7126    /// the file changed underneath us, and surfacing it beats freeing a
7127    /// partial extent list.
7128    fn release_dense_storage(
7129        &self,
7130        heap_addr: u64,
7131        name_bt2_addr: u64,
7132        corder_bt2_addr: Option<u64>,
7133    ) -> IoResult<()> {
7134        use crate::format::chunk_index::btree_v2::collect_btree_v2_extents;
7135        use crate::format::fractal_heap::collect_heap_extents;
7136
7137        let mut reader = crate::io::reader::HandleBlockReader {
7138            handle: &self.handle,
7139        };
7140        let mut extents = Vec::new();
7141        if heap_addr != UNDEF_ADDR {
7142            extents.extend(collect_heap_extents(heap_addr, &self.ctx, &mut reader)?);
7143        }
7144        for addr in [Some(name_bt2_addr), corder_bt2_addr]
7145            .into_iter()
7146            .flatten()
7147            .filter(|&a| a != UNDEF_ADDR)
7148        {
7149            extents.extend(collect_btree_v2_extents(addr, &self.ctx, &mut reader)?);
7150        }
7151        for (addr, len) in extents {
7152            self.allocator.free(addr, len, FreeSpaceClass::Metadata);
7153        }
7154        Ok(())
7155    }
7156
7157    /// Free a deleted dataset's chunk-index structures, after the chunks
7158    /// themselves were freed by a zero-extent prune. Takes the index info
7159    /// out of the slot, so the dataset no longer claims chunked storage.
7160    ///
7161    /// Every block's size is recovered the way its allocation computed it:
7162    /// re-encoding the in-memory copy (EA header and index block, FA
7163    /// header and data block, BT2 header) or sizing a same-shape dummy
7164    /// from the array geometry (EA data blocks, whose element counts come
7165    /// from [`EaGeometry`]; BT2 nodes are all `node_size`).
7166    fn free_chunk_index(&self, index: usize) -> IoResult<()> {
7167        let ds = self.ds(index);
7168        let mut m = ds.lock();
7169        let is_filtered = m.filter_pipeline.is_some();
7170        if let Some(c) = m.chunked.take() {
7171            let p = &c.earray_params;
7172            let bits = p.max_nelmts_bits;
7173            let csl = c.chunk_size_len;
7174            let geo = EaGeometry::new(
7175                p.idx_blk_elmts,
7176                p.data_blk_min_elmts,
7177                p.sup_blk_min_data_ptrs,
7178                bits,
7179                p.max_dblk_page_nelmts_bits,
7180            )?;
7181            let dblk_size = |nelmts: u64| -> u64 {
7182                if is_filtered {
7183                    FilteredDataBlock::new(c.ea_header_addr, 0, nelmts as usize)
7184                        .encode(&self.ctx, bits, csl)
7185                        .len() as u64
7186                } else {
7187                    ExtensibleArrayDataBlock::new(c.ea_header_addr, 0, nelmts as usize)
7188                        .encoded_size(&self.ctx, bits) as u64
7189                }
7190            };
7191            let (dblk_addrs, sblk_addrs, iblk_size) = if is_filtered {
7192                let f = c.filt_iblk.as_ref().unwrap();
7193                (
7194                    f.dblk_addrs.clone(),
7195                    f.sblk_addrs.clone(),
7196                    f.encode(&self.ctx, csl).len() as u64,
7197                )
7198            } else {
7199                (
7200                    c.ea_iblk.dblk_addrs.clone(),
7201                    c.ea_iblk.sblk_addrs.clone(),
7202                    c.ea_iblk.encoded_size(&self.ctx) as u64,
7203                )
7204            };
7205            // Data blocks addressed from the index block belong to the
7206            // first `iblock_nsblks` super blocks; each of those defines the
7207            // element count (and so the disk size) of its data blocks.
7208            let mut g = 0usize;
7209            'direct: for s in geo.sblk.iter().take(geo.iblock_nsblks) {
7210                for _ in 0..s.ndblks {
7211                    let Some(&a) = dblk_addrs.get(g) else {
7212                        break 'direct;
7213                    };
7214                    g += 1;
7215                    if a == UNDEF_ADDR {
7216                        continue;
7217                    }
7218                    if s.dblk_nelmts > geo.dblk_page_nelmts {
7219                        return Err(crate::io::IoError::InvalidState(
7220                            "cannot free a paged extensible-array data block, \
7221                             which is not yet supported"
7222                                .into(),
7223                        ));
7224                    }
7225                    self.allocator
7226                        .free(a, dblk_size(s.dblk_nelmts), FreeSpaceClass::Metadata);
7227                }
7228            }
7229            for (off, &sa) in sblk_addrs.iter().enumerate() {
7230                if sa == UNDEF_ADDR {
7231                    continue;
7232                }
7233                let s = geo.sblk[geo.iblock_nsblks + off];
7234                if s.dblk_nelmts > geo.dblk_page_nelmts {
7235                    return Err(crate::io::IoError::InvalidState(
7236                        "cannot free a paged extensible-array data block, \
7237                         which is not yet supported"
7238                            .into(),
7239                    ));
7240                }
7241                let buf = self.handle.read_at_most(sa, 65536)?;
7242                let sb =
7243                    ExtensibleArraySuperBlock::decode(&buf, &self.ctx, bits, s.ndblks as usize, 0)?;
7244                for &da in &sb.dblk_addrs {
7245                    if da != UNDEF_ADDR {
7246                        self.allocator
7247                            .free(da, dblk_size(s.dblk_nelmts), FreeSpaceClass::Metadata);
7248                    }
7249                }
7250                self.allocator.free(
7251                    sa,
7252                    sb.encode(&self.ctx, bits).len() as u64,
7253                    FreeSpaceClass::Metadata,
7254                );
7255            }
7256            self.allocator
7257                .free(c.ea_iblk_addr, iblk_size, FreeSpaceClass::Metadata);
7258            self.allocator.free(
7259                c.ea_header_addr,
7260                c.ea_header.encoded_size(&self.ctx) as u64,
7261                FreeSpaceClass::Metadata,
7262            );
7263            return Ok(());
7264        }
7265        if let Some(fa) = m.fixed_array.take() {
7266            self.allocator.free(
7267                fa.fa_dblk_addr,
7268                fixed_array_dblk_disk_size(&self.ctx, &fa.fa_header),
7269                FreeSpaceClass::Metadata,
7270            );
7271            self.allocator.free(
7272                fa.fa_header_addr,
7273                fa.fa_header.encode(&self.ctx).len() as u64,
7274                FreeSpaceClass::Metadata,
7275            );
7276            return Ok(());
7277        }
7278        // The implicit index has no structure to free, only the one run of
7279        // chunk space it was given at create — which is the whole of its
7280        // storage, so nothing else can be leaked or double-freed here.
7281        if let Some(imp) = m.implicit.take() {
7282            self.allocator
7283                .free(imp.data_addr, imp.data_size, FreeSpaceClass::RawData);
7284            return Ok(());
7285        }
7286        // The single-chunk index has no structure of its own either: its one
7287        // chunk is the whole of its storage, addressed directly from the
7288        // layout message rather than any index this function's doc comment's
7289        // "chunks already freed by a zero-extent prune" applies to — so
7290        // freeing it here, if it was ever allocated, is the only place it
7291        // happens.
7292        if let Some(sc) = m.single_chunk.take() {
7293            if sc.data_addr != UNDEF_ADDR {
7294                let len = if is_filtered { sc.nbytes } else { sc.data_size };
7295                self.allocator
7296                    .free(sc.data_addr, len, FreeSpaceClass::RawData);
7297            }
7298            return Ok(());
7299        }
7300        // The version-1 B-tree owns nothing but its node blocks: the header
7301        // every other index has is, here, the root pointer inside the layout
7302        // message.
7303        if let Some(bt1) = m.btree_v1.take() {
7304            let element_size = m.datatype.element_size() as u64;
7305            let node_size = bt1
7306                .build_tree(element_size, self.ctx.sizeof_addr as usize)
7307                .node_size();
7308            for &a in &bt1.node_addrs {
7309                self.allocator
7310                    .free(a, node_size as u64, FreeSpaceClass::Metadata);
7311            }
7312            return Ok(());
7313        }
7314        if let Some(bt2) = m.btree_v2.take() {
7315            let tree = bt2.index.build_tree(&self.ctx);
7316            for &a in &bt2.node_addrs {
7317                self.allocator
7318                    .free(a, tree.node_size as u64, FreeSpaceClass::Metadata);
7319            }
7320            self.allocator.free(
7321                bt2.bt2_header_addr,
7322                tree.header(UNDEF_ADDR).encode(&self.ctx).len() as u64,
7323                FreeSpaceClass::Metadata,
7324            );
7325        }
7326        Ok(())
7327    }
7328
7329    /// Return the chunk dimensions for a dataset, if chunked.
7330    ///
7331    /// Returns an owned `Vec` because the chunk geometry now lives behind the
7332    /// per-dataset [`Slot`]; it cannot be borrowed past the guard.
7333    pub fn dataset_chunk_dims(&self, index: usize) -> Option<Vec<u64>> {
7334        let ds = self.ds(index);
7335        let m = ds.lock();
7336        m.chunk_index_kind().map(|kind| match kind {
7337            ChunkIndexKind::ExtensibleArray => m.chunked.as_ref().unwrap().chunk_dims.clone(),
7338            ChunkIndexKind::FixedArray => m.fixed_array.as_ref().unwrap().chunk_dims.clone(),
7339            ChunkIndexKind::BtreeV2 => m.btree_v2.as_ref().unwrap().chunk_dims.clone(),
7340            ChunkIndexKind::Implicit => m.implicit.as_ref().unwrap().chunk_dims.clone(),
7341            ChunkIndexKind::SingleChunk => m.single_chunk.as_ref().unwrap().chunk_dims.clone(),
7342            ChunkIndexKind::BtreeV1 => m.btree_v1.as_ref().unwrap().chunk_dims.clone(),
7343        })
7344    }
7345
7346    /// Return the current dimensions of a dataset.
7347    ///
7348    /// Returns an owned `Vec` because the dataspace now lives behind the
7349    /// per-dataset [`Slot`]; it cannot be borrowed past the guard.
7350    pub fn dataset_dims(&self, index: usize) -> Vec<u64> {
7351        self.ds(index).lock().dataspace.dims.clone()
7352    }
7353
7354    /// Return the maximum extent a dataset declares, per dimension.
7355    ///
7356    /// An absent maximum shape means the shape is fixed at its current extent
7357    /// (libhdf5 defaults maxdims to dims at creation), so the current
7358    /// dimensions are returned; `H5S_UNLIMITED` is `u64::MAX`.
7359    pub fn dataset_max_dims(&self, index: usize) -> Vec<u64> {
7360        let ds = self.ds(index);
7361        let m = ds.lock();
7362        m.dataspace
7363            .max_dims
7364            .clone()
7365            .unwrap_or_else(|| m.dataspace.dims.clone())
7366    }
7367
7368    /// Whether a dataset stores its raw data through a filter pipeline.
7369    ///
7370    /// The write paths ask before choosing how to hand a chunk over: an
7371    /// unfiltered chunk's bytes go to the file exactly as the caller holds
7372    /// them, while a filtered one has to be compressed first.
7373    pub(crate) fn dataset_is_filtered(&self, index: usize) -> bool {
7374        self.ds(index).lock().filter_pipeline.is_some()
7375    }
7376
7377    /// Return the datatype a dataset declares on disk.
7378    ///
7379    /// The typed write paths need it to store bytes in the declared byte
7380    /// order; a reopened dataset handle has no copy of its own, and a cached
7381    /// one could disagree with what the header will say.
7382    pub fn dataset_datatype(&self, index: usize) -> DatatypeMessage {
7383        self.ds(index).lock().datatype.clone()
7384    }
7385
7386    /// Create a group in the file hierarchy.
7387    ///
7388    /// `parent_path` is the full path of the parent group (e.g., "/" for root).
7389    /// `name` is the name of the new group (e.g., "detector").
7390    ///
7391    /// Returns the group index in the writer's group list.
7392    pub fn create_group(&self, parent_path: &str, name: &str) -> IoResult<usize> {
7393        // Hold the create gate across the uniqueness check and the registry
7394        // push so the two are atomic (see `create_lock`).
7395        let _create = self.create_lock.lock();
7396        // A parent path through hard links creates in the link's target,
7397        // as HDF5 traversal does.
7398        let parent_path = self.canonical_group_path(parent_path);
7399        let parent_path = parent_path.as_str();
7400        let full_name = if parent_path == "/" {
7401            format!("/{}", name)
7402        } else {
7403            format!("{}/{}", parent_path, name)
7404        };
7405        // Same rule as dataset creation: a path through a carried external
7406        // link names a group in the other file, which this writer cannot make.
7407        self.reject_external_traversal(&full_name)?;
7408        // `name` may itself carry path components; resolving the whole thing
7409        // is what keeps a '/' out of the link this group will be reached by.
7410        let (parent_idx, _leaf) = self.split_parent(full_name.trim_start_matches('/'))?;
7411
7412        self.ensure_name_free(full_name.trim_start_matches('/'))?;
7413
7414        let group_idx = self.push_group(GroupInfo {
7415            name: full_name,
7416            parent: parent_idx,
7417            creation_seq: self.take_creation_seq(),
7418            track_order: self.track_order,
7419            times: self.created_object_times(),
7420            child_datasets: Vec::new(),
7421            child_groups: Vec::new(),
7422            obj_header_addr: 0,
7423            obj_header_written_addr: None,
7424            obj_header_blocks: Vec::new(),
7425            deleted: false,
7426            attributes: Vec::new(),
7427        });
7428
7429        // Register this group as a child of its parent
7430        if let Some(pidx) = parent_idx {
7431            self.grp(pidx).lock().child_groups.push(group_idx);
7432        }
7433
7434        Ok(group_idx)
7435    }
7436
7437    /// Register a dataset as belonging to a group.
7438    ///
7439    /// `group_path` is the full path of the group (e.g., "/detector").
7440    /// `ds_index` is the dataset index returned by `create_dataset`.
7441    pub fn assign_dataset_to_group(&self, group_path: &str, ds_index: usize) -> IoResult<()> {
7442        let group_path = self.canonical_group_path(group_path);
7443        let group_path = group_path.as_str();
7444        let groups = self.group_refs();
7445        let group_idx = groups
7446            .iter()
7447            .position(|g| {
7448                let gg = g.lock();
7449                gg.name == group_path && !gg.deleted
7450            })
7451            .ok_or_else(|| {
7452                crate::io::IoError::NotFound(format!("group '{}' not found", group_path))
7453            })?;
7454        // A move, not an addition: the create gate has already placed every
7455        // dataset from the path components of its name, so appending here
7456        // would leave one dataset linked from two groups at once.
7457        for g in &groups {
7458            g.lock().child_datasets.retain(|&d| d != ds_index);
7459        }
7460        groups[group_idx].lock().child_datasets.push(ds_index);
7461        Ok(())
7462    }
7463
7464    /// Create a hard link: an additional name for an object that already
7465    /// exists in the file.
7466    ///
7467    /// No data is copied — the link and its target share one object header,
7468    /// exactly as `h5py` / libhdf5 hard links do.
7469    ///
7470    /// * `parent_group_path` — full path of the group that will hold the
7471    ///   link (`"/"` for the root group).
7472    /// * `link_name` — leaf name of the new link within that group.
7473    /// * `target_path` — full path of an existing dataset or group, with or
7474    ///   without a leading `/`.
7475    pub fn create_hard_link(
7476        &self,
7477        parent_group_path: &str,
7478        link_name: &str,
7479        target_path: &str,
7480    ) -> IoResult<()> {
7481        if link_name.is_empty() || link_name.contains('/') {
7482            return Err(crate::io::IoError::InvalidState(format!(
7483                "hard link name '{link_name}' must be a non-empty leaf name"
7484            )));
7485        }
7486
7487        // Neither end may sit across a carried external link: the target
7488        // would be an object in the other file, and the link itself would be
7489        // a name in a group this writer does not own.
7490        self.reject_external_traversal(target_path)?;
7491        self.reject_external_traversal(&format!(
7492            "{}/{link_name}",
7493            parent_group_path.trim_end_matches('/')
7494        ))?;
7495
7496        // Hold the create gate across the collision check and the hard-link
7497        // push so the two are atomic (see `create_lock`).
7498        let _create = self.create_lock.lock();
7499        // Both paths resolve through hard links, as HDF5 traversal does.
7500        let parent_group_path = self.canonical_group_path(parent_group_path);
7501        let parent_group_path = parent_group_path.as_str();
7502
7503        // Resolve the parent group (None == root).
7504        let parent = if parent_group_path == "/" {
7505            None
7506        } else {
7507            Some(
7508                self.group_refs()
7509                    .iter()
7510                    .position(|g| {
7511                        let gg = g.lock();
7512                        gg.name == parent_group_path && !gg.deleted
7513                    })
7514                    .ok_or_else(|| {
7515                        crate::io::IoError::NotFound(format!(
7516                            "parent group '{parent_group_path}' not found"
7517                        ))
7518                    })?,
7519            )
7520        };
7521
7522        // Resolve the target. Dataset names are stored without a leading
7523        // '/', group names with one — compare on the trimmed form. A
7524        // trailing '/' is tolerated too.
7525        let target_rel = self.canonical_dataset_path(target_path.trim_matches('/'));
7526        let target_rel = target_rel.as_str();
7527        if target_rel.is_empty() {
7528            return Err(crate::io::IoError::InvalidState(
7529                "cannot hard-link the root group".into(),
7530            ));
7531        }
7532        let target = self.resolve_object(target_rel).ok_or_else(|| {
7533            crate::io::IoError::NotFound(format!("hard link target '{target_path}' not found"))
7534        })?;
7535
7536        // Reject a name already taken in the parent group.
7537        self.ensure_name_free(&self.link_full_path(parent, link_name))?;
7538
7539        self.hard_links.lock().push(HardLink {
7540            parent,
7541            name: link_name.to_string(),
7542            target,
7543            creation_seq: self.take_creation_seq(),
7544        });
7545        self.register_name(&self.link_full_path(parent, link_name), NameHit::HardLink);
7546        Ok(())
7547    }
7548
7549    /// Whether a hard link will actually be emitted: both its parent group
7550    /// and its target object must still be present (not soft-deleted).
7551    fn hard_link_emitted(&self, link: &HardLink) -> bool {
7552        let parent_ok = self.parent_alive(link.parent);
7553        let target_ok = match link.target {
7554            HardLinkTarget::Dataset(i) => !self.ds(i).lock().deleted,
7555            HardLinkTarget::Group(i) => !self.grp(i).lock().deleted,
7556        };
7557        parent_ok && target_ok
7558    }
7559
7560    /// The full path a link occupies, with no leading `/` — the same form
7561    /// dataset names are stored in. The one place a parent index and a leaf
7562    /// name become a path, so every link kind answers the collision check in
7563    /// the same spelling.
7564    fn link_full_path(&self, parent: Option<usize>, name: &str) -> String {
7565        match parent {
7566            None => name.to_string(),
7567            Some(pi) => format!(
7568                "{}/{name}",
7569                self.grp(pi).lock().name.trim_start_matches('/')
7570            ),
7571        }
7572    }
7573
7574    /// The full path a hard link occupies; see [`Self::link_full_path`].
7575    fn hard_link_full_path(&self, link: &HardLink) -> String {
7576        self.link_full_path(link.parent, &link.name)
7577    }
7578
7579    /// Whether a symbolic link will actually be emitted: its parent group
7580    /// must still be present. There is no target to check — a soft or
7581    /// external link is allowed to dangle, and `H5Lcreate_soft` does not look
7582    /// at the path it stores.
7583    fn symbolic_link_emitted(&self, link: &SymbolicLink) -> bool {
7584        self.parent_alive(link.parent)
7585    }
7586
7587    /// Whether the group that would hold a link still exists; `None` is the
7588    /// root group, which cannot be deleted.
7589    ///
7590    /// A deleted group's header is never written, so nothing it would have
7591    /// held is in the file — and the name is free again. Every registry
7592    /// decides that the same way, through here.
7593    fn parent_alive(&self, parent: Option<usize>) -> bool {
7594        match parent {
7595            None => true,
7596            Some(pi) => !self.grp(pi).lock().deleted,
7597        }
7598    }
7599
7600    /// The full path a symbolic link occupies; see [`Self::link_full_path`].
7601    fn symbolic_link_full_path(&self, link: &SymbolicLink) -> String {
7602        self.link_full_path(link.parent, &link.name)
7603    }
7604
7605    /// Create a soft or external link: a name in a group whose value is a
7606    /// path rather than an object.
7607    ///
7608    /// The single owner of symbolic-link creation — `H5Lcreate_soft` and
7609    /// `H5Lcreate_external` differ only in the value they store, and the
7610    /// name, parent and collision rules they share are all here.
7611    ///
7612    /// * `parent_group_path` — full path of the group that will hold the
7613    ///   link (`"/"` for the root group).
7614    /// * `link_name` — leaf name of the new link within that group.
7615    /// * `target` — the path this link names, and for an external link the
7616    ///   file holding it. Neither is resolved or required to exist: HDF5
7617    ///   answers a symbolic link at traversal time, so a dangling one is a
7618    ///   legal file.
7619    pub fn create_symbolic_link(
7620        &self,
7621        parent_group_path: &str,
7622        link_name: &str,
7623        target: LinkTarget,
7624    ) -> IoResult<()> {
7625        if link_name.is_empty() || link_name.contains('/') {
7626            return Err(crate::io::IoError::InvalidState(format!(
7627                "link name '{link_name}' must be a non-empty leaf name"
7628            )));
7629        }
7630        // `H5Lcreate_external` refuses an empty file or object name, and
7631        // stores the object path normalized; a link written here and one
7632        // libhdf5 writes from the same arguments then hold the same bytes.
7633        let target = match target {
7634            LinkTarget::External { file, path } => {
7635                if file.is_empty() || path.is_empty() {
7636                    return Err(crate::io::IoError::InvalidState(
7637                        "an external link needs both a file name and an object path".into(),
7638                    ));
7639                }
7640                LinkTarget::External {
7641                    file,
7642                    path: crate::format::messages::link::normalize_object_path(&path),
7643                }
7644            }
7645            other => other,
7646        };
7647        // The link itself would be a name in a group that lives in another
7648        // file; its *value* may name anything, including a path this writer
7649        // cannot follow, because nothing follows it here.
7650        self.reject_external_traversal(&format!(
7651            "{}/{link_name}",
7652            parent_group_path.trim_end_matches('/')
7653        ))?;
7654
7655        let _create = self.create_lock.lock();
7656        let parent_group_path = self.canonical_group_path(parent_group_path);
7657        let parent_group_path = parent_group_path.as_str();
7658        let parent = if parent_group_path == "/" {
7659            None
7660        } else {
7661            Some(
7662                self.group_refs()
7663                    .iter()
7664                    .position(|g| {
7665                        let gg = g.lock();
7666                        gg.name == parent_group_path && !gg.deleted
7667                    })
7668                    .ok_or_else(|| {
7669                        crate::io::IoError::NotFound(format!(
7670                            "parent group '{parent_group_path}' not found"
7671                        ))
7672                    })?,
7673            )
7674        };
7675
7676        self.ensure_name_free(&self.link_full_path(parent, link_name))?;
7677        self.symbolic_links.lock().push(SymbolicLink {
7678            parent,
7679            name: link_name.to_string(),
7680            target,
7681            creation_seq: self.take_creation_seq(),
7682        });
7683        self.register_name(
7684            &self.link_full_path(parent, link_name),
7685            NameHit::SymbolicLink,
7686        );
7687        Ok(())
7688    }
7689
7690    // ---------------------------------------------------------- committed types
7691
7692    /// Snapshot the committed-datatype list; see [`Self::hard_links_vec`].
7693    pub(crate) fn committed_datatypes_vec(&self) -> Vec<CommittedDatatype> {
7694        self.committed_datatypes.lock().clone()
7695    }
7696
7697    /// The paths of every committed datatype a name still reaches, in
7698    /// creation order. One inside a deleted group is not among them: no link
7699    /// to it is emitted, so the file will not hold that name.
7700    ///
7701    /// Both halves of the file answer. A datatype an earlier session
7702    /// committed is carried by its bytes, not re-encoded, so it lives in the
7703    /// preserved-link list rather than the registry — and listing only the
7704    /// registry is what made this answer `[]` for a file whose every named
7705    /// type was committed before it was opened, while a reader of the same
7706    /// file named them all.
7707    pub(crate) fn committed_datatype_names(&self) -> Vec<String> {
7708        let mut out: Vec<String> = self
7709            .committed_datatypes_vec()
7710            .iter()
7711            .filter(|c| self.parent_alive(c.parent))
7712            .map(|c| c.name.clone())
7713            .collect();
7714        out.extend(
7715            self.preserved_links
7716                .lock()
7717                .iter()
7718                .filter(|l| l.kind == PreservedKind::NamedDatatype)
7719                .map(|l| self.preserved_link_full_path(l)),
7720        );
7721        out
7722    }
7723
7724    /// Commit `datatype` as an object of its own under `name` —
7725    /// `H5Tcommit2`. Returns its index in the committed-datatype registry.
7726    ///
7727    /// The object holds one datatype message and nothing else. It goes
7728    /// through [`begin_create`](Self::begin_create) like a dataset, so its
7729    /// name is resolved to a real parent group, refused if taken, and refused
7730    /// if it would cross a carried external link.
7731    pub fn commit_datatype(&self, name: &str, datatype: DatatypeMessage) -> IoResult<usize> {
7732        let create = self.begin_create(name.trim_start_matches('/'))?;
7733        let entry = CommittedDatatype {
7734            name: create.name.clone(),
7735            parent: create.parent,
7736            datatype,
7737            creation_seq: self.take_creation_seq(),
7738            times: self.created_object_times(),
7739            obj_header_addr: 0,
7740        };
7741        let name = entry.name.clone();
7742        let idx = {
7743            let mut reg = self.committed_datatypes.lock();
7744            let idx = reg.len();
7745            reg.push(entry);
7746            idx
7747        };
7748        self.register_name(&name, NameHit::Datatype(idx));
7749        Ok(idx)
7750    }
7751
7752    /// Resolve a committed datatype's path to its registry index and the type
7753    /// it holds — the pair a dataset needs to be built on it.
7754    ///
7755    /// Returned together so the caller cannot pair one committed type's index
7756    /// with another's datatype: the dataset's element width, dataspace and
7757    /// payload checks all come from the type, and its header names the index.
7758    pub(crate) fn committed_datatype_for_share(
7759        &self,
7760        name: &str,
7761    ) -> IoResult<(usize, DatatypeMessage)> {
7762        let name = self.canonical_dataset_path(name.trim_start_matches('/'));
7763        let all = self.committed_datatypes_vec();
7764        all.iter()
7765            .position(|c| self.parent_alive(c.parent) && c.name == name)
7766            .map(|i| (i, all[i].datatype.clone()))
7767            .ok_or_else(|| {
7768                crate::io::IoError::NotFound(format!("no committed datatype named '{name}'"))
7769            })
7770    }
7771
7772    /// Record that dataset `dataset` stores its datatype as a pointer to the
7773    /// committed datatype `committed`.
7774    ///
7775    /// Takes an index [`committed_datatype_for_share`](Self::committed_datatype_for_share)
7776    /// produced, alongside the datatype from the same call, so the two cannot
7777    /// disagree and there is nothing here that can fail after the dataset
7778    /// exists.
7779    pub(crate) fn share_committed_type(&self, dataset: usize, committed: usize) {
7780        debug_assert!(committed < self.committed_datatypes.lock().len());
7781        self.ds(dataset).lock().committed_type = Some(CommittedTypeRef::Session(committed));
7782    }
7783
7784    /// How many names reach the committed datatype `index`: the link that
7785    /// gave it its name, plus every live dataset that shares it.
7786    ///
7787    /// `H5O__shared_link_adj` counts a share as a link, which is why a type
7788    /// h5py commits and then builds one dataset on reports `rc == 2`. Zero
7789    /// means nothing reaches it at all — the group holding its name was
7790    /// deleted and no dataset shares it — and then it is not written.
7791    fn committed_datatype_refcount(&self, index: usize) -> u32 {
7792        let linked = {
7793            let parent = self.committed_datatypes.lock()[index].parent;
7794            u32::from(self.parent_alive(parent))
7795        };
7796        let shares = self
7797            .dataset_refs()
7798            .iter()
7799            .filter(|d| {
7800                let m = d.lock();
7801                !m.deleted && m.committed_type == Some(CommittedTypeRef::Session(index))
7802            })
7803            .count() as u32;
7804        linked + shares
7805    }
7806
7807    /// Append the link naming each committed datatype whose parent group is
7808    /// `parent`. A committed datatype is reached by an ordinary hard link —
7809    /// what makes it a datatype rather than a group or a dataset is the one
7810    /// message in the header it points at.
7811    ///
7812    /// Only a live group's links are collected, and a live parent is itself a
7813    /// reference, so every address named here belongs to a header
7814    /// `write_committed_datatype_headers` wrote.
7815    fn push_committed_datatypes(&self, links: &mut Vec<(u64, LinkMessage)>, parent: Option<usize>) {
7816        for cd in self.committed_datatypes_vec() {
7817            if cd.parent != parent {
7818                continue;
7819            }
7820            let leaf = cd.name.rsplit('/').next().unwrap_or(&cd.name);
7821            links.push((cd.creation_seq, LinkMessage::hard(leaf, cd.obj_header_addr)));
7822        }
7823    }
7824
7825    /// Rewrite a group path that passes through hard links into the tree
7826    /// path of the group it reaches — HDF5 traversal, where any link in a
7827    /// path component resolves to its target. Group-name form (leading
7828    /// `/`). Repeats because a substituted target's subtree can hold
7829    /// further links; bounded like libhdf5's link-traversal limit, so a
7830    /// link cycle cannot loop forever. A path with no link components
7831    /// (including one naming nothing at all) comes back unchanged.
7832    pub(crate) fn canonical_group_path(&self, path: &str) -> String {
7833        let mut path = path.to_string();
7834        for _ in 0..64 {
7835            // The longest emitted group-link path that is the whole of
7836            // `path` or a '/'-boundary prefix of it.
7837            let mut best: Option<(usize, usize)> = None; // (prefix len, target)
7838            for l in self.hard_links_vec() {
7839                let HardLinkTarget::Group(gi) = l.target else {
7840                    continue;
7841                };
7842                if !self.hard_link_emitted(&l) {
7843                    continue;
7844                }
7845                let lp = format!("/{}", self.hard_link_full_path(&l));
7846                let covers = path == lp || path.starts_with(&format!("{lp}/"));
7847                if covers && best.is_none_or(|(len, _)| lp.len() > len) {
7848                    best = Some((lp.len(), gi));
7849                }
7850            }
7851            let Some((len, gi)) = best else { break };
7852            let target_name = self.grp(gi).lock().name.clone();
7853            path = format!("{}{}", target_name, &path[len..]);
7854        }
7855        path
7856    }
7857
7858    /// [`canonical_group_path`](Self::canonical_group_path) in the
7859    /// dataset-name form (no leading `/`): the leaf is a dataset, so only
7860    /// group links can appear as components and the whole path can go
7861    /// through the group rewrite unchanged.
7862    fn canonical_dataset_path(&self, name: &str) -> String {
7863        self.canonical_group_path(&format!("/{name}"))
7864            .trim_start_matches('/')
7865            .to_string()
7866    }
7867
7868    /// Total number of hard links resolving to an object: its own tree link
7869    /// plus every emitted user-created hard link pointing at it.
7870    fn object_link_count(&self, target: HardLinkTarget) -> u32 {
7871        let same = |a: HardLinkTarget, b: HardLinkTarget| -> bool {
7872            matches!(
7873                (a, b),
7874                (HardLinkTarget::Dataset(x), HardLinkTarget::Dataset(y))
7875                    | (HardLinkTarget::Group(x), HardLinkTarget::Group(y))
7876                if x == y
7877            )
7878        };
7879        1 + self
7880            .hard_links_vec()
7881            .iter()
7882            .filter(|l| self.hard_link_emitted(l) && same(l.target, target))
7883            .count() as u32
7884    }
7885
7886    /// The object a path names, or `None` when nothing in the file does.
7887    ///
7888    /// `path` is the trimmed, hard-link-canonical form (no leading or
7889    /// trailing `/`) that dataset and group names compare against. The single
7890    /// owner of path→object resolution on the write side: hard links and
7891    /// object references must agree on what a path means, including that a
7892    /// path may itself be a user hard link — links have no chain (each points
7893    /// straight at the object header, as in libhdf5), so the existing link's
7894    /// target is the answer.
7895    pub(crate) fn resolve_object(&self, path: &str) -> Option<HardLinkTarget> {
7896        if let Some(idx) = self.dataset_refs().iter().position(|d| {
7897            let g = d.lock();
7898            !g.deleted && g.name.trim_start_matches('/') == path
7899        }) {
7900            return Some(HardLinkTarget::Dataset(idx));
7901        }
7902        if let Some(idx) = self.group_refs().iter().position(|g| {
7903            let gg = g.lock();
7904            !gg.deleted && gg.name.trim_start_matches('/') == path
7905        }) {
7906            return Some(HardLinkTarget::Group(idx));
7907        }
7908        self.hard_links_vec().iter().find_map(|l| {
7909            (self.hard_link_emitted(l) && self.hard_link_full_path(l) == path).then_some(l.target)
7910        })
7911    }
7912
7913    /// The address of dataset `index`'s own contiguous block, for the two
7914    /// writers that stamp single elements into it by file offset — object and
7915    /// region references, whose values are only known once finalize has placed
7916    /// every object header.
7917    ///
7918    /// Refuses, rather than handing back an address that is not one, every
7919    /// dataset that has no such block: chunked, compact, unallocated, or with
7920    /// its raw data in files outside this one.
7921    fn local_element_block(&self, index: usize, what: &str) -> IoResult<u64> {
7922        let ds = self.ds(index);
7923        let m = ds.lock();
7924        match m.contiguous_target() {
7925            Some(ContiguousTarget::Local(addr)) => Ok(addr),
7926            Some(ContiguousTarget::External { .. }) => {
7927                Err(crate::io::IoError::InvalidState(format!(
7928                    "{what} are stamped into the dataset's own contiguous block, and \
7929                 dataset '{}' has none: its raw data lives in external files",
7930                    m.name
7931                )))
7932            }
7933            Some(ContiguousTarget::Virtual) => Err(crate::io::IoError::InvalidState(format!(
7934                "{what} are stamped into the dataset's own contiguous block, and \
7935                 dataset '{}' has none: it is virtual, and its elements come from \
7936                 the source datasets its mappings name",
7937                m.name
7938            ))),
7939            None => Err(crate::io::IoError::InvalidState(format!(
7940                "{what} are stamped into contiguous storage; create the dataset \
7941                 without chunking"
7942            ))),
7943        }
7944    }
7945
7946    /// Store object references naming `paths` into the elements of dataset
7947    /// `index` starting at `start`.
7948    ///
7949    /// The value of an `H5R_OBJECT1` element is its target's object header
7950    /// address, which finalize assigns, so what lands here is the target path;
7951    /// [`Self::write_object_reference_values`] writes the addresses. Elements
7952    /// never written keep the zero image libhdf5 reads back as a null
7953    /// reference.
7954    pub fn write_object_references(
7955        &self,
7956        index: usize,
7957        start: u64,
7958        paths: &[&str],
7959    ) -> IoResult<()> {
7960        let elements = {
7961            let ds = self.ds(index);
7962            let m = ds.lock();
7963            match &m.datatype {
7964                // Both generations of object reference: `H5T_STD_REF_OBJ` and
7965                // the 1.12 `H5T_STD_REF`. They differ only in the element
7966                // image, which `encode_reference_element` owns.
7967                DatatypeMessage::Reference {
7968                    kind: ReferenceKind::Object1 | ReferenceKind::Object2,
7969                    ..
7970                } => {}
7971                other => {
7972                    return Err(crate::io::IoError::InvalidState(format!(
7973                        "dataset '{}' has datatype {other}, not an object reference",
7974                        m.name
7975                    )))
7976                }
7977            }
7978            m.dataspace
7979                .dims
7980                .iter()
7981                .fold(1u64, |a, &d| a.saturating_mul(d))
7982        };
7983        // Refused here as well as at fixup time, so a dataset whose storage
7984        // cannot hold stamped elements is reported at the call that chose it.
7985        self.local_element_block(index, "object references")?;
7986        let end = start.saturating_add(paths.len() as u64);
7987        if end > elements {
7988            return Err(crate::io::IoError::InvalidState(format!(
7989                "elements {start}..{end} are outside the dataset's {elements}"
7990            )));
7991        }
7992        // Resolve now as well as at fixup time, so a path that names nothing
7993        // is reported at the call that got it wrong.
7994        for path in paths {
7995            self.object_reference_target(path)?;
7996        }
7997        let mut pending = self.pending_object_references.lock();
7998        for (i, path) in paths.iter().enumerate() {
7999            pending.push(PendingObjectReference {
8000                dataset: index,
8001                element: start + i as u64,
8002                target: (*path).to_string(),
8003            });
8004        }
8005        Ok(())
8006    }
8007
8008    /// Record a hard link count of `rc` in `header`, if this file's format
8009    /// needs a message to carry it.
8010    ///
8011    /// A version-2 header carries the count in an Object Reference Count
8012    /// message, and only when more than one link reaches the object. A
8013    /// version-1 header carries it in its prefix and gets no message at all —
8014    /// `H5O_link_oh` gates every refcount-message operation on
8015    /// `oh->version > H5O_VERSION_1` (H5Oint.c:851), so a version-1 header
8016    /// holding one is a shape libhdf5 never writes.
8017    ///
8018    /// The message carries `H5O_MSG_FLAG_DONTSHARE`, which both refcount
8019    /// operations pass (H5Oint.c:874 append, H5Oint.c:864 write): the count is
8020    /// a property of this one object header, so a shared-message index that
8021    /// pointed several headers at one copy would make every object with the
8022    /// same link count share a single number.
8023    fn emit_refcount(&self, header: &mut ObjectHeader, rc: u32, format: ObjectFormat) {
8024        if rc > 1 && format == ObjectFormat::Modern {
8025            header.add_message(MSG_OBJ_REF_COUNT, MSG_FLAG_DONTSHARE, encode_refcount(rc));
8026        }
8027    }
8028
8029    /// Encode `header` as `placement` lays it out, at the version this file's
8030    /// format calls for and with `rc` as the object's hard link count: every
8031    /// `(address, image)` pair to write, chunk 0 first.
8032    ///
8033    /// The count is passed rather than read off the header because the two
8034    /// versions carry it in different places — the version-1 prefix's `nlink`
8035    /// field, the version-2 Reference Count message
8036    /// [`emit_refcount`](Self::emit_refcount) already added — and only the
8037    /// caller knows it.
8038    ///
8039    /// INVARIANT: an object header's chunk 0 never moves once something in the
8040    /// file has named its address. A written header is rewritten over the
8041    /// chunk-0 block it already has, padded when the messages shrank and
8042    /// spilling into a continuation block of its own when they grew — the way
8043    /// `H5O__alloc_new_chunk` (H5Oalloc.c) grows a header libhdf5 cannot
8044    /// extend in place. That is what keeps every object reference already in
8045    /// the file — in a reference dataset, an attribute, a `REFERENCE_LIST`,
8046    /// whoever wrote them — resolving after this session. The one exception is
8047    /// a block too small to hold even the message naming a continuation, which
8048    /// [`place_header`](Self::place_header) gives up and replaces.
8049    ///
8050    /// A fresh header lives in the one block its address and encoded size
8051    /// describe: one whose messages overflow chunk 0 gets its continuation
8052    /// chunk immediately behind it in that same block, so the address is
8053    /// enough to free or supersede the whole header. libhdf5 would have grown
8054    /// chunk 0 into space that free rather than chaining onto it, but it reads
8055    /// a continuation chunk by the address and length its message states and
8056    /// cares nothing for where that lands.
8057    fn encode_header_in(
8058        &self,
8059        header: &ObjectHeader,
8060        rc: u32,
8061        format: ObjectFormat,
8062        placement: &HeaderPlacement,
8063    ) -> IoResult<Vec<(u64, Vec<u8>)>> {
8064        let plan = if placement.kept {
8065            header.plan_chunks_in(format, placement.size, &self.ctx)?
8066        } else {
8067            header.plan_chunks(format, self.chunk0_capacity(header, format), &self.ctx)?
8068        };
8069        let continuation_addr = match placement.continuation {
8070            Some((addr, _)) => addr,
8071            None => placement.addr + plan.chunk0_size as u64,
8072        };
8073        let (mut chunk0, continuation) =
8074            header.encode_chunked(&plan, format, &self.ctx, continuation_addr, rc)?;
8075        match (placement.continuation, continuation) {
8076            (Some((addr, _)), Some(image)) => Ok(vec![(placement.addr, chunk0), (addr, image)]),
8077            (None, Some(image)) => {
8078                chunk0.extend_from_slice(&image);
8079                Ok(vec![(placement.addr, chunk0)])
8080            }
8081            (None, None) => Ok(vec![(placement.addr, chunk0)]),
8082            (Some((addr, size)), None) => Err(crate::io::IoError::InvalidState(format!(
8083                "an object header was placed with a {size}-byte continuation block at \
8084                 {addr:#x} that it no longer needs; a message in it changed length \
8085                 once the addresses it names were known"
8086            ))),
8087        }
8088    }
8089
8090    /// Reserve the blocks `header` will be written over, keeping `kept` — the
8091    /// chunk-0 block the object's existing header occupies — when there is
8092    /// one it can be written over.
8093    ///
8094    /// A header's layout does not depend on the addresses it carries, which is
8095    /// what lets the group pass hand every group header an address before it
8096    /// writes any of their content: every address is a fixed-width field.
8097    ///
8098    /// A kept block is given up only when it cannot describe the header at
8099    /// all: too narrow for the message naming a continuation chunk, or not a
8100    /// shape the header's version can pad (see `ObjectHeader::plan_chunks_in`).
8101    /// Then it is freed and the header gets a fresh block, exactly as a new
8102    /// object does — and the references naming it are the caller's to
8103    /// restamp, which the writer does for every one it registered.
8104    fn place_header(
8105        &mut self,
8106        header: &ObjectHeader,
8107        format: ObjectFormat,
8108        kept: Option<(u64, u64)>,
8109    ) -> IoResult<HeaderPlacement> {
8110        if let Some((addr, len)) = kept {
8111            let plan = usize::try_from(len)
8112                .ok()
8113                .and_then(|len| header.plan_chunks_in(format, len, &self.ctx).ok());
8114            match plan {
8115                Some(plan) => {
8116                    let continuation = (plan.continuation_size > 0).then(|| {
8117                        let size = plan.continuation_size;
8118                        let addr = self
8119                            .allocator
8120                            .allocate(size as u64, FreeSpaceClass::Metadata);
8121                        (addr, size)
8122                    });
8123                    return Ok(HeaderPlacement {
8124                        addr,
8125                        size: len as usize,
8126                        kept: true,
8127                        continuation,
8128                    });
8129                }
8130                // A block a SWMR reader may be walking stays allocated, as
8131                // everywhere else under `swmr_active`.
8132                None if !self.swmr_active => {
8133                    self.allocator.free(addr, len, FreeSpaceClass::Metadata);
8134                }
8135                None => {}
8136            }
8137        }
8138        let plan = header.plan_chunks(format, self.chunk0_capacity(header, format), &self.ctx)?;
8139        let size = plan.chunk0_size + plan.continuation_size;
8140        let addr = self
8141            .allocator
8142            .allocate(size as u64, FreeSpaceClass::Metadata);
8143        Ok(HeaderPlacement::fresh(addr, size))
8144    }
8145
8146    /// How many bytes of messages `header`'s chunk 0 holds before the rest
8147    /// spill into a continuation chunk.
8148    ///
8149    /// libhdf5 sizes chunk 0 once, when the object header is created, and can
8150    /// only grow it while the space behind it is still free — so an object
8151    /// whose creation-time estimate covered every message it would ever hold
8152    /// keeps one chunk, and one whose estimate was a guess does not. A dataset
8153    /// or a committed datatype is created from messages already in hand
8154    /// (`H5D__update_oh_info`, `H5T__commit`), so its estimate is exact and
8155    /// this writer's exact fit is the same answer.
8156    ///
8157    /// A group is the exception: `H5G__obj_create_real` (H5Gobj.c:219) sizes
8158    /// its header for the link info and group info messages plus
8159    /// `H5G_CRT_GINFO_EST_NUM_ENTRIES` links of `H5G_CRT_GINFO_EST_NAME_LEN`
8160    /// characters, and nothing else — attributes above all — is in that
8161    /// estimate. The Link Info message is what identifies one: it is the
8162    /// message that makes an object a new-format group, and
8163    /// `H5G__obj_get_linfo` uses it for exactly this question.
8164    ///
8165    /// A version-1 header is written as one chunk whatever it holds: its
8166    /// groups keep their links in a symbol table, not in the header, so the
8167    /// estimate that makes a version-2 group spill never applies to one.
8168    fn chunk0_capacity(&self, header: &ObjectHeader, format: ObjectFormat) -> usize {
8169        if format == ObjectFormat::Legacy {
8170            return usize::MAX;
8171        }
8172        let envelope = header.message_envelope_size();
8173        let sized = |msg_type: u8| {
8174            header
8175                .messages
8176                .iter()
8177                .find(|m| m.msg_type == msg_type)
8178                .map(|m| envelope + m.data.len())
8179        };
8180        let Some(link_info) = sized(MSG_LINK_INFO) else {
8181            return usize::MAX;
8182        };
8183        // One estimated hard link: version, flags, a one-byte name length for
8184        // a name this short, the name, and the object header address.
8185        let link = envelope + 1 + 1 + 1 + EST_LINK_NAME_LEN + self.ctx.sizeof_addr as usize;
8186        link_info + sized(MSG_GROUP_INFO).unwrap_or(0) + EST_LINK_COUNT * link
8187    }
8188
8189    /// The object an object reference's path names, as a hard-link target;
8190    /// `None` for the root group, which has no registry slot.
8191    fn object_reference_target(&self, path: &str) -> IoResult<Option<HardLinkTarget>> {
8192        let rel = self.canonical_dataset_path(path.trim_matches('/'));
8193        if rel.is_empty() {
8194            return Ok(None);
8195        }
8196        self.resolve_object(&rel)
8197            .map(Some)
8198            .ok_or_else(|| crate::io::IoError::NotFound(format!("reference target '{path}'")))
8199    }
8200
8201    /// The object header address an object reference's `path` names, or zero
8202    /// when that object has not been given one yet.
8203    ///
8204    /// Zero is where the superblock sits, so it is never an object header's
8205    /// address. It is what every object reads as before
8206    /// [`allocate_object_headers`](Self::allocate_object_headers) runs, which
8207    /// is what lets the pass that measures a header stand in for the pass that
8208    /// writes it: an address is a fixed-width field, so the placeholder is the
8209    /// same size as the answer.
8210    fn object_reference_address(&self, path: &str) -> IoResult<u64> {
8211        Ok(match self.object_reference_target(path)? {
8212            Some(HardLinkTarget::Dataset(i)) => self.ds(i).lock().obj_header_addr,
8213            Some(HardLinkTarget::Group(i)) => self.grp(i).lock().obj_header_addr,
8214            None => self.root_group_addr.unwrap_or(0),
8215        })
8216    }
8217
8218    /// `scope`'s attributes as this finalize will write them: the stored set,
8219    /// with every object-reference attribute's value said in the object header
8220    /// addresses assigned so far.
8221    ///
8222    /// The single owner of a reference attribute's value, and the only source
8223    /// an object header build may take an attribute set from. Nothing stored
8224    /// is mutated, so the pass that measures a header and the pass that writes
8225    /// it cannot disagree about anything but the addresses — which they cannot
8226    /// disagree about in length.
8227    ///
8228    /// INVARIANT: the stored attribute list is what says which attributes
8229    /// exist; a recorded reference value can only give a value to one already
8230    /// in it. So a value left behind by an object whose list was emptied — a
8231    /// deleted group or dataset — cannot put the attribute back, and a value
8232    /// whose attribute was replaced by one of another type is dropped at the
8233    /// replacement instead of reaching it (see
8234    /// [`forget_attribute_reference`](Self::forget_attribute_reference)).
8235    fn object_attributes(&self, scope: AttrScope) -> IoResult<Vec<AttributeEntry>> {
8236        let mut attrs = match scope {
8237            AttrScope::Root => self.root_attributes.lock().clone(),
8238            AttrScope::Group(gi) => self.grp(gi).lock().attributes.clone(),
8239            AttrScope::Dataset(i) => self.ds(i).lock().attributes.clone(),
8240        };
8241        // Snapshot first: resolving a path locks group and dataset slots.
8242        let values: Vec<(String, Vec<String>, usize)> = self
8243            .attribute_references
8244            .lock()
8245            .iter()
8246            .filter(|r| r.scope == scope)
8247            .map(|r| (r.name.clone(), r.targets.clone(), r.stride))
8248            .collect();
8249        let width = self.ctx.sizeof_addr as usize;
8250        for (name, targets, stride) in values {
8251            let Some(pos) = attrs.iter().position(|a| a.name() == name) else {
8252                continue;
8253            };
8254            let Some(msg) = attrs[pos].readable() else {
8255                continue;
8256            };
8257            let mut msg = msg.clone();
8258            for (i, target) in targets.iter().enumerate() {
8259                let at = i * stride;
8260                let held = msg.data.len();
8261                let slot = msg.data.get_mut(at..at + width).ok_or_else(|| {
8262                    crate::io::IoError::InvalidState(format!(
8263                        "attribute '{name}' holds {held} bytes, too few for reference {i} at {at}"
8264                    ))
8265                })?;
8266                slot.copy_from_slice(
8267                    &self.object_reference_address(target)?.to_le_bytes()[..width],
8268                );
8269            }
8270            attrs[pos] = AttributeEntry::from(msg).with_creation_index(attrs[pos].creation_index());
8271        }
8272        Ok(attrs)
8273    }
8274
8275    /// The registry scope `target` names — the same object
8276    /// [`with_attr_list`](Self::with_attr_list) reaches, as the key the
8277    /// reference-value registry is indexed by. Refuses what that accessor
8278    /// refuses, and for the same reasons.
8279    fn attr_scope(&self, target: AttrTarget<'_>) -> IoResult<AttrScope> {
8280        match target {
8281            AttrTarget::Root => Ok(AttrScope::Root),
8282            AttrTarget::Group(path) => {
8283                let path = self.canonical_group_path(path);
8284                self.group_refs()
8285                    .iter()
8286                    .position(|g| {
8287                        let gg = g.lock();
8288                        gg.name == path && !gg.deleted
8289                    })
8290                    .map(AttrScope::Group)
8291                    .ok_or_else(|| {
8292                        crate::io::IoError::NotFound(format!("group '{path}' not found"))
8293                    })
8294            }
8295            AttrTarget::Dataset(index) => {
8296                let count = self.dataset_count();
8297                if index >= count {
8298                    return Err(crate::io::IoError::InvalidState(format!(
8299                        "dataset index {index} out of range (have {count})"
8300                    )));
8301                }
8302                Ok(AttrScope::Dataset(index))
8303            }
8304        }
8305    }
8306
8307    /// Drop the reference value recorded for `scope`'s attribute `name`.
8308    ///
8309    /// Called by both owners of attribute-list mutation —
8310    /// [`insert_attribute`](Self::insert_attribute) and
8311    /// [`evict_attr`](Self::evict_attr) — so an attribute that is replaced or
8312    /// removed cannot leave its value behind for whatever takes its name next.
8313    /// A string attribute written over a reference attribute is the case that
8314    /// needs it: without this the string's bytes would be overwritten with
8315    /// addresses at finalize.
8316    fn forget_attribute_reference(&self, scope: AttrScope, name: &str) {
8317        self.attribute_references
8318            .lock()
8319            .retain(|r| !(r.scope == scope && r.name == name));
8320    }
8321
8322    /// Write every pending object reference element as its target's object
8323    /// header address.
8324    ///
8325    /// INVARIANT: a reference element on disk holds its target's header
8326    /// address. Reached through [`write_reference_values`](Self::write_reference_values),
8327    /// which places it after every header has an address; a target that no
8328    /// longer resolves fails the finalize rather than leaving a placeholder
8329    /// behind.
8330    fn write_object_reference_values(&mut self) -> IoResult<()> {
8331        // Snapshot rather than drain: a SWMR session finalizes twice, and the
8332        // close-time finalize rebuilds every header at a fresh address, so the
8333        // elements must be stamped again with the addresses that survive.
8334        let pending: Vec<(usize, u64, String)> = self
8335            .pending_object_references
8336            .lock()
8337            .iter()
8338            .map(|p| (p.dataset, p.element, p.target.clone()))
8339            .collect();
8340        for (dataset, element, target) in &pending {
8341            let addr = match self.object_reference_target(target)? {
8342                Some(HardLinkTarget::Dataset(i)) => self.ds(i).lock().obj_header_addr,
8343                Some(HardLinkTarget::Group(i)) => self.grp(i).lock().obj_header_addr,
8344                None => self.root_group_addr.ok_or_else(|| {
8345                    crate::io::IoError::InvalidState(
8346                        "root group header address is not assigned yet".into(),
8347                    )
8348                })?,
8349            };
8350            // The element image is the dataset's own datatype's business: the
8351            // pre-1.12 and 1.12 forms differ in width and in layout, and the
8352            // dataset says which it holds.
8353            let (kind, width) = {
8354                let ds = self.ds(*dataset);
8355                let m = ds.lock();
8356                let DatatypeMessage::Reference { kind, size } = &m.datatype else {
8357                    return Err(crate::io::IoError::InvalidState(format!(
8358                        "dataset '{}' is no longer a reference dataset",
8359                        m.name
8360                    )));
8361                };
8362                (*kind, *size as usize)
8363            };
8364            let image = match kind {
8365                ReferenceKind::Object1 => ReferenceElementImage::Legacy(addr),
8366                ReferenceKind::Object2 => ReferenceElementImage::Inline(addr),
8367                other => {
8368                    return Err(crate::io::IoError::InvalidState(format!(
8369                        "dataset {dataset} now holds {other:?} elements, not object references"
8370                    )))
8371                }
8372            };
8373            let image = encode_reference_element(&image, width, &self.ctx)?;
8374            let data_addr = self.local_element_block(*dataset, "object references")?;
8375            let at = data_addr + element * width as u64;
8376            self.handle.write_at(at, &image)?;
8377        }
8378        Ok(())
8379    }
8380
8381    /// Store region references over `targets` into the elements of dataset
8382    /// `index` starting at `start`.
8383    ///
8384    /// Each target is the path of a dataset and a selection over it. What the
8385    /// element holds is a global-heap id — collection address then object index
8386    /// (`H5R__encode_heap`) — and the heap object it names is the target's
8387    /// object header address followed by the serialized selection
8388    /// (`H5R__encode_token_region_compat`). Both the object and the element are
8389    /// written here; only the address inside the object waits for
8390    /// [`Self::write_heap_reference_values`]. Elements never written keep the
8391    /// zero image libhdf5 reads back as a null reference.
8392    pub fn write_region_references(
8393        &self,
8394        index: usize,
8395        start: u64,
8396        targets: &[(&str, Selection)],
8397    ) -> IoResult<()> {
8398        let elements = {
8399            let ds = self.ds(index);
8400            let m = ds.lock();
8401            match &m.datatype {
8402                DatatypeMessage::Reference {
8403                    kind: ReferenceKind::DatasetRegion1,
8404                    ..
8405                } => {}
8406                other => {
8407                    return Err(crate::io::IoError::InvalidState(format!(
8408                        "dataset '{}' has datatype {other}, not a region reference",
8409                        m.name
8410                    )))
8411                }
8412            }
8413            m.dataspace
8414                .dims
8415                .iter()
8416                .fold(1u64, |a, &d| a.saturating_mul(d))
8417        };
8418        let data_addr = self.local_element_block(index, "region references")?;
8419        let end = start.saturating_add(targets.len() as u64);
8420        if end > elements {
8421            return Err(crate::io::IoError::InvalidState(format!(
8422                "elements {start}..{end} are outside the dataset's {elements}"
8423            )));
8424        }
8425
8426        // Build every heap object before inserting any: a path that names no
8427        // dataset, or a selection its extent does not admit, is reported at the
8428        // call that got it wrong rather than after half the batch is on disk.
8429        let sa = self.ctx.sizeof_addr as usize;
8430        let mut blobs = Vec::with_capacity(targets.len());
8431        for (path, selection) in targets {
8432            let target = self.region_reference_target(path)?;
8433            let dims = self.ds(target).lock().dataspace.dims.clone();
8434            validate_region_selection(selection, &dims, path)?;
8435            let mut blob = vec![0u8; sa];
8436            blob.extend_from_slice(&selection.encode()?);
8437            blobs.push(blob);
8438        }
8439        let items: Vec<&[u8]> = blobs.iter().map(Vec::as_slice).collect();
8440        let placements = self.insert_vlen_objects(&items)?;
8441
8442        let width = (sa + 4) as u64;
8443        let mut pending = self.pending_heap_references.lock();
8444        for (i, &(collection, obj_index)) in placements.iter().enumerate() {
8445            let mut elem = Vec::with_capacity(width as usize);
8446            elem.extend_from_slice(&collection.to_le_bytes()[..sa]);
8447            elem.extend_from_slice(&u32::from(obj_index).to_le_bytes());
8448            self.handle
8449                .write_at(data_addr + (start + i as u64) * width, &elem)?;
8450            pending.push(PendingHeapReference {
8451                collection,
8452                index: obj_index,
8453                token_offset: 0,
8454                target: PendingHeapTarget::Dataset(targets[i].0.to_string()),
8455            });
8456        }
8457        Ok(())
8458    }
8459
8460    /// Store 1.12 references over `targets` into the elements of dataset
8461    /// `index` starting at `start` — the `H5T_STD_REF` trio.
8462    ///
8463    /// One datatype holds all three kinds, because a 1.12 element leads with
8464    /// the kind it holds; which is why this takes a [`ReferenceTarget`] per
8465    /// element rather than a fixed kind. `H5R_OBJECT2` needs nothing but the
8466    /// target's address, so its element is written inline by the same finalize
8467    /// pass every object reference goes through. The other two encode a
8468    /// selection or an attribute name alongside the token, which does not fit
8469    /// an element, so what is stored is a global-heap blob and the element is
8470    /// its id (`H5T__ref_disk_write`). Elements never written keep the zero
8471    /// image `H5T__ref_disk_isnull` reads back as a null reference.
8472    pub fn write_revised_references(
8473        &self,
8474        index: usize,
8475        start: u64,
8476        targets: &[(&str, ReferenceTarget)],
8477    ) -> IoResult<()> {
8478        let (width, elements) = {
8479            let ds = self.ds(index);
8480            let m = ds.lock();
8481            match &m.datatype {
8482                DatatypeMessage::Reference {
8483                    kind: ReferenceKind::Object2,
8484                    size,
8485                } => (
8486                    *size as u64,
8487                    m.dataspace
8488                        .dims
8489                        .iter()
8490                        .fold(1u64, |a, &d| a.saturating_mul(d)),
8491                ),
8492                other => {
8493                    return Err(crate::io::IoError::InvalidState(format!(
8494                        "dataset '{}' has datatype {other}, not the 1.12 H5T_STD_REF",
8495                        m.name
8496                    )))
8497                }
8498            }
8499        };
8500        let data_addr = self.local_element_block(index, "references")?;
8501        let end = start.saturating_add(targets.len() as u64);
8502        if end > elements {
8503            return Err(crate::io::IoError::InvalidState(format!(
8504                "elements {start}..{end} are outside the dataset's {elements}"
8505            )));
8506        }
8507
8508        // Build every blob before inserting any, so a path that names nothing,
8509        // a selection an extent does not admit or an attribute that does not
8510        // exist is reported at the call that got it wrong rather than after
8511        // half the batch is on disk.
8512        let mut blobs: Vec<(u64, ReferenceKind, PendingHeapTarget, Vec<u8>)> = Vec::new();
8513        let mut inline: Vec<(u64, String)> = Vec::new();
8514        for (i, (path, target)) in targets.iter().enumerate() {
8515            let element = start + i as u64;
8516            // The rank of the extent the selection is over, which only a region
8517            // reference encodes and takes from the target's dataspace.
8518            let mut extent_rank = 0;
8519            let (kind, pending) = match target {
8520                ReferenceTarget::Object => {
8521                    self.object_reference_target(path)?;
8522                    inline.push((element, (*path).to_string()));
8523                    continue;
8524                }
8525                ReferenceTarget::Region(selection) => {
8526                    let ds = self.region_reference_target(path)?;
8527                    let dims = self.ds(ds).lock().dataspace.dims.clone();
8528                    validate_region_selection(selection, &dims, path)?;
8529                    extent_rank = dims.len();
8530                    (
8531                        ReferenceKind::DatasetRegion2,
8532                        PendingHeapTarget::Dataset((*path).to_string()),
8533                    )
8534                }
8535                ReferenceTarget::Attribute(name) => {
8536                    let scope = match self.object_reference_target(path)? {
8537                        Some(HardLinkTarget::Dataset(i)) => AttrScope::Dataset(i),
8538                        Some(HardLinkTarget::Group(i)) => AttrScope::Group(i),
8539                        None => AttrScope::Root,
8540                    };
8541                    if !self
8542                        .object_attributes(scope)?
8543                        .iter()
8544                        .any(|a| a.name() == name)
8545                    {
8546                        return Err(crate::io::IoError::NotFound(format!(
8547                            "attribute '{name}' of reference target '{path}'"
8548                        )));
8549                    }
8550                    (
8551                        ReferenceKind::Attr,
8552                        PendingHeapTarget::Object((*path).to_string()),
8553                    )
8554                }
8555            };
8556            blobs.push((
8557                element,
8558                kind,
8559                pending,
8560                encode_revised_blob(0, target, extent_rank, &self.ctx)?,
8561            ));
8562        }
8563
8564        let items: Vec<&[u8]> = blobs.iter().map(|(_, _, _, b)| b.as_slice()).collect();
8565        let placements = self.insert_vlen_objects(&items)?;
8566
8567        let mut pending = self.pending_heap_references.lock();
8568        for ((element, kind, target, blob), &(collection, obj_index)) in
8569            blobs.iter().zip(&placements)
8570        {
8571            // The size the element declares is the heap object's own byte
8572            // count: `H5VL__native_blob_get` refuses to read one whose size
8573            // does not match what the element says.
8574            let image = encode_reference_element(
8575                &ReferenceElementImage::Blob {
8576                    kind: *kind,
8577                    size: blob.len() as u32,
8578                    collection,
8579                    index: u32::from(obj_index),
8580                },
8581                width as usize,
8582                &self.ctx,
8583            )?;
8584            self.handle.write_at(data_addr + element * width, &image)?;
8585            pending.push(PendingHeapReference {
8586                collection,
8587                index: obj_index,
8588                token_offset: REVISED_BLOB_TOKEN_OFFSET,
8589                target: target.clone(),
8590            });
8591        }
8592        drop(pending);
8593
8594        let mut pending = self.pending_object_references.lock();
8595        for (element, path) in inline {
8596            pending.push(PendingObjectReference {
8597                dataset: index,
8598                element,
8599                target: path,
8600            });
8601        }
8602        Ok(())
8603    }
8604
8605    /// The dataset a region reference's path names.
8606    ///
8607    /// A region reference names a *dataset*: `H5Rcreate` with
8608    /// `H5R_DATASET_REGION` takes the dataspace of one, and every reader
8609    /// dereferences it as one. A path that resolves to a group — or to the root
8610    /// group, which has no registry slot — is refused here rather than stored
8611    /// as a reference nothing can dereference.
8612    fn region_reference_target(&self, path: &str) -> IoResult<usize> {
8613        match self.object_reference_target(path)? {
8614            Some(HardLinkTarget::Dataset(i)) => Ok(i),
8615            _ => Err(crate::io::IoError::InvalidState(format!(
8616                "region reference target '{path}' is not a dataset"
8617            ))),
8618        }
8619    }
8620
8621    /// Stamp every pending heap-backed reference's object with its target's
8622    /// object header address.
8623    ///
8624    /// The references that are still stamped rather than written once: the
8625    /// *element* is a global-heap id, so the heap object has to exist at the
8626    /// call that stores the reference, long before any address does. The object
8627    /// was inserted with its token zeroed, so its size does not change here:
8628    /// each collection is read once, patched, and rewritten at its own declared
8629    /// size, which leaves every element's heap id valid — and leaves the
8630    /// object's byte count equal to the size the 1.12 element declares, which
8631    /// `H5VL__native_blob_get` refuses to read past.
8632    fn write_heap_reference_values(&mut self) -> IoResult<()> {
8633        use crate::format::global_heap::GlobalHeapCollection;
8634
8635        // Snapshot rather than drain, for the same reason the object-reference
8636        // pass does: a SWMR session finalizes twice and the close-time finalize
8637        // rebuilds every header at a fresh address.
8638        let pending: Vec<(u64, u16, usize, PendingHeapTarget)> = self
8639            .pending_heap_references
8640            .lock()
8641            .iter()
8642            .map(|p| (p.collection, p.index, p.token_offset, p.target.clone()))
8643            .collect();
8644        if pending.is_empty() {
8645            return Ok(());
8646        }
8647        let sa = self.ctx.sizeof_addr as usize;
8648        // Group by collection so one holding several references is read and
8649        // rewritten once.
8650        let mut per_collection: std::collections::BTreeMap<u64, Vec<(u16, usize, u64)>> =
8651            Default::default();
8652        for (collection, index, token_offset, target) in &pending {
8653            let addr = match target {
8654                PendingHeapTarget::Dataset(path) => {
8655                    let ds = self.region_reference_target(path)?;
8656                    self.ds(ds).lock().obj_header_addr
8657                }
8658                PendingHeapTarget::Object(path) => self.object_reference_address(path)?,
8659            };
8660            per_collection
8661                .entry(*collection)
8662                .or_default()
8663                .push((*index, *token_offset, addr));
8664        }
8665        for (collection, patches) in per_collection {
8666            // A collection is at least 4096 bytes (H5HG_MINALLOC) and most are
8667            // exactly that, so one read usually covers the whole image.
8668            let mut image = self.handle.read_at_most(collection, 4096)?;
8669            let declared = GlobalHeapCollection::decode_size(&image, &self.ctx)?;
8670            if declared > image.len() {
8671                image = self.handle.read_at(collection, declared)?;
8672            }
8673            let (mut gcol, _) = GlobalHeapCollection::decode(&image[..declared], &self.ctx)?;
8674            for (index, token_offset, addr) in patches {
8675                let token = gcol
8676                    .objects
8677                    .iter_mut()
8678                    .find(|o| o.index == index)
8679                    .and_then(|o| o.data.get_mut(token_offset..token_offset + sa))
8680                    .ok_or_else(|| {
8681                        crate::io::IoError::InvalidState(format!(
8682                            "object {index} of global heap collection {collection:#x} is no \
8683                             longer the reference written into it"
8684                        ))
8685                    })?;
8686                token.copy_from_slice(&addr.to_le_bytes()[..sa]);
8687            }
8688            let rewritten = gcol.encode_at_size(&self.ctx, declared)?;
8689            self.handle.write_at(collection, &rewritten)?;
8690        }
8691        Ok(())
8692    }
8693
8694    /// Give every reference written this session its target's object header
8695    /// address.
8696    ///
8697    /// INVARIANT: no file is closed holding a reference whose target address is
8698    /// still the placeholder its write left. Both finalize paths call this in
8699    /// the content phase — after
8700    /// [`allocate_object_headers`](Self::allocate_object_headers), so every
8701    /// address exists, and before any object header is written — and this is
8702    /// the only caller of the per-kind passes, so a reference kind added later
8703    /// is written at both finalize sites or at neither. A target that no longer
8704    /// resolves fails the finalize rather than leaving a placeholder behind.
8705    ///
8706    /// This covers the two reference kinds whose value lives outside an object
8707    /// header. An attribute's value lives *inside* one, so it has no pass here:
8708    /// [`object_attributes`](Self::object_attributes) says it in addresses as
8709    /// the header is built.
8710    fn write_reference_values(&mut self) -> IoResult<()> {
8711        self.write_object_reference_values()?;
8712        self.write_heap_reference_values()
8713    }
8714
8715    /// Append every user-created hard link whose parent group is `parent`
8716    /// (`None` == the root group). Called while collecting a group's links,
8717    /// once every object's header address has been assigned.
8718    fn push_hard_links(&self, links: &mut Vec<(u64, LinkMessage)>, parent: Option<usize>) {
8719        for link in self.hard_links_vec() {
8720            if link.parent != parent || !self.hard_link_emitted(&link) {
8721                continue;
8722            }
8723            let addr = match link.target {
8724                HardLinkTarget::Dataset(i) => self.ds(i).lock().obj_header_addr,
8725                HardLinkTarget::Group(i) => self.grp(i).lock().obj_header_addr,
8726            };
8727            links.push((link.creation_seq, LinkMessage::hard(&link.name, addr)));
8728        }
8729    }
8730
8731    /// Append every user-created symbolic link whose parent group is `parent`
8732    /// (`None` == the root group).
8733    ///
8734    /// Nothing here waits on the layout pass — the link's value is a path, not
8735    /// an address — but it is collected with the rest so it takes its place in
8736    /// creation order and counts toward the phase change.
8737    fn push_symbolic_links(&self, links: &mut Vec<(u64, LinkMessage)>, parent: Option<usize>) {
8738        for link in self.symbolic_links_vec() {
8739            if link.parent != parent || !self.symbolic_link_emitted(&link) {
8740                continue;
8741            }
8742            links.push((
8743                link.creation_seq,
8744                LinkMessage {
8745                    name: link.name.clone(),
8746                    target: link.target.clone(),
8747                    creation_order: None,
8748                    cset: CharacterSet::for_name(&link.name),
8749                },
8750            ));
8751        }
8752    }
8753
8754    /// Refuse a caller path that would have to leave this file through one of
8755    /// the external links a reopened file brought in.
8756    ///
8757    /// The reader follows such a path into the file the link names; the writer
8758    /// cannot, because it models one file and would have to write into
8759    /// another. Saying which link stops the path — rather than reporting the
8760    /// name as absent, or worse, creating a second link of that name beside
8761    /// it — is the whole of what write mode does here.
8762    pub(crate) fn reject_external_traversal(&self, path: &str) -> IoResult<()> {
8763        let path = path.trim_start_matches('/');
8764        let crossing = self.preserved_link_paths().into_iter().find(|(p, class)| {
8765            matches!(class, crate::io::reader::LinkClass::External { .. })
8766                && (path == p || path.starts_with(&format!("{p}/")))
8767        });
8768        match crossing {
8769            None => Ok(()),
8770            Some((link, crate::io::reader::LinkClass::External { file, path: target })) => {
8771                Err(crate::io::IoError::Unsupported(format!(
8772                    "'{path}' resolves through the external link '{link}' to '{target}' in \
8773                     '{file}'; this writer carries external links through a rewrite but does \
8774                     not open the file they name"
8775                )))
8776            }
8777            // `find` matched on the External arm, so no other class reaches here.
8778            Some(_) => Ok(()),
8779        }
8780    }
8781
8782    /// Resolve `name` to a live dataset index, reporting *why* it does not
8783    /// resolve rather than collapsing every cause into absence.
8784    ///
8785    /// The write-mode counterpart of [`Hdf5Reader::open_dataset`]: the single
8786    /// gate every by-name dataset lookup in write mode goes through.
8787    ///
8788    /// [`Hdf5Reader::open_dataset`]: crate::io::reader::Hdf5Reader::open_dataset
8789    pub(crate) fn open_dataset_index(&self, name: &str) -> IoResult<usize> {
8790        self.reject_external_traversal(name)?;
8791        self.reject_preserved_object(name)?;
8792        self.dataset_index(name)
8793            .ok_or_else(|| crate::io::IoError::NotFound(name.to_string()))
8794    }
8795
8796    /// Refuse a caller path that names an object the reopen kept by its bytes
8797    /// rather than modelling.
8798    ///
8799    /// Such an object is in the file and stays in it, but this writer holds
8800    /// none of what it would need to read or rewrite it. Saying so — with the
8801    /// reason the classification recorded — is the difference between an
8802    /// object the writer will not touch and a name the file does not have.
8803    pub(crate) fn reject_preserved_object(&self, path: &str) -> IoResult<()> {
8804        let path = path.trim_start_matches('/');
8805        let objects: Vec<(String, String)> = {
8806            let preserved = self.preserved_links.lock();
8807            preserved
8808                .iter()
8809                .filter_map(|l| {
8810                    l.reason
8811                        .as_ref()
8812                        .map(|why| (self.preserved_link_full_path(l), why.clone()))
8813                })
8814                .collect()
8815        };
8816        match objects
8817            .into_iter()
8818            .find(|(full, _)| path == full || path.starts_with(&format!("{full}/")))
8819        {
8820            None => Ok(()),
8821            Some((link, why)) => Err(crate::io::IoError::Unsupported(format!(
8822                "'{path}' is, or is inside, the object '{link}', which this file's reopen \
8823                 kept exactly as it found it because {why}"
8824            ))),
8825        }
8826    }
8827
8828    /// Every link this writer will emit that names a *path* rather than an
8829    /// object, with the class a listing reports for it: the soft and external
8830    /// links created this session, and the ones a reopen is carrying through.
8831    ///
8832    /// The object listings answer for hard links, so a write-mode link
8833    /// listing is this plus those; keeping both sources in one place is what
8834    /// stops a listing from seeing a kind the class lookup does not, or the
8835    /// reverse.
8836    pub(crate) fn path_link_classes(&self) -> Vec<(String, crate::io::reader::LinkClass)> {
8837        let mut out: Vec<(String, crate::io::reader::LinkClass)> = self
8838            .symbolic_links_vec()
8839            .iter()
8840            .filter(|l| self.symbolic_link_emitted(l))
8841            .map(|l| {
8842                (
8843                    self.symbolic_link_full_path(l),
8844                    crate::io::reader::LinkClass::from_target(&l.target),
8845                )
8846            })
8847            .collect();
8848        out.extend(self.preserved_link_paths());
8849        out
8850    }
8851
8852    /// Every link this writer is carrying but cannot express, by full path.
8853    pub(crate) fn preserved_link_paths(&self) -> Vec<(String, crate::io::reader::LinkClass)> {
8854        self.preserved_links
8855            .lock()
8856            .iter()
8857            .map(|l| (self.preserved_link_full_path(l), l.class.clone()))
8858            .collect()
8859    }
8860
8861    /// The full path of a preserved link: its parent group's path plus its
8862    /// leaf name, in the no-leading-`/` form the registry uses.
8863    fn preserved_link_full_path(&self, link: &PreservedLink) -> String {
8864        match link.parent {
8865            None => link.name.clone(),
8866            Some(gi) => {
8867                let group = self.grp(gi).lock().name.clone();
8868                format!("{}/{}", group.trim_start_matches('/'), link.name)
8869            }
8870        }
8871    }
8872
8873    /// The single owner of "which links does this group hold", in the order
8874    /// they were created and, when the file tracks creation order, stamped
8875    /// with it.
8876    ///
8877    /// Both the compact form (one `MSG_LINK` per link) and the dense form (the
8878    /// same messages inside a fractal heap) are built from this one list, so
8879    /// the phase-change decision, the storage it selects and the creation
8880    /// order recorded in either can never disagree about what the group
8881    /// contains. The links a reopen kept by their bytes are in it too: they
8882    /// take their rank in the same numbering, or a group that tracks
8883    /// creation order would hold two links with one order.
8884    fn group_links(&self, scope: LinkScope, order: CreationOrder) -> Vec<GroupLink> {
8885        let mut links: Vec<(u64, LinkMessage)> = Vec::new();
8886        match scope {
8887            LinkScope::Root => {
8888                // Datasets that belong to a subgroup are that group's links,
8889                // not the root's. Each group slot is locked one at a time.
8890                let mut datasets_in_subgroups: std::collections::HashSet<usize> =
8891                    std::collections::HashSet::new();
8892                for grp in self.group_refs() {
8893                    let g = grp.lock();
8894                    if g.deleted {
8895                        continue;
8896                    }
8897                    datasets_in_subgroups.extend(g.child_datasets.iter().copied());
8898                }
8899                // `dataset_refs` preserves registry order, so `enumerate`
8900                // yields each dataset's true index.
8901                for (i, ds) in self.dataset_refs().into_iter().enumerate() {
8902                    let m = ds.lock();
8903                    if m.deleted || datasets_in_subgroups.contains(&i) {
8904                        continue;
8905                    }
8906                    // The leaf, never the registry path: a link name is one
8907                    // path component, and `H5G_traverse` would split a '/'
8908                    // in it before `H5L_link` ever saw the name.
8909                    let leaf_name = m.name.rsplit('/').next().unwrap_or(&m.name);
8910                    links.push((
8911                        m.creation_seq,
8912                        LinkMessage::hard(leaf_name, m.obj_header_addr),
8913                    ));
8914                }
8915                for grp in self.group_refs() {
8916                    let g = grp.lock();
8917                    if g.deleted || g.parent.is_some() {
8918                        continue;
8919                    }
8920                    let leaf_name = g.name.rsplit('/').next().unwrap_or(&g.name);
8921                    links.push((
8922                        g.creation_seq,
8923                        LinkMessage::hard(leaf_name, g.obj_header_addr),
8924                    ));
8925                }
8926                self.push_hard_links(&mut links, None);
8927                self.push_symbolic_links(&mut links, None);
8928                self.push_committed_datatypes(&mut links, None);
8929            }
8930            LinkScope::Group(group_idx) => {
8931                // Snapshot the child lists, then drop the slot guard: the
8932                // per-child reads below re-lock dataset and group slots
8933                // (including this one).
8934                let (child_datasets, child_groups) = {
8935                    let grp = self.grp(group_idx);
8936                    let g = grp.lock();
8937                    (g.child_datasets.clone(), g.child_groups.clone())
8938                };
8939                for ds_idx in child_datasets {
8940                    let ds = self.ds(ds_idx);
8941                    let m = ds.lock();
8942                    if m.deleted {
8943                        continue;
8944                    }
8945                    let leaf_name = m.name.rsplit('/').next().unwrap_or(&m.name);
8946                    links.push((
8947                        m.creation_seq,
8948                        LinkMessage::hard(leaf_name, m.obj_header_addr),
8949                    ));
8950                }
8951                for child_idx in child_groups {
8952                    let child_grp = self.grp(child_idx);
8953                    let g = child_grp.lock();
8954                    if g.deleted {
8955                        continue;
8956                    }
8957                    let leaf_name = g.name.rsplit('/').next().unwrap_or(&g.name);
8958                    links.push((
8959                        g.creation_seq,
8960                        LinkMessage::hard(leaf_name, g.obj_header_addr),
8961                    ));
8962                }
8963                self.push_hard_links(&mut links, Some(group_idx));
8964                self.push_symbolic_links(&mut links, Some(group_idx));
8965                self.push_committed_datatypes(&mut links, Some(group_idx));
8966            }
8967        }
8968        let parent = match scope {
8969            LinkScope::Root => None,
8970            LinkScope::Group(i) => Some(i),
8971        };
8972        let mut links: Vec<(u64, LinkMessage, Option<Vec<u8>>)> = links
8973            .into_iter()
8974            .map(|(seq, link)| (seq, link, None))
8975            .collect();
8976        for l in self.preserved_links.lock().iter() {
8977            if l.parent == parent {
8978                links.push((l.creation_seq, l.link.clone(), Some(l.encoded.clone())));
8979            }
8980        }
8981        // Creation order, not order by kind: a run of create_group and
8982        // create_dataset draws from one counter, so this is the order the
8983        // caller made them in. `H5G_obj_insert` numbers from zero within the
8984        // group, so the rank here is the link's creation order.
8985        links.sort_by_key(|(seq, ..)| *seq);
8986        links
8987            .into_iter()
8988            .enumerate()
8989            .map(|(rank, (_, mut link, preserved))| {
8990                if order.is_tracked() {
8991                    link.creation_order = Some(rank as i64);
8992                }
8993                let is_preserved = preserved.is_some();
8994                let encoded = match preserved {
8995                    None => link.encode(&self.ctx),
8996                    Some(mut bytes) => {
8997                        // Only the creation order field changes. Bytes that
8998                        // have none in a group that tracks it are not a
8999                        // libhdf5 group's; re-encoding is what gives them one.
9000                        if let Some(corder) = link.creation_order {
9001                            if !LinkMessage::restamp_encoded_creation_order(&mut bytes, corder) {
9002                                bytes = link.encode(&self.ctx);
9003                            }
9004                        }
9005                        bytes
9006                    }
9007                };
9008                GroupLink {
9009                    link,
9010                    encoded,
9011                    preserved: is_preserved,
9012                }
9013            })
9014            .collect()
9015    }
9016
9017    /// Whether `links` must live in dense storage rather than in the group's
9018    /// object header — the `H5G_obj_insert` phase-change rule, applied to the
9019    /// whole set at once because this writer builds each header from scratch
9020    /// rather than inserting one link at a time.
9021    ///
9022    /// libhdf5 converts when the count *reaches* `max_compact` and another
9023    /// link arrives, so a set of exactly `max_compact` is still compact; and
9024    /// separately when one message would not fit the 16-bit size field an
9025    /// object header message has.
9026    ///
9027    /// The answer depends only on the link names and kinds, never on the
9028    /// addresses they point at, which is what lets a group header be sized
9029    /// before [`prepare_dense_links`](Self::prepare_dense_links) has run.
9030    ///
9031    /// A link a reopen carried through by its bytes pins the group to compact
9032    /// storage: dense storage would have to re-encode each link into the
9033    /// heap, which is exactly the byte fidelity preserving them is for.
9034    fn links_need_dense(&self, links: &[GroupLink]) -> bool {
9035        !links.iter().any(|l| l.preserved)
9036            && (links.len() > MAX_COMPACT_LINKS
9037                || links.iter().any(|l| l.encoded.len() > MAX_MESSAGE_SIZE))
9038    }
9039
9040    /// The single owner of link emission into a group object header: the Link
9041    /// Info and Group Info messages, and then either one `MSG_LINK` per link
9042    /// or nothing at all when the set has spilled to dense storage.
9043    ///
9044    /// The two storage forms are exclusive (`H5G_obj_insert` moves the whole
9045    /// set at once), and a header carrying both would report every link twice.
9046    ///
9047    /// A group whose links are dense but not yet laid out gets a compact Link
9048    /// Info message here. That is deliberate: the message encodes to the same
9049    /// length either way — two addresses, defined or not — so the sizing pass
9050    /// that runs before `prepare_dense_links` still reserves the right number
9051    /// of bytes, and the write pass that runs after it emits the real heap and
9052    /// index addresses. It is the same two-pass rule the child link addresses
9053    /// already follow.
9054    fn emit_links(
9055        &self,
9056        header: &mut ObjectHeader,
9057        scope: LinkScope,
9058        links: &[GroupLink],
9059        order: CreationOrder,
9060    ) {
9061        // A symbol-table group holds no link messages at all: its links are the
9062        // entries of the symbol table `prepare_symbol_tables` laid out, and
9063        // the header carries only the two addresses naming it. Link Info and
9064        // Group Info are version-1.8 messages and have no business in a
9065        // version-1 header — `H5G__stab_valid` reads the Symbol Table message
9066        // and nothing else.
9067        if self.uses_symbol_table(scope, order) {
9068            // Sizing runs before the tables are laid out; the message is the
9069            // same two addresses wide either way, so the placeholder reserves
9070            // exactly what the real one needs. Same two-pass rule the child
9071            // link addresses already follow.
9072            let stab = self
9073                .symbol_tables
9074                .written
9075                .lock()
9076                .get(&scope)
9077                .copied()
9078                .unwrap_or(Stab {
9079                    btree_addr: UNDEF_ADDR,
9080                    heap_addr: UNDEF_ADDR,
9081                });
9082            header.add_message(MSG_SYMBOL_TABLE, 0x00, stab.encode(&self.ctx));
9083            return;
9084        }
9085        let dense = self.links_need_dense(links);
9086        let link_info = self.dense_links.lock().get(&scope).cloned();
9087        let link_info = link_info.unwrap_or_else(|| {
9088            let mut info = LinkInfoMessage::compact();
9089            if order.is_tracked() {
9090                // `H5G__obj_insert` post-increments `max_corder`, so a group
9091                // holding n links reports n.
9092                info.max_creation_order = Some(links.len() as u64);
9093            }
9094            if order.is_indexed() {
9095                // The index address stays undefined while the links live in
9096                // the header, but the message must still carry the field:
9097                // `H5Pget_link_creation_order` reads INDEXED off this flag,
9098                // not off the address.
9099                info.creation_order_btree_address = Some(UNDEF_ADDR);
9100            }
9101            info
9102        });
9103        header.add_message(MSG_LINK_INFO, 0x00, link_info.encode(&self.ctx));
9104        // The link info message takes no flags and the group info message
9105        // takes `H5O_MSG_FLAG_CONSTANT`, exactly as `H5G__obj_create_real`
9106        // creates the pair (H5Gobj.c:255, :259) and as
9107        // `H5G__obj_insert`'s phase change re-creates it (H5Gobj.c:526). The
9108        // asymmetry is real: the link info message records the group's
9109        // storage and its creation-order counter, both of which change as
9110        // links come and go, while the group info message holds the phase
9111        // change and estimated-name-length constants of the creation property
9112        // list, which nothing after creation rewrites.
9113        header.add_message(
9114            MSG_GROUP_INFO,
9115            MSG_FLAG_CONSTANT,
9116            GroupInfoMessage::default().encode(),
9117        );
9118        if dense {
9119            return;
9120        }
9121        for link in links {
9122            header.add_message(MSG_LINK, 0x00, link.encoded.clone());
9123        }
9124    }
9125
9126    /// Lay out and write dense link storage for every group that needs it,
9127    /// recording the resulting `Link Info` message per group.
9128    ///
9129    /// The sole owner of that transition. It must run after every object
9130    /// header address is assigned — the heap holds encoded link messages, and
9131    /// those name their targets — and before any group header is written.
9132    ///
9133    /// Every group whose header this finalize rewrites passes through here,
9134    /// dense or not: the storage a reopened header named is superseded by the
9135    /// rewrite whichever form the new link set takes, and freeing it first is
9136    /// what lets the replacement reuse those blocks.
9137    fn prepare_dense_links(&self) -> IoResult<()> {
9138        let mut scopes: Vec<(LinkScope, Vec<GroupLink>, CreationOrder)> = Vec::new();
9139        for gi in 0..self.group_count() {
9140            let (deleted, order) = {
9141                let grp = self.grp(gi);
9142                let g = grp.lock();
9143                (g.deleted, g.track_order.links)
9144            };
9145            // A symbol-table group is `prepare_symbol_tables`' business; it
9146            // has no Link Info message to hold a fractal heap address, and it
9147            // never had dense storage to release.
9148            if deleted || self.uses_symbol_table(LinkScope::Group(gi), order) {
9149                continue;
9150            }
9151            self.release_superseded_dense_links(LinkScope::Group(gi))?;
9152            let links = self.group_links(LinkScope::Group(gi), order);
9153            if self.links_need_dense(&links) {
9154                scopes.push((LinkScope::Group(gi), links, order));
9155            }
9156        }
9157        let root_order = self.root_track_order.links;
9158        if !self.uses_symbol_table(LinkScope::Root, root_order) {
9159            self.release_superseded_dense_links(LinkScope::Root)?;
9160            let root_links = self.group_links(LinkScope::Root, root_order);
9161            if self.links_need_dense(&root_links) {
9162                scopes.push((LinkScope::Root, root_links, root_order));
9163            }
9164        }
9165
9166        for (scope, links, order) in scopes {
9167            // `close` after `start_swmr` finalizes a second time over the same
9168            // groups, so rebuilding here would allocate a whole second heap
9169            // and strand the one the published headers already name.
9170            if self.dense_links.lock().contains_key(&scope) {
9171                continue;
9172            }
9173            let messages: Vec<LinkMessage> = links.into_iter().map(|l| l.link).collect();
9174            let dense = build_dense_links(&messages, &self.ctx, order, &mut |len| {
9175                self.allocator.allocate(len, FreeSpaceClass::Metadata)
9176            })?;
9177            for block in &dense.blocks {
9178                self.handle.write_at(block.addr, &block.image)?;
9179            }
9180            self.dense_links.lock().insert(scope, dense.linfo);
9181        }
9182        Ok(())
9183    }
9184
9185    /// Lay out whichever of the two forms of link storage this file uses,
9186    /// before any group header is written.
9187    ///
9188    /// The two are exclusive because the formats are: a classic group has no
9189    /// Link Info message to put a fractal heap address in, and a link-message
9190    /// group has no symbol table.
9191    fn prepare_link_storage(&self) -> IoResult<()> {
9192        self.prepare_dense_links()?;
9193        self.prepare_symbol_tables()
9194    }
9195
9196    /// Lay out and write the symbol table of every classic group, and free the
9197    /// storage each rewrite supersedes. A no-op on a link-message file.
9198    ///
9199    /// The classic counterpart of [`prepare_dense_links`](Self::prepare_dense_links),
9200    /// and the sole owner of that transition. The same two placement rules
9201    /// apply for the same two reasons: it runs after every object header has
9202    /// an address, because a symbol table entry names its target's header, and
9203    /// before any group header is written, because the header carries the
9204    /// Symbol Table message naming what this laid out.
9205    ///
9206    /// Deepest group first, root last. A hard link to a group caches that
9207    /// group's own B-tree and heap in the entry's scratch pad
9208    /// (`H5G__link_to_ent`), so the child's table must exist before the
9209    /// parent's is built; `H5G__stab_valid` checks the root entry's cache
9210    /// against the root header's Symbol Table message, so a stale pair there
9211    /// is not a slow lookup but a file `H5Fopen` rejects.
9212    ///
9213    /// Every classic group is rebuilt on every pass — there is no "already
9214    /// done" short-circuit like the dense one, because the only way this runs
9215    /// twice is a `Drop` retry after a failed `close`, and the entries of the
9216    /// first pass name header addresses the second pass has moved. (A SWMR
9217    /// session, the other double-finalize, cannot reach here: SWMR needs a
9218    /// version-3 superblock, so `start_swmr` refuses a classic file.)
9219    fn prepare_symbol_tables(&self) -> IoResult<()> {
9220        // Depth by parent chain, not by counting separators in the registry
9221        // path: the chain is what actually says which table has to exist first.
9222        let mut scopes: Vec<(usize, LinkScope, CreationOrder)> = Vec::new();
9223        for gi in 0..self.group_count() {
9224            let (deleted, order, mut parent) = {
9225                let grp = self.grp(gi);
9226                let g = grp.lock();
9227                (g.deleted, g.track_order.links, g.parent)
9228            };
9229            if deleted || !self.uses_symbol_table(LinkScope::Group(gi), order) {
9230                continue;
9231            }
9232            let mut depth = 1usize;
9233            while let Some(p) = parent {
9234                depth += 1;
9235                parent = self.grp(p).lock().parent;
9236            }
9237            scopes.push((depth, LinkScope::Group(gi), order));
9238        }
9239        scopes.sort_by_key(|&(depth, ..)| std::cmp::Reverse(depth));
9240        let root_order = self.root_track_order.links;
9241        if self.uses_symbol_table(LinkScope::Root, root_order) {
9242            scopes.push((0, LinkScope::Root, root_order));
9243        }
9244
9245        let meta = self.stab_meta();
9246        for (_, scope, order) in scopes {
9247            // Freed before the replacement is laid out, so a rewrite reuses
9248            // the same blocks instead of growing the file on every open/close
9249            // cycle — the rule `prepare_dense_links` and the header rewrite
9250            // already follow. Removed as it is freed, so no second pass can
9251            // free it twice.
9252            let superseded = self.symbol_tables.superseded.lock().remove(&scope);
9253            if let Some(extents) = superseded {
9254                free_stab(&self.allocator, &extents);
9255            }
9256            let links = self.stab_links_for(scope, order)?;
9257            let stab = write_stab(&self.handle, &self.allocator, &meta, &links)?;
9258            self.symbol_tables.written.lock().insert(scope, stab);
9259        }
9260        Ok(())
9261    }
9262
9263    /// The file-level parameters every symbol-table node width is derived from
9264    /// — the address/length widths and the B-tree "K" ranks. Only a version-0/1
9265    /// superblock records ranks of its own; [`btree_v1_config`] is the one
9266    /// place that decides whether this file has any.
9267    ///
9268    /// [`btree_v1_config`]: Self::btree_v1_config
9269    fn stab_meta(&self) -> FileMeta {
9270        FileMeta {
9271            ctx: self.ctx,
9272            btree: self.btree_v1_config(),
9273            sohm: None,
9274        }
9275    }
9276
9277    /// `scope`'s links as symbol table entries.
9278    ///
9279    /// A link a reopen carried through verbatim goes in by its decoded form,
9280    /// because a classic group has no link message to preserve it into.
9281    /// Nothing is lost in the round trip: the walk built that message from a
9282    /// symbol table entry in the first place, and the two forms carry the same
9283    /// three facts.
9284    fn stab_links_for(&self, scope: LinkScope, order: CreationOrder) -> IoResult<Vec<StabLink>> {
9285        let groups = self.group_header_scopes();
9286        let mut out = Vec::new();
9287        for link in self.group_links(scope, order) {
9288            out.push(self.stab_link(&link.link, &groups)?);
9289        }
9290        Ok(out)
9291    }
9292
9293    /// Where each group's object header now sits, so a hard link that lands on
9294    /// one can cache that group's symbol table in its scratch pad.
9295    fn group_header_scopes(&self) -> HashMap<u64, LinkScope> {
9296        let mut map = HashMap::new();
9297        for gi in 0..self.group_count() {
9298            let grp = self.grp(gi);
9299            let g = grp.lock();
9300            if !g.deleted {
9301                map.insert(g.obj_header_addr, LinkScope::Group(gi));
9302            }
9303        }
9304        map
9305    }
9306
9307    /// One link as a symbol table entry.
9308    ///
9309    /// The scratch pad caches the target group's B-tree and heap when the
9310    /// target is a group this pass has already laid out — what
9311    /// `H5G__link_to_ent` does, and what lets `H5G__stab_lookup` walk a path
9312    /// without opening each header on the way. For anything else the pad stays
9313    /// `H5G_NOTHING_CACHED`, the value libhdf5 itself writes whenever the
9314    /// target has no Symbol Table message to read.
9315    fn stab_link(
9316        &self,
9317        link: &LinkMessage,
9318        groups: &HashMap<u64, LinkScope>,
9319    ) -> IoResult<StabLink> {
9320        let target = match &link.target {
9321            LinkTarget::Hard { address } => {
9322                let cached = groups
9323                    .get(address)
9324                    .and_then(|scope| self.symbol_tables.written.lock().get(scope).copied());
9325                StabTarget::Hard {
9326                    addr: *address,
9327                    cached,
9328                }
9329            }
9330            LinkTarget::Soft { target } => StabTarget::Soft {
9331                value: target.clone(),
9332            },
9333            // Unreachable by construction: a group holding one of these is
9334            // not a symbol-table group at all
9335            // ([`LinkMessage::fits_symbol_table`] is what
9336            // [`Hdf5Writer::uses_symbol_table`] asks), so this pass never
9337            // visits it. Reported rather than panicked so a future caller
9338            // that skips that gate learns which link it lost.
9339            LinkTarget::External { .. } | LinkTarget::UserDefined { .. } => {
9340                return Err(crate::io::IoError::InvalidState(format!(
9341                    "cannot store the link {:?} in a symbol table: it holds only \
9342                     hard and soft links, and this group was not converted to link \
9343                     messages the way `H5G_obj_insert` converts it",
9344                    link.name
9345                )))
9346            }
9347        };
9348        Ok(StabLink {
9349            name: link.name.clone(),
9350            target,
9351        })
9352    }
9353
9354    /// The single owner of attribute emission into an object header: appends
9355    /// the Attribute Info message and then one `MSG_ATTRIBUTE` per attribute.
9356    ///
9357    /// On a version-2 object header the two are inseparable.
9358    /// `H5O__attr_count_real` derives `H5Oget_info().num_attrs` from the
9359    /// Attribute Info message alone — with no such message the count reads as
9360    /// zero however many attribute messages follow, which is what made every
9361    /// rust-written file report `num_attrs == 0` to libhdf5 while
9362    /// `H5Aiterate2` still yielded the attributes. The message carries no
9363    /// count of its own: `H5A__get_ainfo` fills `nattrs` from the attribute
9364    /// messages the header loader actually saw, so compact storage needs
9365    /// nothing but the message's presence.
9366    ///
9367    /// When [`prepare_dense_attributes`](Self::prepare_dense_attributes) has
9368    /// spilled `scope`'s attributes to a fractal heap, the same message names
9369    /// that heap instead and *no* attribute message follows: the two storage
9370    /// forms are exclusive (`H5O__attr_create` moves the whole set at once),
9371    /// and a header carrying both would report every attribute twice.
9372    fn emit_attributes(
9373        &self,
9374        header: &mut ObjectHeader,
9375        scope: AttrScope,
9376        attributes: &[AttributeEntry],
9377        order: CreationOrder,
9378        format: ObjectFormat,
9379        owner: ShareOwner,
9380    ) {
9381        // `H5Pget_attr_creation_order` reads the object header's own flags,
9382        // not the Attribute Info message, so this is what makes the object
9383        // report creation-ordered attributes — and tracking widens every
9384        // message envelope by the creation index below.
9385        let order = self.header_attr_order(order);
9386        header.set_attribute_creation_order(order);
9387        if attributes.is_empty() {
9388            return;
9389        }
9390        // A version-1 object header gets the attribute messages alone.
9391        // `H5O__attr_create` gates every mention of the Attribute Info message
9392        // on `oh->version > H5O_VERSION_1` (H5Oattribute.c:218), and so does
9393        // `H5O__attr_count_real`, which is why the count still reads correctly
9394        // without it: on a version-1 header libhdf5 counts the messages.
9395        if format == ObjectFormat::Legacy {
9396            for attr in attributes {
9397                header.add_message(MSG_ATTRIBUTE, 0x00, self.encode_attribute(attr));
9398            }
9399            return;
9400        }
9401        // Whether the set spills is a property of the set alone, so it is the
9402        // same answer in the pass that measures this header and in the pass
9403        // that writes it — even though the storage itself is laid out between
9404        // the two, because it can only be laid out once every object header
9405        // has an address. Sizing therefore falls back to a placeholder message
9406        // of the same width: only the creation-order flags change the
9407        // Attribute Info message's length, so the header measured here holds
9408        // the header written against the storage that replaces it. Same
9409        // two-pass rule `emit_links` follows for dense links and symbol
9410        // tables.
9411        let dense = self.attributes_need_dense(attributes, format);
9412        let stored = self.dense_attributes.lock().get(&scope).cloned();
9413        let ainfo = stored.unwrap_or_else(|| {
9414            let mut ainfo = AttributeInfoMessage::compact();
9415            if order.is_tracked() {
9416                ainfo.max_creation_index = Some(next_creation_index(attributes));
9417            }
9418            if order.is_indexed() {
9419                // Compact storage has no index B-tree, but the message still
9420                // announces one so that its flags match the header's
9421                // (`H5O__attr_create` asserts they agree).
9422                ainfo.creation_order_btree_address = Some(UNDEF_ADDR);
9423            }
9424            ainfo
9425        });
9426        header.add_message(MSG_ATTR_INFO, MSG_FLAG_DONTSHARE, ainfo.encode(&self.ctx));
9427        if dense {
9428            return;
9429        }
9430        // Each attribute states its own creation index — the one it was
9431        // created with here, or the one the file it was read from records. An
9432        // attribute with none belongs to an object that tracks no order, where
9433        // the field is not encoded at all.
9434        for attr in attributes {
9435            let (flags, body) = self.share_attribute(attr, format, owner);
9436            header.add_message_indexed(
9437                MSG_ATTRIBUTE,
9438                flags,
9439                body,
9440                attr.creation_index().unwrap_or(0),
9441            );
9442        }
9443    }
9444
9445    /// One attribute message body, at the version this file's low library
9446    /// bound calls for (`H5A__set_version`, which reads the bound and nothing
9447    /// about the object the attribute hangs on).
9448    fn encode_attribute(&self, attr: &AttributeEntry) -> Vec<u8> {
9449        attr.encode_for(&self.ctx, self.encoding_libver(), self.message_format())
9450    }
9451
9452    /// What a header stores for one attribute: the message flags and the body,
9453    /// with the attribute's own datatype and dataspace shared wherever an
9454    /// index covers them.
9455    ///
9456    /// `H5A__create` offers both to `H5SM_try_share` (H5Aint.c:375-377) before
9457    /// `H5O__attr_create` offers the attribute itself (H5Oattribute.c:726), so
9458    /// the attribute body that reaches the heap already holds their pointers
9459    /// and says which fields they are in its own flags byte
9460    /// (`H5O_ATTR_FLAG_TYPE_SHARED` / `H5O_ATTR_FLAG_SPACE_SHARED`,
9461    /// H5Oattr.c:358-359). Both offers go through
9462    /// [`share_message`](Self::share_message) like any other, so the pass that
9463    /// counts references and the pass that substitutes see the same three
9464    /// messages.
9465    fn share_attribute(
9466        &self,
9467        attr: &AttributeEntry,
9468        format: ObjectFormat,
9469        owner: ShareOwner,
9470    ) -> (u8, Vec<u8>) {
9471        let libver = self.encoding_libver();
9472        // Only a readable attribute has pieces to offer: an unreadable one is
9473        // the bytes it was read from, put back as they were. Version 1 has no
9474        // flags byte to record a shared field in — `H5O__attr_encode` writes a
9475        // reserved zero there — so a classic file shares the attribute whole
9476        // or not at all.
9477        let Some(message) = attr.readable().filter(|_| format.attribute_version() >= 2) else {
9478            return self.share_message(
9479                owner,
9480                MSG_ATTRIBUTE,
9481                0x00,
9482                attr.encode_for(&self.ctx, libver, format),
9483            );
9484        };
9485
9486        let datatype = message.datatype.encode_at(&self.ctx, libver);
9487        let dataspace = message.dataspace.encode_for(&self.ctx, format);
9488        // `H5A__create` passes no open header for either (H5Aint.c:375-377):
9489        // both live inside the attribute's body, so neither has a header
9490        // message a `H5SM_IN_OH` record could name and both reach the heap on
9491        // first use.
9492        let (dt_flags, dt_field) =
9493            self.share_message(ShareOwner::Detached, MSG_DATATYPE, 0x00, datatype.clone());
9494        let (ds_flags, ds_field) =
9495            self.share_message(ShareOwner::Detached, MSG_DATASPACE, 0x00, dataspace.clone());
9496
9497        let mut attr_flags = 0u8;
9498        if dt_flags & MSG_FLAG_SHARED != 0 {
9499            attr_flags |= ATTR_FLAG_TYPE_SHARED;
9500        }
9501        if ds_flags & MSG_FLAG_SHARED != 0 {
9502            attr_flags |= ATTR_FLAG_SPACE_SHARED;
9503        }
9504        let encoded = message.encode_with_fields(attr_flags, &dt_field, &ds_field);
9505
9506        // Each shared field's heap ID sits two bytes into the pointer that
9507        // replaced it; the body offered below carries whatever
9508        // `share_message` just produced, which is a zeroed ID in the pass that
9509        // counts and the real one in the pass that substitutes.
9510        let mut nested = Vec::new();
9511        if attr_flags & ATTR_FLAG_TYPE_SHARED != 0 {
9512            nested.push(NestedShare {
9513                heap_id_at: encoded.datatype_at + SOHM_POINTER_HEAP_ID_AT,
9514                target: (MSG_DATATYPE, datatype),
9515            });
9516        }
9517        if attr_flags & ATTR_FLAG_SPACE_SHARED != 0 {
9518            nested.push(NestedShare {
9519                heap_id_at: encoded.dataspace_at + SOHM_POINTER_HEAP_ID_AT,
9520                target: (MSG_DATASPACE, dataspace),
9521            });
9522        }
9523        self.share_nesting_message(owner, MSG_ATTRIBUTE, 0x00, encoded.body, nested)
9524    }
9525
9526    /// Whether `attributes` must live in dense storage rather than in the
9527    /// object header — the `H5O__attr_create` phase-change rule, applied to
9528    /// the whole set at once because this writer builds each header from
9529    /// scratch rather than inserting one attribute at a time.
9530    ///
9531    /// libhdf5 converts when the count *reaches* `max_compact` and another
9532    /// attribute arrives, so a set of exactly `max_compact` is still compact;
9533    /// and separately when one message would not fit the 16-bit size field an
9534    /// object header message has.
9535    ///
9536    /// Never in a classic file. Dense attribute storage is a fractal heap
9537    /// reached through an Attribute Info message, both introduced in the 1.8
9538    /// format; at `H5F_LIBVER_EARLIEST` libhdf5 keeps every attribute in the
9539    /// header however many there are (`H5O__attr_create` reaches the phase
9540    /// change only when the object header version allows it). An attribute
9541    /// too large for the 16-bit size field is then an error, which
9542    /// `ObjectHeader::encode_v1` raises, rather than a reason to spill.
9543    fn attributes_need_dense(&self, attributes: &[AttributeEntry], format: ObjectFormat) -> bool {
9544        if format == ObjectFormat::Legacy {
9545            return false;
9546        }
9547        attributes.len() > MAX_COMPACT_ATTRS
9548            || attributes
9549                .iter()
9550                .any(|a| self.encode_attribute(a).len() > MAX_MESSAGE_SIZE)
9551    }
9552
9553    /// Every object whose attributes this finalize re-lays-out, with the
9554    /// creation-order policy each one's storage must follow.
9555    ///
9556    /// `datasets` lists the datasets whose headers this finalize will
9557    /// actually write. A reopened dataset that took no writes keeps its
9558    /// original header — and with it whatever storage that header already
9559    /// names — so touching its attribute storage would strand every block of
9560    /// it.
9561    ///
9562    /// The policy is the one the *header* records, not the one the object's
9563    /// creation property list asked for: those differ on a file whose
9564    /// shared-message configuration covers attributes, where
9565    /// [`header_attr_order`](Self::header_attr_order) raises every object to
9566    /// tracked. Storage laid out against the property list would then omit the
9567    /// creation indices the header says are there — and, since the Attribute
9568    /// Info message carries a maximum creation index only when tracked, would
9569    /// be two bytes shorter than the message the sizing pass measured.
9570    fn attribute_scopes(&self, datasets: &[usize]) -> Vec<(AttrScope, CreationOrder)> {
9571        let order_of = |requested| self.header_attr_order(requested);
9572        let mut scopes = vec![(AttrScope::Root, order_of(self.root_track_order.attrs))];
9573        for gi in 0..self.group_count() {
9574            if self.grp(gi).lock().deleted {
9575                continue;
9576            }
9577            let order = self.grp(gi).lock().track_order.attrs;
9578            scopes.push((AttrScope::Group(gi), order_of(order)));
9579        }
9580        for &i in datasets {
9581            let order = self.ds(i).lock().track_attr_order;
9582            scopes.push((AttrScope::Dataset(i), order_of(order)));
9583        }
9584        scopes
9585    }
9586
9587    /// Lay out and write dense attribute storage for every object that needs
9588    /// it, recording the resulting `Attribute Info` message per object.
9589    ///
9590    /// The sole owner of that transition. It runs after every object header
9591    /// has an address — an attribute may hold an object reference, and the
9592    /// heap holds the encoded attribute messages — and before any object
9593    /// header is written, because the header carries the Attribute Info
9594    /// message naming what this laid out. Every block is on disk before the
9595    /// map naming it is populated, so a header written from that map can only
9596    /// point at bytes that exist. The same placement rule, for the same two
9597    /// reasons, as [`prepare_dense_links`](Self::prepare_dense_links).
9598    ///
9599    /// Which objects spill is not decided here: `emit_attributes` asks
9600    /// [`attributes_need_dense`](Self::attributes_need_dense) itself, so the
9601    /// header measured before this ran and the header written after it agree
9602    /// without either consulting the other.
9603    fn prepare_dense_attributes(&self, datasets: &[usize]) -> IoResult<()> {
9604        for (scope, order) in self.attribute_scopes(datasets) {
9605            // Every scope here has its header rewritten, so the storage a
9606            // reopen found on it is superseded whether or not the new set is
9607            // dense again — a free driven by "the new set needs a heap" would
9608            // never reach an object that dropped back to compact. Freed
9609            // immediately before its replacement is laid out, so the rewrite
9610            // lands in the blocks it just gave back instead of growing the
9611            // file on every open/close cycle.
9612            self.release_superseded_dense_attrs(scope)?;
9613            let attributes = self.object_attributes(scope)?;
9614            // `close` after `start_swmr` finalizes a second time: a set
9615            // unchanged since it was published keeps the heap its header
9616            // names, and a changed one gets a new heap, the published one
9617            // left to the readers that may hold it.
9618            if self.dense_attributes.lock().contains_key(&scope)
9619                && self.swmr_published_attrs.lock().get(&scope) == Some(&attributes)
9620            {
9621                continue;
9622            }
9623            self.dense_attributes.lock().remove(&scope);
9624            if !self.attributes_need_dense(&attributes, self.attr_scope_format(scope)) {
9625                continue;
9626            }
9627            let dense = build_dense_attributes(&attributes, &self.ctx, order, &mut |len| {
9628                self.allocator.allocate(len, FreeSpaceClass::Metadata)
9629            })?;
9630            for block in &dense.blocks {
9631                self.handle.write_at(block.addr, &block.image)?;
9632            }
9633            self.dense_attributes.lock().insert(scope, dense.ainfo);
9634        }
9635        Ok(())
9636    }
9637
9638    /// The object header format `scope`'s owner is written at, which is what
9639    /// decides whether its attributes may spill at all.
9640    fn attr_scope_format(&self, scope: AttrScope) -> ObjectFormat {
9641        match scope {
9642            AttrScope::Root => self.header_format(self.root_track_order),
9643            AttrScope::Group(gi) => self.group_header_format(gi),
9644            AttrScope::Dataset(i) => self.dataset_header_format(i),
9645        }
9646    }
9647
9648    /// Whether a dataset's datatype message may be offered to a
9649    /// shared-message index at all.
9650    ///
9651    /// The datatype is the one message class carrying a `can_share` callback
9652    /// (`H5O__dtype_can_share`, H5Odtype.c:99), and `H5SM__can_share_common`
9653    /// asks it before any index is consulted (H5SM.c:895-899). It refuses an
9654    /// immutable type and a committed one (H5Odtype.c:1893-1901); the
9655    /// committed half is already answered by address at the call site.
9656    ///
9657    /// A dataset's type reaches that predicate still immutable only when
9658    /// `H5D__init_type` kept the caller's own `H5T_t` rather than copying it,
9659    /// which it does exactly when the type is immutable, is not relocatable,
9660    /// and the low bound this dataset's messages are written at is below
9661    /// `H5F_LIBVER_V18` (H5Dint.c:569-572) — the bound the dataset was
9662    /// *created* under, which for a dataset a reopen found is not this
9663    /// session's.
9664    /// Any of the three failing produces an `H5T_COPY_ALL` copy, which is
9665    /// `H5T_STATE_RDONLY` rather than immutable (H5T.c:4461-4462) and so is
9666    /// shareable — which is why `H5Tcopy(H5T_STD_I32LE)` shares where
9667    /// `H5T_STD_I32LE` itself does not (tests/fixtures/gen_sohm.c).
9668    ///
9669    /// An attribute has no such branch: `H5A__create` copies unconditionally
9670    /// (H5Aint.c:341), so its datatype is always eligible and
9671    /// [`share_attribute`](Self::share_attribute) offers it without asking.
9672    fn dataset_datatype_shareable(&self, datatype: &DatatypeMessage, libver: LibverBound) -> bool {
9673        !datatype.is_predefined() || datatype.is_relocatable() || libver >= LibverBound::V18
9674    }
9675
9676    /// Whether the first copy of a `msg_type` message may stay literal in the
9677    /// object header that writes it.
9678    ///
9679    /// `H5O_msg_can_share_in_ohdr` reads the class's `H5O_SHARE_IN_OHDR` flag
9680    /// (H5Omessage.c:1426); the five classes that carry it are datatype
9681    /// (H5Odtype.c:89), dataspace (H5Osdspace.c:61), both fill value messages
9682    /// (H5Ofill.c:106 and :130) and the filter pipeline (H5Opline.c:65). The
9683    /// attribute class does not, which is why an attribute reaches the heap on
9684    /// its first use.
9685    const fn shares_in_ohdr(msg_type: u8) -> bool {
9686        matches!(
9687            msg_type,
9688            MSG_DATASPACE
9689                | MSG_DATATYPE
9690                | MSG_FILL_VALUE
9691                | MSG_FILL_VALUE_OLD
9692                | MSG_FILTER_PIPELINE
9693        )
9694    }
9695
9696    /// What a header stores for a message a shared-message index may cover:
9697    /// the body itself, or a pointer into the shared-message heap.
9698    ///
9699    /// The single point at which a message is offered to an index. Every
9700    /// header builder routes its shareable messages through here, so the pass
9701    /// that counts references and the pass that substitutes pointers walk
9702    /// exactly the same set — the counting and the substituting cannot drift
9703    /// apart, because they are one call site in two phases.
9704    ///
9705    /// `owner` is `H5SM_try_share`'s `open_oh`: the header this message
9706    /// belongs to, or [`ShareOwner::Detached`] for a body that is part of
9707    /// another message rather than a message of a header.
9708    ///
9709    /// Outside a finalize, and in any file created without indexes, this is
9710    /// the identity.
9711    fn share_message(
9712        &self,
9713        owner: ShareOwner,
9714        msg_type: u8,
9715        flags: u8,
9716        body: Vec<u8>,
9717    ) -> (u8, Vec<u8>) {
9718        self.share_nesting_message(owner, msg_type, flags, body, Vec::new())
9719    }
9720
9721    /// [`share_message`](Self::share_message) for a body that itself holds
9722    /// shared-message pointers.
9723    ///
9724    /// `nested` names each heap ID inside `body`, which is zero until the
9725    /// table is laid out. Two bodies that differ only in what they point at
9726    /// are the same bytes here and different bytes on disk, so the count and
9727    /// the substitute are keyed on the pair.
9728    fn share_nesting_message(
9729        &self,
9730        owner: ShareOwner,
9731        msg_type: u8,
9732        flags: u8,
9733        body: Vec<u8>,
9734        nested: Vec<NestedShare>,
9735    ) -> (u8, Vec<u8>) {
9736        let Some(sohm) = self.sohm.as_deref() else {
9737            return (flags, body);
9738        };
9739        // A message already carrying a pointer — a committed datatype — is
9740        // shared by address and must not be shared again, and the message
9741        // classes libhdf5 marks `H5O_MSG_FLAG_DONTSHARE` never reach an index.
9742        if flags & (MSG_FLAG_SHARED | MSG_FLAG_DONTSHARE) != 0 {
9743            return (flags, body);
9744        }
9745        let Some(index) = sohm.index_for(msg_type, body.len()) else {
9746            return (flags, body);
9747        };
9748        // `share_in_ohdr && open_oh` (H5SM.c:1400): the first copy of one of
9749        // these classes stays where it was written, marked shareable, and only
9750        // a second use moves the body to the heap.
9751        let ohdr = match owner {
9752            ShareOwner::Header(addr) if Self::shares_in_ohdr(msg_type) => Some(addr),
9753            _ => None,
9754        };
9755        // What a pointer to this body looks like: a zeroed heap ID until the
9756        // table exists, which is the width the real one has.
9757        let pointer = |id| {
9758            (
9759                flags | MSG_FLAG_SHARED,
9760                SharedMessagePointer::encode_sohm(id),
9761            )
9762        };
9763        match &mut *sohm.phase.lock() {
9764            SohmPhase::Idle => (flags, body),
9765            SohmPhase::Predict(first) => {
9766                if ohdr.is_some() && first.insert((msg_type, body.clone())) {
9767                    return (flags | MSG_FLAG_SHAREABLE, body);
9768                }
9769                pointer([0u8; SOHM_HEAP_ID_LEN])
9770            }
9771            // The same substitution `Predict` makes, so that what the collect
9772            // pass builds around a shared message is the width the resolve
9773            // pass will build — which is what lets an attribute body assembled
9774            // in this pass be the body assembled in that one, bar the heap IDs
9775            // it is here recording a need for.
9776            SohmPhase::Collect(collector) => {
9777                let first = collector.record(index, msg_type, &body, &nested, ohdr);
9778                if !first && !nested.is_empty() {
9779                    // This body is already here, so the pointers it holds
9780                    // already exist in the heap and the offers that built
9781                    // this copy of it must not count a second time.
9782                    for share in &nested {
9783                        collector.release(share.target.0, &share.target.1);
9784                    }
9785                }
9786                if ohdr.is_some() && first {
9787                    return (flags | MSG_FLAG_SHAREABLE, body);
9788                }
9789                pointer([0u8; SOHM_HEAP_ID_LEN])
9790            }
9791            SohmPhase::Resolve { ids, first } => {
9792                if ohdr.is_some() && first.insert((msg_type, body.clone())) {
9793                    return (flags | MSG_FLAG_SHAREABLE, body);
9794                }
9795                let key = (msg_type, body);
9796                match ids.get(&key) {
9797                    Some(&id) => pointer(id),
9798                    // The collect pass never saw this body — a dataspace a
9799                    // SWMR extend changed after the table was laid out, say.
9800                    // Left literal, which leaves a heap object counted for one
9801                    // reference more than reaches it and nothing else.
9802                    None => (flags, key.1),
9803                }
9804            }
9805        }
9806    }
9807
9808    /// Answer every shareable message at a heap pointer's width for the rest
9809    /// of this finalize's allocation phase.
9810    ///
9811    /// Half of the bracket [`prepare_shared_messages`](Self::prepare_shared_messages)
9812    /// closes, and the reason the two can sit on opposite sides of the
9813    /// allocation: a header cannot be measured until it is known which of its
9814    /// messages are pointers, and a body cannot be counted until every address
9815    /// it names exists. Only the width is knowable in the first phase, and the
9816    /// width is all the measurement needs.
9817    ///
9818    /// A finalize that will not lay a table out — a `finalize_for_swmr`, a
9819    /// second finalize over a table already published — leaves the phase where
9820    /// it found it, so what that pass measures is what it writes.
9821    fn begin_shared_message_layout(&self) {
9822        let Some(sohm) = self.sohm.as_deref() else {
9823            return;
9824        };
9825        let mut phase = sohm.phase.lock();
9826        if matches!(*phase, SohmPhase::Idle) && sohm.table_addr.lock().is_none() {
9827            *phase = SohmPhase::Predict(FirstCopies::default());
9828        }
9829    }
9830
9831    /// Lay out the file's shared-message table: count the bodies every header
9832    /// this finalize writes would share, put them in their index's heap, and
9833    /// arm the substitution the header builders then apply.
9834    ///
9835    /// The sole owner of the transition to `Resolve`. It runs last in the
9836    /// content phase, after
9837    /// [`prepare_dense_attributes`](Self::prepare_dense_attributes),
9838    /// [`prepare_link_storage`](Self::prepare_link_storage) and
9839    /// [`write_reference_values`](Self::write_reference_values), because a
9840    /// body is only counted once it is the body the file will hold: an
9841    /// attribute that spilled into dense storage is not in a header to be
9842    /// shared at all, and one holding an object reference says an object
9843    /// header address that exists only after the allocation phase. Counting
9844    /// either of them earlier would count a body no header ends up carrying,
9845    /// and leave the header that carries the real one literal — which
9846    /// [`check_header_size`] would then refuse, the block having been
9847    /// reserved at a pointer's width.
9848    ///
9849    /// Once per file: a second finalize (a SWMR session's close) keeps the
9850    /// table the first one published rather than allocating a second one and
9851    /// stranding the first.
9852    fn prepare_shared_messages(&self, datasets: &[usize]) -> IoResult<()> {
9853        let Some(sohm) = self.sohm.as_deref() else {
9854            return Ok(());
9855        };
9856        if sohm.table_addr.lock().is_some() {
9857            return Ok(());
9858        }
9859
9860        // Collect: build every header this finalize will write and throw it
9861        // away, keeping only what its shareable messages were.
9862        *sohm.phase.lock() = SohmPhase::Collect(SohmCollector::new(sohm.indexes.len()));
9863        for &i in datasets {
9864            self.build_dataset_header(i)?;
9865        }
9866        for gi in 0..self.group_count() {
9867            if self.grp(gi).lock().deleted {
9868                continue;
9869            }
9870            self.build_group_header(gi)?;
9871        }
9872        self.build_root_group_header()?;
9873        let SohmPhase::Collect(collector) =
9874            std::mem::replace(&mut *sohm.phase.lock(), SohmPhase::Idle)
9875        else {
9876            return Err(crate::io::IoError::InvalidState(
9877                "the shared-message collect pass did not finish in the collect phase".into(),
9878            ));
9879        };
9880
9881        let indexes: Vec<SohmIndexContent> = sohm
9882            .indexes
9883            .iter()
9884            .zip(collector.messages)
9885            .map(|(&spec, messages)| SohmIndexContent { spec, messages })
9886            .collect();
9887        // The table a reopen found is superseded whole by the one below, and
9888        // every header that pointed into it is in this finalize's rewrite set
9889        // — so its blocks go back immediately before the replacement is laid
9890        // out, and the new table lands in them instead of growing the file on
9891        // every open/close cycle. Taken, not read: a second finalize must not
9892        // free the same blocks twice.
9893        for (addr, len) in std::mem::take(&mut *sohm.superseded.lock()) {
9894            self.allocator.free(addr, len, FreeSpaceClass::Metadata);
9895        }
9896        let built = build_shared_messages(&indexes, &self.ctx, &mut |len| {
9897            self.allocator.allocate(len, FreeSpaceClass::Metadata)
9898        })?;
9899        for block in &built.blocks {
9900            self.handle.write_at(block.addr, &block.image)?;
9901        }
9902
9903        // Only now, with every block on disk: from here the header builders
9904        // substitute pointers, and `write_superblock_extension` names the
9905        // table this laid out.
9906        *sohm.phase.lock() = SohmPhase::Resolve {
9907            ids: built.heap_ids,
9908            first: FirstCopies::default(),
9909        };
9910        *sohm.table_addr.lock() = Some(built.table_addr);
9911        Ok(())
9912    }
9913
9914    /// Write the file's free-space managers over the space this close leaves
9915    /// free, and return the file-space info message body naming them.
9916    ///
9917    /// Called from [`write_superblock_extension`](Self::write_superblock_extension)
9918    /// once every other block of the file has an address, which is what makes
9919    /// the allocator's free list the file's *final* free space: a block
9920    /// allocated after this point would land in space a manager still claims.
9921    ///
9922    /// INVARIANT: from the moment this returns, every byte the allocator holds
9923    /// free is a byte some sections block records, and the two blocks each
9924    /// manager itself occupies are held by neither. Nothing may allocate
9925    /// between here and the superblock write; `write_object_headers` writes
9926    /// over blocks reserved in an earlier phase and is the only thing that
9927    /// runs in between.
9928    ///
9929    /// Returns `None` for a file with no message of its own to write — a
9930    /// reopen whose carried message this session must not touch, and a file
9931    /// created at the library defaults — which leaves both byte-identical to
9932    /// what the same close wrote before free space was recorded at all. A file
9933    /// that carries the message but keeps no managers (either non-manager
9934    /// strategy, or `persist: false`) gets the message back with every address
9935    /// undefined, which is what `H5F__super_init` writes for it.
9936    fn write_free_space_managers(&self) -> IoResult<Option<Vec<u8>>> {
9937        let Some(fs) = self.free_space.as_deref() else {
9938            return Ok(None);
9939        };
9940        if !fs.records_free_space() {
9941            return Ok(Some(fs.info.encode(&self.ctx)?));
9942        }
9943        // The managers a reopen found are superseded whole by the ones below,
9944        // so their blocks go back before anything is laid out: the space the
9945        // old manager occupied is free space the new one records, and the new
9946        // one may be laid out in it.
9947        for &(addr, len) in &fs.superseded {
9948            self.allocator.free(addr, len, FreeSpaceClass::Metadata);
9949        }
9950
9951        let hdr_size = FreeSpaceHeader::encoded_size(&self.ctx) as u64;
9952        let settled = self.settle_free_space_managers(hdr_size, fs.info.threshold)?;
9953
9954        let mut info = fs.info.clone();
9955        info.fs_addr = vec![UNDEF_ADDR; info.fs_addr.len()];
9956        for placed in &settled {
9957            let mut header = manager_header(&placed.sections);
9958            // The settle loop sized the block; that the encode agrees is the
9959            // invariant that makes `sect_size` a length a reader can trust.
9960            let needed = free_space::sinfo_encoded_size(&header, &placed.sections, &self.ctx);
9961            if needed > placed.sect_size {
9962                return Err(crate::io::IoError::InvalidState(format!(
9963                    "the free-space sections need {needed} bytes, not the {} laid out",
9964                    placed.sect_size
9965                )));
9966            }
9967            header.sect_addr = placed.sect_addr;
9968            header.sect_size = placed.sect_size;
9969            header.alloc_sect_size = placed.sect_size;
9970            self.handle.write_at(
9971                placed.sect_addr,
9972                &free_space::encode_sections(
9973                    &header,
9974                    placed.hdr_addr,
9975                    &placed.sections,
9976                    placed.sect_size as usize,
9977                    &self.ctx,
9978                ),
9979            )?;
9980            self.handle
9981                .write_at(placed.hdr_addr, &header.encode(&self.ctx))?;
9982            // `H5MF__close_delete_fstype` leaves a manager with no sections
9983            // without an address, so only the ones written name themselves.
9984            info.fs_addr[placed.manager.message_slot()] = placed.hdr_addr;
9985        }
9986        // The end of the file *after* the settle above, not before it, which
9987        // the field's name denies: it is 1.10 vintage, where two EOAs were
9988        // kept — one taken before the self-referential managers were placed
9989        // and one after (H5MF.c:3305 and 3382 in 1.10.11) — and the message
9990        // carried the first (1.10.11 H5MF.c:1833, 1999). 1.14 keeps one,
9991        // `f->shared->eoa_fsm_fsalloc`, read once the allocation loop has run
9992        // (H5MF.c:3234-3240) and encoded into this field by both close paths
9993        // (H5MF.c:1759, 1923); H5Fsuper.c:826 names it "the final eoa". A
9994        // 1.10 reader wants that value and not the older one: equal EOAs are
9995        // the case `H5MF_tidy_self_referential_fsm_hack` returns on
9996        // (1.10.11 H5MF.c:3620-3622), which is what leaves the managers this
9997        // close wrote in place.
9998        info.eoa_pre_fsm_fsalloc = self.allocator.eof();
9999        Ok(Some(info.encode(&self.ctx)?))
10000    }
10001
10002    /// The file's free space as each manager will record it: address-ordered
10003    /// per manager, tagged with the section class that manager writes, and
10004    /// with everything below `threshold` left out.
10005    ///
10006    /// The allocator is the single owner of merging — `H5FS__sect_merge`'s
10007    /// rules, per manager and, on a paged file, per page — so nothing merges
10008    /// here; overlap is checked because two overlapping sections would be a
10009    /// manager claiming space another structure holds.
10010    fn free_sections(&self, threshold: u64) -> IoResult<Vec<(FreeSpaceManager, Vec<FreeSection>)>> {
10011        let policy = self.allocator.policy();
10012        let extents = self.allocator.free_extents();
10013        let mut sets = Vec::new();
10014        for manager in FreeSpaceManager::ALL {
10015            let mut sections: Vec<FreeSection> = extents
10016                .iter()
10017                .filter(|b| b.manager == manager)
10018                // `H5FS_sect_add` refuses a section below the file's
10019                // threshold, so a block smaller than it is space the file
10020                // leaks rather than records — the same trade the threshold is
10021                // there to make.
10022                .filter(|b| b.len >= threshold)
10023                .map(|b| FreeSection {
10024                    addr: b.addr,
10025                    len: b.len,
10026                    class: policy.section_class(manager),
10027                })
10028                .collect();
10029            sections.sort_unstable_by_key(|s| s.addr);
10030            if let Some(bad) = sections
10031                .windows(2)
10032                .find(|w| w[0].addr + w[0].len > w[1].addr)
10033            {
10034                return Err(crate::io::IoError::InvalidState(format!(
10035                    "this session freed overlapping blocks: {:#x}+{} overlaps {:#x}",
10036                    bad[0].addr, bad[0].len, bad[1].addr
10037                )));
10038            }
10039            sets.push((manager, sections));
10040        }
10041        Ok(sets)
10042    }
10043
10044    /// Give every manager that records anything its own header and sections
10045    /// blocks, and return what each will write.
10046    ///
10047    /// Self-referential, which is the whole difficulty: a manager's two blocks
10048    /// come out of the free space the managers record, and taking them changes
10049    /// that space, which changes how many bytes the sections block needs.
10050    /// Upstream reruns the allocation pass until no manager allocates anything
10051    /// further — the `do { ... } while (continue_alloc_fsm)` loop in
10052    /// `H5MF_settle_meta_data_fsm` (H5MF.c:3213-3247) around
10053    /// `H5FS_vfd_alloc_hdr_and_section_info_if_needed`, which allocates
10054    /// through `H5MF_alloc` like everything else. So does this: the blocks
10055    /// come out of the same [`FileAllocator`], under the same strategy, so a
10056    /// paged file's manager blocks land in pages and their page remainders are
10057    /// recorded like any others.
10058    ///
10059    /// Two rules make it terminate. A manager, once placed, stays placed: were
10060    /// its blocks released because its sections had been consumed, freeing
10061    /// them would put those sections back and the next round would place it
10062    /// again. And a sections block only ever grows: upstream frees a block
10063    /// that turned out too small and reallocates it next round
10064    /// (H5FSsection.c:2418-2423), and a size that only rises reaches its
10065    /// bound.
10066    fn settle_free_space_managers(
10067        &self,
10068        hdr_size: u64,
10069        threshold: u64,
10070    ) -> IoResult<Vec<PlacedManager>> {
10071        /// Rounds before the layout is called divergent. A round either places
10072        /// a manager or grows one sections block, and there are three
10073        /// managers, so a file that needs more than this is not converging.
10074        const ROUNDS: usize = 16;
10075
10076        // Raw data first and metadata last, in `H5MF_settle_raw_data_fsm`'s
10077        // order (H5C.c:689-696): every manager's own blocks are metadata
10078        // allocations, so the metadata manager funds all of them and is the
10079        // one whose section set the others change.
10080        const ORDER: [FreeSpaceManager; 3] = [
10081            FreeSpaceManager::RawData,
10082            FreeSpaceManager::Large,
10083            FreeSpaceManager::Metadata,
10084        ];
10085
10086        let size_of = |sections: &[FreeSection]| {
10087            let ordered = free_space::serialization_order(sections);
10088            free_space::sinfo_encoded_size(&manager_header(&ordered), &ordered, &self.ctx)
10089        };
10090        let mut placed: Vec<PlacedManager> = Vec::new();
10091        for _ in 0..ROUNDS {
10092            let sets = self.free_sections(threshold)?;
10093            let sections_of = |manager: FreeSpaceManager| {
10094                sets.iter()
10095                    .find(|(m, _)| *m == manager)
10096                    .map(|(_, s)| s.as_slice())
10097                    .unwrap_or_default()
10098            };
10099
10100            let mut changed = false;
10101            for manager in ORDER {
10102                let sections = sections_of(manager);
10103                if sections.is_empty() || placed.iter().any(|p| p.manager == manager) {
10104                    continue;
10105                }
10106                let sect_size = size_of(sections);
10107                let hdr_addr = self.allocator.allocate(hdr_size, FreeSpaceClass::Metadata);
10108                let sect_addr = self.allocator.allocate(sect_size, FreeSpaceClass::Metadata);
10109                placed.push(PlacedManager {
10110                    manager,
10111                    hdr_addr,
10112                    sect_addr,
10113                    sect_size,
10114                    sections: Vec::new(),
10115                });
10116                changed = true;
10117            }
10118            if !changed {
10119                for p in &mut placed {
10120                    let needed = size_of(sections_of(p.manager));
10121                    if needed > p.sect_size {
10122                        self.allocator
10123                            .free(p.sect_addr, p.sect_size, FreeSpaceClass::Metadata);
10124                        p.sect_size = needed;
10125                        p.sect_addr = self.allocator.allocate(needed, FreeSpaceClass::Metadata);
10126                        changed = true;
10127                    }
10128                }
10129            }
10130            if !changed {
10131                for p in &mut placed {
10132                    p.sections = free_space::serialization_order(sections_of(p.manager));
10133                }
10134                return Ok(placed);
10135            }
10136        }
10137        Err(crate::io::IoError::InvalidState(format!(
10138            "the free-space managers did not settle in {ROUNDS} rounds"
10139        )))
10140    }
10141
10142    /// Write the file's superblock extension, and the sole owner of that
10143    /// object header.
10144    ///
10145    /// Runs after [`prepare_shared_messages`](Self::prepare_shared_messages),
10146    /// whose table it names, and before the superblock that names it. What it
10147    /// writes is [`CarriedExtension`] — every message the reopened file's
10148    /// extension held — plus the shared-message table message, which is the
10149    /// one message whose content this session owns: the table moved, so the
10150    /// message read is stale and the message written names the new address.
10151    ///
10152    /// A file with neither carried messages nor shared messages gets no
10153    /// extension, which is what libhdf5 writes for it: `H5F__super_ext_create`
10154    /// is called only when there is a message to put in one.
10155    ///
10156    /// Version 1, holding its messages in one chunk: the extension is created
10157    /// before anything raises the file's object header version
10158    /// (`H5F__super_ext_create` passes `H5O_HDR_STORE_TIMES` off and takes the
10159    /// version-1 path), so an extension of any generation of file looks the
10160    /// same.
10161    fn write_superblock_extension(&self) -> IoResult<()> {
10162        if self.extension.addr.lock().is_some() {
10163            return Ok(());
10164        }
10165        let table = self.sohm.as_deref().and_then(|sohm| {
10166            sohm.table_addr
10167                .lock()
10168                .map(|addr| (sohm.indexes.len(), addr))
10169        });
10170        // A file with file-space properties of its own needs an extension
10171        // too: the message that declares them is the only place they are
10172        // recorded, and a file created with them carries nothing else.
10173        if self.extension.carried.is_empty() && table.is_none() && self.free_space.is_none() {
10174            return Ok(());
10175        }
10176
10177        let mut messages: Vec<crate::io::object_header_io::ExtensionMessage> =
10178            self.extension.carried.clone();
10179        if let Some(fs) = self.free_space.as_deref() {
10180            // The declared message, at exactly the length the one written
10181            // below will have — every field of it is fixed-width, and only
10182            // `persist` and the message version change the count of
10183            // addresses, neither of which the close alters. The image is sized
10184            // and its block allocated before the managers can be laid out, so
10185            // the message has to reach its final *length* here even though its
10186            // content is settled later.
10187            let declared = fs.info.encode(&self.ctx)?;
10188            match messages
10189                .iter_mut()
10190                .find(|m| m.msg_type == MSG_FILE_SPACE_INFO)
10191            {
10192                Some(msg) => msg.body = declared,
10193                None => messages.push(crate::io::object_header_io::ExtensionMessage {
10194                    msg_type: MSG_FILE_SPACE_INFO,
10195                    flags: MSG_FLAG_DONTSHARE | MSG_FLAG_MARK_IF_UNKNOWN,
10196                    body: declared,
10197                }),
10198            }
10199        }
10200        if let Some((nindexes, table_addr)) = table {
10201            let nindexes = u8::try_from(nindexes).map_err(|_| {
10202                crate::io::IoError::InvalidState(format!("{nindexes} shared-message indexes"))
10203            })?;
10204            messages.push(crate::io::object_header_io::ExtensionMessage {
10205                msg_type: MSG_SHARED_MESSAGE_TABLE,
10206                flags: MSG_FLAG_CONSTANT | MSG_FLAG_DONTSHARE,
10207                body: SharedMessageTableMessage {
10208                    version: 0,
10209                    table_address: table_addr,
10210                    nindexes,
10211                }
10212                .encode(&self.ctx),
10213            });
10214        }
10215        let encode = |messages: &[crate::io::object_header_io::ExtensionMessage]| {
10216            let mut extension = ObjectHeader::new();
10217            for msg in messages {
10218                extension.add_message(msg.msg_type, msg.flags, msg.body.clone());
10219            }
10220            extension.encode_v1(1)
10221        };
10222        let image = encode(&messages)?;
10223        // Freed before the replacement is placed, so a reopen reuses the block
10224        // instead of stranding one per open/close cycle — the rule every other
10225        // superseded structure follows.
10226        for &(addr, len) in &self.extension.superseded {
10227            self.allocator.free(addr, len, FreeSpaceClass::Metadata);
10228        }
10229        let addr = self
10230            .allocator
10231            .allocate(image.len() as u64, FreeSpaceClass::Metadata);
10232
10233        // Every block of this file now has an address, so the allocator holds
10234        // exactly the file's free space: settle the free-space managers over
10235        // it and say in this extension where they went.
10236        let image = match self.write_free_space_managers()? {
10237            None => image,
10238            Some(body) => {
10239                let msg = messages
10240                    .iter_mut()
10241                    .find(|m| m.msg_type == MSG_FILE_SPACE_INFO)
10242                    .ok_or_else(|| {
10243                        crate::io::IoError::InvalidState(
10244                            "a persisting file lost its file-space info message".into(),
10245                        )
10246                    })?;
10247                // Same length as the declared body put in above, so the
10248                // image measured before the block was allocated still fits.
10249                if body.len() != msg.body.len() {
10250                    return Err(crate::io::IoError::InvalidState(format!(
10251                        "the file-space info message was laid out at {} bytes and \
10252                         written back at {}",
10253                        msg.body.len(),
10254                        body.len()
10255                    )));
10256                }
10257                msg.body = body;
10258                encode(&messages)?
10259            }
10260        };
10261        self.handle.write_at(addr, &image)?;
10262        *self.extension.addr.lock() = Some(addr);
10263        Ok(())
10264    }
10265
10266    /// Define a new contiguous dataset. Returns the dataset index (used with
10267    /// `write_dataset_raw`).
10268    ///
10269    /// The raw-data region is allocated immediately so that
10270    /// `write_dataset_raw` can be called at any time before `close()`.
10271    pub fn create_dataset(
10272        &self,
10273        name: &str,
10274        datatype: DatatypeMessage,
10275        dims: &[u64],
10276    ) -> IoResult<usize> {
10277        let create = self.begin_create(name)?;
10278        let name = create.name.as_str();
10279        let total_elements: u64 = if dims.is_empty() {
10280            1
10281        } else {
10282            dims.iter().product()
10283        };
10284        let element_size = datatype.element_size() as u64;
10285        let data_size = total_elements * element_size;
10286
10287        // Allocate space for the raw data.
10288        let data_addr = if data_size > 0 {
10289            self.allocator.allocate(data_size, FreeSpaceClass::RawData)
10290        } else {
10291            UNDEF_ADDR
10292        };
10293
10294        let dataspace = if dims.is_empty() {
10295            DataspaceMessage::scalar()
10296        } else {
10297            DataspaceMessage::simple(dims)
10298        };
10299
10300        let idx = self.push_dataset(
10301            &create,
10302            DatasetInfo {
10303                name: name.to_string(),
10304                datatype,
10305                committed_type: None,
10306                external: None,
10307                virtual_storage: None,
10308                dataspace,
10309                read_format: None,
10310                obj_header_addr: 0, // set during finalize
10311                data_addr,
10312                data_size,
10313                compact: None,
10314                chunked: None,
10315                fixed_array: None,
10316                implicit: None,
10317                single_chunk: None,
10318                btree_v1: None,
10319                btree_v2: None,
10320                append: None,
10321                attributes: Vec::new(),
10322                obj_header_written_addr: None,
10323                obj_header_blocks: Vec::new(),
10324                filter_pipeline: None,
10325                deleted: false,
10326                extent_dirty: false,
10327                header_dirty: false,
10328                nlink_written: 1,
10329                creation_seq: self.take_creation_seq(),
10330                track_attr_order: self.track_order.attrs,
10331                fill_value: None,
10332                fill_time: FILL_TIME_IFSET,
10333                layout_version: 4,
10334                times: self.created_object_times(),
10335            },
10336        );
10337
10338        Ok(idx)
10339    }
10340
10341    /// Define a new dataset whose raw data lives in files outside this one —
10342    /// `H5Pset_external`, h5py's `external=[(name, offset, size)]`.
10343    ///
10344    /// Each entry names a file, the byte offset in it where that entry's
10345    /// region starts, and how many bytes of the dataset the region holds; the
10346    /// entries concatenate, in order, into the dataset's logical byte range,
10347    /// and together must cover it. Nothing is allocated in this file: the data
10348    /// layout message says contiguous storage at an undefined address, and it
10349    /// is the External File List beside it that says where the bytes are
10350    /// (`H5D__layout_oh_create`).
10351    ///
10352    /// A named file is created on first write and never truncated, so several
10353    /// slots — or several datasets — may own disjoint ranges of one file, the
10354    /// way `H5D__efl_write` opens them.
10355    ///
10356    /// The last slot may take the unlimited size `H5O_EFL_UNLIMITED`, which
10357    /// makes it absorb however many bytes the dataset comes to hold; a
10358    /// dataset whose dataspace is unlimited must have one, since nothing
10359    /// finite could cover it (`H5D__efl_construct`: "unlimited dataspace but
10360    /// finite storage"). Only the first dimension may be extendible, which is
10361    /// the same function's other rule.
10362    pub fn create_external_dataset(
10363        &self,
10364        name: &str,
10365        datatype: DatatypeMessage,
10366        dims: &[u64],
10367        max_dims: Option<&[u64]>,
10368        files: &[(&str, u64, u64)],
10369    ) -> IoResult<usize> {
10370        if files.is_empty() {
10371            return Err(crate::io::IoError::InvalidState(format!(
10372                "external dataset '{name}' names no files; external storage is defined by \
10373                 the files it lives in, so at least one is required"
10374            )));
10375        }
10376        let create = self.begin_create(name)?;
10377        let name = create.name.as_str();
10378        let total_elements: u64 = if dims.is_empty() {
10379            1
10380        } else {
10381            dims.iter().product()
10382        };
10383        let data_size = total_elements * datatype.element_size() as u64;
10384
10385        let mut heap = LocalHeapImage::with_empty_string();
10386        let mut entries = Vec::with_capacity(files.len());
10387        for (i, &(file_name, offset, size)) in files.iter().enumerate() {
10388            if file_name.is_empty() {
10389                return Err(crate::io::IoError::InvalidState(format!(
10390                    "external dataset '{name}' has a slot with an empty file name"
10391                )));
10392            }
10393            // `H5Pset_external` refuses to add a slot behind an unlimited one
10394            // ("previous file size is unlimited"): the unlimited slot already
10395            // owns every byte from its own start onwards, so nothing after it
10396            // could ever be reached.
10397            if size == UNLIMITED && i + 1 != files.len() {
10398                return Err(crate::io::IoError::InvalidState(format!(
10399                    "external dataset '{name}' gives slot {i} ('{file_name}') the unlimited \
10400                     size H5O_EFL_UNLIMITED with {} slot(s) behind it; an unlimited slot \
10401                     absorbs the rest of the dataset, so it can only be the last",
10402                    files.len() - i - 1
10403                )));
10404            }
10405            if offset.checked_add(size).is_none() {
10406                return Err(crate::io::IoError::InvalidState(format!(
10407                    "external dataset '{name}' slot '{file_name}' spans offset {offset} \
10408                     plus {size} bytes, past the end of the 64-bit address space"
10409                )));
10410            }
10411            entries.push(ExternalFile {
10412                name: file_name.to_string(),
10413                name_offset: heap.insert_str(file_name),
10414                offset,
10415                size,
10416            });
10417        }
10418        let external = ExternalStorage {
10419            // Filled in below, once the heap the names went into has an
10420            // address; the names' offsets within it are already final.
10421            heap_addr: UNDEF_ADDR,
10422            files: entries,
10423            // Settled by the open this create hands a handle out for, which
10424            // is `H5D__create` reading the dapl at H5Dint.c:1318.
10425            prefix: EfilePrefix::default(),
10426        };
10427        // `H5D__efl_construct`, over the dataset's *maximum* extent: the
10428        // slots must reserve at least every byte the dataset could come to
10429        // hold, and an unlimited extent can only be covered by an unlimited
10430        // last slot ("unlimited dataspace but finite storage").
10431        let max_dims = max_dims.unwrap_or(dims);
10432        if max_dims.len() != dims.len() {
10433            return Err(crate::io::IoError::InvalidState(format!(
10434                "external dataset '{name}' has {} dimensions but {} maximum ones",
10435                dims.len(),
10436                max_dims.len()
10437            )));
10438        }
10439        for (d, (&max, &cur)) in max_dims.iter().zip(dims).enumerate().skip(1) {
10440            if max > cur {
10441                return Err(crate::io::IoError::InvalidState(format!(
10442                    "external dataset '{name}' makes dimension {d} extendible ({cur} of \
10443                     {max}); only the first dimension can be extendible for external storage"
10444                )));
10445            }
10446        }
10447        let reserved = external.total_size();
10448        if max_dims.contains(&u64::MAX) {
10449            if reserved != UNLIMITED {
10450                return Err(crate::io::IoError::InvalidState(format!(
10451                    "external dataset '{name}' has an unlimited dataspace but its files \
10452                     reserve only {reserved} bytes; the last slot must take the unlimited \
10453                     size H5O_EFL_UNLIMITED"
10454                )));
10455            }
10456        } else {
10457            let max_bytes = max_dims
10458                .iter()
10459                .try_fold(datatype.element_size() as u64, |acc, &d| acc.checked_mul(d))
10460                .ok_or_else(|| {
10461                    crate::io::IoError::InvalidState(format!(
10462                        "external dataset '{name}' maximum extent times its element size \
10463                         overflows 64 bits"
10464                    ))
10465                })?;
10466            if reserved < max_bytes {
10467                return Err(crate::io::IoError::InvalidState(format!(
10468                    "external dataset '{name}' needs {max_bytes} bytes but its files reserve \
10469                     only {reserved}"
10470                )));
10471            }
10472        }
10473
10474        // The names' heap, written now: it is ordinary metadata of this file,
10475        // and the message the header carries is only an address into it.
10476        let sa = self.ctx.sizeof_addr as usize;
10477        let ss = self.ctx.sizeof_size as usize;
10478        let heap_bytes = heap.as_bytes().to_vec();
10479        let heap_addr = self.allocator.allocate(
10480            local_heap_header_size(sa, ss) as u64,
10481            FreeSpaceClass::Metadata,
10482        );
10483        let heap_data_addr = self
10484            .allocator
10485            .allocate(heap_bytes.len() as u64, FreeSpaceClass::Metadata);
10486        let heap_hdr = LocalHeapHeader {
10487            data_size: heap_bytes.len() as u64,
10488            // Sized to hold exactly these names, so no block of it is free.
10489            free_list_offset: LOCAL_HEAP_FREE_NULL,
10490            data_addr: heap_data_addr,
10491        };
10492        self.handle.write_at(heap_addr, &heap_hdr.encode(sa, ss))?;
10493        self.handle.write_at(heap_data_addr, &heap_bytes)?;
10494        let external = ExternalStorage {
10495            heap_addr,
10496            ..external
10497        };
10498
10499        let dataspace = if dims.is_empty() {
10500            DataspaceMessage::scalar()
10501        } else {
10502            let mut ds = DataspaceMessage::simple(dims);
10503            if max_dims != dims {
10504                ds.max_dims = Some(max_dims.to_vec());
10505            }
10506            ds
10507        };
10508
10509        let idx = self.push_dataset(
10510            &create,
10511            DatasetInfo {
10512                name: name.to_string(),
10513                datatype,
10514                committed_type: None,
10515                external: Some(external),
10516                virtual_storage: None,
10517                dataspace,
10518                read_format: None,
10519                obj_header_addr: 0, // set during finalize
10520                // No block of this file's own: the layout message declares
10521                // contiguous storage at an undefined address, which is what
10522                // sends a reader to the external file list instead.
10523                data_addr: UNDEF_ADDR,
10524                data_size,
10525                compact: None,
10526                chunked: None,
10527                fixed_array: None,
10528                btree_v2: None,
10529                implicit: None,
10530                single_chunk: None,
10531                btree_v1: None,
10532                append: None,
10533                attributes: Vec::new(),
10534                obj_header_written_addr: None,
10535                obj_header_blocks: Vec::new(),
10536                filter_pipeline: None,
10537                deleted: false,
10538                extent_dirty: false,
10539                header_dirty: false,
10540                nlink_written: 1,
10541                creation_seq: self.take_creation_seq(),
10542                track_attr_order: self.track_order.attrs,
10543                fill_value: None,
10544                fill_time: FILL_TIME_IFSET,
10545                layout_version: 4,
10546                times: self.created_object_times(),
10547            },
10548        );
10549
10550        Ok(idx)
10551    }
10552
10553    /// Define a new virtual dataset — `H5Pset_virtual`, h5py's
10554    /// `create_virtual_dataset(name, VirtualLayout)`.
10555    ///
10556    /// Each mapping says which elements of this dataset (`virtual_selection`)
10557    /// are read from which elements (`source_selection`) of a dataset in
10558    /// another file; the sources are never opened here, and a mapping naming
10559    /// one that does not exist yet is perfectly legal — libhdf5 resolves each
10560    /// at read time, filling from the fill value where nothing maps.
10561    ///
10562    /// The mappings do not live in the object header: they are serialized
10563    /// into one global heap object and the layout message carries only its
10564    /// address and index (`H5D__virtual_store_layout`), which is why this
10565    /// allocates a heap object and nothing else.
10566    ///
10567    /// An unlimited (`H5S_UNLIMITED`) selection is written as one: the
10568    /// mapping grows with its source, and the virtual dataset's extent in
10569    /// that dimension is whatever the sources reachable at read time supply
10570    /// (`H5D__virtual_set_extent_unlim`). A `printf`-style source name is
10571    /// written as one too: `%b` substitutes the block index, so one mapping
10572    /// stands for the family of source datasets that fill the successive
10573    /// blocks of an unlimited virtual selection.
10574    pub fn create_virtual_dataset(
10575        &self,
10576        name: &str,
10577        datatype: DatatypeMessage,
10578        dims: &[u64],
10579        max_dims: Option<&[u64]>,
10580        mappings: &[VirtualMapping],
10581    ) -> IoResult<usize> {
10582        if mappings.is_empty() {
10583            return Err(crate::io::IoError::InvalidState(format!(
10584                "virtual dataset '{name}' names no mappings; a virtual dataset is defined \
10585                 by the source datasets it maps, so at least one is required"
10586            )));
10587        }
10588        for m in mappings {
10589            check_virtual_mapping(name, m)?;
10590        }
10591
10592        let create = self.begin_create(name)?;
10593        let name = create.name.as_str();
10594
10595        // The mapping list is ordinary file metadata, written now: the header
10596        // built at finalize carries only the heap address and object index it
10597        // lands at.
10598        let block = VirtualMappingList {
10599            mappings: mappings.to_vec(),
10600        }
10601        .encode(&self.ctx)?;
10602        let (heap_addr, heap_index) = self.insert_vlen_objects(&[&block])?[0];
10603
10604        let dataspace = if dims.is_empty() {
10605            DataspaceMessage::scalar()
10606        } else {
10607            let mut ds = DataspaceMessage::simple(dims);
10608            // A caller that named no maximum gets the current dimensions, the
10609            // maximum `simple` already filled in: `H5Screate_simple(rank,
10610            // dims, NULL)` reaches the encoder with `extent.max` set
10611            // (H5S.c:1293-1299), so leaving it absent here would write a
10612            // message no upstream API call can produce.
10613            if let Some(max) = max_dims {
10614                ds.max_dims = Some(max.to_vec());
10615            }
10616            ds
10617        };
10618
10619        let idx = self.push_dataset(
10620            &create,
10621            DatasetInfo {
10622                name: name.to_string(),
10623                datatype,
10624                committed_type: None,
10625                external: None,
10626                virtual_storage: Some(VirtualStorage {
10627                    heap_addr,
10628                    heap_index: heap_index as u32,
10629                    mappings: mappings.to_vec(),
10630                }),
10631                dataspace,
10632                read_format: None,
10633                obj_header_addr: 0, // set during finalize
10634                // Not a block of this file at all: every element is read out
10635                // of a source dataset, so there is nothing here to allocate
10636                // and nothing to free when the dataset is deleted.
10637                data_addr: UNDEF_ADDR,
10638                data_size: 0,
10639                compact: None,
10640                chunked: None,
10641                fixed_array: None,
10642                btree_v2: None,
10643                implicit: None,
10644                single_chunk: None,
10645                btree_v1: None,
10646                append: None,
10647                attributes: Vec::new(),
10648                obj_header_written_addr: None,
10649                obj_header_blocks: Vec::new(),
10650                filter_pipeline: None,
10651                deleted: false,
10652                extent_dirty: false,
10653                header_dirty: false,
10654                nlink_written: 1,
10655                creation_seq: self.take_creation_seq(),
10656                track_attr_order: self.track_order.attrs,
10657                fill_value: None,
10658                fill_time: FILL_TIME_IFSET,
10659                layout_version: 4,
10660                times: self.created_object_times(),
10661            },
10662        );
10663
10664        Ok(idx)
10665    }
10666
10667    /// Define a new compact dataset — `H5Pset_layout(dcpl, H5D_COMPACT)`.
10668    ///
10669    /// The raw data lives inside the data layout message in the dataset's own
10670    /// object header, so it costs no block of its own and no extra seek to
10671    /// read; the price is the ceiling, and that the whole image is rewritten
10672    /// whenever the header is. The buffer is created at its final length and
10673    /// zero-filled, which is what `H5D__compact_fill` does at create time, so
10674    /// a dataset never written still reads back as its fill value.
10675    ///
10676    /// Errors when the image exceeds [`MAX_COMPACT_DATA`].
10677    pub fn create_compact_dataset(
10678        &self,
10679        name: &str,
10680        datatype: DatatypeMessage,
10681        dims: &[u64],
10682    ) -> IoResult<usize> {
10683        let total_elements: u64 = if dims.is_empty() {
10684            1
10685        } else {
10686            dims.iter().product()
10687        };
10688        let data_size = total_elements * datatype.element_size() as u64;
10689        if data_size > MAX_COMPACT_DATA as u64 {
10690            return Err(crate::io::IoError::InvalidState(format!(
10691                "compact dataset '{name}' needs {data_size} bytes, above the \
10692                 {MAX_COMPACT_DATA}-byte ceiling a data layout message can hold; \
10693                 use contiguous or chunked storage"
10694            )));
10695        }
10696
10697        let create = self.begin_create(name)?;
10698        let name = create.name.as_str();
10699        let dataspace = if dims.is_empty() {
10700            DataspaceMessage::scalar()
10701        } else {
10702            DataspaceMessage::simple(dims)
10703        };
10704
10705        let idx = self.push_dataset(
10706            &create,
10707            DatasetInfo {
10708                name: name.to_string(),
10709                datatype,
10710                committed_type: None,
10711                external: None,
10712                virtual_storage: None,
10713                dataspace,
10714                read_format: None,
10715                obj_header_addr: 0, // set during finalize
10716                data_addr: UNDEF_ADDR,
10717                data_size: 0,
10718                compact: Some(vec![0u8; data_size as usize]),
10719                chunked: None,
10720                fixed_array: None,
10721                implicit: None,
10722                single_chunk: None,
10723                btree_v1: None,
10724                btree_v2: None,
10725                append: None,
10726                attributes: Vec::new(),
10727                obj_header_written_addr: None,
10728                obj_header_blocks: Vec::new(),
10729                filter_pipeline: None,
10730                deleted: false,
10731                extent_dirty: false,
10732                header_dirty: false,
10733                nlink_written: 1,
10734                creation_seq: self.take_creation_seq(),
10735                track_attr_order: self.track_order.attrs,
10736                fill_value: None,
10737                fill_time: FILL_TIME_IFSET,
10738                layout_version: 4,
10739                times: self.created_object_times(),
10740            },
10741        );
10742
10743        Ok(idx)
10744    }
10745
10746    /// Define a new dataset with the NULL dataspace: no elements at all.
10747    ///
10748    /// Distinct from a scalar dataset (`create_dataset` with `dims == []`),
10749    /// which holds exactly one element — a NULL dataspace holds zero, so
10750    /// there is no raw image to allocate: `data_addr` stays `UNDEF_ADDR` and
10751    /// `data_size` stays 0 permanently, the same terminal state
10752    /// `create_dataset` already reaches for a zero-length dimension.
10753    pub fn create_null_dataset(&self, name: &str, datatype: DatatypeMessage) -> IoResult<usize> {
10754        let create = self.begin_create(name)?;
10755        let name = create.name.as_str();
10756
10757        let idx = self.push_dataset(
10758            &create,
10759            DatasetInfo {
10760                name: name.to_string(),
10761                datatype,
10762                committed_type: None,
10763                external: None,
10764                virtual_storage: None,
10765                dataspace: DataspaceMessage::null(),
10766                read_format: None,
10767                obj_header_addr: 0, // set during finalize
10768                data_addr: UNDEF_ADDR,
10769                data_size: 0,
10770                compact: None,
10771                chunked: None,
10772                fixed_array: None,
10773                implicit: None,
10774                single_chunk: None,
10775                btree_v1: None,
10776                btree_v2: None,
10777                append: None,
10778                attributes: Vec::new(),
10779                obj_header_written_addr: None,
10780                obj_header_blocks: Vec::new(),
10781                filter_pipeline: None,
10782                deleted: false,
10783                extent_dirty: false,
10784                header_dirty: false,
10785                nlink_written: 1,
10786                creation_seq: self.take_creation_seq(),
10787                track_attr_order: self.track_order.attrs,
10788                fill_value: None,
10789                fill_time: FILL_TIME_IFSET,
10790                layout_version: 4,
10791                times: self.created_object_times(),
10792            },
10793        );
10794
10795        Ok(idx)
10796    }
10797
10798    /// Define a new chunked dataset with an extensible array index.
10799    ///
10800    /// Returns the dataset index. The dataset starts empty (dims[0] = 0 if
10801    /// the first dimension is unlimited). Use `write_chunk` and
10802    /// `extend_dataset` to add data.
10803    pub fn create_chunked_dataset(
10804        &self,
10805        name: &str,
10806        datatype: DatatypeMessage,
10807        dims: &[u64],
10808        max_dims: &[u64],
10809        chunk_dims: &[u64],
10810    ) -> IoResult<usize> {
10811        let create = self.begin_create(name)?;
10812        let name = create.name.as_str();
10813        validate_chunk_geometry(dims, max_dims, chunk_dims)?;
10814        ensure_at_most_one_unlimited(max_dims)?;
10815        let chunk_bytes = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
10816        let layout_version = self.chunk_layout_version(false, chunk_bytes);
10817        let earray_params = EarrayParams::default_params();
10818        let ndblk_addrs = compute_ndblk_addrs(earray_params.sup_blk_min_data_ptrs)?;
10819        let nsblk_addrs = compute_nsblk_addrs(
10820            earray_params.idx_blk_elmts,
10821            earray_params.data_blk_min_elmts,
10822            earray_params.sup_blk_min_data_ptrs,
10823            earray_params.max_nelmts_bits,
10824        )?;
10825
10826        // Create EA header
10827        let mut ea_header = ExtensibleArrayHeader::new_for_chunks(&self.ctx);
10828        ea_header.max_nelmts_bits = earray_params.max_nelmts_bits;
10829        ea_header.idx_blk_elmts = earray_params.idx_blk_elmts;
10830        ea_header.data_blk_min_elmts = earray_params.data_blk_min_elmts;
10831        ea_header.sup_blk_min_data_ptrs = earray_params.sup_blk_min_data_ptrs;
10832        ea_header.max_dblk_page_nelmts_bits = earray_params.max_dblk_page_nelmts_bits;
10833
10834        // Allocate and write EA header (placeholder, will be updated)
10835        let hdr_encoded = ea_header.encode(&self.ctx);
10836        let ea_header_addr = self
10837            .allocator
10838            .allocate(hdr_encoded.len() as u64, FreeSpaceClass::Metadata);
10839
10840        // Create EA index block with pre-allocated super block address slots
10841        let ea_iblk = ExtensibleArrayIndexBlock::new(
10842            ea_header_addr,
10843            earray_params.idx_blk_elmts,
10844            ndblk_addrs,
10845            nsblk_addrs,
10846        );
10847
10848        // Allocate and write EA index block
10849        let iblk_encoded = ea_iblk.encode(&self.ctx);
10850        let ea_iblk_addr = self
10851            .allocator
10852            .allocate(iblk_encoded.len() as u64, FreeSpaceClass::Metadata);
10853
10854        // Update header with index block address
10855        ea_header.idx_blk_addr = ea_iblk_addr;
10856
10857        // Write both to disk
10858        let hdr_encoded = ea_header.encode(&self.ctx);
10859        self.handle.write_at(ea_header_addr, &hdr_encoded)?;
10860        self.handle.write_at(ea_iblk_addr, &iblk_encoded)?;
10861
10862        // Build dataspace with max dims
10863        let dataspace = DataspaceMessage {
10864            // Chunked storage always requires at least one dimension, so
10865            // this is never Scalar or Null.
10866            class: DataspaceClass::Simple,
10867            dims: dims.to_vec(),
10868            max_dims: Some(max_dims.to_vec()),
10869        };
10870
10871        let idx = self.push_dataset(
10872            &create,
10873            DatasetInfo {
10874                name: name.to_string(),
10875                datatype,
10876                committed_type: None,
10877                external: None,
10878                virtual_storage: None,
10879                dataspace,
10880                read_format: None,
10881                obj_header_addr: 0,
10882                data_addr: UNDEF_ADDR,
10883                data_size: 0,
10884                compact: None,
10885                attributes: Vec::new(),
10886                obj_header_written_addr: None,
10887                obj_header_blocks: Vec::new(),
10888                filter_pipeline: None,
10889                deleted: false,
10890                extent_dirty: false,
10891                header_dirty: false,
10892                nlink_written: 1,
10893                creation_seq: self.take_creation_seq(),
10894                track_attr_order: self.track_order.attrs,
10895                fill_value: None,
10896                fill_time: FILL_TIME_IFSET,
10897                layout_version,
10898                times: self.created_object_times(),
10899                fixed_array: None,
10900                implicit: None,
10901                single_chunk: None,
10902                btree_v1: None,
10903                btree_v2: None,
10904                chunked: Some(ChunkedDatasetInfo {
10905                    chunk_dims: chunk_dims.to_vec(),
10906                    earray_params,
10907                    ea_header_addr,
10908                    ea_iblk_addr,
10909                    ea_header,
10910                    ea_iblk,
10911                    chunks_written: 0,
10912                    filt_iblk: None,
10913                    chunk_size_len: 0,
10914                }),
10915                append: None,
10916            },
10917        );
10918
10919        Ok(idx)
10920    }
10921
10922    /// Write `data` into a contiguous dataset's raw storage at *dataset-
10923    /// relative* byte offset `off`.
10924    ///
10925    /// The single owner of a contiguous raw-data write. Which storage that is
10926    /// — a block of this file, or the files an External File List names — is
10927    /// decided once, by [`DatasetInfo::contiguous_target`], and never at a
10928    /// call site.
10929    fn write_contiguous_bytes(
10930        &self,
10931        target: &ContiguousTarget,
10932        off: u64,
10933        data: &[u8],
10934    ) -> IoResult<()> {
10935        match target {
10936            ContiguousTarget::Local(addr) => Ok(self.handle.write_at(addr + off, data)?),
10937            ContiguousTarget::External { files, prefix } => {
10938                // The prefix the open settled, not one resolved here:
10939                // `H5D__efl_write` joins against `dset->shared->extfile_prefix`
10940                // (H5Defl.c:429-431), the same field `H5D__efl_read` joins
10941                // against, so a relative name lands where a later read looks.
10942                write_external_file_bytes(files, prefix.as_deref(), off, data)
10943            }
10944            ContiguousTarget::Virtual => Err(virtual_write_refused()),
10945        }
10946    }
10947
10948    /// Write raw bytes to a contiguous dataset identified by `index`.
10949    ///
10950    /// The caller is responsible for providing data in the correct byte order
10951    /// and layout. The length must match the total data size declared at
10952    /// creation time.
10953    pub fn write_dataset_raw(&self, index: usize, data: &[u8]) -> IoResult<()> {
10954        let ds = self.ds(index);
10955        let _op = ds.op.lock();
10956        let target = {
10957            let mut g = ds.lock();
10958            if g.is_chunked() {
10959                return Err(crate::io::IoError::InvalidState(
10960                    "use write_chunk for chunked datasets".into(),
10961                ));
10962            }
10963            // A compact dataset's raw image is its layout message, so the
10964            // write lands in the buffer the header is built from rather than
10965            // at a file offset, and the header it is built into is now stale.
10966            if let Some(image) = g.compact.as_mut() {
10967                if data.len() != image.len() {
10968                    return Err(crate::io::IoError::InvalidState(format!(
10969                        "data size mismatch: expected {} bytes, got {}",
10970                        image.len(),
10971                        data.len()
10972                    )));
10973                }
10974                image.copy_from_slice(data);
10975                g.header_dirty = true;
10976                return Ok(());
10977            }
10978            let Some(target) = g.contiguous_target() else {
10979                return Err(crate::io::IoError::InvalidState(
10980                    "dataset has no data allocated".into(),
10981                ));
10982            };
10983            // A dataset that stores nothing of its own has no byte count to
10984            // check a write against — `write_contiguous_bytes` refuses it by
10985            // name below, which is the answer the caller needs.
10986            if target.is_storage() && data.len() as u64 != g.data_size {
10987                return Err(crate::io::IoError::InvalidState(format!(
10988                    "data size mismatch: expected {} bytes, got {}",
10989                    g.data_size,
10990                    data.len()
10991                )));
10992            }
10993            target
10994        };
10995        self.write_contiguous_bytes(&target, 0, data)
10996    }
10997
10998    /// Write a chunk of data to a chunked dataset.
10999    ///
11000    /// `chunk_offset` is the chunk coordinates (e.g., [frame_idx] for a 1D-chunked
11001    /// streaming dataset where chunk_dims = [1, H, W]).
11002    /// Only the first (unlimited) dimension index is used for EA indexing.
11003    ///
11004    /// `data` must be exactly chunk_size bytes (product of chunk_dims * element_size).
11005    pub fn write_chunk(&self, index: usize, chunk_idx: u64, data: &[u8]) -> IoResult<()> {
11006        let ds = self.ds(index);
11007        let _op = ds.op.lock();
11008        self.write_chunk_inner(index, chunk_idx, data)
11009    }
11010
11011    /// [`Self::write_chunk`] body; the caller holds the dataset's op lock or
11012    /// the writer exclusively.
11013    pub(crate) fn write_chunk_inner(
11014        &self,
11015        index: usize,
11016        chunk_idx: u64,
11017        data: &[u8],
11018    ) -> IoResult<()> {
11019        let ds = self.ds(index);
11020        // Read the chunk geometry and filter pipeline under one brief lock,
11021        // then drop it: compression runs *outside* the lock, and
11022        // `record_ea_chunk` re-locks the same slot, so the guard must not be
11023        // held across either.
11024        let (chunk_bytes, pipeline) = {
11025            let g = ds.lock();
11026            let element_size = g.datatype.element_size() as u64;
11027            let chunked = g
11028                .chunked
11029                .as_ref()
11030                .ok_or_else(|| crate::io::IoError::InvalidState("not a chunked dataset".into()))?;
11031            (
11032                chunked.chunk_dims.iter().product::<u64>() * element_size,
11033                g.filter_pipeline.clone(),
11034            )
11035        };
11036
11037        if data.len() as u64 != chunk_bytes {
11038            return Err(crate::io::IoError::InvalidState(format!(
11039                "chunk data size mismatch: expected {} bytes, got {}",
11040                chunk_bytes,
11041                data.len()
11042            )));
11043        }
11044
11045        // Apply compression if filter pipeline is set
11046        let compressed;
11047        let write_data = if let Some(ref pipeline) = pipeline {
11048            compressed = filter::apply_filters(pipeline, data)?;
11049            &compressed
11050        } else {
11051            data
11052        };
11053        // filter_mask = 0: this path runs the whole pipeline, so no filter is
11054        // skipped for the chunk.
11055        self.record_ea_chunk(index, chunk_idx, write_data, 0)
11056    }
11057
11058    /// Decide where a chunk's bytes belong and put them there, returning the
11059    /// address to record in the index.
11060    ///
11061    /// `old` is the chunk's current `(address, stored length)` if the index
11062    /// already holds an entry for it. This is the single owner of the
11063    /// rewrite-placement rule, mirroring libhdf5's `H5D__chunk_file_alloc`
11064    /// (`H5Dchunk.c`): a chunk whose stored size is unchanged is overwritten
11065    /// where it already lives, and only a chunk that no longer fits moves,
11066    /// releasing its old block. Without this every rewrite would abandon the
11067    /// old block and grow the file.
11068    fn place_chunk(&self, old: Option<(u64, u64)>, new_len: u64) -> u64 {
11069        match old {
11070            // Same stored size: overwrite in place. This is every unfiltered
11071            // rewrite (the stored size is fixed by the chunk shape) and every
11072            // filtered rewrite that compressed to the same length.
11073            Some((addr, len)) if addr != UNDEF_ADDR && len == new_len => addr,
11074            Some((addr, len)) if addr != UNDEF_ADDR => {
11075                // The chunk has to move. Under SWMR a reader may still hold an
11076                // index that points at the old block, so libhdf5 keeps it
11077                // (H5D__chunk_file_alloc skips H5MF_xfree when the file is
11078                // open for SWMR writing); do the same.
11079                if !self.swmr_active {
11080                    self.allocator.free(addr, len, FreeSpaceClass::RawData);
11081                }
11082                self.allocator.allocate(new_len, FreeSpaceClass::RawData)
11083            }
11084            _ => self.allocator.allocate(new_len, FreeSpaceClass::RawData),
11085        }
11086    }
11087
11088    /// Place a chunk's already-final bytes (filtered if the dataset is
11089    /// filtered) in the file and record them in the extensible-array index —
11090    /// in the index block, a data block, or a super block per the EA geometry.
11091    /// Shared by write_chunk and write_compressed_chunk.
11092    ///
11093    /// The index lookup happens *before* the bytes are placed, because the
11094    /// entry it finds is what tells [`place_chunk`](Self::place_chunk) whether
11095    /// this is a rewrite that can stay put.
11096    fn record_ea_chunk(
11097        &self,
11098        index: usize,
11099        chunk_idx: u64,
11100        final_bytes: &[u8],
11101        filter_mask: u32,
11102    ) -> IoResult<()> {
11103        let compressed_size = final_bytes.len() as u64;
11104        let ds = self.ds(index);
11105        // Hold one slot guard for the whole method: every dataset-state access
11106        // below goes through `m`, while `self.handle`/`self.allocator`/`self.ctx`
11107        // are disjoint fields safe to touch with the guard held.
11108        let mut m = ds.lock();
11109        let is_filtered = m.filter_pipeline.is_some();
11110        // For a filtered dataset the chunk's stored size is encoded in the
11111        // `chunk_size_len`-byte field of each filtered EA entry
11112        // (`FilteredChunkEntry::encode` writes `nbytes[..chunk_size_len]`,
11113        // which truncates silently). Reject a size that would not fit, the way
11114        // libhdf5's H5D_CHUNK_ENCODE_SIZE_CHECK does, instead of corrupting the
11115        // index. The compress path never exceeds this (chunk_size_len holds the
11116        // uncompressed chunk size); a direct/raw write with caller-supplied
11117        // bytes can.
11118        if is_filtered {
11119            let chunk_size_len = m.chunked.as_ref().unwrap().chunk_size_len as usize;
11120            if chunk_size_len < 8 && compressed_size >= (1u64 << (chunk_size_len * 8)) {
11121                return Err(crate::io::IoError::InvalidState(format!(
11122                    "filtered chunk size {compressed_size} does not fit in the \
11123                     {chunk_size_len}-byte extensible-array chunk-size field"
11124                )));
11125            }
11126        }
11127        let idx_blk_elmts = {
11128            let c = m.chunked.as_ref().unwrap();
11129            c.earray_params.idx_blk_elmts as u64
11130        };
11131
11132        if chunk_idx < idx_blk_elmts {
11133            let chunked = m.chunked.as_mut().unwrap();
11134            if is_filtered {
11135                if let Some(ref mut fiblk) = chunked.filt_iblk {
11136                    let old = fiblk.elements[chunk_idx as usize];
11137                    let chunk_addr =
11138                        self.place_chunk(Some((old.addr, old.nbytes)), compressed_size);
11139                    self.handle.write_at(chunk_addr, final_bytes)?;
11140                    fiblk.elements[chunk_idx as usize] = FilteredChunkEntry {
11141                        addr: chunk_addr,
11142                        nbytes: compressed_size,
11143                        filter_mask,
11144                    };
11145                }
11146            } else {
11147                // An unfiltered chunk's stored size is fixed by the chunk
11148                // shape, so a rewrite always fits where it already is.
11149                let old = chunked.ea_iblk.elements[chunk_idx as usize];
11150                let chunk_addr = self.place_chunk(Some((old, compressed_size)), compressed_size);
11151                self.handle.write_at(chunk_addr, final_bytes)?;
11152                chunked.ea_iblk.elements[chunk_idx as usize] = chunk_addr;
11153            }
11154            chunked.chunks_written += 1;
11155            if chunk_idx + 1 > chunked.ea_header.max_idx_set {
11156                chunked.ea_header.max_idx_set = chunk_idx + 1;
11157            }
11158            if chunked.ea_header.num_elmts_realized < idx_blk_elmts {
11159                chunked.ea_header.num_elmts_realized = idx_blk_elmts;
11160            }
11161        } else {
11162            // chunk_idx >= idx_blk_elmts: place the chunk through the EA
11163            // data-block / super-block hierarchy (libhdf5-compatible geometry).
11164            let (geo, max_nelmts_bits, chunk_size_len, ea_header_addr) = {
11165                let c = m.chunked.as_ref().unwrap();
11166                let p = &c.earray_params;
11167                (
11168                    EaGeometry::new(
11169                        p.idx_blk_elmts,
11170                        p.data_blk_min_elmts,
11171                        p.sup_blk_min_data_ptrs,
11172                        p.max_nelmts_bits,
11173                        p.max_dblk_page_nelmts_bits,
11174                    )?,
11175                    p.max_nelmts_bits,
11176                    c.chunk_size_len,
11177                    c.ea_header_addr,
11178                )
11179            };
11180            let loc = match geo.locate(chunk_idx)? {
11181                EaLoc::Dblk(l) => l,
11182                EaLoc::Index { .. } => unreachable!("chunk_idx >= idx_blk_elmts"),
11183            };
11184            if loc.paged {
11185                return Err(crate::io::IoError::InvalidState(format!(
11186                    "chunk index {} needs a paged extensible-array data block, \
11187                     which is not yet supported",
11188                    chunk_idx
11189                )));
11190            }
11191            let class_id = if is_filtered {
11192                EA_CLS_FILT_CHUNK
11193            } else {
11194                EA_CLS_CHUNK
11195            };
11196            let dblk_nelmts = loc.dblk_nelmts as usize;
11197
11198            // Resolve the data block's current address and its parent slot,
11199            // creating the owning super block on demand.
11200            let parent: DblkParent;
11201            let mut dblk_addr: u64;
11202            match loc.path {
11203                EaDblkPath::Direct { idx: di } => {
11204                    let c = m.chunked.as_ref().unwrap();
11205                    dblk_addr = if is_filtered {
11206                        c.filt_iblk.as_ref().unwrap().dblk_addrs[di]
11207                    } else {
11208                        c.ea_iblk.dblk_addrs[di]
11209                    };
11210                    parent = DblkParent::IndexBlock(di);
11211                }
11212                EaDblkPath::ViaSblk {
11213                    sblk_off,
11214                    local_dblk,
11215                    ndblks_in_sblk,
11216                    sblk_block_offset,
11217                } => {
11218                    let mut sblk_addr = {
11219                        let c = m.chunked.as_ref().unwrap();
11220                        if is_filtered {
11221                            c.filt_iblk.as_ref().unwrap().sblk_addrs[sblk_off]
11222                        } else {
11223                            c.ea_iblk.sblk_addrs[sblk_off]
11224                        }
11225                    };
11226                    if sblk_addr == UNDEF_ADDR {
11227                        let sb = ExtensibleArraySuperBlock::new(
11228                            class_id,
11229                            ea_header_addr,
11230                            sblk_block_offset,
11231                            ndblks_in_sblk,
11232                        );
11233                        let enc = sb.encode(&self.ctx, max_nelmts_bits);
11234                        sblk_addr = self
11235                            .allocator
11236                            .allocate(enc.len() as u64, FreeSpaceClass::Metadata);
11237                        self.handle.write_at(sblk_addr, &enc)?;
11238                        let c = m.chunked.as_mut().unwrap();
11239                        if is_filtered {
11240                            c.filt_iblk.as_mut().unwrap().sblk_addrs[sblk_off] = sblk_addr;
11241                        } else {
11242                            c.ea_iblk.sblk_addrs[sblk_off] = sblk_addr;
11243                        }
11244                        c.ea_header.num_sblks_created += 1;
11245                        c.ea_header.size_sblks_created += enc.len() as u64;
11246                    }
11247                    let sb_buf = self.handle.read_at_most(sblk_addr, 65536)?;
11248                    // The writer never creates paged super blocks (it errors
11249                    // before the paging threshold), so page_init_total is 0.
11250                    let sb = ExtensibleArraySuperBlock::decode(
11251                        &sb_buf,
11252                        &self.ctx,
11253                        max_nelmts_bits,
11254                        ndblks_in_sblk,
11255                        0,
11256                    )?;
11257                    dblk_addr = sb.dblk_addrs[local_dblk];
11258                    parent = DblkParent::SuperBlock {
11259                        sblk_addr,
11260                        ndblks_in_sblk,
11261                        local_dblk,
11262                    };
11263                }
11264            }
11265
11266            // Create or update the data block holding this chunk's entry.
11267            let created = dblk_addr == UNDEF_ADDR;
11268            if is_filtered {
11269                let mut dblk = if created {
11270                    FilteredDataBlock::new(ea_header_addr, loc.dblk_block_offset, dblk_nelmts)
11271                } else {
11272                    let buf = self.handle.read_at_most(dblk_addr, 65536)?;
11273                    FilteredDataBlock::decode(
11274                        &buf,
11275                        &self.ctx,
11276                        max_nelmts_bits,
11277                        dblk_nelmts,
11278                        chunk_size_len,
11279                    )?
11280                };
11281                // A freshly created data block holds only undefined addresses,
11282                // so this reads as "no previous chunk" without a special case.
11283                let old = dblk.elements[loc.offset_in_dblk as usize];
11284                let chunk_addr = self.place_chunk(Some((old.addr, old.nbytes)), compressed_size);
11285                self.handle.write_at(chunk_addr, final_bytes)?;
11286                let entry = FilteredChunkEntry {
11287                    addr: chunk_addr,
11288                    nbytes: compressed_size,
11289                    filter_mask,
11290                };
11291                dblk.elements[loc.offset_in_dblk as usize] = entry;
11292                let enc = dblk.encode(&self.ctx, max_nelmts_bits, chunk_size_len);
11293                if created {
11294                    dblk_addr = self
11295                        .allocator
11296                        .allocate(enc.len() as u64, FreeSpaceClass::Metadata);
11297                }
11298                self.handle.write_at(dblk_addr, &enc)?;
11299                if created {
11300                    let c = m.chunked.as_mut().unwrap();
11301                    c.ea_header.num_dblks_created += 1;
11302                    c.ea_header.size_dblks_created += enc.len() as u64;
11303                }
11304            } else {
11305                let mut dblk = if created {
11306                    ExtensibleArrayDataBlock::new(
11307                        ea_header_addr,
11308                        loc.dblk_block_offset,
11309                        dblk_nelmts,
11310                    )
11311                } else {
11312                    let buf = self.handle.read_at_most(dblk_addr, 65536)?;
11313                    ExtensibleArrayDataBlock::decode(&buf, &self.ctx, max_nelmts_bits, dblk_nelmts)?
11314                };
11315                // Unfiltered: the stored size is fixed by the chunk shape, so
11316                // a rewrite always fits its old block. A freshly created data
11317                // block holds undefined addresses and falls through to a new
11318                // allocation.
11319                let old = dblk.elements[loc.offset_in_dblk as usize];
11320                let chunk_addr = self.place_chunk(Some((old, compressed_size)), compressed_size);
11321                self.handle.write_at(chunk_addr, final_bytes)?;
11322                dblk.elements[loc.offset_in_dblk as usize] = chunk_addr;
11323                let enc = dblk.encode(&self.ctx, max_nelmts_bits);
11324                if created {
11325                    dblk_addr = self
11326                        .allocator
11327                        .allocate(enc.len() as u64, FreeSpaceClass::Metadata);
11328                }
11329                self.handle.write_at(dblk_addr, &enc)?;
11330                if created {
11331                    let c = m.chunked.as_mut().unwrap();
11332                    c.ea_header.num_dblks_created += 1;
11333                    c.ea_header.size_dblks_created += enc.len() as u64;
11334                }
11335            }
11336
11337            // Record a newly-created data block's address in its parent.
11338            if created {
11339                match parent {
11340                    DblkParent::IndexBlock(di) => {
11341                        let c = m.chunked.as_mut().unwrap();
11342                        if is_filtered {
11343                            c.filt_iblk.as_mut().unwrap().dblk_addrs[di] = dblk_addr;
11344                        } else {
11345                            c.ea_iblk.dblk_addrs[di] = dblk_addr;
11346                        }
11347                    }
11348                    DblkParent::SuperBlock {
11349                        sblk_addr,
11350                        ndblks_in_sblk,
11351                        local_dblk,
11352                    } => {
11353                        let buf = self.handle.read_at_most(sblk_addr, 65536)?;
11354                        let mut sb = ExtensibleArraySuperBlock::decode(
11355                            &buf,
11356                            &self.ctx,
11357                            max_nelmts_bits,
11358                            ndblks_in_sblk,
11359                            0,
11360                        )?;
11361                        sb.dblk_addrs[local_dblk] = dblk_addr;
11362                        let enc = sb.encode(&self.ctx, max_nelmts_bits);
11363                        self.handle.write_at(sblk_addr, &enc)?;
11364                    }
11365                }
11366            }
11367
11368            // Statistics.
11369            let c = m.chunked.as_mut().unwrap();
11370            c.chunks_written += 1;
11371            if chunk_idx + 1 > c.ea_header.max_idx_set {
11372                c.ea_header.max_idx_set = chunk_idx + 1;
11373            }
11374            if created {
11375                c.ea_header.num_elmts_realized += loc.dblk_nelmts;
11376            }
11377        }
11378        Ok(())
11379    }
11380
11381    /// Write a slice (hyperslab) of data to a dataset, contiguous or chunked.
11382    ///
11383    /// `starts` and `counts` define the N-dimensional selection.
11384    /// `data` must be exactly `product(counts) * element_size` bytes.
11385    ///
11386    /// The selection is validated once here and then handed to the layout's
11387    /// own writer, so a caller never has to know which storage the dataset
11388    /// uses.
11389    pub fn write_slice(
11390        &self,
11391        index: usize,
11392        starts: &[u64],
11393        counts: &[u64],
11394        data: &[u8],
11395    ) -> IoResult<()> {
11396        let ds = self.ds(index);
11397        let _op = ds.op.lock();
11398        self.write_slice_inner(index, starts, counts, data)
11399    }
11400
11401    /// [`Self::write_slice`] body; the caller holds the dataset's op lock or
11402    /// the writer exclusively.
11403    pub(crate) fn write_slice_inner(
11404        &self,
11405        index: usize,
11406        starts: &[u64],
11407        counts: &[u64],
11408        data: &[u8],
11409    ) -> IoResult<()> {
11410        let ds_ref = self.ds(index);
11411        let ds = ds_ref.lock();
11412        let is_chunked = ds.is_chunked();
11413
11414        let dims = &ds.dataspace.dims;
11415        let element_size = ds.datatype.element_size() as u64;
11416        let ndims = dims.len();
11417
11418        // Every hyperslab edge must stay inside the dataset; without this an
11419        // out-of-bounds selection writes raw bytes over neighbouring data.
11420        check_hyperslab(dims, starts, counts)?;
11421        if ndims == 0 {
11422            return Err(crate::io::IoError::InvalidState(
11423                "write_slice does not support scalar datasets; use write_dataset_raw".into(),
11424            ));
11425        }
11426
11427        let out_elems: u64 = counts.iter().product();
11428        if data.len() as u64 != out_elems * element_size {
11429            return Err(crate::io::IoError::InvalidState(format!(
11430                "data size mismatch: expected {} bytes, got {}",
11431                out_elems * element_size,
11432                data.len()
11433            )));
11434        }
11435
11436        // `dims` borrows the dataset slot; collect what the writers below need
11437        // so the guard can be dropped before they re-lock it.
11438        let dims = dims.clone();
11439        let target = ds.contiguous_target();
11440        drop(ds);
11441
11442        if is_chunked {
11443            // Rows the append buffer holds are not in the chunks yet; writing
11444            // them there anyway would be undone when the buffer flushes at
11445            // close. Hand them to the chunks first.
11446            self.flush_append_buffer_if_intersecting(index, starts[0], starts[0] + counts[0])?;
11447            return self.write_slice_chunked(index, starts, counts, data);
11448        }
11449        let Some(target) = target else {
11450            return Err(crate::io::IoError::InvalidState(
11451                "dataset has no data allocated".into(),
11452            ));
11453        };
11454
11455        // Write each maximal contiguous run in one write. Trailing
11456        // full-selected dimensions coalesce, mirroring the read path: a slice
11457        // with a full last axis becomes one write per outer index instead of
11458        // one write per last-axis row.
11459        for_each_contiguous_run(
11460            &dims,
11461            starts,
11462            counts,
11463            element_size,
11464            |dst_off, src_off, len| {
11465                self.write_contiguous_bytes(&target, dst_off, &data[src_off..src_off + len])
11466            },
11467        )?;
11468
11469        Ok(())
11470    }
11471
11472    /// Write a hyperslab into a chunked dataset, one chunk at a time.
11473    ///
11474    /// The selection is already validated by [`write_slice`](Self::write_slice).
11475    /// For each chunk the selection touches, the chunk's share of `data` is
11476    /// scattered into a whole-chunk buffer and the chunk is rewritten:
11477    ///
11478    /// - a chunk the selection covers completely is built from `data` alone —
11479    ///   nothing needs reading back (libhdf5 takes the same shortcut with the
11480    ///   `relax` flag of `H5D__chunk_lock`);
11481    /// - a chunk covered only in part starts from what is already stored, or
11482    ///   from a fill-value buffer when the chunk has never been written, so
11483    ///   neighbouring elements survive and untouched ones read as fill.
11484    ///
11485    /// An edge chunk that hangs past the dataset extent is always the partial
11486    /// case, so the region beyond the extent keeps its fill value.
11487    fn write_slice_chunked(
11488        &self,
11489        index: usize,
11490        starts: &[u64],
11491        counts: &[u64],
11492        data: &[u8],
11493    ) -> IoResult<()> {
11494        if counts.contains(&0) {
11495            return Ok(());
11496        }
11497        let geo = self.chunk_geometry(index)?;
11498        let ndims = geo.dims.len();
11499        if geo.chunk_dims.len() != ndims {
11500            return Err(crate::io::IoError::InvalidState(format!(
11501                "dataset chunk shape has {} dimensions but the dataspace has {}",
11502                geo.chunk_dims.len(),
11503                ndims
11504            )));
11505        }
11506        if geo.chunk_dims.contains(&0) {
11507            return Err(crate::io::IoError::InvalidState(
11508                "chunk shape has a zero-length dimension".into(),
11509            ));
11510        }
11511        let chunk_bytes = geo.chunk_bytes() as usize;
11512
11513        // Grid range the selection touches, inclusive on both ends.
11514        let first: Vec<u64> = (0..ndims).map(|d| starts[d] / geo.chunk_dims[d]).collect();
11515        let last: Vec<u64> = (0..ndims)
11516            .map(|d| (starts[d] + counts[d] - 1) / geo.chunk_dims[d])
11517            .collect();
11518
11519        let mut coords = first.clone();
11520        loop {
11521            // Intersect the selection with this chunk. `in_chunk` is the
11522            // region's origin inside the chunk, `in_data` its origin inside
11523            // the caller's counts-shaped buffer, `extent` its size.
11524            let mut in_chunk = vec![0u64; ndims];
11525            let mut in_data = vec![0u64; ndims];
11526            let mut extent = vec![0u64; ndims];
11527            let mut covers_whole_chunk = true;
11528            for d in 0..ndims {
11529                let chunk_origin = coords[d] * geo.chunk_dims[d];
11530                let lo = starts[d].max(chunk_origin);
11531                let hi = (starts[d] + counts[d]).min(chunk_origin + geo.chunk_dims[d]);
11532                in_chunk[d] = lo - chunk_origin;
11533                in_data[d] = lo - starts[d];
11534                extent[d] = hi - lo;
11535                if in_chunk[d] != 0 || extent[d] != geo.chunk_dims[d] {
11536                    covers_whole_chunk = false;
11537                }
11538            }
11539
11540            let mut buf = if covers_whole_chunk {
11541                // Every byte is overwritten below.
11542                vec![0u8; chunk_bytes]
11543            } else {
11544                match self.read_chunk_at_coords(index, &coords)? {
11545                    Some(existing) => {
11546                        if existing.len() != chunk_bytes {
11547                            return Err(crate::io::IoError::InvalidState(format!(
11548                                "stored chunk at {coords:?} is {} bytes but the chunk shape \
11549                                 needs {chunk_bytes}",
11550                                existing.len()
11551                            )));
11552                        }
11553                        existing
11554                    }
11555                    None => self.new_write_chunk_buffer(index, chunk_bytes),
11556                }
11557            };
11558
11559            for_each_dual_run(
11560                &geo.chunk_dims,
11561                &in_chunk,
11562                counts,
11563                &in_data,
11564                &extent,
11565                geo.element_size,
11566                |dst_off, src_off, len| {
11567                    let dst = dst_off as usize;
11568                    let src = src_off as usize;
11569                    buf[dst..dst + len].copy_from_slice(&data[src..src + len]);
11570                    Ok(())
11571                },
11572            )?;
11573            self.write_chunk_at_coords(index, &coords, &buf)?;
11574
11575            // Odometer over the touched grid range.
11576            let mut d = ndims;
11577            loop {
11578                if d == 0 {
11579                    return Ok(());
11580                }
11581                d -= 1;
11582                if coords[d] < last[d] {
11583                    coords[d] += 1;
11584                    break;
11585                }
11586                coords[d] = first[d];
11587            }
11588        }
11589    }
11590
11591    /// Add an attribute to the root group (file-level attribute), replacing
11592    /// a same-name attribute. See [`set_attribute`](Self::set_attribute).
11593    pub fn add_root_attribute(&self, attr: AttributeMessage) -> IoResult<()> {
11594        self.set_attribute(AttrTarget::Root, attr)
11595    }
11596
11597    /// Insert `attr` into the attribute list `target` names, replacing a
11598    /// same-name attribute.
11599    ///
11600    /// The single owner of attribute-list mutation: an `AttributeMessage`
11601    /// that leaves a list here has its vlen global-heap objects released, so
11602    /// no replacement — vlen over vlen, numeric over vlen — can strand heap
11603    /// space (the attribute counterpart of issue #10's dataset fix).
11604    ///
11605    /// Under SWMR the change is committed at close, when the closing
11606    /// finalize rewrites the header (a continuation chunk taking what no
11607    /// longer fits its published block), as libhdf5 1.14 commits
11608    /// `H5Acreate2`/`H5Awrite` under SWMR; a superseded vlen value keeps its
11609    /// heap objects, since a streaming reader may hold their references.
11610    pub fn set_attribute(&self, target: AttrTarget<'_>, attr: AttributeMessage) -> IoResult<()> {
11611        self.insert_attribute(target, attr, Created)
11612    }
11613
11614    /// The body of [`set_attribute`](Self::set_attribute), told whether the
11615    /// attribute it is inserting is genuinely new — see [`AttrOrigin`].
11616    fn insert_attribute(
11617        &self,
11618        target: AttrTarget<'_>,
11619        attr: AttributeMessage,
11620        origin: AttrOrigin,
11621    ) -> IoResult<()> {
11622        // Whatever this name meant before, it means the incoming message now.
11623        self.forget_attribute_reference(self.attr_scope(target)?, &attr.name);
11624        // No size gate: an attribute whose message is too large for the
11625        // 16-bit size field an object header message has spills the object's
11626        // whole attribute set to dense storage at finalize, exactly as
11627        // `H5O__attr_create` does. See `attributes_need_dense`.
11628        let mut entry = AttributeEntry::from(attr);
11629        let old = self.with_attr_list(target, |attrs| {
11630            if let Some(pos) = attrs.iter().position(|a| a.name() == entry.name()) {
11631                // `H5O__attr_write` replaces an existing attribute's value and
11632                // leaves its `crt_idx` alone: the attribute was not created
11633                // again, so its creation index does not move.
11634                entry.set_creation_index(attrs[pos].creation_index());
11635                Some(std::mem::replace(&mut attrs[pos], entry))
11636            } else {
11637                // `H5O__attr_create` stamps the set's running maximum onto the
11638                // new attribute and post-increments it — but only a create
11639                // reaches for it.
11640                entry.set_creation_index(match origin {
11641                    Created => Some(next_creation_index(attrs)),
11642                    Rewritten(kept) => kept,
11643                });
11644                attrs.push(entry);
11645                None
11646            }
11647        })?;
11648        match old {
11649            Some(old) => self.release_attr_vlen(&old),
11650            None => Ok(()),
11651        }
11652    }
11653
11654    /// Set a variable-length string attribute on `target`, replacing any
11655    /// same-name attribute.
11656    ///
11657    /// Owns the whole replacement sequence: the superseded attribute is
11658    /// removed and its heap objects released *before* the new value's
11659    /// collection is allocated — the free-before-alloc order (issue #10)
11660    /// that lets a reopen-replace loop land in the block it just freed
11661    /// instead of growing the file every session. The cost, as on the
11662    /// dataset path: a failure between the eviction and the insert below
11663    /// loses the attribute rather than leaking its heap space.
11664    pub fn set_vlen_string_attribute(
11665        &self,
11666        target: AttrTarget<'_>,
11667        name: &str,
11668        value: &str,
11669    ) -> IoResult<()> {
11670        let origin = self.evict_attr(target, name)?;
11671        let attr = self.vlen_string_attribute(name, value)?;
11672        self.insert_attribute(target, attr, origin)
11673    }
11674
11675    /// The array counterpart of
11676    /// [`set_vlen_string_attribute`](Self::set_vlen_string_attribute).
11677    pub fn set_vlen_string_array_attribute(
11678        &self,
11679        target: AttrTarget<'_>,
11680        name: &str,
11681        values: &[&str],
11682        dims: &[u64],
11683    ) -> IoResult<()> {
11684        let origin = self.evict_attr(target, name)?;
11685        let attr = self.vlen_string_array_attribute(name, values, dims)?;
11686        self.insert_attribute(target, attr, origin)
11687    }
11688
11689    /// Set an attribute on `target` whose value is the object references
11690    /// naming `paths` — h5py's `obj.attrs['ref'] = f['/target'].ref`.
11691    ///
11692    /// `dims` is the attribute's dataspace: empty for the scalar shape a
11693    /// single reference takes, `&[n]` for an array of them. Each path names a
11694    /// dataset or a group (`/` is the root group) and must already exist. What
11695    /// reaches the file is each target's object header address, which finalize
11696    /// assigns — so the paths are what is stored, and the attribute's message
11697    /// is built from them every time an object header is
11698    /// ([`object_attributes`](Self::object_attributes)). The message carries a
11699    /// zero image of the final width until then.
11700    pub fn set_object_reference_attribute(
11701        &self,
11702        target: AttrTarget<'_>,
11703        name: &str,
11704        paths: &[&str],
11705        dims: &[u64],
11706    ) -> IoResult<()> {
11707        let scope = self.attr_scope(target)?;
11708        // An empty `dims` is the scalar shape, whose one element the empty
11709        // product already reports.
11710        let elements: u64 = dims.iter().product();
11711        if elements != paths.len() as u64 {
11712            return Err(crate::io::IoError::InvalidState(format!(
11713                "attribute '{name}' shape {dims:?} needs {elements} references, got {}",
11714                paths.len()
11715            )));
11716        }
11717        // Resolve now as well as at finalize, so a path that names nothing is
11718        // reported at the call that got it wrong.
11719        for path in paths {
11720            self.object_reference_target(path)?;
11721        }
11722        let datatype = DatatypeMessage::object_reference(&self.ctx);
11723        let image = vec![0u8; paths.len() * datatype.element_size() as usize];
11724        let attr = if dims.is_empty() {
11725            AttributeMessage::scalar_numeric(name, datatype, image)
11726        } else {
11727            AttributeMessage::array_numeric(name, datatype, dims, image)
11728        };
11729        // Through the same owner as every other attribute, which is also what
11730        // drops any value this name carried before.
11731        self.set_attribute(target, attr)?;
11732        self.attribute_references
11733            .lock()
11734            .push(AttributeReferenceValue {
11735                scope,
11736                name: name.to_string(),
11737                targets: paths.iter().map(|p| (*p).to_string()).collect(),
11738                stride: self.ctx.sizeof_addr as usize,
11739            });
11740        Ok(())
11741    }
11742
11743    // -----------------------------------------------------------------------
11744    // Dimension scales — the H5DS high-level API (hl/src/H5DS.c)
11745    // -----------------------------------------------------------------------
11746
11747    /// Mark dataset `dsid` as a dimension scale — `H5DSset_scale`.
11748    ///
11749    /// Writes `CLASS` as the fixed-length null-terminated ASCII string
11750    /// `DIMENSION_SCALE` and, when `name` is given, `NAME` the same way: the
11751    /// `H5LT_set_attribute_string` form, one byte longer than the text so the
11752    /// terminator is stored, which is what `H5DSis_scale` requires of a scale
11753    /// (a 16-byte null-terminated `CLASS`). Either attribute already there is
11754    /// deleted and created anew, as `H5LT_set_attribute_string` does, so it
11755    /// takes a fresh creation index. A dataset with scales of its own
11756    /// (`DIMENSION_LIST`) is refused, as upstream refuses it.
11757    pub fn set_dimension_scale(&self, dsid: usize, name: Option<&str>) -> IoResult<()> {
11758        let scale_path = self.dataset_name(dsid)?;
11759        if self.dataset_attribute(dsid, DIMENSION_LIST)?.is_some() {
11760            return Err(crate::io::IoError::InvalidState(format!(
11761                "dataset '{scale_path}' has dimension scales attached and cannot become one"
11762            )));
11763        }
11764        self.set_fixed_string_attribute(dsid, "CLASS", DIMENSION_SCALE_CLASS)?;
11765        if let Some(name) = name {
11766            self.set_fixed_string_attribute(dsid, "NAME", name)?;
11767        }
11768        Ok(())
11769    }
11770
11771    /// Attach dataset `dsid` as a dimension scale of axis `idx` of dataset
11772    /// `did` — `H5DSattach_scale`.
11773    ///
11774    /// Two attributes record the attachment: `DIMENSION_LIST` on `did`, one
11775    /// variable-length sequence of object references per axis (a scalar
11776    /// dataset counts as rank 1), and `REFERENCE_LIST` on `dsid`, an array
11777    /// of `{dataset: H5T_STD_REF_OBJ, dimension: uint}` compounds naming
11778    /// every (dataset, axis) the scale is attached to. `dsid` is then made a
11779    /// scale if it is not one already ([`set_dimension_scale`] with no name).
11780    /// Both lists are rewritten whole; what an existing list holds is read
11781    /// back as paths (registered this session, or resolved from the file's
11782    /// addresses), so an attach in an append session keeps earlier
11783    /// attachments and every reference is stamped with the address its
11784    /// target ends up at.
11785    ///
11786    /// Refused, as upstream refuses them: `did == dsid`; a `did` that is a
11787    /// scale or carries a reserved `CLASS` (`IMAGE`, `PALETTE`, `TABLE`); a
11788    /// `dsid` that has scales of its own; an axis beyond `did`'s rank.
11789    ///
11790    /// Attaching a scale already attached to that axis changes nothing. This
11791    /// is stricter than upstream, which leaves `DIMENSION_LIST` as it is but
11792    /// still appends a duplicate `REFERENCE_LIST` entry; a second entry for
11793    /// the same (dataset, axis) tells `H5DSis_attached` nothing the first
11794    /// does not.
11795    ///
11796    /// [`set_dimension_scale`]: Self::set_dimension_scale
11797    pub fn attach_dimension_scale(&self, did: usize, dsid: usize, idx: usize) -> IoResult<()> {
11798        let data_path = self.dataset_name(did)?;
11799        let scale_path = self.dataset_name(dsid)?;
11800        if did == dsid {
11801            return Err(crate::io::IoError::InvalidState(format!(
11802                "dataset '{data_path}' cannot be its own dimension scale"
11803            )));
11804        }
11805        if self.is_dimension_scale(did)? {
11806            return Err(crate::io::IoError::InvalidState(format!(
11807                "dataset '{data_path}' is a dimension scale and cannot have scales attached"
11808            )));
11809        }
11810        if self.dataset_attribute(dsid, DIMENSION_LIST)?.is_some() {
11811            return Err(crate::io::IoError::InvalidState(format!(
11812                "dataset '{scale_path}' has dimension scales attached and cannot be one"
11813            )));
11814        }
11815        if self.has_reserved_class(did)? {
11816            return Err(crate::io::IoError::InvalidState(format!(
11817                "dataset '{data_path}' holds an image, palette or table and cannot have \
11818                 dimension scales"
11819            )));
11820        }
11821        let rank = self.ds(did).lock().dataspace.dims.len().max(1);
11822        if idx >= rank {
11823            return Err(crate::io::IoError::InvalidState(format!(
11824                "axis {idx} is out of range for the rank-{rank} dataset '{data_path}'"
11825            )));
11826        }
11827
11828        let mut lists = match self.dimension_list(did)? {
11829            Some(lists) => lists,
11830            None => vec![Vec::new(); rank],
11831        };
11832        if lists.len() != rank {
11833            return Err(crate::io::IoError::InvalidState(format!(
11834                "DIMENSION_LIST of '{data_path}' has {} entries for a rank-{rank} dataset",
11835                lists.len()
11836            )));
11837        }
11838        if lists[idx].contains(&scale_path) {
11839            return Ok(());
11840        }
11841        lists[idx].push(scale_path);
11842        self.write_dimension_list(did, &lists)?;
11843
11844        let mut entries = self.reference_list(dsid)?;
11845        entries.push((data_path, idx as u32));
11846        self.write_reference_list(dsid, &entries)?;
11847
11848        if !self.is_dimension_scale(dsid)? {
11849            self.set_dimension_scale(dsid, None)?;
11850        }
11851        Ok(())
11852    }
11853
11854    /// The registry name of live dataset `index`, or why there is none.
11855    fn dataset_name(&self, index: usize) -> IoResult<String> {
11856        let count = self.dataset_count();
11857        if index >= count {
11858            return Err(crate::io::IoError::InvalidState(format!(
11859                "dataset index {index} out of range (have {count})"
11860            )));
11861        }
11862        let ds = self.ds(index);
11863        let m = ds.lock();
11864        if m.deleted {
11865            return Err(crate::io::IoError::NotFound(format!(
11866                "dataset '{}' has been deleted",
11867                m.name
11868            )));
11869        }
11870        Ok(m.name.clone())
11871    }
11872
11873    /// The stored attribute `name` of dataset `index`, without marking the
11874    /// header dirty the way [`with_attr_list`](Self::with_attr_list) must.
11875    fn dataset_attribute(&self, index: usize, name: &str) -> IoResult<Option<AttributeEntry>> {
11876        self.dataset_name(index)?;
11877        Ok(self
11878            .ds(index)
11879            .lock()
11880            .attributes
11881            .iter()
11882            .find(|a| a.name() == name)
11883            .cloned())
11884    }
11885
11886    /// Write the scalar fixed-length string attribute `name` = `value` on
11887    /// dataset `index` — `H5LT_set_attribute_string`: the string is stored
11888    /// null-terminated in `strlen + 1` bytes, and an attribute of that name
11889    /// is deleted first rather than written over.
11890    fn set_fixed_string_attribute(&self, index: usize, name: &str, value: &str) -> IoResult<()> {
11891        if value.as_bytes().contains(&0) {
11892            return Err(crate::io::IoError::InvalidState(format!(
11893                "attribute '{name}' value holds an interior NUL"
11894            )));
11895        }
11896        let size = u32::try_from(value.len() + 1).map_err(|_| {
11897            crate::io::IoError::InvalidState(format!(
11898                "attribute '{name}' value of {} bytes exceeds the fixed-string width field",
11899                value.len()
11900            ))
11901        })?;
11902        let mut data = value.as_bytes().to_vec();
11903        data.push(0);
11904        let attr =
11905            AttributeMessage::scalar_numeric(name, DatatypeMessage::fixed_string(size), data);
11906        let target = AttrTarget::Dataset(index);
11907        self.evict_attr(target, name)?;
11908        self.insert_attribute(target, attr, Created)
11909    }
11910
11911    /// The `CLASS` attribute of dataset `index`, read the way `H5DS` reads
11912    /// it: as a C string, up to the first NUL.
11913    fn class_attribute(&self, index: usize) -> IoResult<Option<ClassAttr>> {
11914        use crate::format::global_heap::decode_vlen_reference;
11915
11916        let Some(entry) = self.dataset_attribute(index, "CLASS")? else {
11917            return Ok(None);
11918        };
11919        let msg = entry.decoded().map_err(|reason| {
11920            crate::io::IoError::InvalidState(format!(
11921                "CLASS attribute of '{}' cannot be decoded: {reason}",
11922                self.ds(index).lock().name
11923            ))
11924        })?;
11925        Ok(Some(match &msg.datatype {
11926            DatatypeMessage::FixedString { size, padding, .. } => {
11927                let avail = (*size as usize).min(msg.data.len());
11928                ClassAttr::Fixed {
11929                    size: *size,
11930                    null_terminated: *padding == 0,
11931                    text: c_string(&msg.data[..avail]),
11932                }
11933            }
11934            DatatypeMessage::VarLenString { .. } => {
11935                let (_, addr, obj_idx) = decode_vlen_reference(&msg.data, &self.ctx)?;
11936                let bytes = if addr == 0 || addr == UNDEF_ADDR {
11937                    Vec::new()
11938                } else {
11939                    let obj_idx = u16::try_from(obj_idx).map_err(|_| {
11940                        crate::io::IoError::InvalidState(format!(
11941                            "global heap object index {obj_idx} does not fit the 16-bit on-disk \
11942                             field"
11943                        ))
11944                    })?;
11945                    self.read_heap_object(addr, obj_idx)?
11946                };
11947                ClassAttr::VarLen(c_string(&bytes))
11948            }
11949            _ => ClassAttr::NotString,
11950        }))
11951    }
11952
11953    /// `H5DSis_scale`: a `CLASS` that is a string saying `DIMENSION_SCALE` —
11954    /// and, for a fixed-length string, null-terminated and exactly 16 bytes
11955    /// wide, the width the spec gives the attribute.
11956    fn is_dimension_scale(&self, index: usize) -> IoResult<bool> {
11957        Ok(match self.class_attribute(index)? {
11958            None | Some(ClassAttr::NotString) => false,
11959            Some(ClassAttr::Fixed {
11960                size,
11961                null_terminated,
11962                text,
11963            }) => null_terminated && size == 16 && text == DIMENSION_SCALE_CLASS,
11964            Some(ClassAttr::VarLen(text)) => text == DIMENSION_SCALE_CLASS,
11965        })
11966    }
11967
11968    /// `H5DS_is_reserved`: a `CLASS` naming an image, palette or table — the
11969    /// datasets the other high-level APIs own. A `CLASS` that is not a string
11970    /// is an error here, where [`is_dimension_scale`](Self::is_dimension_scale)
11971    /// reads it as "not a scale", because that is how upstream splits them.
11972    fn has_reserved_class(&self, index: usize) -> IoResult<bool> {
11973        Ok(match self.class_attribute(index)? {
11974            None => false,
11975            Some(ClassAttr::NotString) => {
11976                return Err(crate::io::IoError::InvalidState(format!(
11977                    "CLASS attribute of '{}' is not a string",
11978                    self.ds(index).lock().name
11979                )))
11980            }
11981            Some(ClassAttr::Fixed { text, .. }) | Some(ClassAttr::VarLen(text)) => {
11982                matches!(text.as_str(), "IMAGE" | "PALETTE" | "TABLE")
11983            }
11984        })
11985    }
11986
11987    /// The bytes of object `index` in the global heap collection at
11988    /// `collection` — `H5HG_read`.
11989    fn read_heap_object(&self, collection: u64, index: u16) -> IoResult<Vec<u8>> {
11990        use crate::format::global_heap::GlobalHeapCollection;
11991
11992        let mut image = self.handle.read_at_most(collection, 4096)?;
11993        let declared = GlobalHeapCollection::decode_size(&image, &self.ctx)?;
11994        if declared > image.len() {
11995            image = self.handle.read_at(collection, declared)?;
11996        }
11997        let (gcol, _) = GlobalHeapCollection::decode(&image[..declared], &self.ctx)?;
11998        gcol.get_object(index).map(<[u8]>::to_vec).ok_or_else(|| {
11999            crate::io::IoError::InvalidState(format!(
12000                "global heap collection {collection:#x} has no object {index}"
12001            ))
12002        })
12003    }
12004
12005    /// The path of the object whose header is at `addr` in the file as it
12006    /// was opened — what an object reference read back from an append
12007    /// session's existing attributes names.
12008    fn path_of_header_address(&self, addr: u64) -> IoResult<String> {
12009        for ds in self.dataset_refs() {
12010            let m = ds.lock();
12011            if !m.deleted && m.obj_header_written_addr == Some(addr) {
12012                return Ok(m.name.clone());
12013            }
12014        }
12015        for grp in self.group_refs() {
12016            let g = grp.lock();
12017            if !g.deleted && g.obj_header_written_addr == Some(addr) {
12018                return Ok(g.name.clone());
12019            }
12020        }
12021        Err(crate::io::IoError::InvalidState(format!(
12022            "object reference to header {addr:#x} names no dataset or group of this file"
12023        )))
12024    }
12025
12026    /// The path a reference slot inside a global heap object names: the one
12027    /// registered for stamping when this session wrote the slot, else the
12028    /// one the address on disk resolves to.
12029    fn heap_reference_path(
12030        &self,
12031        collection: u64,
12032        index: u16,
12033        token_offset: usize,
12034        on_disk: &[u8],
12035    ) -> IoResult<String> {
12036        let registered = self
12037            .pending_heap_references
12038            .lock()
12039            .iter()
12040            .find(|p| {
12041                p.collection == collection && p.index == index && p.token_offset == token_offset
12042            })
12043            .map(|p| match &p.target {
12044                PendingHeapTarget::Dataset(path) | PendingHeapTarget::Object(path) => path.clone(),
12045            });
12046        if let Some(path) = registered {
12047            return Ok(path);
12048        }
12049        let mut raw = [0u8; 8];
12050        raw[..on_disk.len()].copy_from_slice(on_disk);
12051        self.path_of_header_address(u64::from_le_bytes(raw))
12052    }
12053
12054    /// Dataset `did`'s `DIMENSION_LIST` as the paths of the scales on each
12055    /// axis, or `None` when it has no such attribute.
12056    fn dimension_list(&self, did: usize) -> IoResult<Option<Vec<Vec<String>>>> {
12057        use crate::format::global_heap::{decode_vlen_reference, vlen_reference_size};
12058
12059        let Some(entry) = self.dataset_attribute(did, DIMENSION_LIST)? else {
12060            return Ok(None);
12061        };
12062        let name = || self.ds(did).lock().name.clone();
12063        let msg = entry.decoded().map_err(|reason| {
12064            crate::io::IoError::InvalidState(format!(
12065                "DIMENSION_LIST of '{}' cannot be decoded: {reason}",
12066                name()
12067            ))
12068        })?;
12069        match &msg.datatype {
12070            DatatypeMessage::VarLenSequence { base }
12071                if matches!(
12072                    **base,
12073                    DatatypeMessage::Reference {
12074                        kind: ReferenceKind::Object1,
12075                        ..
12076                    }
12077                ) => {}
12078            other => {
12079                return Err(crate::io::IoError::InvalidState(format!(
12080                    "DIMENSION_LIST of '{}' is {other}; only a sequence of H5T_STD_REF_OBJ \
12081                     references is supported",
12082                    name()
12083                )))
12084            }
12085        }
12086        let sa = self.ctx.sizeof_addr as usize;
12087        let ref_size = vlen_reference_size(&self.ctx);
12088        let mut lists = Vec::new();
12089        for elem in msg.data.chunks_exact(ref_size) {
12090            let (seq_len, addr, obj_idx) = decode_vlen_reference(elem, &self.ctx)?;
12091            let seq_len = seq_len as usize;
12092            let mut paths = Vec::with_capacity(seq_len);
12093            if seq_len > 0 {
12094                let index = u16::try_from(obj_idx).map_err(|_| {
12095                    crate::io::IoError::InvalidState(format!(
12096                        "global heap object index {obj_idx} does not fit the 16-bit on-disk field"
12097                    ))
12098                })?;
12099                let bytes = self.read_heap_object(addr, index)?;
12100                if bytes.len() < seq_len * sa {
12101                    return Err(crate::io::IoError::InvalidState(format!(
12102                        "DIMENSION_LIST of '{}' names {seq_len} scales in a {}-byte heap object",
12103                        name(),
12104                        bytes.len()
12105                    )));
12106                }
12107                for k in 0..seq_len {
12108                    paths.push(self.heap_reference_path(
12109                        addr,
12110                        index,
12111                        k * sa,
12112                        &bytes[k * sa..(k + 1) * sa],
12113                    )?);
12114                }
12115            }
12116            lists.push(paths);
12117        }
12118        Ok(Some(lists))
12119    }
12120
12121    /// Store `lists` — the scales attached to each axis — as dataset `did`'s
12122    /// `DIMENSION_LIST`, replacing the one it has.
12123    ///
12124    /// Each axis is one global heap object of `sizeof_addr` bytes per scale,
12125    /// zero until finalize stamps the scale's header address in through
12126    /// [`write_heap_reference_values`](Self::write_heap_reference_values);
12127    /// an axis with no scale is an empty heap object, as libhdf5's
12128    /// `H5VL__native_blob_put` stores an empty sequence. The attribute's
12129    /// value is the vlen reference to each object, final at write time.
12130    fn write_dimension_list(&self, did: usize, lists: &[Vec<String>]) -> IoResult<()> {
12131        use crate::format::global_heap::{
12132            encode_vlen_reference, vlen_reference_size, vlen_seq_len,
12133        };
12134
12135        let target = AttrTarget::Dataset(did);
12136        let origin = self.evict_attr(target, DIMENSION_LIST)?;
12137        let sa = self.ctx.sizeof_addr as usize;
12138        let blobs: Vec<Vec<u8>> = lists.iter().map(|l| vec![0u8; l.len() * sa]).collect();
12139        let items: Vec<&[u8]> = blobs.iter().map(Vec::as_slice).collect();
12140        let placements = self.insert_vlen_objects(&items)?;
12141
12142        let mut data = Vec::with_capacity(lists.len() * vlen_reference_size(&self.ctx));
12143        let mut pending = self.pending_heap_references.lock();
12144        for (axis, &(collection, index)) in placements.iter().enumerate() {
12145            for (k, path) in lists[axis].iter().enumerate() {
12146                pending.push(PendingHeapReference {
12147                    collection,
12148                    index,
12149                    token_offset: k * sa,
12150                    target: PendingHeapTarget::Object(path.clone()),
12151                });
12152            }
12153            data.extend_from_slice(&encode_vlen_reference(
12154                vlen_seq_len(lists[axis].len())?,
12155                collection,
12156                u32::from(index),
12157                &self.ctx,
12158            ));
12159        }
12160        drop(pending);
12161
12162        let attr = AttributeMessage {
12163            name: DIMENSION_LIST.to_string(),
12164            datatype: DatatypeMessage::VarLenSequence {
12165                base: Box::new(DatatypeMessage::object_reference(&self.ctx)),
12166            },
12167            dataspace: DataspaceMessage::simple(&[lists.len() as u64]),
12168            data,
12169        };
12170        self.insert_attribute(target, attr, origin)
12171    }
12172
12173    /// Scale `dsid`'s `REFERENCE_LIST` as (dataset path, axis) pairs; empty
12174    /// when it has no such attribute.
12175    fn reference_list(&self, dsid: usize) -> IoResult<Vec<(String, u32)>> {
12176        let Some(entry) = self.dataset_attribute(dsid, REFERENCE_LIST)? else {
12177            return Ok(Vec::new());
12178        };
12179        let name = || self.ds(dsid).lock().name.clone();
12180        let msg = entry.decoded().map_err(|reason| {
12181            crate::io::IoError::InvalidState(format!(
12182                "REFERENCE_LIST of '{}' cannot be decoded: {reason}",
12183                name()
12184            ))
12185        })?;
12186        let unsupported = |why: String| {
12187            crate::io::IoError::InvalidState(format!(
12188                "REFERENCE_LIST of '{}' is {}; {why}",
12189                name(),
12190                msg.datatype
12191            ))
12192        };
12193        let DatatypeMessage::Compound { size, members } = &msg.datatype else {
12194            return Err(unsupported("a compound is required".into()));
12195        };
12196        let member = |m: &str| {
12197            members
12198                .iter()
12199                .find(|c| c.name == m)
12200                .ok_or_else(|| unsupported(format!("member '{m}' is missing")))
12201        };
12202        let dataset = member("dataset")?;
12203        let dimension = member("dimension")?;
12204        let sa = self.ctx.sizeof_addr as usize;
12205        if !matches!(
12206            dataset.datatype,
12207            DatatypeMessage::Reference {
12208                kind: ReferenceKind::Object1,
12209                ..
12210            }
12211        ) {
12212            return Err(unsupported(
12213                "only an H5T_STD_REF_OBJ 'dataset' member is supported".into(),
12214            ));
12215        }
12216        let DatatypeMessage::FixedPoint {
12217            size: 4,
12218            byte_order,
12219            ..
12220        } = dimension.datatype
12221        else {
12222            return Err(unsupported(
12223                "a 4-byte integer 'dimension' member is required".into(),
12224            ));
12225        };
12226        let stride = *size as usize;
12227        let registered: Option<Vec<String>> = self
12228            .attribute_references
12229            .lock()
12230            .iter()
12231            .find(|r| r.scope == AttrScope::Dataset(dsid) && r.name == REFERENCE_LIST)
12232            .map(|r| r.targets.clone());
12233        let mut entries = Vec::with_capacity(msg.data.len() / stride);
12234        for (i, elem) in msg.data.chunks_exact(stride).enumerate() {
12235            let at = |offset: u32, len: usize| {
12236                elem.get(offset as usize..offset as usize + len)
12237                    .ok_or_else(|| unsupported(format!("element {i} is too short for its members")))
12238            };
12239            let path = match &registered {
12240                Some(targets) => targets.get(i).cloned().ok_or_else(|| {
12241                    crate::io::IoError::InvalidState(format!(
12242                        "REFERENCE_LIST of '{}' entry {i} has no registered target",
12243                        name()
12244                    ))
12245                })?,
12246                None => {
12247                    let mut raw = [0u8; 8];
12248                    raw[..sa].copy_from_slice(at(dataset.offset, sa)?);
12249                    self.path_of_header_address(u64::from_le_bytes(raw))?
12250                }
12251            };
12252            let dim: [u8; 4] = at(dimension.offset, 4)?.try_into().expect("4 bytes");
12253            let dim = match byte_order {
12254                ByteOrder::LittleEndian => u32::from_le_bytes(dim),
12255                ByteOrder::BigEndian => u32::from_be_bytes(dim),
12256            };
12257            entries.push((path, dim));
12258        }
12259        Ok(entries)
12260    }
12261
12262    /// Store `entries` as scale `dsid`'s `REFERENCE_LIST`, replacing the one
12263    /// it has — deleted and created anew, as upstream does, so it takes a
12264    /// fresh creation index.
12265    ///
12266    /// The element is libhdf5's `ds_list_t` as it lands on disk: the
12267    /// reference at offset 0, `dimension` right after it, and the struct's
12268    /// trailing padding — 16 bytes over 8-byte addresses. The addresses are
12269    /// stamped at finalize through [`object_attributes`](Self::object_attributes)
12270    /// like any reference attribute's; the `dimension` fields are final here.
12271    fn write_reference_list(&self, dsid: usize, entries: &[(String, u32)]) -> IoResult<()> {
12272        use crate::format::messages::datatype::CompoundMember;
12273
12274        let target = AttrTarget::Dataset(dsid);
12275        self.evict_attr(target, REFERENCE_LIST)?;
12276        let sa = self.ctx.sizeof_addr as usize;
12277        let stride = sa + 8;
12278        let datatype = DatatypeMessage::compound(
12279            stride as u32,
12280            vec![
12281                CompoundMember {
12282                    name: "dataset".to_string(),
12283                    offset: 0,
12284                    datatype: DatatypeMessage::object_reference(&self.ctx),
12285                },
12286                CompoundMember {
12287                    name: "dimension".to_string(),
12288                    offset: sa as u32,
12289                    datatype: DatatypeMessage::u32_type(),
12290                },
12291            ],
12292        );
12293        let mut data = vec![0u8; entries.len() * stride];
12294        for (i, (_, dim)) in entries.iter().enumerate() {
12295            data[i * stride + sa..i * stride + sa + 4].copy_from_slice(&dim.to_le_bytes());
12296        }
12297        let attr = AttributeMessage::array_numeric(
12298            REFERENCE_LIST,
12299            datatype,
12300            &[entries.len() as u64],
12301            data,
12302        );
12303        self.insert_attribute(target, attr, Created)?;
12304        self.attribute_references
12305            .lock()
12306            .push(AttributeReferenceValue {
12307                scope: AttrScope::Dataset(dsid),
12308                name: REFERENCE_LIST.to_string(),
12309                targets: entries.iter().map(|(p, _)| p.clone()).collect(),
12310                stride,
12311            });
12312        Ok(())
12313    }
12314
12315    /// Take the attribute `name` off `target`'s list, releasing its heap
12316    /// objects. No-op when absent.
12317    ///
12318    /// What it answers is what the insert that follows it must be told: an
12319    /// attribute that was there is being rewritten and keeps its creation
12320    /// index, and one that was not is created.
12321    fn evict_attr(&self, target: AttrTarget<'_>, name: &str) -> IoResult<AttrOrigin> {
12322        self.forget_attribute_reference(self.attr_scope(target)?, name);
12323        let old = self.with_attr_list(target, |attrs| {
12324            attrs
12325                .iter()
12326                .position(|a| a.name() == name)
12327                .map(|pos| attrs.remove(pos))
12328        })?;
12329        match old {
12330            Some(old) => {
12331                let origin = Rewritten(old.creation_index());
12332                self.release_attr_vlen(&old)?;
12333                Ok(origin)
12334            }
12335            None => Ok(Created),
12336        }
12337    }
12338
12339    /// Release the global-heap objects a superseded attribute owned.
12340    /// Recognizes top-level vlen datatypes only: a *compound* attribute
12341    /// with vlen members — which this crate cannot write, only a foreign
12342    /// file can carry — keeps its members' heap objects when replaced or
12343    /// deleted, the storage cost the foreign writer accepted. Every other
12344    /// class stores its value inline in the message. Per-object removal
12345    /// keeps collections shared with other refs (libhdf5-written files)
12346    /// intact. Under SWMR nothing is released: a streaming reader may hold
12347    /// the references (the same rule `swmr_active` sets for chunks).
12348    fn release_attr_vlen(&self, old: &AttributeEntry) -> IoResult<()> {
12349        use crate::format::messages::datatype::DatatypeMessage;
12350        if self.swmr_active {
12351            return Ok(());
12352        }
12353        // An attribute whose message this crate could not decode keeps
12354        // whatever heap space it references: releasing objects named by bytes
12355        // we cannot interpret would free storage that is still live.
12356        let Some(old) = old.readable() else {
12357            return Ok(());
12358        };
12359        if matches!(
12360            old.datatype,
12361            DatatypeMessage::VarLenString { .. } | DatatypeMessage::VarLenSequence { .. }
12362        ) {
12363            self.release_vlen_references(&old.data)?;
12364        }
12365        Ok(())
12366    }
12367
12368    /// Run `f` on the attribute list `target` names — the accessor every
12369    /// attribute mutation shares.
12370    fn with_attr_list<R>(
12371        &self,
12372        target: AttrTarget<'_>,
12373        f: impl FnOnce(&mut Vec<AttributeEntry>) -> R,
12374    ) -> IoResult<R> {
12375        match target {
12376            AttrTarget::Root => Ok(f(&mut self.root_attributes.lock())),
12377            AttrTarget::Group(path) => {
12378                let path = self.canonical_group_path(path);
12379                for grp in self.group_refs() {
12380                    let mut g = grp.lock();
12381                    if g.name == path && !g.deleted {
12382                        return Ok(f(&mut g.attributes));
12383                    }
12384                }
12385                Err(crate::io::IoError::NotFound(format!(
12386                    "group '{path}' not found"
12387                )))
12388            }
12389            AttrTarget::Dataset(index) => {
12390                let count = self.dataset_count();
12391                if index >= count {
12392                    return Err(crate::io::IoError::InvalidState(format!(
12393                        "dataset index {index} out of range (have {count})"
12394                    )));
12395                }
12396                let ds = self.ds(index);
12397                let mut m = ds.lock();
12398                // Every caller of this mutates the list, and a reopened
12399                // dataset's header is rewritten only when it is marked stale.
12400                m.header_dirty = true;
12401                Ok(f(&mut m.attributes))
12402            }
12403        }
12404    }
12405
12406    /// Store each of `items` as a global heap object and return its
12407    /// placement `(collection address, object index)`, in input order —
12408    /// the writer side of libhdf5's `H5HG_insert`.
12409    ///
12410    /// Placement follows libhdf5: a collection from the CWFS list takes an
12411    /// item when its free space holds the object *and* a residual
12412    /// free-space marker header (`encode_at_size` always emits the
12413    /// marker); what no listed collection can take goes into a fresh
12414    /// collection, spilling into another at the 65535-object index cap.
12415    /// One batch may therefore span several collections — invisible to
12416    /// readers, which resolve each reference's own collection address. An
12417    /// empty batch allocates nothing: an empty collection still encodes
12418    /// to the 4096-byte `H5HG_MINALLOC` minimum, a block nothing would
12419    /// reference. libhdf5 additionally tries to extend a nearly-full
12420    /// collection's block in place (`H5MF_try_extend`); this writer does
12421    /// not — an oversized item always starts a fresh collection.
12422    ///
12423    /// The `cwfs` lock is held across every read-modify-rewrite of a
12424    /// listed collection block: it serializes concurrent inserts (two
12425    /// datasets' writers can pack the same block) and inserts against
12426    /// [`release_vlen_references`](Self::release_vlen_references), which
12427    /// rewrites the same blocks when objects are freed.
12428    ///
12429    /// Under SWMR the CWFS list is neither consulted nor updated and every
12430    /// batch gets fresh collections: packing rewrites a block a streaming
12431    /// reader may be mid-walk on — the same reason `place_chunk` keeps a
12432    /// relocated chunk's old block.
12433    fn insert_vlen_objects(&self, items: &[&[u8]]) -> IoResult<Vec<(u64, u16)>> {
12434        use crate::format::global_heap::{GlobalHeapCollection, GlobalHeapObject};
12435
12436        if items.is_empty() {
12437            return Ok(Vec::new());
12438        }
12439        let objhdr = GlobalHeapCollection::object_disk_size(&self.ctx, 0);
12440        let mut placements = Vec::with_capacity(items.len());
12441        let mut i = 0;
12442
12443        // Pack into listed collections while one can take the next item.
12444        if !self.swmr_active {
12445            let mut cwfs = self.cwfs.lock();
12446            while i < items.len() {
12447                let need = GlobalHeapCollection::object_disk_size(&self.ctx, items[i].len());
12448                let Some(pos) = cwfs.iter().position(|e| e.free >= need + objhdr) else {
12449                    // Second pass of libhdf5's H5F_cwfs_find_free_heap: no
12450                    // listed collection has room, so try to grow one in
12451                    // place before falling back to a fresh collection.
12452                    if self.extend_listed_collection(&mut cwfs, need + objhdr)? {
12453                        continue;
12454                    }
12455                    break;
12456                };
12457                let (addr, size) = (cwfs[pos].addr, cwfs[pos].size);
12458                let image = self.handle.read_at(addr, size)?;
12459                let (mut gcol, _) = GlobalHeapCollection::decode(&image[..size], &self.ctx)?;
12460                // The disk is the truth for free space; the entry is a hint.
12461                let Some(mut free) = gcol.free_space_at(&self.ctx, size) else {
12462                    cwfs.remove(pos);
12463                    continue;
12464                };
12465                let mut next_idx = gcol.max_index();
12466                let mut took = false;
12467                while i < items.len() && next_idx < u16::MAX {
12468                    let need = GlobalHeapCollection::object_disk_size(&self.ctx, items[i].len());
12469                    if free < need + objhdr {
12470                        break;
12471                    }
12472                    next_idx += 1;
12473                    gcol.objects.push(GlobalHeapObject {
12474                        index: next_idx,
12475                        ref_count: 0,
12476                        data: items[i].to_vec(),
12477                    });
12478                    placements.push((addr, next_idx));
12479                    free -= need;
12480                    took = true;
12481                    i += 1;
12482                }
12483                if took {
12484                    let rewritten = gcol.encode_at_size(&self.ctx, size)?;
12485                    self.handle.write_at(addr, &rewritten)?;
12486                    // Correct the entry to the measured free space and move
12487                    // it to the front — libhdf5 keeps `cwfs` in
12488                    // most-recently-used order.
12489                    let mut e = cwfs.remove(pos);
12490                    e.free = free;
12491                    cwfs.insert(0, e);
12492                } else if next_idx == u16::MAX {
12493                    // At the index cap nothing can be inserted no matter the
12494                    // free space; drop the entry or the scan re-picks it
12495                    // forever. (A removal can lower the top index again, and
12496                    // the release side re-lists the collection then.)
12497                    cwfs.remove(pos);
12498                } else {
12499                    // The hint overstated the block's free space — shrink it
12500                    // to the measured value so the scan moves on.
12501                    cwfs[pos].free = free;
12502                }
12503            }
12504        }
12505
12506        // What remains goes into fresh collections.
12507        while i < items.len() {
12508            let mut gcol = GlobalHeapCollection::new();
12509            // Objects are pushed with a running index: `add_object` rescans
12510            // for the max index per call, O(n²) across a spill-sized batch.
12511            let mut next_idx: u16 = 0;
12512            while i < items.len() && next_idx < u16::MAX {
12513                next_idx += 1;
12514                gcol.objects.push(GlobalHeapObject {
12515                    index: next_idx,
12516                    ref_count: 0,
12517                    data: items[i].to_vec(),
12518                });
12519                i += 1;
12520            }
12521            let encoded = gcol.encode(&self.ctx);
12522            let addr = self
12523                .allocator
12524                .allocate(encoded.len() as u64, FreeSpaceClass::RawData);
12525            self.handle.write_at(addr, &encoded)?;
12526            for idx in 1..=next_idx {
12527                placements.push((addr, idx));
12528            }
12529            // List the block's leftover free space for later inserts — the
12530            // minimum-size padding of a small batch is most of 4096 bytes.
12531            // Below two object headers not even an empty object fits.
12532            if !self.swmr_active {
12533                if let Some(free) = gcol.free_space_at(&self.ctx, encoded.len()) {
12534                    if free >= 2 * objhdr {
12535                        cwfs_note(&mut self.cwfs.lock(), addr, encoded.len(), free);
12536                    }
12537                }
12538            }
12539        }
12540        Ok(placements)
12541    }
12542
12543    /// Try to extend one listed collection in place so it can take an
12544    /// object needing `want` bytes of free space — the second pass of
12545    /// libhdf5's `H5F_cwfs_find_free_heap`: grow the file allocation
12546    /// ([`FileAllocator::try_extend`], mirroring `H5MF_try_extend`) and then
12547    /// the collection itself (`H5HG_extend`: a larger declared size and a
12548    /// free-space marker covering the new tail — here by re-encoding at the
12549    /// grown size, which writes exactly those two things).
12550    ///
12551    /// Extension size is `max(collection_size, shortfall)` — at least a
12552    /// doubling — capped so the result stays within [`GCOL_MAX_SIZE`], both
12553    /// as upstream computes them. On success the grown entry moves to the
12554    /// front of the list and the caller's scan re-picks it; the free-space
12555    /// measurement is taken from the block on disk, not the list's hint, so
12556    /// the rewrite and the entry agree.
12557    ///
12558    /// Caller holds the `cwfs` lock (it passes the guarded list), which is
12559    /// what serializes this read-modify-rewrite against concurrent inserts
12560    /// and releases.
12561    fn extend_listed_collection(&self, cwfs: &mut Vec<CwfsEntry>, want: usize) -> IoResult<bool> {
12562        use crate::format::global_heap::{GlobalHeapCollection, GCOL_MAX_SIZE};
12563
12564        let mut pos = 0;
12565        while pos < cwfs.len() {
12566            let (addr, size) = (cwfs[pos].addr, cwfs[pos].size);
12567            let image = self.handle.read_at(addr, size)?;
12568            let (gcol, _) = GlobalHeapCollection::decode(&image[..size], &self.ctx)?;
12569            // The disk is the truth for free space; the entry is a hint.
12570            let Some(free) = gcol.free_space_at(&self.ctx, size) else {
12571                cwfs.remove(pos);
12572                continue;
12573            };
12574            // A hint can understate the block (upstream's FREE_SIZE is its
12575            // in-memory truth and cannot): if the block already has room,
12576            // correct the hint instead of doubling the collection.
12577            if free >= want {
12578                cwfs[pos].free = free;
12579                return Ok(true);
12580            }
12581            let new_need = size.max(want.saturating_sub(free));
12582            if size + new_need > GCOL_MAX_SIZE
12583                || !self.allocator.try_extend(
12584                    addr,
12585                    size as u64,
12586                    new_need as u64,
12587                    FreeSpaceClass::RawData,
12588                )
12589            {
12590                pos += 1;
12591                continue;
12592            }
12593            let new_size = size + new_need;
12594            let rewritten = gcol.encode_at_size(&self.ctx, new_size)?;
12595            self.handle.write_at(addr, &rewritten)?;
12596            let mut e = cwfs.remove(pos);
12597            e.size = new_size;
12598            e.free = free + new_need;
12599            cwfs.insert(0, e);
12600            return Ok(true);
12601        }
12602        Ok(false)
12603    }
12604
12605    /// Create a variable-length string dataset and write string data.
12606    ///
12607    /// Stores strings in the global heap. The dataset raw data consists of
12608    /// vlen references (collection_addr + object_index pairs).
12609    ///
12610    /// `charset` is the datatype's declared character set (0 = ASCII,
12611    /// 1 = UTF-8); the strings are checked against it before anything is
12612    /// written, so the type never misdescribes the bytes under it.
12613    pub fn create_vlen_string_dataset(
12614        &self,
12615        name: &str,
12616        strings: &[&str],
12617        charset: u8,
12618    ) -> IoResult<usize> {
12619        use crate::format::global_heap::encode_vlen_reference;
12620        use crate::format::messages::datatype::DatatypeMessage;
12621
12622        ensure_vlen_charset(charset, strings)?;
12623
12624        let create = self.begin_create(name)?;
12625        let name = create.name.as_str();
12626        let num_strings = strings.len() as u64;
12627
12628        // Store the strings as heap objects; a batch that fits an earlier
12629        // collection's free space shares its block.
12630        let items: Vec<&[u8]> = strings.iter().map(|s| s.as_bytes()).collect();
12631        let placements = self.insert_vlen_objects(&items)?;
12632
12633        // Build raw data: vlen references
12634        let ref_size = crate::format::global_heap::vlen_reference_size(&self.ctx);
12635        let data_size = (num_strings as usize) * ref_size;
12636        let mut raw_data = Vec::with_capacity(data_size);
12637        for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
12638            let seq_len = crate::format::global_heap::vlen_seq_len(strings[i].len())?;
12639            raw_data.extend_from_slice(&encode_vlen_reference(
12640                seq_len,
12641                gcol_addr,
12642                obj_idx as u32,
12643                &self.ctx,
12644            ));
12645        }
12646
12647        // Allocate and write raw data
12648        let data_addr = self
12649            .allocator
12650            .allocate(data_size as u64, FreeSpaceClass::RawData);
12651        self.handle.write_at(data_addr, &raw_data)?;
12652
12653        // Create the dataset with vlen string datatype
12654        let datatype = DatatypeMessage::VarLenString {
12655            padding: 0,
12656            charset,
12657        };
12658        let dataspace =
12659            crate::format::messages::dataspace::DataspaceMessage::simple(&[num_strings]);
12660
12661        let idx = self.push_dataset(
12662            &create,
12663            DatasetInfo {
12664                name: name.to_string(),
12665                datatype,
12666                committed_type: None,
12667                external: None,
12668                virtual_storage: None,
12669                dataspace,
12670                read_format: None,
12671                obj_header_addr: 0,
12672                data_addr,
12673                data_size: data_size as u64,
12674                compact: None,
12675                attributes: Vec::new(),
12676                obj_header_written_addr: None,
12677                obj_header_blocks: Vec::new(),
12678                filter_pipeline: None,
12679                deleted: false,
12680                extent_dirty: false,
12681                header_dirty: false,
12682                nlink_written: 1,
12683                creation_seq: self.take_creation_seq(),
12684                track_attr_order: self.track_order.attrs,
12685                fill_value: None,
12686                fill_time: FILL_TIME_IFSET,
12687                layout_version: 4,
12688                times: self.created_object_times(),
12689                chunked: None,
12690                fixed_array: None,
12691                implicit: None,
12692                single_chunk: None,
12693                btree_v1: None,
12694                btree_v2: None,
12695                append: None,
12696            },
12697        );
12698
12699        Ok(idx)
12700    }
12701
12702    /// Create a 1-D variable-length byte-array dataset.
12703    ///
12704    /// The `u8` case of [`create_vlen_sequence_dataset`], where an item's
12705    /// byte image and its element count are the same number.
12706    ///
12707    /// [`create_vlen_sequence_dataset`]: Self::create_vlen_sequence_dataset
12708    ///
12709    /// Superseded in production by [`write_vlen_numeric`](crate::H5File::write_vlen_numeric)
12710    /// (`H5Group::write_vlen_bytes` routes through it, not through here);
12711    /// kept as a direct entry point for this crate's own white-box tests.
12712    #[cfg(test)]
12713    pub fn create_vlen_bytes_dataset(&self, name: &str, items: &[&[u8]]) -> IoResult<usize> {
12714        use crate::format::messages::datatype::DatatypeMessage;
12715
12716        self.create_vlen_sequence_dataset(name, DatatypeMessage::u8_type(), items)
12717    }
12718
12719    /// Create a 1-D variable-length sequence dataset over `base`.
12720    ///
12721    /// Each item is the encoded image of one sequence — `n * base.element_size()`
12722    /// bytes in the base type's own byte order — and is stored as a global-heap
12723    /// object; the dataset holds one vlen reference per item, the same on-disk
12724    /// shape a vlen string dataset has. The `H5T_VLEN` length field counts base
12725    /// elements rather than bytes, so an image whose length is not a whole
12726    /// number of elements is refused here rather than stored under a length
12727    /// that misreads it.
12728    pub fn create_vlen_sequence_dataset(
12729        &self,
12730        name: &str,
12731        base: DatatypeMessage,
12732        items: &[&[u8]],
12733    ) -> IoResult<usize> {
12734        use crate::format::global_heap::encode_vlen_reference;
12735        use crate::format::messages::datatype::DatatypeMessage;
12736
12737        let elem_size = base.element_size() as usize;
12738        if elem_size == 0 {
12739            return Err(crate::io::IoError::InvalidState(format!(
12740                "vlen base datatype {base} has no element size"
12741            )));
12742        }
12743        for (i, item) in items.iter().enumerate() {
12744            if !item.len().is_multiple_of(elem_size) {
12745                return Err(crate::io::IoError::InvalidState(format!(
12746                    "sequence {i} is {} bytes, not a whole number of {elem_size}-byte elements",
12747                    item.len()
12748                )));
12749            }
12750        }
12751
12752        let create = self.begin_create(name)?;
12753        let name = create.name.as_str();
12754        let num_items = items.len() as u64;
12755
12756        // Store the sequence images as heap objects, sharing collection
12757        // blocks as `create_vlen_string_dataset` does.
12758        let placements = self.insert_vlen_objects(items)?;
12759
12760        // Build raw data: one vlen reference per item.
12761        let ref_size = crate::format::global_heap::vlen_reference_size(&self.ctx);
12762        let data_size = (num_items as usize) * ref_size;
12763        let mut raw_data = Vec::with_capacity(data_size);
12764        for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
12765            let seq_len = crate::format::global_heap::vlen_seq_len(items[i].len() / elem_size)?;
12766            raw_data.extend_from_slice(&encode_vlen_reference(
12767                seq_len,
12768                gcol_addr,
12769                obj_idx as u32,
12770                &self.ctx,
12771            ));
12772        }
12773
12774        // Allocate and write raw data.
12775        let data_addr = self
12776            .allocator
12777            .allocate(data_size as u64, FreeSpaceClass::RawData);
12778        self.handle.write_at(data_addr, &raw_data)?;
12779
12780        let datatype = DatatypeMessage::VarLenSequence {
12781            base: Box::new(base),
12782        };
12783        let dataspace = crate::format::messages::dataspace::DataspaceMessage::simple(&[num_items]);
12784
12785        let idx = self.push_dataset(
12786            &create,
12787            DatasetInfo {
12788                name: name.to_string(),
12789                datatype,
12790                committed_type: None,
12791                external: None,
12792                virtual_storage: None,
12793                dataspace,
12794                read_format: None,
12795                obj_header_addr: 0,
12796                data_addr,
12797                data_size: data_size as u64,
12798                compact: None,
12799                attributes: Vec::new(),
12800                obj_header_written_addr: None,
12801                obj_header_blocks: Vec::new(),
12802                filter_pipeline: None,
12803                deleted: false,
12804                extent_dirty: false,
12805                header_dirty: false,
12806                nlink_written: 1,
12807                creation_seq: self.take_creation_seq(),
12808                track_attr_order: self.track_order.attrs,
12809                fill_value: None,
12810                fill_time: FILL_TIME_IFSET,
12811                layout_version: 4,
12812                times: self.created_object_times(),
12813                chunked: None,
12814                fixed_array: None,
12815                implicit: None,
12816                single_chunk: None,
12817                btree_v1: None,
12818                btree_v2: None,
12819                append: None,
12820            },
12821        );
12822
12823        Ok(idx)
12824    }
12825
12826    /// Create a chunked, compressed variable-length string dataset.
12827    ///
12828    /// Strings are stored in the global heap (same as `create_vlen_string_dataset`),
12829    /// but the vlen references are stored in chunked layout with the given filter
12830    /// pipeline (e.g., deflate, zstd). `chunk_size` is the number of strings per chunk.
12831    pub fn create_vlen_string_dataset_compressed(
12832        &self,
12833        name: &str,
12834        strings: &[&str],
12835        chunk_size: usize,
12836        pipeline: FilterPipeline,
12837    ) -> IoResult<usize> {
12838        use crate::format::global_heap::encode_vlen_reference;
12839        use crate::format::messages::datatype::DatatypeMessage;
12840
12841        let create = self.begin_create(name)?;
12842        let name = create.name.as_str();
12843        let num_strings = strings.len() as u64;
12844        validate_chunk_geometry(&[num_strings], &[num_strings], &[chunk_size as u64])?;
12845
12846        // Store the strings as heap objects; the geometry validation above
12847        // must precede this so a refused call allocates nothing.
12848        let items: Vec<&[u8]> = strings.iter().map(|s| s.as_bytes()).collect();
12849        let placements = self.insert_vlen_objects(&items)?;
12850
12851        // Build raw data: vlen references
12852        let ref_size = crate::format::global_heap::vlen_reference_size(&self.ctx);
12853        let data_size = (num_strings as usize) * ref_size;
12854        let mut raw_data = Vec::with_capacity(data_size);
12855        for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
12856            let seq_len = crate::format::global_heap::vlen_seq_len(strings[i].len())?;
12857            raw_data.extend_from_slice(&encode_vlen_reference(
12858                seq_len,
12859                gcol_addr,
12860                obj_idx as u32,
12861                &self.ctx,
12862            ));
12863        }
12864
12865        // Set up chunked compressed layout
12866        let datatype = DatatypeMessage::vlen_string_utf8();
12867        let element_size = datatype.element_size_ctx(&self.ctx) as u64;
12868        let chunk_dims: Vec<u64> = vec![chunk_size as u64];
12869        let dims: Vec<u64> = vec![num_strings];
12870        let max_dims: Vec<u64> = vec![num_strings];
12871        let chunk_bytes = chunk_size as u64 * element_size;
12872        let layout_version = self.chunk_layout_version(true, chunk_bytes);
12873        let chunk_size_len = self.chunk_size_len_for(layout_version, chunk_bytes);
12874
12875        let earray_params = EarrayParams::default_params();
12876        let ndblk_addrs = compute_ndblk_addrs(earray_params.sup_blk_min_data_ptrs)?;
12877        let nsblk_addrs = compute_nsblk_addrs(
12878            earray_params.idx_blk_elmts,
12879            earray_params.data_blk_min_elmts,
12880            earray_params.sup_blk_min_data_ptrs,
12881            earray_params.max_nelmts_bits,
12882        )?;
12883
12884        // Create filtered EA header
12885        let mut ea_header =
12886            ExtensibleArrayHeader::new_for_filtered_chunks(&self.ctx, chunk_size_len);
12887        ea_header.max_nelmts_bits = earray_params.max_nelmts_bits;
12888        ea_header.idx_blk_elmts = earray_params.idx_blk_elmts;
12889        ea_header.data_blk_min_elmts = earray_params.data_blk_min_elmts;
12890        ea_header.sup_blk_min_data_ptrs = earray_params.sup_blk_min_data_ptrs;
12891        ea_header.max_dblk_page_nelmts_bits = earray_params.max_dblk_page_nelmts_bits;
12892
12893        let hdr_encoded = ea_header.encode(&self.ctx);
12894        let ea_header_addr = self
12895            .allocator
12896            .allocate(hdr_encoded.len() as u64, FreeSpaceClass::Metadata);
12897
12898        // Create filtered index block
12899        let filt_iblk = FilteredIndexBlock::new(
12900            ea_header_addr,
12901            earray_params.idx_blk_elmts,
12902            ndblk_addrs,
12903            nsblk_addrs,
12904        );
12905        let iblk_encoded = filt_iblk.encode(&self.ctx, chunk_size_len);
12906        let ea_iblk_addr = self
12907            .allocator
12908            .allocate(iblk_encoded.len() as u64, FreeSpaceClass::Metadata);
12909
12910        ea_header.idx_blk_addr = ea_iblk_addr;
12911
12912        let hdr_encoded = ea_header.encode(&self.ctx);
12913        self.handle.write_at(ea_header_addr, &hdr_encoded)?;
12914        self.handle.write_at(ea_iblk_addr, &iblk_encoded)?;
12915
12916        let dataspace = DataspaceMessage {
12917            // Chunked storage always requires at least one dimension, so
12918            // this is never Scalar or Null.
12919            class: DataspaceClass::Simple,
12920            dims: dims.to_vec(),
12921            max_dims: Some(max_dims.to_vec()),
12922        };
12923
12924        let ea_iblk = ExtensibleArrayIndexBlock::new(
12925            ea_header_addr,
12926            earray_params.idx_blk_elmts,
12927            ndblk_addrs,
12928            nsblk_addrs,
12929        );
12930
12931        let idx = self.push_dataset(
12932            &create,
12933            DatasetInfo {
12934                name: name.to_string(),
12935                datatype,
12936                committed_type: None,
12937                external: None,
12938                virtual_storage: None,
12939                dataspace,
12940                read_format: None,
12941                obj_header_addr: 0,
12942                data_addr: UNDEF_ADDR,
12943                data_size: 0,
12944                compact: None,
12945                attributes: Vec::new(),
12946                obj_header_written_addr: None,
12947                obj_header_blocks: Vec::new(),
12948                filter_pipeline: Some(pipeline),
12949                deleted: false,
12950                extent_dirty: false,
12951                header_dirty: false,
12952                nlink_written: 1,
12953                creation_seq: self.take_creation_seq(),
12954                track_attr_order: self.track_order.attrs,
12955                fill_value: None,
12956                fill_time: FILL_TIME_IFSET,
12957                layout_version,
12958                times: self.created_object_times(),
12959                fixed_array: None,
12960                implicit: None,
12961                single_chunk: None,
12962                btree_v1: None,
12963                btree_v2: None,
12964                chunked: Some(ChunkedDatasetInfo {
12965                    chunk_dims: chunk_dims.clone(),
12966                    earray_params,
12967                    ea_header_addr,
12968                    ea_iblk_addr,
12969                    ea_header,
12970                    ea_iblk,
12971                    chunks_written: 0,
12972                    filt_iblk: Some(filt_iblk),
12973                    chunk_size_len,
12974                }),
12975                append: None,
12976            },
12977        );
12978
12979        // Write chunks of vlen references with compression
12980        let chunk_byte_size = chunk_bytes as usize;
12981        let num_chunks = raw_data.len().div_ceil(chunk_byte_size);
12982        for chunk_i in 0..num_chunks {
12983            let start = chunk_i * chunk_byte_size;
12984            let end = (start + chunk_byte_size).min(raw_data.len());
12985            let chunk_data = if end - start < chunk_byte_size {
12986                // Pad last chunk to full size (vlen datasets carry no user
12987                // fill value, so this resolves to zero = null vlen reference).
12988                let mut padded = self.new_chunk_buffer(idx, chunk_byte_size);
12989                padded[..end - start].copy_from_slice(&raw_data[start..end]);
12990                padded
12991            } else {
12992                raw_data[start..end].to_vec()
12993            };
12994            self.write_chunk(idx, chunk_i as u64, &chunk_data)?;
12995        }
12996
12997        Ok(idx)
12998    }
12999
13000    /// Create an empty chunked vlen string dataset ready for incremental appends.
13001    ///
13002    /// The dataset starts with `dims = [0]` and `max_dims = [unlimited]`.
13003    /// Use `append_vlen_strings` to add data.
13004    pub fn create_appendable_vlen_string_dataset(
13005        &self,
13006        name: &str,
13007        chunk_size: usize,
13008        pipeline: Option<FilterPipeline>,
13009    ) -> IoResult<usize> {
13010        let datatype = DatatypeMessage::vlen_string_utf8();
13011        let chunk_dims: Vec<u64> = vec![chunk_size as u64];
13012        let dims: Vec<u64> = vec![0];
13013        let max_dims: Vec<u64> = vec![u64::MAX];
13014
13015        if let Some(ref pl) = pipeline {
13016            self.create_chunked_dataset_with_pipeline(
13017                name,
13018                datatype,
13019                &dims,
13020                &max_dims,
13021                &chunk_dims,
13022                pl.clone(),
13023            )
13024        } else {
13025            self.create_chunked_dataset(name, datatype, &dims, &max_dims, &chunk_dims)
13026        }
13027    }
13028
13029    /// Append variable-length strings to an existing chunked vlen string dataset.
13030    ///
13031    /// Creates a new global heap collection for the strings, builds vlen
13032    /// references, and appends them as new chunks to the dataset.
13033    pub fn append_vlen_strings(&self, ds_index: usize, strings: &[&str]) -> IoResult<()> {
13034        use crate::format::global_heap::encode_vlen_reference;
13035        use crate::format::messages::datatype::DatatypeMessage;
13036
13037        if strings.is_empty() {
13038            return Ok(());
13039        }
13040
13041        // Whole-operation guard: buffer take, frame writes, re-buffer and
13042        // extend below are separate slot acquisitions that a concurrent
13043        // same-dataset append must not interleave with.
13044        let cell = self.ds(ds_index);
13045        let _op = cell.op.lock();
13046
13047        // The elements about to be written are vlen references; any other
13048        // element type would be overwritten with them as raw bytes.
13049        let charset = {
13050            let ds = self.ds(ds_index);
13051            let m = ds.lock();
13052            match m.datatype {
13053                DatatypeMessage::VarLenString { charset, .. } => charset,
13054                _ => {
13055                    return Err(crate::io::IoError::InvalidState(
13056                        "append_vlen_strings is only for variable-length string datasets".into(),
13057                    ))
13058                }
13059            }
13060        };
13061        ensure_vlen_charset(charset, strings)?;
13062
13063        // Every deterministic rejection must precede the heap write below:
13064        // a collection written for a batch the append then refuses (a
13065        // contiguous dataset, or a reopened dataset whose chunk index was
13066        // not reconstructed) is a 4096-byte orphan nothing references.
13067        let chunk_dims = self
13068            .dataset_chunk_dims(ds_index)
13069            .ok_or_else(|| crate::io::IoError::InvalidState("not a chunked dataset".into()))?
13070            .to_vec();
13071        let dims = self.dataset_dims(ds_index).to_vec();
13072
13073        // Store the batch's strings as heap objects; a batch that fits an
13074        // earlier collection's free space shares its block.
13075        let items: Vec<&[u8]> = strings.iter().map(|s| s.as_bytes()).collect();
13076        let placements = self.insert_vlen_objects(&items)?;
13077
13078        // Build raw vlen reference bytes
13079        let ref_size = crate::format::global_heap::vlen_reference_size(&self.ctx);
13080        let mut raw = Vec::with_capacity(strings.len() * ref_size);
13081        for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
13082            let seq_len = crate::format::global_heap::vlen_seq_len(strings[i].len())?;
13083            raw.extend_from_slice(&encode_vlen_reference(
13084                seq_len,
13085                gcol_addr,
13086                obj_idx as u32,
13087                &self.ctx,
13088            ));
13089        }
13090
13091        let n_new_frames = strings.len();
13092        let current_dim0 = dims[0] as usize;
13093        let chunk_dim0 = chunk_dims[0] as usize;
13094        let frame_bytes = ref_size;
13095
13096        // Merge the buffer with the new frames when it is the dataset's tail;
13097        // a buffer left mid-extent (the extent moved past it) keeps its
13098        // recorded place — flush it and start fresh at the current end.
13099        let taken = { self.ds(ds_index).lock().append.take() };
13100        let (base_dim0, buffered_frames, mut combined) = match taken {
13101            Some(b) if b.base + b.frames == current_dim0 as u64 => {
13102                (b.base as usize, b.frames as usize, b.bytes)
13103            }
13104            Some(b) => {
13105                self.write_append_frames(ds_index, b.base, b.frames, &b.bytes)?;
13106                (current_dim0, 0, Vec::new())
13107            }
13108            None => (current_dim0, 0, Vec::new()),
13109        };
13110        combined.extend_from_slice(&raw);
13111
13112        let total_frames = buffered_frames + n_new_frames;
13113
13114        // Rows up to the last chunk boundary are written now; the tail that
13115        // does not complete a chunk goes back in the buffer for the next
13116        // append (or the flush at close). The boundary can precede
13117        // `base_dim0` — a reopened file's flushed partial chunk leaves the
13118        // base mid-chunk — in which case everything is tail.
13119        let last_boundary = ((base_dim0 + total_frames) / chunk_dim0) * chunk_dim0;
13120        let write_frames = last_boundary.saturating_sub(base_dim0);
13121        let tail_frames = total_frames - write_frames;
13122        if write_frames > 0 {
13123            self.write_append_frames(
13124                ds_index,
13125                base_dim0 as u64,
13126                write_frames as u64,
13127                &combined[..write_frames * frame_bytes],
13128            )?;
13129        }
13130        if tail_frames > 0 {
13131            let ds = self.ds(ds_index);
13132            let mut m = ds.lock();
13133            m.append = Some(AppendBuffer {
13134                base: (base_dim0 + write_frames) as u64,
13135                frames: tail_frames as u64,
13136                bytes: combined[write_frames * frame_bytes..].to_vec(),
13137            });
13138        }
13139
13140        // Extend dims
13141        let logical_dim0 = base_dim0 + total_frames;
13142        let mut new_dims = dims;
13143        new_dims[0] = logical_dim0 as u64;
13144        self.extend_dataset_inner(ds_index, &new_dims)?;
13145
13146        Ok(())
13147    }
13148
13149    /// Replace elements `start .. start + strings.len()` of a 1-D
13150    /// variable-length string dataset, leaving its extent and every other
13151    /// element alone.
13152    ///
13153    /// The replacements go into the global heap and only the vlen
13154    /// references of the named elements are rewritten, so the cost is the
13155    /// new strings plus the chunks those references live in — not the column.
13156    /// The objects the old references pointed at are freed *before* the
13157    /// replacement is allocated, so repeated updates reuse space instead of
13158    /// growing the file — including across close/reopen cycles, where the
13159    /// in-memory free list starts empty and only this free-first order lets
13160    /// the session reuse the block it just released. This is what libhdf5
13161    /// does: `H5T__vlen_disk_write` deletes the reference it read into the
13162    /// conversion background buffer before storing the new one.
13163    ///
13164    /// Elements the append buffer still holds are flushed to their chunks
13165    /// first, so the whole range is on disk and one write path covers it.
13166    pub fn write_vlen_strings_slice(
13167        &self,
13168        ds_index: usize,
13169        start: u64,
13170        strings: &[&str],
13171    ) -> IoResult<()> {
13172        use crate::format::global_heap::{encode_vlen_reference, vlen_reference_size};
13173        use crate::format::messages::datatype::DatatypeMessage;
13174
13175        // An empty batch is a no-op: nothing to replace, nothing to free.
13176        if strings.is_empty() {
13177            return Ok(());
13178        }
13179
13180        // Whole-operation guard: the flush, the old-reference reads and the
13181        // slice write below must not interleave with a concurrent
13182        // same-dataset operation.
13183        let cell = self.ds(ds_index);
13184        let _op = cell.op.lock();
13185
13186        // Snapshot what the write needs, then drop the guard: `write_slice`
13187        // below re-locks the same slot.
13188        let (charset, dims, writable) = {
13189            let ds = self.ds(ds_index);
13190            let m = ds.lock();
13191            let charset = match m.datatype {
13192                DatatypeMessage::VarLenString { charset, .. } => charset,
13193                _ => {
13194                    return Err(crate::io::IoError::InvalidState(
13195                        "write_vlen_strings_slice is only for variable-length string datasets"
13196                            .into(),
13197                    ))
13198                }
13199            };
13200            let writable = if m.is_chunked() {
13201                Ok(())
13202            } else {
13203                match m.contiguous_target() {
13204                    Some(ContiguousTarget::Virtual) => Err(virtual_write_refused()),
13205                    Some(_) => Ok(()),
13206                    None => Err(crate::io::IoError::InvalidState(
13207                        "dataset has no data allocated".into(),
13208                    )),
13209                }
13210            };
13211            (charset, m.dataspace.dims.clone(), writable)
13212        };
13213
13214        // `write_slice_inner` rejects a dataset with neither chunk machinery
13215        // nor allocated data (a reopened dataset whose index was not
13216        // reconstructed), and refuses a virtual one outright — those
13217        // rejections must come before the heap write below, or every failed
13218        // call orphans a 4096-byte collection.
13219        writable?;
13220
13221        if dims.len() != 1 {
13222            return Err(crate::io::IoError::InvalidState(format!(
13223                "write_vlen_strings_slice is only for 1-dimension datasets, this one has {}",
13224                dims.len()
13225            )));
13226        }
13227        let end = start + strings.len() as u64;
13228        if end > dims[0] {
13229            return Err(crate::io::IoError::InvalidState(format!(
13230                "elements {start}..{end} are outside the dataset's {} elements",
13231                dims[0]
13232            )));
13233        }
13234        ensure_vlen_charset(charset, strings)?;
13235
13236        let ref_size = vlen_reference_size(&self.ctx);
13237
13238        // Elements the append buffer holds are not in the chunks yet: hand
13239        // them to the chunks first so the whole range is on disk and the one
13240        // write path below covers it.
13241        self.flush_append_buffer_if_intersecting(ds_index, start, end)?;
13242
13243        // The on-disk references about to be overwritten, read before anything
13244        // moves. libhdf5 reads the same bytes into the conversion background
13245        // buffer (`H5D__scatgath_write` gathers the file's current elements
13246        // when `need_bkg` is set) and hands them to `H5T__vlen_disk_write`,
13247        // which deletes them before storing the new reference.
13248        let superseded = self.current_element_bytes(ds_index, start, end - start, ref_size)?;
13249
13250        // Free the superseded objects *before* allocating the replacement,
13251        // the order `H5T__vlen_disk_write` uses. The freed block satisfies
13252        // the allocation below within this same session, so a reopen-and-
13253        // replace loop keeps the file flat — no persisted free-space
13254        // information exists to carry it across sessions (issue #10). The
13255        // cost, shared with libhdf5: a failure between here and the ref
13256        // write below leaves the dataset's old references dangling.
13257        self.release_vlen_references(&superseded)?;
13258
13259        // The insert comes after the release above so the space the release
13260        // recovered — a freed block, or in-collection bytes the release just
13261        // listed in `cwfs` — can satisfy this batch.
13262        let items: Vec<&[u8]> = strings.iter().map(|s| s.as_bytes()).collect();
13263        let placements = self.insert_vlen_objects(&items)?;
13264
13265        let mut refs = Vec::with_capacity(strings.len() * ref_size);
13266        for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
13267            refs.extend_from_slice(&encode_vlen_reference(
13268                crate::format::global_heap::vlen_seq_len(strings[i].len())?,
13269                gcol_addr,
13270                obj_idx as u32,
13271                &self.ctx,
13272            ));
13273        }
13274
13275        self.write_slice_inner(ds_index, &[start], &[strings.len() as u64], &refs)?;
13276
13277        Ok(())
13278    }
13279
13280    /// The bytes elements `start .. start + count` of a 1-D dataset currently
13281    /// hold, whichever layout stores them.
13282    ///
13283    /// Elements no write has reached yet read as zeros — for a vlen dataset
13284    /// that is the nil reference, which names no heap object.
13285    fn current_element_bytes(
13286        &self,
13287        ds_index: usize,
13288        start: u64,
13289        count: u64,
13290        element_size: usize,
13291    ) -> IoResult<Vec<u8>> {
13292        let mut out = vec![0u8; count as usize * element_size];
13293        if count == 0 {
13294            return Ok(out);
13295        }
13296
13297        let (is_chunked, data_addr) = {
13298            let ds = self.ds(ds_index);
13299            let m = ds.lock();
13300            (m.is_chunked(), m.data_addr)
13301        };
13302
13303        if !is_chunked {
13304            if data_addr != UNDEF_ADDR {
13305                // `read_at_most`, not `read_at`: a contiguous dataset's block is
13306                // reserved when it is created, so the file can still be shorter
13307                // than the block until something writes it. What is missing has
13308                // never been written, which is the zeros above.
13309                let at = data_addr + start * element_size as u64;
13310                let got = self.handle.read_at_most(at, out.len())?;
13311                out[..got.len()].copy_from_slice(&got);
13312            }
13313            return Ok(out);
13314        }
13315
13316        let geo = self.chunk_geometry(ds_index)?;
13317        let per_chunk = geo.chunk_dims[0];
13318        // Only a corrupt or crafted file declares a zero-length chunk
13319        // dimension; the divisions below must reject it the way
13320        // `write_slice` does, not panic.
13321        if per_chunk == 0 {
13322            return Err(crate::io::IoError::InvalidState(
13323                "chunk shape has a zero-length dimension".into(),
13324            ));
13325        }
13326        let end = start + count;
13327        for c in (start / per_chunk)..=((end - 1) / per_chunk) {
13328            let origin = c * per_chunk;
13329            let lo = start.max(origin);
13330            let hi = end.min(origin + per_chunk);
13331            // A chunk with no block yet leaves this span as the zeros above.
13332            let Some(chunk) = self.read_chunk_at_coords(ds_index, &[c])? else {
13333                continue;
13334            };
13335            let src = ((lo - origin) as usize) * element_size;
13336            let dst = ((lo - start) as usize) * element_size;
13337            let len = ((hi - lo) as usize) * element_size;
13338            if src + len > chunk.len() {
13339                return Err(crate::io::IoError::InvalidState(format!(
13340                    "chunk {c} is {} bytes, too short for elements {lo}..{hi}",
13341                    chunk.len()
13342                )));
13343            }
13344            out[dst..dst + len].copy_from_slice(&chunk[src..src + len]);
13345        }
13346        Ok(out)
13347    }
13348
13349    /// Free the global heap objects `refs` names, so replacing a vlen element
13350    /// does not strand what it used to point at.
13351    ///
13352    /// Callers pass refs only for *top-level* vlen datatypes (the
13353    /// `collect_refs` / `is_vlen` decisions at the prune, delete and
13354    /// attribute-release sites all match `VarLenString`/`VarLenSequence`).
13355    /// A compound datatype with vlen members — writable only by a foreign
13356    /// library, never by this crate — keeps its members' heap objects when
13357    /// its storage is pruned, deleted or replaced.
13358    ///
13359    /// This is libhdf5's `H5HG_remove` reached through `H5T__vlen_disk_delete`:
13360    /// the object leaves its collection, the collection is rewritten at its
13361    /// existing size with the recovered bytes given to the free-space marker,
13362    /// and a collection that ends up empty returns its block to the allocator.
13363    /// A rewritten collection's recovered space is listed in `cwfs` for
13364    /// [`insert_vlen_objects`](Self::insert_vlen_objects) to pack into; a
13365    /// freed block leaves the list.
13366    /// A nil reference (address 0 or `UNDEF_ADDR`) names no object. The
13367    /// address decides, not the sequence length: this crate's writers store
13368    /// even the empty string as a real heap object, so a zero-length reference
13369    /// with a defined address still holds one that must be released. libhdf5
13370    /// diverges here against itself — `H5T__vlen_disk_delete` returns before
13371    /// `H5HG_remove` when the sequence length is zero, yet its write path
13372    /// (`H5VL__native_blob_put`) inserts a heap object even for an empty
13373    /// sequence, stranding it forever. The address rule frees those objects.
13374    ///
13375    /// Heap objects carry no reference count on this path, matching libhdf5:
13376    /// its vlen code never calls `H5HG_link` (only the virtual-dataset layer
13377    /// does). Releasing the same reference twice is absorbed by the
13378    /// missing-index check below, but a crafted file in which two elements
13379    /// share one heap object would lose it for the survivor when either is
13380    /// replaced — the same exposure the file has under libhdf5. This crate's
13381    /// writers never share: each element write inserts its own object.
13382    ///
13383    /// Under SWMR nothing is freed and no collection is rewritten: a reader may
13384    /// be following those references, the same reason `place_chunk` keeps a
13385    /// relocated chunk's old block.
13386    fn release_vlen_references(&self, refs: &[u8]) -> IoResult<()> {
13387        use crate::format::global_heap::{decode_vlen_reference, vlen_reference_size};
13388
13389        let ref_size = vlen_reference_size(&self.ctx);
13390        if ref_size == 0 || refs.len() < ref_size {
13391            return Ok(());
13392        }
13393
13394        // Group by collection so one holding several replaced objects is read,
13395        // rewritten and judged empty exactly once.
13396        let mut per_collection: std::collections::BTreeMap<u64, Vec<u16>> = Default::default();
13397        for r in refs.chunks_exact(ref_size) {
13398            let (_seq_len, addr, obj_idx) = decode_vlen_reference(r, &self.ctx)?;
13399            if addr == 0 || addr == UNDEF_ADDR {
13400                continue;
13401            }
13402            let Ok(idx) = u16::try_from(obj_idx) else {
13403                return Err(crate::io::IoError::InvalidState(format!(
13404                    "global heap object index {obj_idx} does not fit the 16-bit on-disk field"
13405                )));
13406            };
13407            per_collection.entry(addr).or_default().push(idx);
13408        }
13409        self.remove_heap_objects(per_collection)
13410    }
13411
13412    /// Remove global heap objects — `H5HG_remove` — given the object indices
13413    /// grouped by the collection they live in.
13414    ///
13415    /// The single owner of heap-object removal: the vlen release path above
13416    /// reaches it with the objects a replaced element used to name, and
13417    /// [`release_dataset_storage`](Self::release_dataset_storage) with the
13418    /// one mapping-list object a deleted virtual dataset owned, which is what
13419    /// `H5D__virtual_delete` frees the same way.
13420    fn remove_heap_objects(
13421        &self,
13422        per_collection: std::collections::BTreeMap<u64, Vec<u16>>,
13423    ) -> IoResult<()> {
13424        use crate::format::global_heap::GlobalHeapCollection;
13425
13426        if self.swmr_active {
13427            return Ok(());
13428        }
13429
13430        // An object on its way out can hold no stamp: a reference this
13431        // session wrote into it would otherwise be stamped into whatever a
13432        // later insert puts at the same index. Pruned here, by the one owner
13433        // of removal, so no release path — attribute replacement, element
13434        // rewrite, dataset deletion — can leave one behind.
13435        self.pending_heap_references.lock().retain(|p| {
13436            !per_collection
13437                .get(&p.collection)
13438                .is_some_and(|indices| indices.contains(&p.index))
13439        });
13440
13441        // The `cwfs` lock is held across the sweep: it serializes these
13442        // collection-block rewrites (and frees) against
13443        // `insert_vlen_objects`, which may be packing new objects into the
13444        // same blocks.
13445        let objhdr = GlobalHeapCollection::object_disk_size(&self.ctx, 0);
13446        let mut cwfs = self.cwfs.lock();
13447        for (addr, indices) in per_collection {
13448            // A collection is at least 4096 bytes (H5HG_MINALLOC) and most are
13449            // exactly that, so one read usually covers the whole image; only
13450            // an oversized collection needs a second read at its declared size.
13451            let mut image = self.handle.read_at_most(addr, 4096)?;
13452            let declared = GlobalHeapCollection::decode_size(&image, &self.ctx)?;
13453            if declared > image.len() {
13454                image = self.handle.read_at(addr, declared)?;
13455            }
13456            let (mut gcol, _) = GlobalHeapCollection::decode(&image[..declared], &self.ctx)?;
13457            let mut removed_any = false;
13458            for idx in indices {
13459                removed_any |= gcol.remove_object(idx);
13460            }
13461            // Every index already gone (a stale or duplicate reference):
13462            // leave the image alone. Rewriting is not just wasted I/O — a
13463            // 100%-full collection written by libhdf5 has no free-space
13464            // marker, so re-encoding it at its declared size cannot fit one
13465            // and the whole element update would fail.
13466            if !removed_any {
13467                continue;
13468            }
13469            if gcol.is_empty() {
13470                self.allocator
13471                    .free(addr, declared as u64, FreeSpaceClass::RawData);
13472                // The block is gone; a lingering entry would let an insert
13473                // pack into space the allocator can hand to anything.
13474                cwfs.retain(|e| e.addr != addr);
13475            } else {
13476                let rewritten = gcol.encode_at_size(&self.ctx, declared)?;
13477                self.handle.write_at(addr, &rewritten)?;
13478                // The recovered bytes are packable now — list them, the way
13479                // libhdf5's `H5HG_remove` adds the heap to `cwfs`.
13480                if let Some(free) = gcol.free_space_at(&self.ctx, declared) {
13481                    if free >= 2 * objhdr {
13482                        cwfs_note(&mut cwfs, addr, declared, free);
13483                    }
13484                }
13485            }
13486        }
13487        Ok(())
13488    }
13489
13490    /// Add an attribute to a dataset.
13491    ///
13492    /// The attribute will be written as a message in the dataset's object
13493    /// header when the file is finalized.
13494    pub fn add_dataset_attribute(&self, ds_index: usize, attr: AttributeMessage) -> IoResult<()> {
13495        self.set_attribute(AttrTarget::Dataset(ds_index), attr)
13496    }
13497
13498    /// Build a variable-length UTF-8 string attribute message.
13499    ///
13500    /// The string is stored as one object in a global heap collection and the
13501    /// returned [`AttributeMessage`] carries the vlen reference as its data,
13502    /// with a vlen-string datatype and scalar dataspace. h5py reads the value
13503    /// back as a Python `str` (not `bytes`).
13504    ///
13505    /// This is the single owner of vlen-string-attribute construction: every
13506    /// public string-attribute setter (dataset, group, root, and the SWMR
13507    /// equivalents) routes through it, so a `VarLenUnicode` /
13508    /// `set_attr_string` value is always stored as a true variable-length
13509    /// string rather than the fixed-length string it used to be.
13510    ///
13511    /// The string's heap object is placed by
13512    /// [`insert_vlen_objects`](Self::insert_vlen_objects), so consecutive
13513    /// attributes pack into a shared collection instead of each paying the
13514    /// 4096-byte `H5HG_MINALLOC` minimum for a block that holds one string.
13515    fn vlen_string_attribute(&self, name: &str, value: &str) -> IoResult<AttributeMessage> {
13516        use crate::format::global_heap::encode_vlen_reference;
13517        use crate::format::messages::dataspace::DataspaceMessage;
13518        use crate::format::messages::datatype::DatatypeMessage;
13519
13520        let (gcol_addr, obj_idx) = self.insert_vlen_objects(&[value.as_bytes()])?[0];
13521        let seq_len = crate::format::global_heap::vlen_seq_len(value.len())?;
13522        let data = encode_vlen_reference(seq_len, gcol_addr, obj_idx as u32, &self.ctx);
13523        Ok(AttributeMessage {
13524            name: name.to_string(),
13525            datatype: DatatypeMessage::vlen_string_utf8(),
13526            dataspace: DataspaceMessage::scalar(),
13527            data,
13528        })
13529    }
13530
13531    /// Build a variable-length UTF-8 string **array** attribute message.
13532    ///
13533    /// The N-dimensional counterpart of
13534    /// [`vlen_string_attribute`](Self::vlen_string_attribute): every element
13535    /// string is stored as one object in a single global heap collection, and
13536    /// the attribute data is the row-major concatenation of one vlen reference
13537    /// per element. The datatype is the same vlen-string datatype; the dataspace
13538    /// is the simple dataspace described by `shape` (an empty `shape` is a
13539    /// scalar). h5py reads the value back as a numpy array of Python `str` with
13540    /// that shape.
13541    ///
13542    /// The caller owns the invariant that `values.len()` equals the product of
13543    /// `shape` (the public setters validate it before calling). The element
13544    /// objects are placed by
13545    /// [`insert_vlen_objects`](Self::insert_vlen_objects) — a zero-element
13546    /// array allocates nothing, and each reference carries its element's
13547    /// own collection address.
13548    fn vlen_string_array_attribute(
13549        &self,
13550        name: &str,
13551        values: &[&str],
13552        shape: &[u64],
13553    ) -> IoResult<AttributeMessage> {
13554        use crate::format::global_heap::encode_vlen_reference;
13555        use crate::format::messages::dataspace::DataspaceMessage;
13556        use crate::format::messages::datatype::DatatypeMessage;
13557
13558        debug_assert_eq!(
13559            values.len() as u64,
13560            shape.iter().product::<u64>(),
13561            "vlen_string_array_attribute values.len() must equal product(shape)"
13562        );
13563
13564        let items: Vec<&[u8]> = values.iter().map(|v| v.as_bytes()).collect();
13565        let placements = self.insert_vlen_objects(&items)?;
13566
13567        let mut data = Vec::with_capacity(values.len() * 16);
13568        for (i, &(gcol_addr, obj_idx)) in placements.iter().enumerate() {
13569            data.extend_from_slice(&encode_vlen_reference(
13570                crate::format::global_heap::vlen_seq_len(values[i].len())?,
13571                gcol_addr,
13572                obj_idx as u32,
13573                &self.ctx,
13574            ));
13575        }
13576        Ok(AttributeMessage {
13577            name: name.to_string(),
13578            datatype: DatatypeMessage::vlen_string_utf8(),
13579            dataspace: DataspaceMessage::simple(shape),
13580            data,
13581        })
13582    }
13583
13584    /// Set a user-defined fill value for a dataset.
13585    ///
13586    /// `bytes` must be exactly one element wide (matching the dataset's
13587    /// datatype). The value is emitted as a `fill_defined = 2` fill-value
13588    /// message in the dataset object header when the file is finalized.
13589    ///
13590    /// IMPORTANT: for a *contiguous* dataset this also immediately writes
13591    /// the tiled fill value across the whole data block, so it must be
13592    /// called BEFORE any `write_dataset_raw` / `write_slice` — otherwise the
13593    /// fill write clobbers data already written. (The high-level builder
13594    /// always calls this right after creating the dataset.)
13595    pub fn set_dataset_fill_value(&self, ds_index: usize, bytes: Vec<u8>) -> IoResult<()> {
13596        let count = self.dataset_count();
13597        if ds_index >= count {
13598            return Err(crate::io::IoError::InvalidState(format!(
13599                "dataset index {} out of range",
13600                ds_index
13601            )));
13602        }
13603        let ds_ref = self.ds(ds_index);
13604        let mut ds = ds_ref.lock();
13605        let es = ds.datatype.element_size() as usize;
13606        if bytes.len() != es {
13607            return Err(crate::io::IoError::InvalidState(format!(
13608                "fill value is {} bytes but dataset element size is {}",
13609                bytes.len(),
13610                es
13611            )));
13612        }
13613        // For a dataset with no per-chunk fill path the fill-value message
13614        // only declares fill-on-allocation — tile the fill value across the
13615        // storage itself now, so unwritten elements read back as the fill
13616        // value. Which storage that is depends on the layout: a compact
13617        // dataset's is the image inside its layout message, a contiguous
13618        // one's is its data block. (The high-level builder calls this
13619        // immediately after create, before any data is written; a subsequent
13620        // write_raw/write_slice overwrites its region.)
13621        // An implicitly indexed dataset is filled here too, and for the same
13622        // reason: that index has no per-chunk fill path because it has no
13623        // per-chunk anything — its whole chunk grid is one run of space,
13624        // allocated and filled at create like a contiguous block. So the test
13625        // is not "is it chunked" but "does something else fill its chunks".
13626        let fills_per_chunk = ds
13627            .chunk_index_kind()
13628            .is_some_and(|k| k != ChunkIndexKind::Implicit);
13629        // `H5D_FILL_TIME_NEVER` means exactly this: the library never writes
13630        // the fill value into allocated storage. Call `set_dataset_fill_time`
13631        // before this method to have it observed here — the storage this
13632        // would otherwise tile keeps whatever zero bytes its allocation
13633        // already gave it.
13634        if !fills_per_chunk && ds.fill_time != FILL_TIME_NEVER {
13635            if let Some(len) = ds.compact.as_ref().map(Vec::len) {
13636                ds.compact = Some(crate::format::messages::fill_value::tiled_fill(
13637                    len,
13638                    Some(&bytes),
13639                ));
13640            } else {
13641                // An implicit index's chunk grid is filled as one run, the
13642                // same way a contiguous block is, and storage this file did
13643                // not allocate is not filled at all; `allocated_storage_run`
13644                // is where both of those are decided.
13645                let run = ds.allocated_storage_run();
13646                if let Some((target, data_size)) = run.filter(|&(_, size)| size > 0) {
13647                    let filled = crate::format::messages::fill_value::tiled_fill(
13648                        data_size as usize,
13649                        Some(&bytes),
13650                    );
13651                    self.write_contiguous_bytes(&target, 0, &filled)?;
13652                }
13653            }
13654        }
13655
13656        ds.fill_value = Some(bytes);
13657        ds.header_dirty = true;
13658        Ok(())
13659    }
13660
13661    /// Set when the fill value is written into allocated storage —
13662    /// `H5Pset_fill_time`. `time` is one of [`FILL_TIME_ALLOC`],
13663    /// [`FILL_TIME_NEVER`], [`FILL_TIME_IFSET`]; anything else is rejected
13664    /// the way `H5Pset_fill_time` rejects an out-of-range `H5D_fill_time_t`.
13665    ///
13666    /// Call this before [`set_dataset_fill_value`](Self::set_dataset_fill_value)
13667    /// so that a `FILL_TIME_NEVER` policy is in place before that call
13668    /// decides whether to eager-tile the value into storage. (The
13669    /// high-level builder always calls it first.)
13670    pub fn set_dataset_fill_time(&self, ds_index: usize, time: u8) -> IoResult<()> {
13671        if !matches!(time, FILL_TIME_ALLOC | FILL_TIME_NEVER | FILL_TIME_IFSET) {
13672            return Err(crate::io::IoError::InvalidState(format!(
13673                "invalid fill time {time}; must be {FILL_TIME_ALLOC} (alloc), \
13674                 {FILL_TIME_NEVER} (never) or {FILL_TIME_IFSET} (if-set)"
13675            )));
13676        }
13677        let count = self.dataset_count();
13678        if ds_index >= count {
13679            return Err(crate::io::IoError::InvalidState(format!(
13680                "dataset index {} out of range",
13681                ds_index
13682            )));
13683        }
13684        let ds_ref = self.ds(ds_index);
13685        let mut ds = ds_ref.lock();
13686        ds.fill_time = time;
13687        ds.header_dirty = true;
13688        Ok(())
13689    }
13690
13691    /// Allocate a `chunk_bytes`-sized buffer pre-filled with dataset
13692    /// `ds_index`'s fill value (tiled one element wide), or zeros when no
13693    /// user-defined fill value exists.
13694    ///
13695    /// Every partial chunk the writer emits must be built on top of a
13696    /// buffer from this method, so that the unwritten element region of an
13697    /// allocated chunk reads back as the fill value rather than zero.
13698    ///
13699    /// Unconditional: a shrink's straddler refill
13700    /// (`refill_chunk_beyond_extent`) calls this to repair data about to
13701    /// become reachable again, which libhdf5's `H5D__chunk_prune_fill` does
13702    /// regardless of the fill-time policy. [`new_write_chunk_buffer`](Self::new_write_chunk_buffer)
13703    /// is the gated counterpart for a chunk touched for the first time
13704    /// during a write, where the policy does apply.
13705    pub(crate) fn new_chunk_buffer(&self, ds_index: usize, chunk_bytes: usize) -> Vec<u8> {
13706        let ds = self.ds(ds_index);
13707        let m = ds.lock();
13708        let fv = m.fill_value.as_deref();
13709        crate::format::messages::fill_value::tiled_fill(chunk_bytes, fv)
13710    }
13711
13712    /// The buffer a chunk gets the first time a write touches it — this
13713    /// dataset's allocation-time fill gate. `H5D__chunk_lock`'s cache-miss
13714    /// path (H5Dchunk.c:4894) fills such a buffer only for `ALLOC`, or for
13715    /// `IFSET` with a fill value defined; `NEVER` leaves it as the zeros a
13716    /// fresh buffer already has. Everything else about the buffer is
13717    /// [`new_chunk_buffer`](Self::new_chunk_buffer)'s.
13718    fn new_write_chunk_buffer(&self, ds_index: usize, chunk_bytes: usize) -> Vec<u8> {
13719        let never = {
13720            let ds = self.ds(ds_index);
13721            let m = ds.lock();
13722            m.fill_time == FILL_TIME_NEVER
13723        };
13724        if never {
13725            vec![0u8; chunk_bytes]
13726        } else {
13727            self.new_chunk_buffer(ds_index, chunk_bytes)
13728        }
13729    }
13730
13731    /// Write `n_frames` whole frames whose first row is `base_frame`, for
13732    /// whichever chunk index the dataset uses and whatever its chunk shape.
13733    ///
13734    /// The single owner of an append's chunk writes. The frames are one
13735    /// hyperslab — rows `base_frame .. base_frame + n_frames` over the full
13736    /// row shape — so the write goes through
13737    /// [`write_slice_chunked`](Self::write_slice_chunked), the same engine
13738    /// `write_slice` uses: a chunk the span covers completely is written
13739    /// straight through, a partial one is read-modify-write on top of what
13740    /// is stored (or the fill value), and a chunk row narrower or wider
13741    /// than the frame row is scattered at the chunk stride. The previous
13742    /// owner required the extensible-array index and packed rows at the
13743    /// frame stride, so appends to a fixed-array or v2 B-tree dataset
13744    /// failed at close and lost the buffered rows.
13745    ///
13746    /// The caller holds the dataset's op lock or the writer exclusively.
13747    pub(crate) fn write_append_frames(
13748        &self,
13749        ds_index: usize,
13750        base_frame: u64,
13751        n_frames: u64,
13752        frames: &[u8],
13753    ) -> IoResult<()> {
13754        if n_frames == 0 {
13755            return Ok(());
13756        }
13757        let geo = self.chunk_geometry(ds_index)?;
13758        let mut starts = vec![0u64; geo.dims.len()];
13759        starts[0] = base_frame;
13760        let mut counts = geo.dims.clone();
13761        counts[0] = n_frames;
13762        let expected = counts.iter().product::<u64>() * geo.element_size;
13763        if frames.len() as u64 != expected {
13764            return Err(crate::io::IoError::InvalidState(format!(
13765                "{n_frames} frames at rows {base_frame}.. need {expected} bytes, got {}",
13766                frames.len()
13767            )));
13768        }
13769        self.write_slice_chunked(ds_index, &starts, &counts, frames)
13770    }
13771
13772    /// Write the dataset's append buffer (if any) into its chunks and clear
13773    /// it. The single owner of the buffer-to-chunks transition: the flush at
13774    /// close, an append meeting a non-contiguous buffer, and any operation
13775    /// about to write rows the buffer holds all come through here.
13776    ///
13777    /// The caller holds the dataset's op lock or the writer exclusively —
13778    /// the take and the frame writes are separate acquisitions.
13779    pub(crate) fn flush_append_buffer(&self, ds_index: usize) -> IoResult<()> {
13780        let taken = { self.ds(ds_index).lock().append.take() };
13781        match taken {
13782            Some(b) => self.write_append_frames(ds_index, b.base, b.frames, &b.bytes),
13783            None => Ok(()),
13784        }
13785    }
13786
13787    /// Flush the append buffer when rows `start_row .. end_row` intersect
13788    /// the buffered range — those rows' current content is the buffer, and
13789    /// writing them on disk while the buffer still holds them would be
13790    /// undone by the flush at close.
13791    ///
13792    /// The caller holds the dataset's op lock or the writer exclusively.
13793    pub(crate) fn flush_append_buffer_if_intersecting(
13794        &self,
13795        ds_index: usize,
13796        start_row: u64,
13797        end_row: u64,
13798    ) -> IoResult<()> {
13799        let intersects = {
13800            let ds = self.ds(ds_index);
13801            let m = ds.lock();
13802            m.append
13803                .as_ref()
13804                .is_some_and(|b| start_row < b.base + b.frames && end_row > b.base)
13805        };
13806        if intersects {
13807            self.flush_append_buffer(ds_index)
13808        } else {
13809            Ok(())
13810        }
13811    }
13812
13813    /// Read an already-written chunk's *decompressed* bytes when the chunk
13814    /// is allocated and resolvable from the in-memory extensible-array
13815    /// index. Handles index-block and data-block chunks, filtered and
13816    /// unfiltered.
13817    ///
13818    /// Returns `Ok(None)` only when the chunk has never been written
13819    /// (address `UNDEF`) or the index genuinely does not reach it, which for
13820    /// a read-modify-write means the chunk's content is the fill value.
13821    pub(crate) fn read_chunk_if_present(
13822        &self,
13823        ds_index: usize,
13824        chunk_idx: u64,
13825    ) -> IoResult<Option<Vec<u8>>> {
13826        // Phase 1: resolve the chunk's location from the in-memory index.
13827        // Hold the slot guard through Phase 1: `chunked` borrows it, while the
13828        // `self.handle`/`self.ctx` reads below touch disjoint fields.
13829        let ds = self.ds(ds_index);
13830        let m = ds.lock();
13831        let element_size = m.datatype.element_size() as u64;
13832        let pipeline = m.filter_pipeline.clone();
13833        let Some(chunked) = m.chunked.as_ref() else {
13834            return Ok(None);
13835        };
13836        let chunk_bytes = chunked.chunk_dims.iter().product::<u64>() * element_size;
13837        let max_nelmts_bits = chunked.earray_params.max_nelmts_bits;
13838        let chunk_size_len = chunked.chunk_size_len;
13839        let is_filtered = chunked.filt_iblk.is_some();
13840
13841        // The chunk entry is either read straight from an index block, or
13842        // located via a data block that must itself be read from disk.
13843        enum Loc {
13844            Direct(u64, u64, u32),
13845            DataBlock {
13846                dblk_addr: u64,
13847                offset: usize,
13848                nelmts: usize,
13849            },
13850        }
13851
13852        // Resolve the chunk's location with the libhdf5-compatible EA
13853        // geometry (super-block-grouped data blocks), matching `record_ea_chunk`.
13854        let ea_loc = {
13855            let p = &chunked.earray_params;
13856            EaGeometry::new(
13857                p.idx_blk_elmts,
13858                p.data_blk_min_elmts,
13859                p.sup_blk_min_data_ptrs,
13860                p.max_nelmts_bits,
13861                p.max_dblk_page_nelmts_bits,
13862            )?
13863            .locate(chunk_idx)?
13864        };
13865        let loc = match ea_loc {
13866            EaLoc::Index { elem } => {
13867                if is_filtered {
13868                    let e = &chunked.filt_iblk.as_ref().unwrap().elements[elem];
13869                    Loc::Direct(e.addr, e.nbytes, e.filter_mask)
13870                } else {
13871                    Loc::Direct(chunked.ea_iblk.elements[elem], chunk_bytes, 0)
13872                }
13873            }
13874            EaLoc::Dblk(l) => {
13875                if l.paged {
13876                    return Err(crate::io::IoError::InvalidState(format!(
13877                        "chunk index {} lives in a paged extensible-array data \
13878                         block, which is not yet supported for read-modify-write",
13879                        chunk_idx
13880                    )));
13881                }
13882                let dblk_addr = match l.path {
13883                    EaDblkPath::Direct { idx } => {
13884                        if is_filtered {
13885                            chunked.filt_iblk.as_ref().unwrap().dblk_addrs[idx]
13886                        } else {
13887                            chunked.ea_iblk.dblk_addrs[idx]
13888                        }
13889                    }
13890                    EaDblkPath::ViaSblk {
13891                        sblk_off,
13892                        local_dblk,
13893                        ndblks_in_sblk,
13894                        ..
13895                    } => {
13896                        let sblk_addr = if is_filtered {
13897                            chunked.filt_iblk.as_ref().unwrap().sblk_addrs[sblk_off]
13898                        } else {
13899                            chunked.ea_iblk.sblk_addrs[sblk_off]
13900                        };
13901                        if sblk_addr == UNDEF_ADDR {
13902                            return Ok(None);
13903                        }
13904                        let sb_buf = self.handle.read_at_most(sblk_addr, 65536)?;
13905                        let sb = ExtensibleArraySuperBlock::decode(
13906                            &sb_buf,
13907                            &self.ctx,
13908                            max_nelmts_bits,
13909                            ndblks_in_sblk,
13910                            0,
13911                        )?;
13912                        sb.dblk_addrs[local_dblk]
13913                    }
13914                };
13915                if dblk_addr == UNDEF_ADDR {
13916                    return Ok(None);
13917                }
13918                Loc::DataBlock {
13919                    dblk_addr,
13920                    offset: l.offset_in_dblk as usize,
13921                    nelmts: l.dblk_nelmts as usize,
13922                }
13923            }
13924        };
13925
13926        // Phase 2: resolve through the data block (if needed) and read. The
13927        // mask is the chunk's filter mask (0 for unfiltered), so a chunk
13928        // written via a direct chunk write with a skipped filter is reversed
13929        // correctly during read-modify-write.
13930        let (addr, nbytes, mask) = match loc {
13931            Loc::Direct(a, n, m) => (a, n, m),
13932            Loc::DataBlock {
13933                dblk_addr,
13934                offset,
13935                nelmts,
13936            } => {
13937                let buf = self.handle.read_at_most(dblk_addr, 65536)?;
13938                if is_filtered {
13939                    let dblk = FilteredDataBlock::decode(
13940                        &buf,
13941                        &self.ctx,
13942                        max_nelmts_bits,
13943                        nelmts,
13944                        chunk_size_len,
13945                    )?;
13946                    let e = &dblk.elements[offset];
13947                    (e.addr, e.nbytes, e.filter_mask)
13948                } else {
13949                    let dblk =
13950                        ExtensibleArrayDataBlock::decode(&buf, &self.ctx, max_nelmts_bits, nelmts)?;
13951                    (dblk.elements[offset], chunk_bytes, 0)
13952                }
13953            }
13954        };
13955        self.read_chunk_block(pipeline.as_ref(), addr, nbytes, mask)
13956    }
13957
13958    /// Read one stored chunk block and undo its filters.
13959    ///
13960    /// `nbytes` is the *stored* length and `mask` the chunk's filter mask, so
13961    /// a chunk written by a direct chunk write with a skipped filter is
13962    /// reversed correctly. `Ok(None)` means the chunk has no block yet — the
13963    /// single place that judgement is made, shared by every chunk index.
13964    fn read_chunk_block(
13965        &self,
13966        pipeline: Option<&FilterPipeline>,
13967        addr: u64,
13968        nbytes: u64,
13969        mask: u32,
13970    ) -> IoResult<Option<Vec<u8>>> {
13971        if addr == UNDEF_ADDR || nbytes == 0 {
13972            return Ok(None);
13973        }
13974        let raw = self.handle.read_at(addr, nbytes as usize)?;
13975        match pipeline {
13976            Some(pl) => Ok(Some(filter::reverse_filters_masked(pl, &raw, mask)?)),
13977            None => Ok(Some(raw)),
13978        }
13979    }
13980
13981    /// Read the *decompressed* bytes of the chunk at `chunk_coords`, whichever
13982    /// chunk index the dataset uses, or `Ok(None)` when that chunk has never
13983    /// been written.
13984    ///
13985    /// This is the read half of a partial-chunk read-modify-write: a hyperslab
13986    /// write that covers only part of a chunk must start from what is already
13987    /// there. Keeping one entry point for all three index types is what lets
13988    /// [`write_slice`](Self::write_slice) stay index-agnostic.
13989    pub(crate) fn read_chunk_at_coords(
13990        &self,
13991        ds_index: usize,
13992        chunk_coords: &[u64],
13993    ) -> IoResult<Option<Vec<u8>>> {
13994        let geo = self.chunk_geometry(ds_index)?;
13995        // Only the linearly-addressed indexes compute a slot; a v2 B-tree is
13996        // keyed by the coordinates themselves (and may hold unlimited inner
13997        // dimensions, which have no linear slot).
13998        match geo.kind {
13999            ChunkIndexKind::ExtensibleArray => {
14000                let linear = geo.linear_index(chunk_coords)?;
14001                self.read_chunk_if_present(ds_index, linear)
14002            }
14003            ChunkIndexKind::FixedArray => {
14004                let linear = geo.linear_index(chunk_coords)?;
14005                let ds = self.ds(ds_index);
14006                let m = ds.lock();
14007                let pipeline = m.filter_pipeline.clone();
14008                let fa = m.fixed_array.as_ref().unwrap();
14009                let lidx = linear as usize;
14010                let (addr, nbytes, mask) = if pipeline.is_some() {
14011                    match fa.fa_dblk.filtered_elements.get(lidx) {
14012                        Some(e) => (e.address, e.chunk_size, e.filter_mask),
14013                        None => return Ok(None),
14014                    }
14015                } else {
14016                    match fa.fa_dblk.elements.get(lidx) {
14017                        Some(&a) => (a, geo.chunk_bytes(), 0),
14018                        None => return Ok(None),
14019                    }
14020                };
14021                drop(m);
14022                self.read_chunk_block(pipeline.as_ref(), addr, nbytes, mask)
14023            }
14024            ChunkIndexKind::BtreeV2 => {
14025                let ds = self.ds(ds_index);
14026                let m = ds.lock();
14027                let pipeline = m.filter_pipeline.clone();
14028                let bt2 = m.btree_v2.as_ref().unwrap();
14029                // A filtered index records the stored size and mask per chunk;
14030                // an unfiltered one stores whole chunks, so their size is the
14031                // chunk shape and no filter ran.
14032                let found = if bt2.index.filtered {
14033                    bt2.index
14034                        .lookup_filtered(chunk_coords)
14035                        .map(|r| (r.chunk_address, r.chunk_size, r.filter_mask))
14036                } else {
14037                    bt2.index
14038                        .lookup(chunk_coords)
14039                        .map(|r| (r.chunk_address, geo.chunk_bytes(), 0))
14040                };
14041                drop(m);
14042                match found {
14043                    Some((addr, nbytes, mask)) => {
14044                        self.read_chunk_block(pipeline.as_ref(), addr, nbytes, mask)
14045                    }
14046                    None => Ok(None),
14047                }
14048            }
14049            // Every chunk of an implicitly indexed dataset exists from the
14050            // moment the dataset does, so there is no "never written" answer
14051            // to give: an untouched chunk reads back as the fill value the
14052            // create wrote there.
14053            ChunkIndexKind::Implicit => {
14054                let (grid, offset) = self.implicit_chunk_slot(ds_index, &geo, chunk_coords)?;
14055                self.read_chunk_block(None, grid + offset, geo.chunk_bytes(), 0)
14056            }
14057            // A single-chunk dataset's one chunk is never written until its
14058            // first write (unless the dataset was early-allocated and
14059            // unfiltered, in which case create already gave it an address) —
14060            // unlike Implicit, `UNDEF_ADDR` here is a real "never written".
14061            ChunkIndexKind::SingleChunk => {
14062                let ds = self.ds(ds_index);
14063                let m = ds.lock();
14064                let pipeline = m.filter_pipeline.clone();
14065                let sc = m.single_chunk.as_ref().unwrap();
14066                if sc.data_addr == UNDEF_ADDR {
14067                    return Ok(None);
14068                }
14069                let (addr, nbytes, mask) = if pipeline.is_some() {
14070                    (sc.data_addr, sc.nbytes, sc.filter_mask)
14071                } else {
14072                    (sc.data_addr, geo.chunk_bytes(), 0)
14073                };
14074                drop(m);
14075                self.read_chunk_block(pipeline.as_ref(), addr, nbytes, mask)
14076            }
14077            ChunkIndexKind::BtreeV1 => {
14078                let ds = self.ds(ds_index);
14079                let m = ds.lock();
14080                let pipeline = m.filter_pipeline.clone();
14081                let bt1 = m.btree_v1.as_ref().unwrap();
14082                let found = bt1
14083                    .position(chunk_coords)
14084                    .ok()
14085                    .map(|i| &bt1.records[i])
14086                    .map(|r| (r.address, r.nbytes as u64, r.filter_mask));
14087                drop(m);
14088                match found {
14089                    Some((addr, nbytes, mask)) => {
14090                        self.read_chunk_block(pipeline.as_ref(), addr, nbytes, mask)
14091                    }
14092                    None => Ok(None),
14093                }
14094            }
14095        }
14096    }
14097
14098    /// The slot one chunk of an implicitly indexed dataset occupies: the
14099    /// address its whole chunk grid starts at, and the chunk's offset within
14100    /// that grid. `data_addr + linear_index * chunk_bytes` is the whole of
14101    /// that index (`H5D__none_idx_get_addr`, H5Dnone.c).
14102    ///
14103    /// The one place a chunk of such a dataset is placed — read and write both
14104    /// come through here, so the bounds check below covers both. The grid it
14105    /// names is [`DatasetInfo::implicit_grid`], which is why the write side
14106    /// can hand [`ContiguousTarget::Local`] to
14107    /// [`write_contiguous_bytes`](Self::write_contiguous_bytes) without asking
14108    /// anything: the external and virtual destinations that owner also knows
14109    /// about are unreachable from a chunked dataset.
14110    fn implicit_chunk_slot(
14111        &self,
14112        ds_index: usize,
14113        geo: &ChunkGeometry,
14114        chunk_coords: &[u64],
14115    ) -> IoResult<(u64, u64)> {
14116        let linear = geo.linear_index(chunk_coords)?;
14117        let ds = self.ds(ds_index);
14118        let m = ds.lock();
14119        let (grid, grid_size) = m.implicit_grid().ok_or_else(|| {
14120            crate::io::IoError::InvalidState("no implicitly indexed chunk grid".into())
14121        })?;
14122        let offset = linear.checked_mul(geo.chunk_bytes()).ok_or_else(|| {
14123            crate::io::IoError::InvalidState("implicit chunk offset overflows u64".into())
14124        })?;
14125        if offset + geo.chunk_bytes() > grid_size {
14126            return Err(crate::io::IoError::InvalidState(format!(
14127                "chunk {chunk_coords:?} lies outside the {grid_size} bytes of chunk space \
14128                 this implicitly indexed dataset was created with"
14129            )));
14130        }
14131        Ok((grid, offset))
14132    }
14133
14134    /// Write one whole chunk addressed by its grid coordinates, whichever
14135    /// chunk index the dataset uses. `data` is the chunk's unfiltered bytes;
14136    /// the dataset's filter pipeline (if any) runs here.
14137    ///
14138    /// The write half of the pair with
14139    /// [`read_chunk_at_coords`](Self::read_chunk_at_coords). Unlike the
14140    /// dataset-level `write_chunk_at`, this never grows the dataspace — a
14141    /// hyperslab write is bounded by the current extent by definition.
14142    ///
14143    /// The caller holds the dataset's op lock or the writer exclusively.
14144    pub(crate) fn write_chunk_at_coords(
14145        &self,
14146        ds_index: usize,
14147        chunk_coords: &[u64],
14148        data: &[u8],
14149    ) -> IoResult<()> {
14150        let geo = self.chunk_geometry(ds_index)?;
14151        match geo.kind {
14152            ChunkIndexKind::ExtensibleArray => {
14153                let linear = geo.linear_index(chunk_coords)?;
14154                self.write_chunk_inner(ds_index, linear, data)
14155            }
14156            ChunkIndexKind::FixedArray => {
14157                self.write_chunk_fixed_array_inner(ds_index, chunk_coords, data)
14158            }
14159            ChunkIndexKind::BtreeV2 => {
14160                self.write_chunk_btree_v2_inner(ds_index, chunk_coords, data)
14161            }
14162            ChunkIndexKind::Implicit => {
14163                self.write_chunk_implicit_inner(ds_index, chunk_coords, data)
14164            }
14165            ChunkIndexKind::SingleChunk => {
14166                self.write_chunk_single_chunk_inner(ds_index, chunk_coords, data)
14167            }
14168            ChunkIndexKind::BtreeV1 => {
14169                self.write_chunk_btree_v1_inner(ds_index, chunk_coords, data)
14170            }
14171        }
14172    }
14173
14174    /// Write one whole chunk to a dataset indexed by a version-1 B-tree.
14175    ///
14176    /// `chunk_coords` is the chunk's grid position. `data` is the chunk's
14177    /// unfiltered bytes; the dataset's filter pipeline runs here if it has
14178    /// one, and the key records the stored size and mask the way libhdf5's
14179    /// does (`H5D__btree_new_node`).
14180    ///
14181    /// The caller holds the dataset's op lock or the writer exclusively.
14182    pub(crate) fn write_chunk_btree_v1_inner(
14183        &self,
14184        ds_index: usize,
14185        chunk_coords: &[u64],
14186        data: &[u8],
14187    ) -> IoResult<()> {
14188        // Read what the write needs under a brief guard, then filter OUTSIDE
14189        // the lock, as every other index's write path does.
14190        let ds = self.ds(ds_index);
14191        let (chunk_bytes, pipeline) = {
14192            let m = ds.lock();
14193            let element_size = m.datatype.element_size() as u64;
14194            let bt1 = m.btree_v1.as_ref().ok_or_else(|| {
14195                crate::io::IoError::InvalidState("not a version-1 B-tree dataset".into())
14196            })?;
14197            (
14198                bt1.chunk_dims.iter().product::<u64>() * element_size,
14199                m.filter_pipeline.clone(),
14200            )
14201        };
14202        if data.len() as u64 != chunk_bytes {
14203            return Err(crate::io::IoError::InvalidState(format!(
14204                "chunk data size mismatch: expected {} bytes, got {}",
14205                chunk_bytes,
14206                data.len()
14207            )));
14208        }
14209
14210        let filtered;
14211        let stored = match pipeline {
14212            Some(ref pl) => {
14213                filtered = filter::apply_filters(pl, data)?;
14214                &filtered[..]
14215            }
14216            None => data,
14217        };
14218        self.record_btree_v1_chunk(ds_index, chunk_coords, stored, 0)
14219    }
14220
14221    /// Write a pre-filtered chunk verbatim to a version-1 B-tree dataset,
14222    /// recording the caller-supplied `filter_mask` — the classic-index half
14223    /// of the HDF5 "direct chunk write" (`H5Dwrite_chunk`).
14224    ///
14225    /// The caller holds the dataset's op lock or the writer exclusively.
14226    pub(crate) fn write_compressed_chunk_btree_v1_inner(
14227        &self,
14228        ds_index: usize,
14229        chunk_coords: &[u64],
14230        data: &[u8],
14231        filter_mask: u32,
14232    ) -> IoResult<()> {
14233        if self.ds(ds_index).lock().filter_pipeline.is_none() {
14234            return Err(crate::io::IoError::InvalidState(
14235                "write_chunk_raw requires a filtered dataset (an unfiltered chunk \
14236                 is stored at its full size, so there is nothing for a stored size \
14237                 or a filter mask to say)"
14238                    .into(),
14239            ));
14240        }
14241        self.record_btree_v1_chunk(ds_index, chunk_coords, data, filter_mask)
14242    }
14243
14244    /// Place a chunk's already-final bytes in the file and record them in the
14245    /// version-1 B-tree under the caller-supplied `filter_mask`.
14246    ///
14247    /// Shared by the two writes above, so both reach the index through one
14248    /// placement rule. The records are kept in key order here — the bulk load
14249    /// at flush walks them in that order and a lookup bisects them.
14250    fn record_btree_v1_chunk(
14251        &self,
14252        ds_index: usize,
14253        chunk_coords: &[u64],
14254        final_bytes: &[u8],
14255        filter_mask: u32,
14256    ) -> IoResult<()> {
14257        let stored_len = final_bytes.len() as u64;
14258        // The key's size field is 32 bits wide (`H5D_btree_key_t::nbytes`),
14259        // which is also libhdf5's limit on a chunk in this index.
14260        let Ok(nbytes) = u32::try_from(stored_len) else {
14261            return Err(crate::io::IoError::InvalidState(format!(
14262                "stored chunk size {stored_len} does not fit in the 32-bit size \
14263                 field of a version-1 B-tree chunk key"
14264            )));
14265        };
14266        let ds = self.ds(ds_index);
14267        let mut m = ds.lock();
14268        let bt1 = m.btree_v1.as_ref().ok_or_else(|| {
14269            crate::io::IoError::InvalidState("not a version-1 B-tree dataset".into())
14270        })?;
14271        if chunk_coords.len() != bt1.chunk_dims.len() {
14272            return Err(crate::io::IoError::InvalidState(format!(
14273                "chunk_coords has {} entries but the dataset has {} dimensions",
14274                chunk_coords.len(),
14275                bt1.chunk_dims.len()
14276            )));
14277        }
14278        // A coordinate past the maximum extent has no chunk to be: unlike the
14279        // array indexes there is no slot to run out of, so the bound is
14280        // checked here or not at all. An unlimited dimension has none.
14281        for (d, ((&c, &cd), &max)) in chunk_coords
14282            .iter()
14283            .zip(&bt1.chunk_dims)
14284            .zip(&bt1.max_dims)
14285            .enumerate()
14286        {
14287            if max != u64::MAX && c.saturating_mul(cd) >= max {
14288                return Err(crate::io::IoError::InvalidState(format!(
14289                    "chunk coordinate {c} in dimension {d} is outside the maximum \
14290                     extent {max}"
14291                )));
14292            }
14293        }
14294        let slot = bt1.position(chunk_coords);
14295        let old = slot.ok().map(|i| {
14296            let r = &bt1.records[i];
14297            (r.address, r.nbytes as u64)
14298        });
14299        // A rewrite whose stored size is unchanged stays where it is (always
14300        // so when unfiltered), one that no longer fits moves. See `place_chunk`.
14301        let address = self.place_chunk(old, stored_len);
14302        self.handle.write_at(address, final_bytes)?;
14303
14304        let bt1 = m.btree_v1.as_mut().unwrap();
14305        let record = BtreeV1ChunkRecord {
14306            scaled: chunk_coords.to_vec(),
14307            address,
14308            nbytes,
14309            filter_mask,
14310        };
14311        match slot {
14312            Ok(i) => bt1.records[i] = record,
14313            Err(i) => bt1.records.insert(i, record),
14314        }
14315        bt1.chunks_written += 1;
14316        Ok(())
14317    }
14318
14319    /// Write one whole chunk of an implicitly indexed dataset into the slot
14320    /// its coordinates name. There is no index to record anything in — the
14321    /// slot is where it always was — so this is the write in full.
14322    ///
14323    /// The bytes go through [`write_contiguous_bytes`](Self::write_contiguous_bytes),
14324    /// the one owner of a raw-byte write, against the grid
14325    /// [`implicit_chunk_slot`](Self::implicit_chunk_slot) names.
14326    ///
14327    /// The caller holds the dataset's op lock or the writer exclusively.
14328    pub(crate) fn write_chunk_implicit_inner(
14329        &self,
14330        ds_index: usize,
14331        chunk_coords: &[u64],
14332        data: &[u8],
14333    ) -> IoResult<()> {
14334        let geo = self.chunk_geometry(ds_index)?;
14335        let chunk_bytes = geo.chunk_bytes();
14336        if data.len() as u64 != chunk_bytes {
14337            return Err(crate::io::IoError::InvalidState(format!(
14338                "chunk data size mismatch: expected {} bytes, got {}",
14339                chunk_bytes,
14340                data.len()
14341            )));
14342        }
14343        let (grid, offset) = self.implicit_chunk_slot(ds_index, &geo, chunk_coords)?;
14344        self.write_contiguous_bytes(&ContiguousTarget::Local(grid), offset, data)
14345    }
14346
14347    /// Snapshot the geometry needed to address a chunked dataset's grid.
14348    ///
14349    /// Taken under one brief slot guard so the callers below — which re-lock
14350    /// the slot through `write_chunk`/`read_chunk_*` — never hold it across
14351    /// compression or I/O.
14352    fn chunk_geometry(&self, ds_index: usize) -> IoResult<ChunkGeometry> {
14353        let ds = self.ds(ds_index);
14354        let m = ds.lock();
14355        let Some(kind) = m.chunk_index_kind() else {
14356            return Err(crate::io::IoError::InvalidState(
14357                "not a chunked dataset".into(),
14358            ));
14359        };
14360        let chunk_dims = match kind {
14361            ChunkIndexKind::ExtensibleArray => m.chunked.as_ref().unwrap().chunk_dims.clone(),
14362            ChunkIndexKind::FixedArray => m.fixed_array.as_ref().unwrap().chunk_dims.clone(),
14363            ChunkIndexKind::BtreeV2 => m.btree_v2.as_ref().unwrap().chunk_dims.clone(),
14364            ChunkIndexKind::Implicit => m.implicit.as_ref().unwrap().chunk_dims.clone(),
14365            ChunkIndexKind::SingleChunk => m.single_chunk.as_ref().unwrap().chunk_dims.clone(),
14366            ChunkIndexKind::BtreeV1 => m.btree_v1.as_ref().unwrap().chunk_dims.clone(),
14367        };
14368        Ok(ChunkGeometry {
14369            kind,
14370            dims: m.dataspace.dims.clone(),
14371            max_dims: m.dataspace.max_dims.clone(),
14372            chunk_dims,
14373            element_size: m.datatype.element_size() as u64,
14374        })
14375    }
14376
14377    /// Index-grid slot of the chunk at grid `coords` (see
14378    /// [`crate::io::chunk_grid`]).
14379    pub(crate) fn chunk_slot(&self, ds_index: usize, coords: &[u64]) -> IoResult<u64> {
14380        self.chunk_geometry(ds_index)?.linear_index(coords)
14381    }
14382
14383    /// Grid coordinates of the chunk recorded under index-grid slot `linear`
14384    /// — the inverse of [`Self::chunk_slot`].
14385    pub(crate) fn chunk_coords_from_slot(
14386        &self,
14387        ds_index: usize,
14388        linear: u64,
14389    ) -> IoResult<Vec<u64>> {
14390        let geo = self.chunk_geometry(ds_index)?;
14391        crate::io::chunk_grid::coords_of(
14392            &geo.dims,
14393            geo.max_dims.as_deref(),
14394            &geo.chunk_dims,
14395            linear,
14396        )
14397    }
14398
14399    /// Define a chunked dataset indexed by a fixed array, fixed at its
14400    /// current shape (`max_dims == dims`). `chunk_dims` defines the chunk
14401    /// shape. Returns the dataset index.
14402    pub fn create_fixed_array_dataset(
14403        &self,
14404        name: &str,
14405        datatype: DatatypeMessage,
14406        dims: &[u64],
14407        chunk_dims: &[u64],
14408    ) -> IoResult<usize> {
14409        self.create_fixed_array_dataset_with_max(name, datatype, dims, dims, chunk_dims, None)
14410    }
14411
14412    /// Define a fixed-shape compressed chunked dataset indexed by a
14413    /// *filtered* Fixed Array (`max_dims == dims`).
14414    ///
14415    /// Like `create_fixed_array_dataset`, but the FA header carries the filtered
14416    /// client id and a `chunk_size_len`-wide compressed-size field per chunk
14417    /// (`FixedArrayFilteredChunkElement`), and the dataset gets a filter
14418    /// pipeline. Chunks written via `write_chunk_fixed_array` are compressed and
14419    /// their compressed size + filter mask are recorded in the data block.
14420    ///
14421    /// A convenience over [`create_fixed_array_dataset_with_max`]'s own
14422    /// pipeline argument; production dataset creation calls that directly,
14423    /// so this is kept as a direct entry point for this crate's own
14424    /// white-box tests.
14425    ///
14426    /// [`create_fixed_array_dataset_with_max`]: Self::create_fixed_array_dataset_with_max
14427    #[cfg(all(test, feature = "deflate"))]
14428    pub fn create_fixed_array_dataset_with_pipeline(
14429        &self,
14430        name: &str,
14431        datatype: DatatypeMessage,
14432        dims: &[u64],
14433        chunk_dims: &[u64],
14434        pipeline: FilterPipeline,
14435    ) -> IoResult<usize> {
14436        self.create_fixed_array_dataset_with_max(
14437            name,
14438            datatype,
14439            dims,
14440            dims,
14441            chunk_dims,
14442            Some(pipeline),
14443        )
14444    }
14445
14446    /// Define a chunked dataset indexed by a fixed array, growable up to
14447    /// `max_dims` (every maximum finite — libhdf5 picks this index exactly
14448    /// when no dimension is unlimited).
14449    ///
14450    /// The array is sized for the chunk grid of the *maximum* extent, the
14451    /// libhdf5 rule (`H5D__farray_idx_create` uses `max_nchunks`), so the
14452    /// dataset can be extended to `max_dims` without re-indexing chunks.
14453    pub fn create_fixed_array_dataset_with_max(
14454        &self,
14455        name: &str,
14456        datatype: DatatypeMessage,
14457        dims: &[u64],
14458        max_dims: &[u64],
14459        chunk_dims: &[u64],
14460        pipeline: Option<FilterPipeline>,
14461    ) -> IoResult<usize> {
14462        let create = self.begin_create(name)?;
14463        let name = create.name.as_str();
14464        validate_chunk_geometry(dims, max_dims, chunk_dims)?;
14465        if max_dims.contains(&u64::MAX) {
14466            return Err(crate::io::IoError::InvalidState(
14467                "a fixed-array index requires a fixed maximum shape (no unlimited dimension)"
14468                    .into(),
14469            ));
14470        }
14471        let mut num_chunks: u64 = 1;
14472        for g in crate::io::chunk_grid::index_grid(dims, Some(max_dims), chunk_dims)? {
14473            num_chunks = num_chunks.checked_mul(g).ok_or_else(|| {
14474                crate::io::IoError::InvalidState("chunk count overflows u64".into())
14475            })?;
14476        }
14477
14478        let chunk_bytes: u64 = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
14479        let layout_version = self.chunk_layout_version(pipeline.is_some(), chunk_bytes);
14480
14481        // Create the FA header. For a filtered FA, chunk_size_len is sized
14482        // the same way the filtered Extensible Array path computes it:
14483        // derived from the uncompressed chunk byte count under layout v4,
14484        // the fixed `sizeof_size` under layout v5.
14485        let mut fa_header = if pipeline.is_some() {
14486            let chunk_size_len = self.chunk_size_len_for(layout_version, chunk_bytes);
14487            FixedArrayHeader::new_for_filtered_chunks(&self.ctx, num_chunks, chunk_size_len)
14488        } else {
14489            FixedArrayHeader::new_for_chunks(&self.ctx, num_chunks)
14490        };
14491        let hdr_encoded = fa_header.encode(&self.ctx);
14492        let fa_header_addr = self
14493            .allocator
14494            .allocate(hdr_encoded.len() as u64, FreeSpaceClass::Metadata);
14495
14496        // Create the FA data block. libhdf5 switches to a paged layout once
14497        // num_elmts exceeds dblk_page_nelmts; both layouts allocate space
14498        // for `num_chunks` entries up front, but the paged layout also
14499        // reserves the page-init bitmap and a per-page checksum.
14500        let fa_dblk = if pipeline.is_some() {
14501            FixedArrayDataBlock::new_filtered(fa_header_addr, num_chunks as usize)
14502        } else {
14503            FixedArrayDataBlock::new_unfiltered(fa_header_addr, num_chunks as usize)
14504        };
14505        let dblk_size = fixed_array_dblk_disk_size(&self.ctx, &fa_header);
14506        let fa_dblk_addr = self.allocator.allocate(dblk_size, FreeSpaceClass::Metadata);
14507
14508        // Update header with data block address
14509        fa_header.data_blk_addr = fa_dblk_addr;
14510
14511        // Write both. The data block content is finalized in `flush_dataset`
14512        // once all chunk addresses are known; here we just reserve space and
14513        // write the header so the file is structurally consistent.
14514        let hdr_encoded = fa_header.encode(&self.ctx);
14515        self.handle.write_at(fa_header_addr, &hdr_encoded)?;
14516        let dblk_encoded = encode_fixed_array_dblk(&self.ctx, &fa_header, &fa_dblk);
14517        debug_assert_eq!(dblk_encoded.len() as u64, dblk_size);
14518        self.handle.write_at(fa_dblk_addr, &dblk_encoded)?;
14519
14520        // The maximum is stored even when it equals the dims: it is what
14521        // `extend_dataset` checks growth against, and the FA capacity above
14522        // is exactly its chunk grid.
14523        let dataspace = DataspaceMessage {
14524            // Chunked storage always requires at least one dimension, so
14525            // this is never Scalar or Null.
14526            class: DataspaceClass::Simple,
14527            dims: dims.to_vec(),
14528            max_dims: Some(max_dims.to_vec()),
14529        };
14530
14531        let idx = self.push_dataset(
14532            &create,
14533            DatasetInfo {
14534                name: name.to_string(),
14535                datatype,
14536                committed_type: None,
14537                external: None,
14538                virtual_storage: None,
14539                dataspace,
14540                read_format: None,
14541                obj_header_addr: 0,
14542                data_addr: UNDEF_ADDR,
14543                data_size: 0,
14544                compact: None,
14545                attributes: Vec::new(),
14546                obj_header_written_addr: None,
14547                obj_header_blocks: Vec::new(),
14548                filter_pipeline: pipeline,
14549                deleted: false,
14550                extent_dirty: false,
14551                header_dirty: false,
14552                nlink_written: 1,
14553                creation_seq: self.take_creation_seq(),
14554                track_attr_order: self.track_order.attrs,
14555                fill_value: None,
14556                fill_time: FILL_TIME_IFSET,
14557                layout_version,
14558                times: self.created_object_times(),
14559                chunked: None,
14560                btree_v2: None,
14561                implicit: None,
14562                single_chunk: None,
14563                btree_v1: None,
14564                fixed_array: Some(FixedArrayDatasetInfo {
14565                    chunk_dims: chunk_dims.to_vec(),
14566                    fa_header_addr,
14567                    fa_dblk_addr,
14568                    fa_header,
14569                    fa_dblk,
14570                    chunks_written: 0,
14571                }),
14572                append: None,
14573            },
14574        );
14575
14576        Ok(idx)
14577    }
14578
14579    /// Define a chunked dataset with the *implicit* index: no index structure
14580    /// at all, every chunk of the grid allocated at create in one contiguous
14581    /// run, addressed by arithmetic (`H5Dnone.c`).
14582    ///
14583    /// libhdf5 picks this index only where that arithmetic is total, and this
14584    /// enforces the same three conditions
14585    /// (`H5D__layout_set_latest_indexing`, H5Dlayout.c): no filter — a
14586    /// filtered chunk is not `chunk_bytes` long, so the run would not be a
14587    /// grid; no unlimited dimension — the run has to have a length; and early
14588    /// allocation, which is what this creator *does* rather than something it
14589    /// checks. The dataset's fill-value message says so
14590    /// (`build_dataset_header`), because a file claiming incremental
14591    /// allocation is one libhdf5 would never have chosen this index for.
14592    pub fn create_implicit_dataset(
14593        &self,
14594        name: &str,
14595        datatype: DatatypeMessage,
14596        dims: &[u64],
14597        chunk_dims: &[u64],
14598    ) -> IoResult<usize> {
14599        let create = self.begin_create(name)?;
14600        let name = create.name.as_str();
14601        validate_chunk_geometry(dims, dims, chunk_dims)?;
14602        let mut num_chunks: u64 = 1;
14603        for g in crate::io::chunk_grid::index_grid(dims, None, chunk_dims)? {
14604            num_chunks = num_chunks.checked_mul(g).ok_or_else(|| {
14605                crate::io::IoError::InvalidState("chunk count overflows u64".into())
14606            })?;
14607        }
14608        let chunk_bytes: u64 = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
14609        let data_size = num_chunks.checked_mul(chunk_bytes).ok_or_else(|| {
14610            crate::io::IoError::InvalidState("implicit chunk storage overflows u64".into())
14611        })?;
14612        let layout_version = self.chunk_layout_version(false, chunk_bytes);
14613
14614        // Early allocation is the whole of this index: the run exists, and
14615        // holds the fill value, before any chunk is written. It is written
14616        // out rather than merely reserved because the file's end-of-file
14617        // address is what libhdf5 checks a file's completeness against — a
14618        // reserved-but-absent tail is a truncated file to it.
14619        let data_addr = self.allocator.allocate(data_size, FreeSpaceClass::RawData);
14620        self.handle.write_at(
14621            data_addr,
14622            &crate::format::messages::fill_value::tiled_fill(data_size as usize, None),
14623        )?;
14624
14625        let dataspace = DataspaceMessage {
14626            // Chunked storage always requires at least one dimension, so
14627            // this is never Scalar or Null.
14628            class: DataspaceClass::Simple,
14629            dims: dims.to_vec(),
14630            max_dims: Some(dims.to_vec()),
14631        };
14632
14633        let idx = self.push_dataset(
14634            &create,
14635            DatasetInfo {
14636                name: name.to_string(),
14637                datatype,
14638                committed_type: None,
14639                external: None,
14640                virtual_storage: None,
14641                dataspace,
14642                read_format: None,
14643                obj_header_addr: 0,
14644                data_addr: UNDEF_ADDR,
14645                data_size: 0,
14646                compact: None,
14647                attributes: Vec::new(),
14648                obj_header_written_addr: None,
14649                obj_header_blocks: Vec::new(),
14650                filter_pipeline: None,
14651                deleted: false,
14652                extent_dirty: false,
14653                header_dirty: false,
14654                nlink_written: 1,
14655                creation_seq: self.take_creation_seq(),
14656                track_attr_order: self.track_order.attrs,
14657                fill_value: None,
14658                fill_time: FILL_TIME_IFSET,
14659                layout_version,
14660                times: self.created_object_times(),
14661                chunked: None,
14662                btree_v2: None,
14663                fixed_array: None,
14664                implicit: Some(ImplicitDatasetInfo {
14665                    chunk_dims: chunk_dims.to_vec(),
14666                    data_addr,
14667                    data_size,
14668                }),
14669                single_chunk: None,
14670                btree_v1: None,
14671                append: None,
14672            },
14673        );
14674
14675        Ok(idx)
14676    }
14677
14678    /// Define a chunked dataset indexed by the single-chunk index: a fixed
14679    /// shape covered by exactly one whole chunk (`chunk_dims == dims`), its
14680    /// address — and, once written, size and filter mask if filtered — held
14681    /// directly in the layout message instead of any index structure
14682    /// (`H5Dsingle.c`). libhdf5 selects this index ahead of both Implicit and
14683    /// Fixed Array whenever the shape qualifies, filtered or not, early
14684    /// allocation or not (`H5D__layout_set_latest_indexing`).
14685    ///
14686    /// `early_alloc` mirrors [`create_implicit_dataset`](Self::create_implicit_dataset):
14687    /// when true, the chunk's storage is allocated and filled with the fill
14688    /// value immediately, matching an early-allocated unfiltered dataset
14689    /// whose one chunk covers the whole shape. When false, the chunk has no
14690    /// address until its first write, the same as an unfiltered Fixed Array
14691    /// element.
14692    pub fn create_single_chunk_dataset(
14693        &self,
14694        name: &str,
14695        datatype: DatatypeMessage,
14696        dims: &[u64],
14697        chunk_dims: &[u64],
14698        early_alloc: bool,
14699    ) -> IoResult<usize> {
14700        let create = self.begin_create(name)?;
14701        let name = create.name.as_str();
14702        validate_chunk_geometry(dims, dims, chunk_dims)?;
14703        let data_size = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
14704        let layout_version = self.chunk_layout_version(false, data_size);
14705
14706        let data_addr = if early_alloc {
14707            // Same reasoning as `create_implicit_dataset`: the fill-value
14708            // bytes are written now, not merely reserved, because the
14709            // file's end-of-file address is what libhdf5 checks a file's
14710            // completeness against.
14711            let addr = self.allocator.allocate(data_size, FreeSpaceClass::RawData);
14712            self.handle.write_at(
14713                addr,
14714                &crate::format::messages::fill_value::tiled_fill(data_size as usize, None),
14715            )?;
14716            addr
14717        } else {
14718            UNDEF_ADDR
14719        };
14720
14721        let dataspace = DataspaceMessage {
14722            // Chunked storage always requires at least one dimension, so
14723            // this is never Scalar or Null.
14724            class: DataspaceClass::Simple,
14725            dims: dims.to_vec(),
14726            max_dims: Some(dims.to_vec()),
14727        };
14728
14729        let idx = self.push_dataset(
14730            &create,
14731            DatasetInfo {
14732                name: name.to_string(),
14733                datatype,
14734                committed_type: None,
14735                external: None,
14736                virtual_storage: None,
14737                dataspace,
14738                read_format: None,
14739                obj_header_addr: 0,
14740                data_addr: UNDEF_ADDR,
14741                data_size: 0,
14742                compact: None,
14743                attributes: Vec::new(),
14744                obj_header_written_addr: None,
14745                obj_header_blocks: Vec::new(),
14746                filter_pipeline: None,
14747                deleted: false,
14748                extent_dirty: false,
14749                header_dirty: false,
14750                nlink_written: 1,
14751                creation_seq: self.take_creation_seq(),
14752                track_attr_order: self.track_order.attrs,
14753                fill_value: None,
14754                fill_time: FILL_TIME_IFSET,
14755                layout_version,
14756                times: self.created_object_times(),
14757                chunked: None,
14758                btree_v2: None,
14759                fixed_array: None,
14760                implicit: None,
14761                single_chunk: Some(SingleChunkDatasetInfo {
14762                    chunk_dims: chunk_dims.to_vec(),
14763                    data_addr,
14764                    data_size,
14765                    nbytes: if early_alloc { data_size } else { 0 },
14766                    filter_mask: 0,
14767                    chunks_written: 0,
14768                    early_alloc,
14769                }),
14770                btree_v1: None,
14771                append: None,
14772            },
14773        );
14774
14775        Ok(idx)
14776    }
14777
14778    /// Define a fixed-shape compressed chunked dataset — of exactly one
14779    /// whole chunk — indexed by a *filtered* single-chunk index
14780    /// (`H5O_LAYOUT_CHUNK_SINGLE_INDEX_WITH_FILTER`, H5Dsingle.c). The
14781    /// chunk's stored size and filter mask are recorded inline in the
14782    /// layout message once the chunk is written.
14783    ///
14784    /// Like [`create_fixed_array_dataset_with_pipeline`](Self::create_fixed_array_dataset_with_pipeline),
14785    /// there is nothing to allocate ahead of that first write — a filtered
14786    /// chunk's stored length isn't known until it is compressed — so this
14787    /// dataset is always incrementally allocated regardless of the caller's
14788    /// requested allocation time.
14789    pub fn create_single_chunk_dataset_with_pipeline(
14790        &self,
14791        name: &str,
14792        datatype: DatatypeMessage,
14793        dims: &[u64],
14794        chunk_dims: &[u64],
14795        pipeline: FilterPipeline,
14796    ) -> IoResult<usize> {
14797        let create = self.begin_create(name)?;
14798        let name = create.name.as_str();
14799        validate_chunk_geometry(dims, dims, chunk_dims)?;
14800        let data_size = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
14801        let layout_version = self.chunk_layout_version(true, data_size);
14802
14803        let dataspace = DataspaceMessage {
14804            // Chunked storage always requires at least one dimension, so
14805            // this is never Scalar or Null.
14806            class: DataspaceClass::Simple,
14807            dims: dims.to_vec(),
14808            max_dims: Some(dims.to_vec()),
14809        };
14810
14811        let idx = self.push_dataset(
14812            &create,
14813            DatasetInfo {
14814                name: name.to_string(),
14815                datatype,
14816                committed_type: None,
14817                external: None,
14818                virtual_storage: None,
14819                dataspace,
14820                read_format: None,
14821                obj_header_addr: 0,
14822                data_addr: UNDEF_ADDR,
14823                data_size: 0,
14824                compact: None,
14825                attributes: Vec::new(),
14826                obj_header_written_addr: None,
14827                obj_header_blocks: Vec::new(),
14828                filter_pipeline: Some(pipeline),
14829                deleted: false,
14830                extent_dirty: false,
14831                header_dirty: false,
14832                nlink_written: 1,
14833                creation_seq: self.take_creation_seq(),
14834                track_attr_order: self.track_order.attrs,
14835                fill_value: None,
14836                fill_time: FILL_TIME_IFSET,
14837                layout_version,
14838                times: self.created_object_times(),
14839                chunked: None,
14840                btree_v2: None,
14841                fixed_array: None,
14842                implicit: None,
14843                single_chunk: Some(SingleChunkDatasetInfo {
14844                    chunk_dims: chunk_dims.to_vec(),
14845                    data_addr: UNDEF_ADDR,
14846                    data_size,
14847                    nbytes: 0,
14848                    filter_mask: 0,
14849                    chunks_written: 0,
14850                    early_alloc: false,
14851                }),
14852                btree_v1: None,
14853                append: None,
14854            },
14855        );
14856
14857        Ok(idx)
14858    }
14859
14860    /// Define a chunked dataset indexed by a version-1 B-tree — the classic
14861    /// chunk index, and the only one a version-0/1 superblock file can carry.
14862    ///
14863    /// The tree itself is not created here: libhdf5 leaves the layout
14864    /// message's address undefined until the first chunk is inserted
14865    /// (`H5D__btree_idx_create` runs on that insert), and so does this — the
14866    /// flush that bulk-loads the records is what puts a node in the file.
14867    ///
14868    /// Unlike the array indexes this one has no grid to size, so it takes any
14869    /// number of unlimited dimensions: a key *is* the chunk's position, and
14870    /// the tree is ordered by it.
14871    pub fn create_btree_v1_dataset(
14872        &self,
14873        name: &str,
14874        datatype: DatatypeMessage,
14875        dims: &[u64],
14876        max_dims: &[u64],
14877        chunk_dims: &[u64],
14878        pipeline: Option<FilterPipeline>,
14879    ) -> IoResult<usize> {
14880        let create = self.begin_create(name)?;
14881        let name = create.name.as_str();
14882        validate_chunk_geometry(dims, max_dims, chunk_dims)?;
14883        let chunk_bytes: u64 = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
14884        if chunk_bytes > u32::MAX as u64 {
14885            return Err(crate::io::IoError::InvalidState(format!(
14886                "a {chunk_bytes}-byte chunk does not fit the 32-bit size field of a \
14887                 version-1 B-tree chunk key"
14888            )));
14889        }
14890
14891        let dataspace = DataspaceMessage {
14892            // Chunked storage always requires at least one dimension, so
14893            // this is never Scalar or Null.
14894            class: DataspaceClass::Simple,
14895            dims: dims.to_vec(),
14896            max_dims: Some(max_dims.to_vec()),
14897        };
14898
14899        let idx = self.push_dataset(
14900            &create,
14901            DatasetInfo {
14902                name: name.to_string(),
14903                datatype,
14904                committed_type: None,
14905                external: None,
14906                virtual_storage: None,
14907                dataspace,
14908                read_format: None,
14909                obj_header_addr: 0,
14910                data_addr: UNDEF_ADDR,
14911                data_size: 0,
14912                compact: None,
14913                attributes: Vec::new(),
14914                obj_header_written_addr: None,
14915                obj_header_blocks: Vec::new(),
14916                filter_pipeline: pipeline,
14917                deleted: false,
14918                extent_dirty: false,
14919                header_dirty: false,
14920                nlink_written: 1,
14921                creation_seq: self.take_creation_seq(),
14922                track_attr_order: self.track_order.attrs,
14923                fill_value: None,
14924                fill_time: FILL_TIME_IFSET,
14925                // The version-3 data layout message this index encodes as:
14926                // `H5O_LAYOUT_VERSION_DEFAULT`, which is the floor of
14927                // `H5D__chunk_set_info`'s final MAX and the whole of it below
14928                // the version-4 gate — a bound whose row is lower does not
14929                // push the message down, it only keeps the v1.10 indexes out.
14930                layout_version: LAYOUT_VERSION_DEFAULT,
14931                times: self.created_object_times(),
14932                chunked: None,
14933                fixed_array: None,
14934                btree_v2: None,
14935                implicit: None,
14936                single_chunk: None,
14937                btree_v1: Some(BtreeV1DatasetInfo {
14938                    chunk_dims: chunk_dims.to_vec(),
14939                    max_dims: max_dims.to_vec(),
14940                    config: self.btree_v1_config(),
14941                    records: Vec::new(),
14942                    node_addrs: Vec::new(),
14943                    root_addr: UNDEF_ADDR,
14944                    chunks_written: 0,
14945                }),
14946                append: None,
14947            },
14948        );
14949
14950        Ok(idx)
14951    }
14952
14953    /// Define a chunked dataset indexed by a B-tree v2 (multiple unlimited dimensions).
14954    ///
14955    /// Returns the dataset index.
14956    pub fn create_btree_v2_dataset(
14957        &self,
14958        name: &str,
14959        datatype: DatatypeMessage,
14960        dims: &[u64],
14961        max_dims: &[u64],
14962        chunk_dims: &[u64],
14963    ) -> IoResult<usize> {
14964        self.create_btree_v2_dataset_inner(name, datatype, dims, max_dims, chunk_dims, None)
14965    }
14966
14967    /// Define a *filtered* chunked dataset indexed by a B-tree v2.
14968    ///
14969    /// The v2 B-tree counterpart of
14970    /// [`create_chunked_dataset_with_pipeline`](Self::create_chunked_dataset_with_pipeline):
14971    /// chunks are compressed on write and the index records each chunk's
14972    /// stored size and filter mask (record type 11), the same shape libhdf5
14973    /// builds when a multi-unlimited-dimension dataset has a filter pipeline
14974    /// (`H5Dbtree2.c`, `H5D_BT2_FILT`).
14975    pub fn create_btree_v2_dataset_with_pipeline(
14976        &self,
14977        name: &str,
14978        datatype: DatatypeMessage,
14979        dims: &[u64],
14980        max_dims: &[u64],
14981        chunk_dims: &[u64],
14982        pipeline: FilterPipeline,
14983    ) -> IoResult<usize> {
14984        self.create_btree_v2_dataset_inner(
14985            name,
14986            datatype,
14987            dims,
14988            max_dims,
14989            chunk_dims,
14990            Some(pipeline),
14991        )
14992    }
14993
14994    fn create_btree_v2_dataset_inner(
14995        &self,
14996        name: &str,
14997        datatype: DatatypeMessage,
14998        dims: &[u64],
14999        max_dims: &[u64],
15000        chunk_dims: &[u64],
15001        pipeline: Option<FilterPipeline>,
15002    ) -> IoResult<usize> {
15003        use crate::format::chunk_index::btree_v2::Bt2Header;
15004
15005        let create = self.begin_create(name)?;
15006        let name = create.name.as_str();
15007        validate_chunk_geometry(dims, max_dims, chunk_dims)?;
15008        let ndims = dims.len();
15009        let chunk_bytes: u64 = chunk_dims.iter().product::<u64>() * datatype.element_size() as u64;
15010        let layout_version = self.chunk_layout_version(pipeline.is_some(), chunk_bytes);
15011
15012        // The filtered record's size field is as wide as libhdf5 will
15013        // recompute it — from the uncompressed chunk size under layout v4,
15014        // the fixed `sizeof_size` under layout v5 — exactly as the
15015        // extensible- and fixed-array filtered paths size theirs.
15016        let bt2_index = match pipeline {
15017            Some(_) => {
15018                let len = self.chunk_size_len_for(layout_version, chunk_bytes);
15019                Bt2ChunkIndex::new_filtered(ndims, len)
15020            }
15021            None => Bt2ChunkIndex::new_unfiltered(ndims),
15022        };
15023
15024        // The bulk loader spreads a level's records evenly over its nodes, one
15025        // separator between adjacent siblings, which needs room for a few
15026        // records per node. HDF5's rank limit of 32 leaves room for seven; a
15027        // wider rank than that has no valid geometry, so reject it here rather
15028        // than emit a tree no reader can walk.
15029        let record_size = bt2_index.record_size(&self.ctx) as usize;
15030        let node_size = bt2_index.node_size as usize;
15031        if node_size < 10 + 3 * record_size {
15032            return Err(crate::io::IoError::InvalidState(format!(
15033                "a {ndims}-dimension v2 B-tree record is {record_size} bytes, too wide \
15034                 for a {node_size}-byte node"
15035            )));
15036        }
15037
15038        // Only the header gets a home now: it names an empty tree, whose root
15039        // is undefined until the first flush bulk-loads the index into nodes.
15040        let hdr = if bt2_index.filtered {
15041            Bt2Header::new_for_filtered_chunks(&self.ctx, ndims, bt2_index.chunk_size_len)
15042        } else {
15043            Bt2Header::new_for_chunks(&self.ctx, ndims)
15044        };
15045        let hdr_encoded = hdr.encode(&self.ctx);
15046        let bt2_header_addr = self
15047            .allocator
15048            .allocate(hdr_encoded.len() as u64, FreeSpaceClass::Metadata);
15049        self.handle.write_at(bt2_header_addr, &hdr_encoded)?;
15050
15051        let dataspace = DataspaceMessage {
15052            // Chunked storage always requires at least one dimension, so
15053            // this is never Scalar or Null.
15054            class: DataspaceClass::Simple,
15055            dims: dims.to_vec(),
15056            max_dims: Some(max_dims.to_vec()),
15057        };
15058
15059        let idx = self.push_dataset(
15060            &create,
15061            DatasetInfo {
15062                name: name.to_string(),
15063                datatype,
15064                committed_type: None,
15065                external: None,
15066                virtual_storage: None,
15067                dataspace,
15068                read_format: None,
15069                obj_header_addr: 0,
15070                data_addr: UNDEF_ADDR,
15071                data_size: 0,
15072                compact: None,
15073                attributes: Vec::new(),
15074                obj_header_written_addr: None,
15075                obj_header_blocks: Vec::new(),
15076                filter_pipeline: pipeline,
15077                deleted: false,
15078                extent_dirty: false,
15079                header_dirty: false,
15080                nlink_written: 1,
15081                creation_seq: self.take_creation_seq(),
15082                track_attr_order: self.track_order.attrs,
15083                fill_value: None,
15084                fill_time: FILL_TIME_IFSET,
15085                layout_version,
15086                times: self.created_object_times(),
15087                chunked: None,
15088                fixed_array: None,
15089                implicit: None,
15090                single_chunk: None,
15091                btree_v1: None,
15092                btree_v2: Some(Bt2DatasetInfo {
15093                    chunk_dims: chunk_dims.to_vec(),
15094                    bt2_header_addr,
15095                    node_addrs: Vec::new(),
15096                    index: bt2_index,
15097                    chunks_written: 0,
15098                }),
15099                append: None,
15100            },
15101        );
15102
15103        Ok(idx)
15104    }
15105
15106    /// Create a chunked dataset with a custom filter pipeline.
15107    pub fn create_chunked_dataset_with_pipeline(
15108        &self,
15109        name: &str,
15110        datatype: DatatypeMessage,
15111        dims: &[u64],
15112        max_dims: &[u64],
15113        chunk_dims: &[u64],
15114        pipeline: FilterPipeline,
15115    ) -> IoResult<usize> {
15116        let create = self.begin_create(name)?;
15117        let name = create.name.as_str();
15118        validate_chunk_geometry(dims, max_dims, chunk_dims)?;
15119        ensure_at_most_one_unlimited(max_dims)?;
15120        let element_size = datatype.element_size() as u64;
15121        let chunk_bytes: u64 = chunk_dims.iter().product::<u64>() * element_size;
15122        let layout_version = self.chunk_layout_version(true, chunk_bytes);
15123        let chunk_size_len = self.chunk_size_len_for(layout_version, chunk_bytes);
15124
15125        let earray_params = EarrayParams::default_params();
15126        let ndblk_addrs = compute_ndblk_addrs(earray_params.sup_blk_min_data_ptrs)?;
15127        let nsblk_addrs = compute_nsblk_addrs(
15128            earray_params.idx_blk_elmts,
15129            earray_params.data_blk_min_elmts,
15130            earray_params.sup_blk_min_data_ptrs,
15131            earray_params.max_nelmts_bits,
15132        )?;
15133
15134        let mut ea_header =
15135            ExtensibleArrayHeader::new_for_filtered_chunks(&self.ctx, chunk_size_len);
15136        ea_header.max_nelmts_bits = earray_params.max_nelmts_bits;
15137        ea_header.idx_blk_elmts = earray_params.idx_blk_elmts;
15138        ea_header.data_blk_min_elmts = earray_params.data_blk_min_elmts;
15139        ea_header.sup_blk_min_data_ptrs = earray_params.sup_blk_min_data_ptrs;
15140        ea_header.max_dblk_page_nelmts_bits = earray_params.max_dblk_page_nelmts_bits;
15141
15142        let hdr_encoded = ea_header.encode(&self.ctx);
15143        let ea_header_addr = self
15144            .allocator
15145            .allocate(hdr_encoded.len() as u64, FreeSpaceClass::Metadata);
15146
15147        let filt_iblk = FilteredIndexBlock::new(
15148            ea_header_addr,
15149            earray_params.idx_blk_elmts,
15150            ndblk_addrs,
15151            nsblk_addrs,
15152        );
15153        let iblk_encoded = filt_iblk.encode(&self.ctx, chunk_size_len);
15154        let ea_iblk_addr = self
15155            .allocator
15156            .allocate(iblk_encoded.len() as u64, FreeSpaceClass::Metadata);
15157
15158        ea_header.idx_blk_addr = ea_iblk_addr;
15159        let hdr_encoded = ea_header.encode(&self.ctx);
15160        self.handle.write_at(ea_header_addr, &hdr_encoded)?;
15161        self.handle.write_at(ea_iblk_addr, &iblk_encoded)?;
15162
15163        let dataspace = DataspaceMessage {
15164            // Chunked storage always requires at least one dimension, so
15165            // this is never Scalar or Null.
15166            class: DataspaceClass::Simple,
15167            dims: dims.to_vec(),
15168            max_dims: Some(max_dims.to_vec()),
15169        };
15170        let ea_iblk = ExtensibleArrayIndexBlock::new(
15171            ea_header_addr,
15172            earray_params.idx_blk_elmts,
15173            ndblk_addrs,
15174            nsblk_addrs,
15175        );
15176
15177        let idx = self.push_dataset(
15178            &create,
15179            DatasetInfo {
15180                name: name.to_string(),
15181                datatype,
15182                committed_type: None,
15183                external: None,
15184                virtual_storage: None,
15185                dataspace,
15186                read_format: None,
15187                obj_header_addr: 0,
15188                data_addr: UNDEF_ADDR,
15189                data_size: 0,
15190                compact: None,
15191                attributes: Vec::new(),
15192                obj_header_written_addr: None,
15193                obj_header_blocks: Vec::new(),
15194                filter_pipeline: Some(pipeline),
15195                deleted: false,
15196                extent_dirty: false,
15197                header_dirty: false,
15198                nlink_written: 1,
15199                creation_seq: self.take_creation_seq(),
15200                track_attr_order: self.track_order.attrs,
15201                fill_value: None,
15202                fill_time: FILL_TIME_IFSET,
15203                layout_version,
15204                times: self.created_object_times(),
15205                fixed_array: None,
15206                implicit: None,
15207                single_chunk: None,
15208                btree_v1: None,
15209                btree_v2: None,
15210                chunked: Some(ChunkedDatasetInfo {
15211                    chunk_dims: chunk_dims.to_vec(),
15212                    earray_params,
15213                    ea_header_addr,
15214                    ea_iblk_addr,
15215                    ea_header,
15216                    ea_iblk,
15217                    chunks_written: 0,
15218                    filt_iblk: Some(filt_iblk),
15219                    chunk_size_len,
15220                }),
15221                append: None,
15222            },
15223        );
15224        Ok(idx)
15225    }
15226
15227    /// Write a chunk to a fixed-array-indexed dataset.
15228    ///
15229    /// `chunk_coords` is the multidimensional chunk index (e.g., [row_chunk, col_chunk]).
15230    /// The uncompressed `data` must be exactly one chunk wide; the filter
15231    /// pipeline (if any) runs here before the bytes reach the index.
15232    pub fn write_chunk_fixed_array(
15233        &self,
15234        index: usize,
15235        chunk_coords: &[u64],
15236        data: &[u8],
15237    ) -> IoResult<()> {
15238        let ds = self.ds(index);
15239        let _op = ds.op.lock();
15240        self.write_chunk_fixed_array_inner(index, chunk_coords, data)
15241    }
15242
15243    /// [`Self::write_chunk_fixed_array`] body; the caller holds the dataset's
15244    /// op lock or the writer exclusively.
15245    pub(crate) fn write_chunk_fixed_array_inner(
15246        &self,
15247        index: usize,
15248        chunk_coords: &[u64],
15249        data: &[u8],
15250    ) -> IoResult<()> {
15251        // Read what we need under one brief slot guard, then compress
15252        // OUTSIDE the lock: `record_fixed_array_chunk` re-locks the same slot,
15253        // so the guard must be dropped before it (and before apply_filters).
15254        let ds = self.ds(index);
15255        let (chunk_bytes, pipeline) = {
15256            let m = ds.lock();
15257            let element_size = m.datatype.element_size() as u64;
15258            let fa = m.fixed_array.as_ref().ok_or_else(|| {
15259                crate::io::IoError::InvalidState("not a fixed-array dataset".into())
15260            })?;
15261            (
15262                fa.chunk_dims.iter().product::<u64>() * element_size,
15263                m.filter_pipeline.clone(),
15264            )
15265        };
15266
15267        if data.len() as u64 != chunk_bytes {
15268            return Err(crate::io::IoError::InvalidState(format!(
15269                "chunk data size mismatch: expected {} bytes, got {}",
15270                chunk_bytes,
15271                data.len()
15272            )));
15273        }
15274        let write_data;
15275        let data_to_write = if let Some(ref pipeline) = pipeline {
15276            write_data = filter::apply_filters(pipeline, data)?;
15277            &write_data[..]
15278        } else {
15279            data
15280        };
15281        // filter_mask = 0: the whole pipeline ran (or the dataset is
15282        // unfiltered), so no filter is skipped for this chunk.
15283        self.record_fixed_array_chunk(index, chunk_coords, data_to_write, 0)
15284    }
15285
15286    /// Write a pre-filtered chunk verbatim to a fixed-array dataset, recording
15287    /// the caller-supplied `filter_mask`.
15288    ///
15289    /// The bytes are stored exactly as given (no filter pipeline is run); this
15290    /// is the fixed-array half of the HDF5 "direct chunk write"
15291    /// (`H5Dwrite_chunk`) operation. `filter_mask` is a bitfield: bit *i* set
15292    /// means filter *i* of the pipeline was **not** applied to this chunk and
15293    /// must be skipped on read; pass 0 when the full pipeline was applied
15294    /// upstream.
15295    ///
15296    /// Requires a filtered dataset — only the filtered FA element carries the
15297    /// size+mask slot.
15298    ///
15299    /// The caller holds the dataset's op lock or the writer exclusively.
15300    pub(crate) fn write_compressed_chunk_fixed_array_inner(
15301        &self,
15302        index: usize,
15303        chunk_coords: &[u64],
15304        data: &[u8],
15305        filter_mask: u32,
15306    ) -> IoResult<()> {
15307        if self.ds(index).lock().filter_pipeline.is_none() {
15308            return Err(crate::io::IoError::InvalidState(
15309                "write_compressed_chunk_fixed_array requires a filtered dataset \
15310                 (no slot for a compressed size or filter mask on an unfiltered \
15311                 chunk index)"
15312                    .into(),
15313            ));
15314        }
15315        self.record_fixed_array_chunk(index, chunk_coords, data, filter_mask)
15316    }
15317
15318    /// Place an already-final chunk (`final_bytes` is whatever goes to disk —
15319    /// filtered if the dataset is filtered, raw otherwise) into a fixed-array
15320    /// dataset's data block, recording the caller-supplied `filter_mask`.
15321    /// Shared by [`write_chunk_fixed_array`](Self::write_chunk_fixed_array)
15322    /// and [`write_compressed_chunk_fixed_array`](Self::write_compressed_chunk_fixed_array).
15323    fn record_fixed_array_chunk(
15324        &self,
15325        index: usize,
15326        chunk_coords: &[u64],
15327        final_bytes: &[u8],
15328        filter_mask: u32,
15329    ) -> IoResult<()> {
15330        // Hold one slot guard for the whole method; `self.allocator`/`self.handle`/
15331        // `self.ctx` below touch disjoint fields safe to use with the guard held.
15332        let ds = self.ds(index);
15333        let mut m = ds.lock();
15334        let is_filtered = m.filter_pipeline.is_some();
15335        let fa = m
15336            .fixed_array
15337            .as_ref()
15338            .ok_or_else(|| crate::io::IoError::InvalidState("not a fixed-array dataset".into()))?;
15339
15340        // Linear chunk index in the maximum-extent grid — the slot the fixed
15341        // array (sized from that grid at create) records the chunk under.
15342        let linear_idx = crate::io::chunk_grid::linear_index(
15343            &m.dataspace.dims,
15344            m.dataspace.max_dims.as_deref(),
15345            &fa.chunk_dims,
15346            chunk_coords,
15347        )?;
15348
15349        // Update the fixed array data block. The slot is read before the bytes
15350        // are placed so a rewrite can stay where it is (see `place_chunk`).
15351        let fa = m.fixed_array.as_mut().unwrap();
15352        let lidx = linear_idx as usize;
15353        if is_filtered {
15354            // Filtered FA: store address + stored size + filter mask. A
15355            // non-zero mask bit means "filter i was skipped for this chunk".
15356            let stored_size = final_bytes.len();
15357            // The stored size is encoded in the FA header's `chunk_size_len`-byte
15358            // field; libhdf5 errors if it does not fit (H5D_CHUNK_ENCODE_SIZE_CHECK)
15359            // rather than truncating silently. element_size = sizeof_addr +
15360            // chunk_size_len + 4 by construction.
15361            let chunk_size_len = (fa.fa_header.element_size as usize)
15362                .checked_sub(self.ctx.sizeof_addr as usize + 4)
15363                .ok_or_else(|| {
15364                    crate::io::IoError::InvalidState(
15365                        "filtered fixed-array element size is too small".into(),
15366                    )
15367                })?;
15368            if chunk_size_len < 8 && stored_size >= (1usize << (chunk_size_len * 8)) {
15369                return Err(crate::io::IoError::InvalidState(format!(
15370                    "compressed chunk size {stored_size} does not fit in the \
15371                     {chunk_size_len}-byte fixed-array chunk-size field"
15372                )));
15373            }
15374            if lidx < fa.fa_dblk.filtered_elements.len() {
15375                let old = &fa.fa_dblk.filtered_elements[lidx];
15376                let chunk_addr =
15377                    self.place_chunk(Some((old.address, old.chunk_size)), stored_size as u64);
15378                self.handle.write_at(chunk_addr, final_bytes)?;
15379                fa.fa_dblk.filtered_elements[lidx] = FixedArrayFilteredChunkElement {
15380                    address: chunk_addr,
15381                    chunk_size: stored_size as u64,
15382                    filter_mask,
15383                };
15384                fa.chunks_written += 1;
15385            } else {
15386                return Err(crate::io::IoError::InvalidState(format!(
15387                    "chunk index {} out of range (max {})",
15388                    linear_idx,
15389                    fa.fa_dblk.filtered_elements.len()
15390                )));
15391            }
15392        } else {
15393            // An unfiltered fixed array stores only addresses — there is no
15394            // slot for a filter mask, so a non-zero mask cannot be honored.
15395            if filter_mask != 0 {
15396                return Err(crate::io::IoError::InvalidState(
15397                    "filter_mask is non-zero but the dataset is unfiltered".into(),
15398                ));
15399            }
15400            if lidx < fa.fa_dblk.elements.len() {
15401                // Unfiltered: the stored size is fixed by the chunk shape, so
15402                // a rewrite always fits its old block.
15403                let old = fa.fa_dblk.elements[lidx];
15404                let len = final_bytes.len() as u64;
15405                let chunk_addr = self.place_chunk(Some((old, len)), len);
15406                self.handle.write_at(chunk_addr, final_bytes)?;
15407                fa.fa_dblk.elements[lidx] = chunk_addr;
15408                fa.chunks_written += 1;
15409            } else {
15410                return Err(crate::io::IoError::InvalidState(format!(
15411                    "chunk index {} out of range (max {})",
15412                    linear_idx,
15413                    fa.fa_dblk.elements.len()
15414                )));
15415            }
15416        }
15417
15418        Ok(())
15419    }
15420
15421    /// Write the one chunk of a single-chunk indexed dataset.
15422    ///
15423    /// `chunk_coords` is validated against the grid the same way every other
15424    /// coordinate-addressed index does (`ChunkGeometry::linear_index`), even
15425    /// though the grid holds exactly one slot — this is what rejects an
15426    /// out-of-range coordinate instead of silently writing to that slot.
15427    /// `data` is the chunk's unfiltered bytes; the dataset's filter pipeline
15428    /// runs here if it has one.
15429    ///
15430    /// The caller holds the dataset's op lock or the writer exclusively.
15431    pub(crate) fn write_chunk_single_chunk_inner(
15432        &self,
15433        index: usize,
15434        chunk_coords: &[u64],
15435        data: &[u8],
15436    ) -> IoResult<()> {
15437        let geo = self.chunk_geometry(index)?;
15438        geo.linear_index(chunk_coords)?;
15439        let chunk_bytes = geo.chunk_bytes();
15440        if data.len() as u64 != chunk_bytes {
15441            return Err(crate::io::IoError::InvalidState(format!(
15442                "chunk data size mismatch: expected {} bytes, got {}",
15443                chunk_bytes,
15444                data.len()
15445            )));
15446        }
15447        let pipeline = self.ds(index).lock().filter_pipeline.clone();
15448        let write_data;
15449        let data_to_write = if let Some(ref pipeline) = pipeline {
15450            write_data = filter::apply_filters(pipeline, data)?;
15451            &write_data[..]
15452        } else {
15453            data
15454        };
15455        // filter_mask = 0: the whole pipeline ran (or the dataset is
15456        // unfiltered), so no filter is skipped for this chunk.
15457        self.record_single_chunk(index, data_to_write, 0)
15458    }
15459
15460    /// Write a pre-filtered chunk verbatim to a single-chunk dataset,
15461    /// recording the caller-supplied `filter_mask`.
15462    ///
15463    /// The bytes are stored exactly as given (no filter pipeline is run); this
15464    /// is the single-chunk half of the HDF5 "direct chunk write"
15465    /// (`H5Dwrite_chunk`) operation. `filter_mask` is a bitfield: bit *i* set
15466    /// means filter *i* of the pipeline was **not** applied to this chunk and
15467    /// must be skipped on read; pass 0 when the full pipeline was applied
15468    /// upstream.
15469    ///
15470    /// Requires a filtered dataset — only the filtered single-chunk layout
15471    /// carries a size+mask slot.
15472    ///
15473    /// The caller holds the dataset's op lock or the writer exclusively.
15474    pub(crate) fn write_compressed_chunk_single_chunk_inner(
15475        &self,
15476        index: usize,
15477        chunk_coords: &[u64],
15478        data: &[u8],
15479        filter_mask: u32,
15480    ) -> IoResult<()> {
15481        if self.ds(index).lock().filter_pipeline.is_none() {
15482            return Err(crate::io::IoError::InvalidState(
15483                "write_compressed_chunk_single_chunk requires a filtered dataset \
15484                 (no slot for a compressed size or filter mask on an unfiltered \
15485                 chunk index)"
15486                    .into(),
15487            ));
15488        }
15489        let geo = self.chunk_geometry(index)?;
15490        geo.linear_index(chunk_coords)?;
15491        self.record_single_chunk(index, data, filter_mask)
15492    }
15493
15494    /// Place an already-final chunk (`final_bytes` is whatever goes to disk —
15495    /// filtered if the dataset is filtered, raw otherwise) into a single-chunk
15496    /// dataset's layout message fields, recording the caller-supplied
15497    /// `filter_mask`. Shared by
15498    /// [`write_chunk_single_chunk_inner`](Self::write_chunk_single_chunk_inner)
15499    /// and
15500    /// [`write_compressed_chunk_single_chunk_inner`](Self::write_compressed_chunk_single_chunk_inner).
15501    ///
15502    /// Unlike the array indexes there is no per-chunk slot to look up — the
15503    /// dataset has exactly one chunk, and its address/size/mask live directly
15504    /// in the layout message (`H5Dsingle.c`) — so this only ever rewrites the
15505    /// one chunk in place, via [`place_chunk`](Self::place_chunk) the same as
15506    /// every other index's rewrite path.
15507    fn record_single_chunk(
15508        &self,
15509        index: usize,
15510        final_bytes: &[u8],
15511        filter_mask: u32,
15512    ) -> IoResult<()> {
15513        let ds = self.ds(index);
15514        let mut m = ds.lock();
15515        let is_filtered = m.filter_pipeline.is_some();
15516        if !is_filtered && filter_mask != 0 {
15517            return Err(crate::io::IoError::InvalidState(
15518                "filter_mask is non-zero but the dataset is unfiltered".into(),
15519            ));
15520        }
15521        let sc = m
15522            .single_chunk
15523            .as_ref()
15524            .ok_or_else(|| crate::io::IoError::InvalidState("not a single-chunk dataset".into()))?;
15525
15526        // A rewrite whose stored size is unchanged stays where it is (always
15527        // so when unfiltered), one that no longer fits moves. See `place_chunk`.
15528        let old = if sc.data_addr == UNDEF_ADDR {
15529            None
15530        } else {
15531            Some((
15532                sc.data_addr,
15533                if is_filtered { sc.nbytes } else { sc.data_size },
15534            ))
15535        };
15536        let stored_size = final_bytes.len() as u64;
15537        let addr = self.place_chunk(old, stored_size);
15538        self.handle.write_at(addr, final_bytes)?;
15539
15540        let sc = m.single_chunk.as_mut().unwrap();
15541        sc.data_addr = addr;
15542        sc.nbytes = stored_size;
15543        sc.filter_mask = filter_mask;
15544        sc.chunks_written = 1;
15545        Ok(())
15546    }
15547
15548    /// Write a chunk to a B-tree v2 indexed dataset.
15549    ///
15550    /// `chunk_coords` is the scaled chunk coordinates (one per dimension).
15551    /// `data` is the chunk's unfiltered bytes; if the dataset has a filter
15552    /// pipeline it runs here and the index records the stored size and mask.
15553    ///
15554    /// Production writes call [`write_chunk_btree_v2_inner`](Self::write_chunk_btree_v2_inner)
15555    /// directly (they already hold the dataset's op lock); this self-locking
15556    /// form is kept as a direct entry point for this crate's own white-box
15557    /// tests.
15558    #[cfg(test)]
15559    pub fn write_chunk_btree_v2(
15560        &self,
15561        index: usize,
15562        chunk_coords: &[u64],
15563        data: &[u8],
15564    ) -> IoResult<()> {
15565        let ds = self.ds(index);
15566        let _op = ds.op.lock();
15567        self.write_chunk_btree_v2_inner(index, chunk_coords, data)
15568    }
15569
15570    /// [`Self::write_chunk_btree_v2`] body; the caller holds the dataset's op
15571    /// lock or the writer exclusively.
15572    pub(crate) fn write_chunk_btree_v2_inner(
15573        &self,
15574        index: usize,
15575        chunk_coords: &[u64],
15576        data: &[u8],
15577    ) -> IoResult<()> {
15578        // Read what the write needs under a brief guard, then compress OUTSIDE
15579        // the lock — filtering a chunk must not hold the dataset slot.
15580        let ds = self.ds(index);
15581        let (chunk_bytes, pipeline) = {
15582            let m = ds.lock();
15583            let element_size = m.datatype.element_size() as u64;
15584            let bt2 = m.btree_v2.as_ref().ok_or_else(|| {
15585                crate::io::IoError::InvalidState("not a B-tree v2 dataset".into())
15586            })?;
15587            (
15588                bt2.chunk_dims.iter().product::<u64>() * element_size,
15589                m.filter_pipeline.clone(),
15590            )
15591        };
15592
15593        if data.len() as u64 != chunk_bytes {
15594            return Err(crate::io::IoError::InvalidState(format!(
15595                "chunk data size mismatch: expected {} bytes, got {}",
15596                chunk_bytes,
15597                data.len()
15598            )));
15599        }
15600
15601        let filtered;
15602        let stored = match pipeline {
15603            Some(ref pl) => {
15604                filtered = filter::apply_filters(pl, data)?;
15605                &filtered[..]
15606            }
15607            None => data,
15608        };
15609
15610        // filter_mask = 0: the whole pipeline ran (or the dataset is
15611        // unfiltered), so no filter is skipped.
15612        self.record_btree_v2_chunk(index, chunk_coords, stored, 0)
15613    }
15614
15615    /// Write a pre-filtered chunk verbatim to a BT2-indexed dataset, recording
15616    /// the caller-supplied `filter_mask`.
15617    ///
15618    /// The v2-B-tree half of the HDF5 "direct chunk write" (`H5Dwrite_chunk`).
15619    /// The bytes are stored exactly as given; `filter_mask` bit *i* set means
15620    /// filter *i* of the pipeline was **not** applied and must be skipped on
15621    /// read. Requires a filtered dataset — only a type-11 record has a slot for
15622    /// a stored size and mask.
15623    ///
15624    /// The caller holds the dataset's op lock or the writer exclusively.
15625    pub(crate) fn write_compressed_chunk_btree_v2_inner(
15626        &self,
15627        index: usize,
15628        chunk_coords: &[u64],
15629        data: &[u8],
15630        filter_mask: u32,
15631    ) -> IoResult<()> {
15632        if self.ds(index).lock().filter_pipeline.is_none() {
15633            return Err(crate::io::IoError::InvalidState(
15634                "write_compressed_chunk_btree_v2 requires a filtered dataset (no \
15635                 slot for a compressed size or filter mask on an unfiltered chunk \
15636                 index)"
15637                    .into(),
15638            ));
15639        }
15640        self.record_btree_v2_chunk(index, chunk_coords, data, filter_mask)
15641    }
15642
15643    /// Place a chunk's already-final bytes (filtered if the dataset is
15644    /// filtered, raw otherwise) in the file and record them in the v2 B-tree,
15645    /// under the caller-supplied `filter_mask`.
15646    ///
15647    /// Shared by [`write_chunk_btree_v2`](Self::write_chunk_btree_v2) and
15648    /// [`write_compressed_chunk_btree_v2`](Self::write_compressed_chunk_btree_v2),
15649    /// so both reach the index through one placement rule.
15650    fn record_btree_v2_chunk(
15651        &self,
15652        index: usize,
15653        chunk_coords: &[u64],
15654        final_bytes: &[u8],
15655        filter_mask: u32,
15656    ) -> IoResult<()> {
15657        let stored_len = final_bytes.len() as u64;
15658        let ds = self.ds(index);
15659        let mut m = ds.lock();
15660        let element_size = m.datatype.element_size() as u64;
15661        let bt2 = m
15662            .btree_v2
15663            .as_ref()
15664            .ok_or_else(|| crate::io::IoError::InvalidState("not a B-tree v2 dataset".into()))?;
15665        let chunk_bytes = bt2.chunk_dims.iter().product::<u64>() * element_size;
15666        // A filtered record encodes the stored size in a `chunk_size_len`-byte
15667        // field that truncates silently. Reject a size that would not fit, as
15668        // the extensible-array path does — the compress path never exceeds it,
15669        // but a direct write with caller-supplied bytes can.
15670        if bt2.index.filtered {
15671            let chunk_size_len = bt2.index.chunk_size_len as usize;
15672            if chunk_size_len < 8 && stored_len >= (1u64 << (chunk_size_len * 8)) {
15673                return Err(crate::io::IoError::InvalidState(format!(
15674                    "filtered chunk size {stored_len} does not fit in the \
15675                     {chunk_size_len}-byte v2 B-tree chunk-size field"
15676                )));
15677            }
15678        }
15679        // Place the bytes: a rewrite whose stored size is unchanged stays
15680        // where it is (always so when unfiltered — the size is fixed by the
15681        // chunk shape), and one that no longer fits moves, releasing its old
15682        // block. See `place_chunk`.
15683        let old = if bt2.index.filtered {
15684            bt2.index
15685                .lookup_filtered(chunk_coords)
15686                .map(|r| (r.chunk_address, r.chunk_size))
15687        } else {
15688            bt2.index
15689                .lookup(chunk_coords)
15690                .map(|r| (r.chunk_address, chunk_bytes))
15691        };
15692        let chunk_addr = self.place_chunk(old, stored_len);
15693        self.handle.write_at(chunk_addr, final_bytes)?;
15694
15695        let bt2 = m.btree_v2.as_mut().unwrap();
15696        if bt2.index.filtered {
15697            bt2.index
15698                .insert_filtered(chunk_coords.to_vec(), chunk_addr, stored_len, filter_mask);
15699        } else {
15700            bt2.index.insert(chunk_coords.to_vec(), chunk_addr);
15701        }
15702        bt2.chunks_written += 1;
15703
15704        Ok(())
15705    }
15706
15707    /// Write multiple chunks in a batch, optionally compressing in parallel.
15708    ///
15709    /// `chunks` is a list of (chunk_idx, data) pairs for an EA-indexed dataset.
15710    pub fn write_chunks_batch(&self, ds_index: usize, chunks: &[(u64, &[u8])]) -> IoResult<()> {
15711        let ds = self.ds(ds_index);
15712        let _op = ds.op.lock();
15713        self.write_chunks_batch_inner(ds_index, chunks)
15714    }
15715
15716    /// [`Self::write_chunks_batch`] body; the caller holds the dataset's op
15717    /// lock or the writer exclusively.
15718    pub(crate) fn write_chunks_batch_inner(
15719        &self,
15720        ds_index: usize,
15721        chunks: &[(u64, &[u8])],
15722    ) -> IoResult<()> {
15723        #[cfg(feature = "parallel")]
15724        {
15725            // If filter pipeline is set, compress all chunks in parallel.
15726            // Clone the pipeline out under a brief slot guard so the parallel
15727            // compression below runs off the lock.
15728            let pipeline = self.ds(ds_index).lock().filter_pipeline.clone();
15729            if let Some(ref pipeline) = pipeline {
15730                let chunk_data: Vec<&[u8]> = chunks.iter().map(|&(_, d)| d).collect();
15731                // Propagate a filter error rather than storing raw bytes under a
15732                // filter_mask that claims the pipeline ran (see
15733                // apply_filters_parallel). Ok reaching here means every chunk
15734                // compressed fully, so filter_mask = 0 is truthful.
15735                let compressed = filter::apply_filters_parallel(pipeline, &chunk_data)?;
15736                for ((idx, _), compressed_data) in chunks.iter().zip(compressed.iter()) {
15737                    self.write_compressed_chunk_inner(ds_index, *idx, compressed_data, 0)?;
15738                }
15739                return Ok(());
15740            }
15741        }
15742        // Fallback: sequential
15743        for (idx, data) in chunks {
15744            self.write_chunk_inner(ds_index, *idx, data)?;
15745        }
15746        Ok(())
15747    }
15748
15749    /// Write multiple fixed-array chunks in a batch, compressing them in
15750    /// parallel when a filter pipeline is set and the `parallel` feature is on.
15751    ///
15752    /// The fixed-array analogue of [`write_chunks_batch`](Self::write_chunks_batch):
15753    /// chunks are addressed by grid coordinates rather than a linear index.
15754    /// `record_fixed_array_chunk` writes already-compressed bytes verbatim, so
15755    /// the parallel compressor is the only place a filter runs. Falls back to
15756    /// per-chunk [`write_chunk_fixed_array`](Self::write_chunk_fixed_array) when
15757    /// unfiltered or when `parallel` is off.
15758    ///
15759    /// The caller holds the dataset's op lock or the writer exclusively.
15760    pub(crate) fn write_chunks_fixed_array_batch_inner(
15761        &self,
15762        ds_index: usize,
15763        chunks: &[(&[u64], &[u8])],
15764    ) -> IoResult<()> {
15765        #[cfg(feature = "parallel")]
15766        {
15767            // Clone the pipeline out under a brief slot guard so the parallel
15768            // compression below runs off the lock.
15769            let pipeline = self.ds(ds_index).lock().filter_pipeline.clone();
15770            if let Some(ref pipeline) = pipeline {
15771                let chunk_data: Vec<&[u8]> = chunks.iter().map(|&(_, d)| d).collect();
15772                // Same single owner as the EA batch: apply_filters_parallel
15773                // propagates a filter error instead of storing raw bytes under a
15774                // filter_mask that claims the pipeline ran. Ok here means every
15775                // chunk compressed fully, so filter_mask = 0 is truthful.
15776                let compressed = filter::apply_filters_parallel(pipeline, &chunk_data)?;
15777                for ((coords, _), compressed_data) in chunks.iter().zip(compressed.iter()) {
15778                    self.record_fixed_array_chunk(ds_index, coords, compressed_data, 0)?;
15779                }
15780                return Ok(());
15781            }
15782        }
15783        // Fallback: sequential (write_chunk_fixed_array_inner compresses per
15784        // chunk).
15785        for (coords, data) in chunks {
15786            self.write_chunk_fixed_array_inner(ds_index, coords, data)?;
15787        }
15788        Ok(())
15789    }
15790
15791    /// Write a pre-filtered chunk verbatim to an EA-indexed dataset, recording
15792    /// the caller-supplied `filter_mask`.
15793    ///
15794    /// The bytes are stored exactly as given (no filter pipeline is run); this
15795    /// is the extensible-array half of the HDF5 "direct chunk write"
15796    /// (`H5Dwrite_chunk`) operation. `filter_mask` is a bitfield: bit *i* set
15797    /// means filter *i* of the pipeline was **not** applied to this chunk and
15798    /// must be skipped on read; pass 0 when the full pipeline was applied
15799    /// upstream.
15800    ///
15801    /// Requires a filtered dataset — only the filtered EA entry carries the
15802    /// size+mask slot. An unfiltered dataset has nowhere to record either.
15803    ///
15804    /// The caller holds the dataset's op lock or the writer exclusively.
15805    pub(crate) fn write_compressed_chunk_inner(
15806        &self,
15807        index: usize,
15808        chunk_idx: u64,
15809        compressed_data: &[u8],
15810        filter_mask: u32,
15811    ) -> IoResult<()> {
15812        if self.ds(index).lock().filter_pipeline.is_none() {
15813            return Err(crate::io::IoError::InvalidState(
15814                "write_compressed_chunk requires a filtered dataset (no slot for \
15815                 a compressed size or filter mask on an unfiltered chunk index)"
15816                    .into(),
15817            ));
15818        }
15819        self.record_ea_chunk(index, chunk_idx, compressed_data, filter_mask)
15820    }
15821
15822    /// Extend the dimensions of a chunked dataset.
15823    pub fn extend_dataset(&self, index: usize, new_dims: &[u64]) -> IoResult<()> {
15824        let ds = self.ds(index);
15825        let _op = ds.op.lock();
15826        self.extend_dataset_inner(index, new_dims)
15827    }
15828
15829    /// [`Self::extend_dataset`] body; the caller holds the dataset's op lock
15830    /// or the writer exclusively.
15831    pub(crate) fn extend_dataset_inner(&self, index: usize, new_dims: &[u64]) -> IoResult<()> {
15832        let ds = self.ds(index);
15833        let mut m = ds.lock();
15834        if !m.is_chunked() {
15835            return Err(crate::io::IoError::InvalidState(
15836                "can only extend chunked datasets".into(),
15837            ));
15838        }
15839        if new_dims.len() != m.dataspace.dims.len() {
15840            return Err(crate::io::IoError::InvalidState(format!(
15841                "extend_dataset rank mismatch: dataset has {} dimensions, got {}",
15842                m.dataspace.dims.len(),
15843                new_dims.len()
15844            )));
15845        }
15846        // The chunk index and append buffers assume the logical size only
15847        // grows; shrinking below already-written data desynchronizes them.
15848        for (d, (&new, &cur)) in new_dims.iter().zip(&m.dataspace.dims).enumerate() {
15849            if new < cur {
15850                return Err(crate::io::IoError::InvalidState(format!(
15851                    "extend_dataset cannot shrink dimension {d} from {cur} to {new}"
15852                )));
15853            }
15854            // An absent maximum shape means the shape is fixed (libhdf5
15855            // defaults maxdims to dims at creation), so any growth exceeds it.
15856            match m.dataspace.max_dims {
15857                Some(ref max) if new > max[d] => {
15858                    return Err(crate::io::IoError::InvalidState(format!(
15859                        "extend_dataset dimension {d} ({new}) exceeds the maximum {}",
15860                        max[d]
15861                    )));
15862                }
15863                None if new > cur => {
15864                    return Err(crate::io::IoError::InvalidState(format!(
15865                        "extend_dataset dimension {d} ({new}) exceeds the maximum {cur}: \
15866                         a dataset without a stored maximum shape is fixed at its extent"
15867                    )));
15868                }
15869                _ => {}
15870            }
15871        }
15872        if m.dataspace.dims != new_dims {
15873            m.dataspace.dims = new_dims.to_vec();
15874            m.extent_dirty = true;
15875        }
15876        Ok(())
15877    }
15878
15879    /// Set the logical extent of a chunked dataset, growing **or shrinking**
15880    /// any dimension (unlike [`extend_dataset`](Self::extend_dataset), which
15881    /// only grows).
15882    ///
15883    /// A shrink prunes the stored chunks the way libhdf5's
15884    /// `H5D__chunk_prune_by_extent` (H5Dchunk.c) does: a chunk entirely
15885    /// beyond the new extent leaves the chunk index and its block is freed
15886    /// for reuse (kept under SWMR, where a live reader may still hold its
15887    /// address — the rule `H5Dearray.c` applies in `idx_remove`), and a
15888    /// chunk the new extent cuts through has its out-of-extent region
15889    /// overwritten with the fill value, so growing the extent back exposes
15890    /// fill values rather than the stale data.
15891    pub fn set_dataset_extent(&self, index: usize, new_dims: &[u64]) -> IoResult<()> {
15892        let ds = self.ds(index);
15893        let _op = ds.op.lock();
15894        let old_dims = {
15895            let m = ds.lock();
15896            if !m.is_chunked() {
15897                return Err(crate::io::IoError::InvalidState(
15898                    "can only set the extent of chunked datasets".into(),
15899                ));
15900            }
15901            if new_dims.len() != m.dataspace.dims.len() {
15902                return Err(crate::io::IoError::InvalidState(format!(
15903                    "set_extent rank mismatch: dataset has {} dimensions, got {}",
15904                    m.dataspace.dims.len(),
15905                    new_dims.len()
15906                )));
15907            }
15908            // A shrink can cut into buffered rows, whose recorded base would
15909            // then point past the extent; refuse rather than reconcile.
15910            if m.append.is_some() {
15911                return Err(crate::io::IoError::InvalidState(
15912                    "set_extent cannot run while the dataset has buffered appends; \
15913                     flush them first"
15914                        .into(),
15915                ));
15916            }
15917            // An absent maximum shape means the shape is fixed (libhdf5
15918            // defaults maxdims to dims at creation), so growth is bounded by
15919            // the extent.
15920            match m.dataspace.max_dims {
15921                Some(ref max) => {
15922                    for (d, (&new, &mx)) in new_dims.iter().zip(max).enumerate() {
15923                        if new > mx {
15924                            return Err(crate::io::IoError::InvalidState(format!(
15925                                "set_extent dimension {d} ({new}) exceeds the maximum {mx}"
15926                            )));
15927                        }
15928                    }
15929                }
15930                None => {
15931                    for (d, (&new, &cur)) in new_dims.iter().zip(&m.dataspace.dims).enumerate() {
15932                        if new > cur {
15933                            return Err(crate::io::IoError::InvalidState(format!(
15934                                "set_extent dimension {d} ({new}) exceeds the maximum {cur}: \
15935                                 a dataset without a stored maximum shape is fixed at its extent"
15936                            )));
15937                        }
15938                    }
15939                }
15940            }
15941            m.dataspace.dims.clone()
15942        };
15943        // A shrink strands chunks; prune them (and refill the straddlers)
15944        // *before* the dims update — chunk addressing uses the
15945        // maximum-extent grid, which the update does not change, and the
15946        // helpers re-lock the slot themselves.
15947        if new_dims.iter().zip(&old_dims).any(|(&n, &o)| n < o) {
15948            self.prune_chunks_beyond(index, new_dims)?;
15949        }
15950        let mut m = ds.lock();
15951        if m.dataspace.dims != new_dims {
15952            m.dataspace.dims = new_dims.to_vec();
15953            m.extent_dirty = true;
15954        }
15955        Ok(())
15956    }
15957
15958    /// Remove and refill the chunks a shrink to `new_dims` strands — the
15959    /// libhdf5 `H5D__chunk_prune_by_extent` behavior. A chunk entirely
15960    /// beyond the new extent leaves the index and its block is freed (kept
15961    /// under SWMR, where a live reader may still hold its address); a chunk
15962    /// the extent cuts through gets its out-of-extent region refilled with
15963    /// the fill value, so a later regrow reads fill, not stale elements.
15964    ///
15965    /// Runs *before* the dims update: the index grid chunks are addressed in
15966    /// comes from the maximum extent, which a shrink never changes, so every
15967    /// stored entry still resolves. The caller holds the dataset's op lock.
15968    fn prune_chunks_beyond(&self, index: usize, new_dims: &[u64]) -> IoResult<()> {
15969        let geo = self.chunk_geometry(index)?;
15970        // A vlen dataset's elements are global-heap IDs: the pruned chunks
15971        // still reference live heap objects, so the walkers read each dead
15972        // chunk's bytes before freeing its block and the heap objects are
15973        // released here — otherwise every shrink strands its strings in the
15974        // file. `release_vlen_references` is a SWMR no-op, so the reads are
15975        // skipped under SWMR too.
15976        let collect_refs = !self.swmr_active && {
15977            let ds = self.ds(index);
15978            let m = ds.lock();
15979            matches!(
15980                m.datatype,
15981                DatatypeMessage::VarLenString { .. } | DatatypeMessage::VarLenSequence { .. }
15982            )
15983        };
15984        let (straddlers, dead_refs) = match geo.kind {
15985            ChunkIndexKind::ExtensibleArray => {
15986                self.prune_ea_chunks(index, &geo, new_dims, collect_refs)?
15987            }
15988            ChunkIndexKind::FixedArray => {
15989                self.prune_fa_chunks(index, &geo, new_dims, collect_refs)?
15990            }
15991            ChunkIndexKind::BtreeV2 => {
15992                self.prune_bt2_chunks(index, &geo, new_dims, collect_refs)?
15993            }
15994            // Removing a chunk from the implicit index is
15995            // `H5D__none_idx_remove`: a no-op, because the chunk's space is
15996            // the dataset's space and stays allocated either way. Only the
15997            // straddlers matter, and they are refilled by the caller.
15998            ChunkIndexKind::Implicit => (self.implicit_straddlers(&geo, new_dims)?, Vec::new()),
15999            // A single-chunk index has no per-chunk remove either — its one
16000            // chunk's address lives in the layout message, not an index
16001            // structure, and stays exactly where it is; a shrink only ever
16002            // straddles that one chunk (`H5D__single_idx_remove` is likewise
16003            // a no-op).
16004            ChunkIndexKind::SingleChunk => (self.implicit_straddlers(&geo, new_dims)?, Vec::new()),
16005            ChunkIndexKind::BtreeV1 => {
16006                self.prune_btree_v1_chunks(index, &geo, new_dims, collect_refs)?
16007            }
16008        };
16009        if !dead_refs.is_empty() {
16010            self.release_vlen_references(&dead_refs)?;
16011        }
16012        // Whole-chunk read-modify-write per straddler: an unfiltered chunk
16013        // rewrites in place, a filtered one re-places through `place_chunk`.
16014        let chunk_bytes = geo.chunk_bytes() as usize;
16015        for coords in straddlers {
16016            let Some(mut data) = self.read_chunk_at_coords(index, &coords)? else {
16017                continue;
16018            };
16019            let fill = self.new_chunk_buffer(index, chunk_bytes);
16020            let replaced = refill_chunk_beyond_extent(
16021                &mut data,
16022                &fill,
16023                &coords,
16024                &geo.chunk_dims,
16025                new_dims,
16026                geo.element_size as usize,
16027            );
16028            // Release before the write-back: a filtered straddler re-places
16029            // its block, and freed heap space must be visible to that
16030            // allocation (free-before-alloc, as everywhere else).
16031            if collect_refs && !replaced.is_empty() {
16032                self.release_vlen_references(&replaced)?;
16033            }
16034            self.write_chunk_at_coords(index, &coords, &data)?;
16035        }
16036        Ok(())
16037    }
16038
16039    /// Extensible-array half of [`prune_chunks_beyond`](Self::prune_chunks_beyond):
16040    /// walk every slot the array has ever set, free and clear the entries of
16041    /// chunks entirely beyond `new_dims`, and return the grid coordinates of
16042    /// the chunks that straddle it, plus — when `collect_refs` — the dead
16043    /// chunks' element bytes so the caller can release their heap objects.
16044    fn prune_ea_chunks(
16045        &self,
16046        index: usize,
16047        geo: &ChunkGeometry,
16048        new_dims: &[u64],
16049        collect_refs: bool,
16050    ) -> IoResult<(Vec<Vec<u64>>, Vec<u8>)> {
16051        let ds = self.ds(index);
16052        // One slot guard for the whole walk, the `record_ea_chunk` pattern:
16053        // `self.handle`/`self.allocator`/`self.ctx` are disjoint fields.
16054        let mut m = ds.lock();
16055        let is_filtered = m.filter_pipeline.is_some();
16056        let pipeline = m.filter_pipeline.clone();
16057        let chunk_bytes = geo.chunk_bytes();
16058        let (ea_geo, max_nelmts_bits, chunk_size_len, max_idx) = {
16059            let c = m.chunked.as_ref().unwrap();
16060            let p = &c.earray_params;
16061            (
16062                EaGeometry::new(
16063                    p.idx_blk_elmts,
16064                    p.data_blk_min_elmts,
16065                    p.sup_blk_min_data_ptrs,
16066                    p.max_nelmts_bits,
16067                    p.max_dblk_page_nelmts_bits,
16068                )?,
16069                p.max_nelmts_bits,
16070                c.chunk_size_len,
16071                c.ea_header.max_idx_set,
16072            )
16073        };
16074
16075        let mut straddlers = Vec::new();
16076        let mut dead_refs = Vec::new();
16077
16078        // The decoded data block the walk is currently inside, written back
16079        // when the walk leaves it (or ends) having cleared an entry.
16080        enum Dblk {
16081            Unfiltered(ExtensibleArrayDataBlock),
16082            Filtered(FilteredDataBlock),
16083        }
16084        let mut cache: Option<(u64, Dblk, bool)> = None;
16085        let flush = |cache: &mut Option<(u64, Dblk, bool)>| -> IoResult<()> {
16086            if let Some((addr, blk, dirty)) = cache.take() {
16087                if dirty {
16088                    let enc = match &blk {
16089                        Dblk::Unfiltered(d) => d.encode(&self.ctx, max_nelmts_bits),
16090                        Dblk::Filtered(d) => d.encode(&self.ctx, max_nelmts_bits, chunk_size_len),
16091                    };
16092                    self.handle.write_at(addr, &enc)?;
16093                }
16094            }
16095            Ok(())
16096        };
16097        // Consecutive slots resolve through the same super block, so keep
16098        // the last decode. Super blocks are only read here — clearing a
16099        // data-block element never moves the block — so it never dirties.
16100        let mut sblk_cache: Option<(usize, ExtensibleArraySuperBlock)> = None;
16101
16102        let mut slot = 0u64;
16103        while slot < max_idx {
16104            let coords = crate::io::chunk_grid::coords_of(
16105                &geo.dims,
16106                geo.max_dims.as_deref(),
16107                &geo.chunk_dims,
16108                slot,
16109            )?;
16110            if !chunk_outside_extent(&coords, &geo.chunk_dims, new_dims) {
16111                if chunk_straddles_extent(&coords, &geo.chunk_dims, new_dims) {
16112                    straddlers.push(coords);
16113                }
16114                slot += 1;
16115                continue;
16116            }
16117            match ea_geo.locate(slot)? {
16118                EaLoc::Index { elem } => {
16119                    let c = m.chunked.as_mut().unwrap();
16120                    if is_filtered {
16121                        let fiblk = c.filt_iblk.as_mut().unwrap();
16122                        let e = fiblk.elements[elem];
16123                        if e.addr != UNDEF_ADDR {
16124                            if collect_refs {
16125                                if let Some(bytes) = self.read_chunk_block(
16126                                    pipeline.as_ref(),
16127                                    e.addr,
16128                                    e.nbytes,
16129                                    e.filter_mask,
16130                                )? {
16131                                    dead_refs.extend_from_slice(&bytes);
16132                                }
16133                            }
16134                            if !self.swmr_active {
16135                                self.allocator
16136                                    .free(e.addr, e.nbytes, FreeSpaceClass::RawData);
16137                            }
16138                            fiblk.elements[elem] = FilteredChunkEntry {
16139                                addr: UNDEF_ADDR,
16140                                nbytes: 0,
16141                                filter_mask: 0,
16142                            };
16143                        }
16144                    } else {
16145                        let a = c.ea_iblk.elements[elem];
16146                        if a != UNDEF_ADDR {
16147                            if collect_refs {
16148                                if let Some(bytes) =
16149                                    self.read_chunk_block(pipeline.as_ref(), a, chunk_bytes, 0)?
16150                                {
16151                                    dead_refs.extend_from_slice(&bytes);
16152                                }
16153                            }
16154                            if !self.swmr_active {
16155                                self.allocator.free(a, chunk_bytes, FreeSpaceClass::RawData);
16156                            }
16157                            c.ea_iblk.elements[elem] = UNDEF_ADDR;
16158                        }
16159                    }
16160                    slot += 1;
16161                }
16162                EaLoc::Dblk(l) => {
16163                    if l.paged {
16164                        return Err(crate::io::IoError::InvalidState(format!(
16165                            "chunk index {slot} lives in a paged extensible-array \
16166                             data block, which is not yet supported"
16167                        )));
16168                    }
16169                    let dblk_start = slot - l.offset_in_dblk;
16170                    let dblk_end = dblk_start + l.dblk_nelmts;
16171                    // Resolve the data block's address; an undefined super or
16172                    // data block means nothing in its whole element range was
16173                    // ever written, so the walk skips the range.
16174                    let dblk_addr = {
16175                        let c = m.chunked.as_ref().unwrap();
16176                        match l.path {
16177                            EaDblkPath::Direct { idx } => {
16178                                if is_filtered {
16179                                    c.filt_iblk.as_ref().unwrap().dblk_addrs[idx]
16180                                } else {
16181                                    c.ea_iblk.dblk_addrs[idx]
16182                                }
16183                            }
16184                            EaDblkPath::ViaSblk {
16185                                sblk_off,
16186                                local_dblk,
16187                                ndblks_in_sblk,
16188                                ..
16189                            } => {
16190                                let sblk_addr = if is_filtered {
16191                                    c.filt_iblk.as_ref().unwrap().sblk_addrs[sblk_off]
16192                                } else {
16193                                    c.ea_iblk.sblk_addrs[sblk_off]
16194                                };
16195                                if sblk_addr == UNDEF_ADDR {
16196                                    UNDEF_ADDR
16197                                } else {
16198                                    if sblk_cache.as_ref().map(|&(o, _)| o) != Some(sblk_off) {
16199                                        let buf = self.handle.read_at_most(sblk_addr, 65536)?;
16200                                        let sb = ExtensibleArraySuperBlock::decode(
16201                                            &buf,
16202                                            &self.ctx,
16203                                            max_nelmts_bits,
16204                                            ndblks_in_sblk,
16205                                            0,
16206                                        )?;
16207                                        sblk_cache = Some((sblk_off, sb));
16208                                    }
16209                                    sblk_cache.as_ref().unwrap().1.dblk_addrs[local_dblk]
16210                                }
16211                            }
16212                        }
16213                    };
16214                    if dblk_addr == UNDEF_ADDR {
16215                        slot = dblk_end;
16216                        continue;
16217                    }
16218                    if cache.as_ref().map(|&(a, _, _)| a) != Some(dblk_addr) {
16219                        flush(&mut cache)?;
16220                        let buf = self.handle.read_at_most(dblk_addr, 65536)?;
16221                        let blk = if is_filtered {
16222                            Dblk::Filtered(FilteredDataBlock::decode(
16223                                &buf,
16224                                &self.ctx,
16225                                max_nelmts_bits,
16226                                l.dblk_nelmts as usize,
16227                                chunk_size_len,
16228                            )?)
16229                        } else {
16230                            Dblk::Unfiltered(ExtensibleArrayDataBlock::decode(
16231                                &buf,
16232                                &self.ctx,
16233                                max_nelmts_bits,
16234                                l.dblk_nelmts as usize,
16235                            )?)
16236                        };
16237                        cache = Some((dblk_addr, blk, false));
16238                    }
16239                    let (_, blk, dirty) = cache.as_mut().unwrap();
16240                    match blk {
16241                        Dblk::Filtered(d) => {
16242                            let e = d.elements[l.offset_in_dblk as usize];
16243                            if e.addr != UNDEF_ADDR {
16244                                if collect_refs {
16245                                    if let Some(bytes) = self.read_chunk_block(
16246                                        pipeline.as_ref(),
16247                                        e.addr,
16248                                        e.nbytes,
16249                                        e.filter_mask,
16250                                    )? {
16251                                        dead_refs.extend_from_slice(&bytes);
16252                                    }
16253                                }
16254                                if !self.swmr_active {
16255                                    self.allocator
16256                                        .free(e.addr, e.nbytes, FreeSpaceClass::RawData);
16257                                }
16258                                d.elements[l.offset_in_dblk as usize] = FilteredChunkEntry {
16259                                    addr: UNDEF_ADDR,
16260                                    nbytes: 0,
16261                                    filter_mask: 0,
16262                                };
16263                                *dirty = true;
16264                            }
16265                        }
16266                        Dblk::Unfiltered(d) => {
16267                            let a = d.elements[l.offset_in_dblk as usize];
16268                            if a != UNDEF_ADDR {
16269                                if collect_refs {
16270                                    if let Some(bytes) =
16271                                        self.read_chunk_block(pipeline.as_ref(), a, chunk_bytes, 0)?
16272                                    {
16273                                        dead_refs.extend_from_slice(&bytes);
16274                                    }
16275                                }
16276                                if !self.swmr_active {
16277                                    self.allocator.free(a, chunk_bytes, FreeSpaceClass::RawData);
16278                                }
16279                                d.elements[l.offset_in_dblk as usize] = UNDEF_ADDR;
16280                                *dirty = true;
16281                            }
16282                        }
16283                    }
16284                    slot += 1;
16285                }
16286            }
16287        }
16288        flush(&mut cache)?;
16289        Ok((straddlers, dead_refs))
16290    }
16291
16292    /// Fixed-array half of [`prune_chunks_beyond`](Self::prune_chunks_beyond):
16293    /// the whole element array is in memory and flushed at close, so
16294    /// clearing an entry is pure bookkeeping.
16295    fn prune_fa_chunks(
16296        &self,
16297        index: usize,
16298        geo: &ChunkGeometry,
16299        new_dims: &[u64],
16300        collect_refs: bool,
16301    ) -> IoResult<(Vec<Vec<u64>>, Vec<u8>)> {
16302        let ds = self.ds(index);
16303        let mut m = ds.lock();
16304        let is_filtered = m.filter_pipeline.is_some();
16305        let pipeline = m.filter_pipeline.clone();
16306        let chunk_bytes = geo.chunk_bytes();
16307        let mut straddlers = Vec::new();
16308        let mut dead_refs = Vec::new();
16309        let fa = m.fixed_array.as_mut().unwrap();
16310        let nslots = if is_filtered {
16311            fa.fa_dblk.filtered_elements.len()
16312        } else {
16313            fa.fa_dblk.elements.len()
16314        };
16315        for lidx in 0..nslots {
16316            let (addr, stored, mask) = if is_filtered {
16317                let e = &fa.fa_dblk.filtered_elements[lidx];
16318                (e.address, e.chunk_size, e.filter_mask)
16319            } else {
16320                (fa.fa_dblk.elements[lidx], chunk_bytes, 0)
16321            };
16322            if addr == UNDEF_ADDR {
16323                continue;
16324            }
16325            let coords = crate::io::chunk_grid::coords_of(
16326                &geo.dims,
16327                geo.max_dims.as_deref(),
16328                &geo.chunk_dims,
16329                lidx as u64,
16330            )?;
16331            if chunk_outside_extent(&coords, &geo.chunk_dims, new_dims) {
16332                if collect_refs {
16333                    if let Some(bytes) =
16334                        self.read_chunk_block(pipeline.as_ref(), addr, stored, mask)?
16335                    {
16336                        dead_refs.extend_from_slice(&bytes);
16337                    }
16338                }
16339                if !self.swmr_active {
16340                    self.allocator.free(addr, stored, FreeSpaceClass::RawData);
16341                }
16342                if is_filtered {
16343                    fa.fa_dblk.filtered_elements[lidx] = FixedArrayFilteredChunkElement {
16344                        address: UNDEF_ADDR,
16345                        chunk_size: 0,
16346                        filter_mask: 0,
16347                    };
16348                } else {
16349                    fa.fa_dblk.elements[lidx] = UNDEF_ADDR;
16350                }
16351            } else if chunk_straddles_extent(&coords, &geo.chunk_dims, new_dims) {
16352                straddlers.push(coords);
16353            }
16354        }
16355        Ok((straddlers, dead_refs))
16356    }
16357
16358    /// Implicit half of [`prune_chunks_beyond`](Self::prune_chunks_beyond):
16359    /// the grid coordinates of the chunks a shrink to `new_dims` cuts
16360    /// through. Nothing is freed or cleared — this index has no per-chunk
16361    /// state to clear and no per-chunk block to free — so the chunks wholly
16362    /// beyond the extent keep their bytes, exactly as `H5D__none_idx_remove`
16363    /// leaves them. That also means their elements stay reachable, so a
16364    /// variable-length dataset's heap objects must *not* be released here.
16365    fn implicit_straddlers(
16366        &self,
16367        geo: &ChunkGeometry,
16368        new_dims: &[u64],
16369    ) -> IoResult<Vec<Vec<u64>>> {
16370        let mut nchunks: u64 = 1;
16371        for g in
16372            crate::io::chunk_grid::index_grid(&geo.dims, geo.max_dims.as_deref(), &geo.chunk_dims)?
16373        {
16374            nchunks = nchunks.checked_mul(g).ok_or_else(|| {
16375                crate::io::IoError::InvalidState("chunk count overflows u64".into())
16376            })?;
16377        }
16378        let mut straddlers = Vec::new();
16379        for lidx in 0..nchunks {
16380            let coords = crate::io::chunk_grid::coords_of(
16381                &geo.dims,
16382                geo.max_dims.as_deref(),
16383                &geo.chunk_dims,
16384                lidx,
16385            )?;
16386            if chunk_straddles_extent(&coords, &geo.chunk_dims, new_dims) {
16387                straddlers.push(coords);
16388            }
16389        }
16390        Ok(straddlers)
16391    }
16392
16393    /// V2-B-tree half of [`prune_chunks_beyond`](Self::prune_chunks_beyond):
16394    /// drop the records of chunks beyond the extent — the next flush
16395    /// re-serializes the smaller tree over the node pool and releases the
16396    /// surplus node blocks.
16397    fn prune_bt2_chunks(
16398        &self,
16399        index: usize,
16400        geo: &ChunkGeometry,
16401        new_dims: &[u64],
16402        collect_refs: bool,
16403    ) -> IoResult<(Vec<Vec<u64>>, Vec<u8>)> {
16404        let ds = self.ds(index);
16405        let mut m = ds.lock();
16406        let pipeline = m.filter_pipeline.clone();
16407        let chunk_bytes = geo.chunk_bytes();
16408        let swmr = self.swmr_active;
16409        let mut straddlers = Vec::new();
16410        let mut dead_refs = Vec::new();
16411        let bt2 = m.btree_v2.as_mut().unwrap();
16412        if bt2.index.filtered {
16413            let records = std::mem::take(&mut bt2.index.filtered_records);
16414            let mut kept = Vec::with_capacity(records.len());
16415            for r in records {
16416                if chunk_outside_extent(&r.scaled_offsets, &geo.chunk_dims, new_dims) {
16417                    if collect_refs {
16418                        if let Some(bytes) = self.read_chunk_block(
16419                            pipeline.as_ref(),
16420                            r.chunk_address,
16421                            r.chunk_size,
16422                            r.filter_mask,
16423                        )? {
16424                            dead_refs.extend_from_slice(&bytes);
16425                        }
16426                    }
16427                    if !swmr {
16428                        self.allocator
16429                            .free(r.chunk_address, r.chunk_size, FreeSpaceClass::RawData);
16430                    }
16431                } else {
16432                    if chunk_straddles_extent(&r.scaled_offsets, &geo.chunk_dims, new_dims) {
16433                        straddlers.push(r.scaled_offsets.clone());
16434                    }
16435                    kept.push(r);
16436                }
16437            }
16438            bt2.index.filtered_records = kept;
16439        } else {
16440            let records = std::mem::take(&mut bt2.index.records);
16441            let mut kept = Vec::with_capacity(records.len());
16442            for r in records {
16443                if chunk_outside_extent(&r.scaled_offsets, &geo.chunk_dims, new_dims) {
16444                    if collect_refs {
16445                        if let Some(bytes) = self.read_chunk_block(
16446                            pipeline.as_ref(),
16447                            r.chunk_address,
16448                            chunk_bytes,
16449                            0,
16450                        )? {
16451                            dead_refs.extend_from_slice(&bytes);
16452                        }
16453                    }
16454                    if !swmr {
16455                        self.allocator
16456                            .free(r.chunk_address, chunk_bytes, FreeSpaceClass::RawData);
16457                    }
16458                } else {
16459                    if chunk_straddles_extent(&r.scaled_offsets, &geo.chunk_dims, new_dims) {
16460                        straddlers.push(r.scaled_offsets.clone());
16461                    }
16462                    kept.push(r);
16463                }
16464            }
16465            bt2.index.records = kept;
16466        }
16467        Ok((straddlers, dead_refs))
16468    }
16469
16470    /// Version-1-B-tree half of [`prune_chunks_beyond`](Self::prune_chunks_beyond):
16471    /// drop the records of chunks beyond the extent — the next flush
16472    /// re-serializes the smaller tree over the node pool and releases the
16473    /// surplus node blocks.
16474    fn prune_btree_v1_chunks(
16475        &self,
16476        index: usize,
16477        geo: &ChunkGeometry,
16478        new_dims: &[u64],
16479        collect_refs: bool,
16480    ) -> IoResult<(Vec<Vec<u64>>, Vec<u8>)> {
16481        let ds = self.ds(index);
16482        let mut m = ds.lock();
16483        let pipeline = m.filter_pipeline.clone();
16484        let swmr = self.swmr_active;
16485        let mut straddlers = Vec::new();
16486        let mut dead_refs = Vec::new();
16487        let bt1 = m.btree_v1.as_mut().unwrap();
16488        let records = std::mem::take(&mut bt1.records);
16489        let mut kept = Vec::with_capacity(records.len());
16490        for r in records {
16491            if chunk_outside_extent(&r.scaled, &geo.chunk_dims, new_dims) {
16492                if collect_refs {
16493                    if let Some(bytes) = self.read_chunk_block(
16494                        pipeline.as_ref(),
16495                        r.address,
16496                        r.nbytes as u64,
16497                        r.filter_mask,
16498                    )? {
16499                        dead_refs.extend_from_slice(&bytes);
16500                    }
16501                }
16502                if !swmr {
16503                    self.allocator
16504                        .free(r.address, r.nbytes as u64, FreeSpaceClass::RawData);
16505                }
16506            } else {
16507                if chunk_straddles_extent(&r.scaled, &geo.chunk_dims, new_dims) {
16508                    straddlers.push(r.scaled.clone());
16509                }
16510                kept.push(r);
16511            }
16512        }
16513        m.btree_v1.as_mut().unwrap().records = kept;
16514        Ok((straddlers, dead_refs))
16515    }
16516
16517    /// Flush a chunked dataset's index structures to disk (durable).
16518    ///
16519    /// Writes the index blocks and issues an `fdatasync` so the data is
16520    /// durable — the guarantee SWMR readers and standalone callers rely on.
16521    pub fn flush_dataset(&self, index: usize) -> IoResult<()> {
16522        let ds = self.ds(index);
16523        let _op = ds.op.lock();
16524        self.flush_dataset_synced(index, true)
16525    }
16526
16527    /// Flush a chunked dataset's index structures, syncing only if `sync`.
16528    ///
16529    /// `finalize` threads its own durability choice here so that a
16530    /// [`close_no_sync`](Self::close_no_sync) skips this per-dataset
16531    /// `sync_data` too — otherwise gating only the final `sync_all` would
16532    /// leave one `fdatasync` per indexed dataset and defeat the fast close.
16533    fn flush_dataset_synced(&self, index: usize, sync: bool) -> IoResult<()> {
16534        // Hold one slot guard for the whole method; `self.handle`/`self.ctx`/
16535        // `self.allocator` below touch disjoint fields.
16536        let ds = self.ds(index);
16537        let mut m = ds.lock();
16538
16539        // EA-indexed dataset
16540        if let Some(ref chunked) = m.chunked {
16541            if let Some(ref fiblk) = chunked.filt_iblk {
16542                // Filtered EA
16543                let iblk_encoded = fiblk.encode(&self.ctx, chunked.chunk_size_len);
16544                self.handle.write_at(chunked.ea_iblk_addr, &iblk_encoded)?;
16545            } else {
16546                // Unfiltered EA
16547                let iblk_encoded = chunked.ea_iblk.encode(&self.ctx);
16548                self.handle.write_at(chunked.ea_iblk_addr, &iblk_encoded)?;
16549            }
16550            let hdr_encoded = chunked.ea_header.encode(&self.ctx);
16551            self.handle.write_at(chunked.ea_header_addr, &hdr_encoded)?;
16552            if sync {
16553                self.handle.sync_data()?;
16554            }
16555            return Ok(());
16556        }
16557
16558        // Fixed-array-indexed dataset
16559        if let Some(ref fa) = m.fixed_array {
16560            let dblk_encoded = encode_fixed_array_dblk(&self.ctx, &fa.fa_header, &fa.fa_dblk);
16561            self.handle.write_at(fa.fa_dblk_addr, &dblk_encoded)?;
16562            let hdr_encoded = fa.fa_header.encode(&self.ctx);
16563            self.handle.write_at(fa.fa_header_addr, &hdr_encoded)?;
16564            if sync {
16565                self.handle.sync_data()?;
16566            }
16567            return Ok(());
16568        }
16569
16570        // BT2-indexed dataset
16571        if let Some(ref bt2) = m.btree_v2 {
16572            // Bulk-load the index into fixed-size nodes and lay them over the
16573            // dataset's block pool. Because every node is the same size, the
16574            // blocks already on disk are reused in place and only the shortfall
16575            // is allocated — the pool is the single owner of these addresses,
16576            // so no flush leaves a block behind. The addresses a reader already
16577            // holds stay valid, which is also what SWMR needs.
16578            let tree = bt2.index.build_tree(&self.ctx);
16579            let mut node_addrs = bt2.node_addrs.clone();
16580            while node_addrs.len() < tree.nodes.len() {
16581                node_addrs.push(
16582                    self.allocator
16583                        .allocate(tree.node_size as u64, FreeSpaceClass::Metadata),
16584                );
16585            }
16586            // A tree with fewer nodes than last flush releases the surplus
16587            // rather than leaving it recorded and unreachable, so the pool is
16588            // exactly one block per node whichever way the count moved. Under
16589            // SWMR a reader may still hold a header naming those blocks, so
16590            // keep them out of the free list — the same rule `place_chunk`
16591            // applies to a relocated chunk.
16592            for addr in node_addrs.split_off(tree.nodes.len()) {
16593                if !self.swmr_active {
16594                    self.allocator
16595                        .free(addr, tree.node_size as u64, FreeSpaceClass::Metadata);
16596                }
16597            }
16598
16599            for (image, &addr) in tree.encode(&self.ctx, &node_addrs).iter().zip(&node_addrs) {
16600                self.handle.write_at(addr, image)?;
16601            }
16602
16603            // The root is the last node the bulk load emits.
16604            let root_addr = match tree.nodes.len() {
16605                0 => UNDEF_ADDR,
16606                n => node_addrs[n - 1],
16607            };
16608            let hdr_encoded = tree.header(root_addr).encode(&self.ctx);
16609            self.handle.write_at(bt2.bt2_header_addr, &hdr_encoded)?;
16610
16611            m.btree_v2.as_mut().unwrap().node_addrs = node_addrs;
16612
16613            if sync {
16614                self.handle.sync_data()?;
16615            }
16616            return Ok(());
16617        }
16618
16619        // Version-1-B-tree-indexed dataset
16620        if let Some(ref bt1) = m.btree_v1 {
16621            // Bulk-loaded over the same block pool the v2 B-tree above uses,
16622            // and for the same reason: every node of a v1 tree is the width
16623            // its "K" value gives, so a block stays usable however the tree
16624            // reshapes, and only the shortfall is ever allocated.
16625            let element_size = m.datatype.element_size() as u64;
16626            let tree = bt1.build_tree(element_size, self.ctx.sizeof_addr as usize);
16627            let node_size = tree.node_size() as u64;
16628            let mut node_addrs = bt1.node_addrs.clone();
16629            while node_addrs.len() < tree.node_count() {
16630                node_addrs.push(self.allocator.allocate(node_size, FreeSpaceClass::Metadata));
16631            }
16632            // A tree with fewer nodes than last flush releases the surplus
16633            // straight away, where the v2 B-tree has to keep it out of the
16634            // free list for a live SWMR reader: this index lives only in a
16635            // classic file, which `start_swmr` refuses outright (and upstream
16636            // says the same in `H5D_COPS_BTREE`).
16637            for addr in node_addrs.split_off(tree.node_count()) {
16638                self.allocator
16639                    .free(addr, node_size, FreeSpaceClass::Metadata);
16640            }
16641            for (image, &addr) in tree.encode(&node_addrs)?.iter().zip(&node_addrs) {
16642                self.handle.write_at(addr, image)?;
16643            }
16644            // The root is the last node the bulk load emits, and is undefined
16645            // while the dataset has no chunks — what the version-3 data
16646            // layout message then carries, exactly as libhdf5 leaves it.
16647            let root_addr = tree.root_address(&node_addrs);
16648            let bt1 = m.btree_v1.as_mut().unwrap();
16649            bt1.node_addrs = node_addrs;
16650            bt1.root_addr = root_addr;
16651
16652            if sync {
16653                self.handle.sync_data()?;
16654            }
16655            return Ok(());
16656        }
16657
16658        Ok(())
16659    }
16660
16661    /// Finalize and close the file.
16662    ///
16663    /// Writes the dataset object headers, root group object header, and
16664    /// superblock. After this call the file is a valid HDF5 file.
16665    pub fn close(mut self) -> IoResult<()> {
16666        self.close_in_place()
16667    }
16668
16669    /// [`close`](Self::close) for a holder that cannot give the writer up by
16670    /// value because it has a `Drop` of its own ([`SwmrWriter`]): the same
16671    /// one-shot commit, after which this writer's `Drop` is a no-op.
16672    ///
16673    /// [`SwmrWriter`]: crate::io::swmr::SwmrWriter
16674    pub(crate) fn close_in_place(&mut self) -> IoResult<()> {
16675        // Mark closed BEFORE finalizing: finalize writes external truth
16676        // (object headers + superblock) and must run exactly once. If we
16677        // finalized first and it failed, the `?` would return with `closed`
16678        // still false, and dropping `self` would re-run `finalize` a second
16679        // time over a half-written file (and print the "call close()" notice
16680        // the caller already heeded). Committing to the close path first makes
16681        // `Drop` (the only other finalize site) a no-op regardless of outcome,
16682        // so the error is reported exactly once via this `Result`.
16683        self.closed = true;
16684        self.finalize(true)
16685    }
16686
16687    /// Finalize and close the file without a final `fsync`.
16688    ///
16689    /// Identical to [`close`](Self::close) — the same object headers and
16690    /// superblock are written, so on return the file is a complete, valid HDF5
16691    /// file readable by any process — except that the trailing `sync_all`
16692    /// (fsync) is skipped. The bytes are handed to the OS but are not
16693    /// guaranteed durable against power loss or an OS crash until the OS
16694    /// flushes its page cache; a normal process exit or a same-machine reader
16695    /// sees the full file regardless.
16696    ///
16697    /// This trades durability for speed: `sync_all` typically dominates close
16698    /// latency, so bulk writers that do not need crash durability (the file can
16699    /// be regenerated) can use this to avoid that cost. Use [`close`](Self::close)
16700    /// when durability matters. `Drop` always finalizes durably, so a writer
16701    /// finalized this way must reach `close_no_sync` explicitly.
16702    pub fn close_no_sync(mut self) -> IoResult<()> {
16703        // Same close-once discipline as `close`: commit to the close path
16704        // before finalizing so `Drop` cannot re-run `finalize` on failure.
16705        self.closed = true;
16706        self.finalize(false)
16707    }
16708
16709    /// Provide mutable access to the underlying file handle.
16710    pub fn handle(&mut self) -> &mut FileHandle {
16711        &mut self.handle
16712    }
16713
16714    /// The superblock version this file will be written with.
16715    ///
16716    /// `H5F__super_init` takes the oldest version that can describe the file
16717    /// and raises it to the one the file's library-version low bound implies:
16718    /// `super_vers = MAX(super_vers, HDF5_superblock_ver_bounds[low_bound])`,
16719    /// with the bounds table reading 0, 2, 3, 3, 3, 3, 3 for EARLIEST, V18,
16720    /// V110, V112, V114, V200, LATEST (H5Fsuper.c:68, :1128-1154). A file
16721    /// created at `H5F_LIBVER_EARLIEST` takes that bound's entry directly
16722    /// ([`SuperblockVersion::Chosen`], and the classic branch below) — version
16723    /// 0, or version 2 when the file carries shared messages, whose master
16724    /// table needs the superblock extension only a version-2 superblock has
16725    /// (H5Fsuper.c:1135). For every other file the bound is read back from
16726    /// what this crate writes:
16727    ///
16728    /// * The floor is `H5F_LIBVER_V18`, hence version 2. Every group such a
16729    ///   file holds is a link-message group, which libhdf5 only writes at a
16730    ///   low bound of V18 or newer (`use_at_least_v18`, H5Gobj.c:179), and
16731    ///   every object header in it is version 2, which `H5O_obj_ver_bounds`
16732    ///   likewise puts at V18 (H5Oint.c:125). A version-0 superblock over
16733    ///   this content would claim a file libhdf5 1.6 can read, and no libhdf5
16734    ///   writes that combination.
16735    /// * A chunked dataset — extensible array, fixed array or version-2
16736    ///   B-tree, all reached through a version-4 or -5 data layout message —
16737    ///   reads back as V110 (`H5O_layout_ver_bounds`, H5Dlayout.c:44), hence
16738    ///   version 3.
16739    /// * SWMR writes version 3 outright (H5Fsuper.c:1129).
16740    ///
16741    /// A file whose caller *named* a bound skips the read-back and takes that
16742    /// bound's row directly, so `V18` stays at version 2 however its chunked
16743    /// datasets are indexed — which is what libhdf5 does, the layout version
16744    /// being no input to `H5F__super_init` at all.
16745    ///
16746    /// None of that applies to a reopened file. `H5F__super_read` validates
16747    /// the version it finds and never recomputes one, so the version written
16748    /// back is the version read, whatever this session appends — see
16749    /// [`SuperblockVersion`].
16750    fn superblock_version_for(&self, flags: u8) -> u8 {
16751        let chosen = match self.superblock_version {
16752            SuperblockVersion::Existing(version) => return version,
16753            SuperblockVersion::Chosen(version) => version,
16754        };
16755        if self.is_legacy() {
16756            // A classic file keeps the version it was created at — 0, or 2
16757            // when its shared messages needed the extension. Nothing a session
16758            // can add reaches past that: its objects get symbol-table links,
16759            // its chunked datasets the version-1 B-tree behind a version-3
16760            // layout message, and the two features that would raise the bound
16761            // — SWMR and the 2.0 format — are refused where the caller asks
16762            // for them.
16763            return chosen;
16764        }
16765        let mut version = chosen
16766            .max(SUPERBLOCK_V2)
16767            .max(self.effective_libver().superblock_version());
16768        if self.swmr_active || flags & FLAG_SWMR_WRITE != 0 {
16769            version = version.max(SUPERBLOCK_V3);
16770        }
16771        version
16772    }
16773
16774    /// The low bound a modern file this writer *created* is effectively
16775    /// written at: the one the caller named, or — with none named — the one
16776    /// its content reads back as. A reopened file never reaches here; its
16777    /// superblock version is not derived from its content at all.
16778    ///
16779    /// The read-back is what `superblock_version_for` needs and the field
16780    /// alone cannot give: this crate's default file names no bound, and the
16781    /// generation it writes is not one bound but two rows (see the `libver`
16782    /// field). The floor is `V18`, the oldest bound under which libhdf5 writes
16783    /// link-message groups (`use_at_least_v18`, H5Gobj.c:179) and version-2
16784    /// object headers (`H5O_obj_ver_bounds`, H5Oint.c:125), which is all such
16785    /// a file holds; a v1.10 chunk index in it raises that to `V110`, the
16786    /// oldest bound whose `H5O_layout_ver_bounds` row reaches the version-4
16787    /// layout message that index is written behind.
16788    fn effective_libver(&self) -> LibverBound {
16789        self.libver.unwrap_or_else(|| {
16790            if self.has_v110_chunk_index() {
16791                LibverBound::V110
16792            } else {
16793                LibverBound::V18
16794            }
16795        })
16796    }
16797
16798    /// Whether any dataset still in the file is indexed by a v1.10 chunk
16799    /// index — the markers `build_dataset_header` turns into a version-4/5
16800    /// data layout message, and nothing else it can emit reaches that
16801    /// version.
16802    ///
16803    /// Not "is any dataset chunked": the version-1 B-tree is a chunk index
16804    /// that encodes as a *version-3* layout message, the version
16805    /// `H5O_layout_ver_bounds` gives the earliest bound, so a dataset using
16806    /// it asks nothing of the superblock.
16807    fn has_v110_chunk_index(&self) -> bool {
16808        self.dataset_refs().iter().any(|d| {
16809            let m = d.lock();
16810            !m.deleted
16811                && m.chunk_index_kind()
16812                    .is_some_and(|k| k != ChunkIndexKind::BtreeV1)
16813        })
16814    }
16815
16816    /// Write the superblock at offset 0 with the given flags.
16817    ///
16818    /// Requires that the root group has already been written (via `finalize`
16819    /// or `finalize_for_swmr`).
16820    pub fn write_superblock(&mut self, flags: u8) -> IoResult<()> {
16821        let root_addr = self
16822            .root_group_addr
16823            .ok_or_else(|| crate::io::IoError::InvalidState("root group not yet written".into()))?;
16824        // The userblock this file was opened with. `H5F__super_read` prefers
16825        // the located address over this field, but `H5Pget_userblock` reports
16826        // it, so a rewrite that zeroed it would hide the block from every
16827        // reader that asks for its size.
16828        let base = self.handle.base();
16829        // The end of file is the one address in the superblock measured from
16830        // the start of the *file* rather than from the base: `H5F__super_read`
16831        // sets the EOA to `stored_eof - base_addr` (H5Fsuper.c:635) and calls
16832        // the file truncated when `eof + base_addr < stored_eof` (:573). The
16833        // allocator counts in the based space, so the userblock is added back.
16834        let eof = self.allocator.eof() + base;
16835        let version = self.superblock_version_for(flags);
16836        // Which of the two images is written follows the version, not the
16837        // generation: a classic file carrying shared messages is a version-2
16838        // superblock over version-1 messages and symbol-table groups
16839        // (H5Fsuper.c:1135), and only the version-2/3 image has the extension
16840        // address that table is reached through. Below version 2 the file is
16841        // always a classic one — the other branch floors at 2.
16842        if let Some(legacy) = self.legacy.as_deref().filter(|_| version < SUPERBLOCK_V2) {
16843            // Re-emitted, not rebuilt: the "K" ranks, the userblock size and
16844            // the driver info address are recorded nowhere else in the file,
16845            // and every node width in it is derived from the ranks. Only the
16846            // three things this session can have changed are recomputed.
16847            let root_stab = self
16848                .symbol_tables
16849                .written
16850                .lock()
16851                .get(&LinkScope::Root)
16852                .copied();
16853            let mut sb = legacy.superblock.clone();
16854            sb.version = version;
16855            sb.file_consistency_flags = flags as u32;
16856            sb.end_of_file_address = eof;
16857            sb.root_symbol_table_entry.obj_header_addr = root_addr;
16858            // `H5G__stab_valid` (H5Groot.c) reads this pair back and compares
16859            // it against the root header's Symbol Table message, repairing the
16860            // superblock when they disagree. Writing the pair that message now
16861            // names is what keeps the file from needing that repair. A root
16862            // that keeps its links in messages has no such pair and no entry
16863            // in `written`, and gets `H5G_NOTHING_CACHED` — what libhdf5
16864            // writes for the same root.
16865            sb.root_symbol_table_entry.cache = match root_stab {
16866                Some(s) => SymbolTableCache::SymbolTable {
16867                    btree_addr: s.btree_addr,
16868                    heap_addr: s.heap_addr,
16869                },
16870                None => SymbolTableCache::Nothing,
16871            };
16872            self.handle.write_at(0, &sb.encode())?;
16873            return Ok(());
16874        }
16875        let sb = SuperblockV2V3 {
16876            version,
16877            sizeof_offsets: self.ctx.sizeof_addr,
16878            sizeof_lengths: self.ctx.sizeof_size,
16879            file_consistency_flags: flags,
16880            base_address: base,
16881            // Whatever `write_superblock_extension` put there, which is the
16882            // only place an extension is written.
16883            superblock_extension_address: self.extension.addr.lock().unwrap_or(UNDEF_ADDR),
16884            end_of_file_address: eof,
16885            root_group_object_header_address: root_addr,
16886        };
16887        self.handle.write_at(0, &sb.encode())?;
16888        Ok(())
16889    }
16890
16891    /// Re-write a dataset's object header in place (SWMR update).
16892    ///
16893    /// The header must have been written by `finalize_for_swmr`, and goes
16894    /// back over the same blocks: chunk 0 held to its block and the
16895    /// continuation chunk, when it has one, to its own. Only the dataspace
16896    /// dimensions are meant to change — attributes are the published set —
16897    /// and a header that no longer fits is refused rather than moved, since
16898    /// a reader holds its address.
16899    pub fn write_dataset_header_inplace(&mut self, index: usize) -> IoResult<()> {
16900        // Scope the slot guard: `build_dataset_header` re-locks the same slot.
16901        let placement = {
16902            let ds = self.ds(index);
16903            let m = ds.lock();
16904            HeaderPlacement::over(&m.obj_header_blocks).ok_or_else(|| {
16905                crate::io::IoError::InvalidState("dataset header not yet written".into())
16906            })?
16907        };
16908
16909        let published = self
16910            .swmr_published_attrs
16911            .lock()
16912            .get(&AttrScope::Dataset(index))
16913            .cloned()
16914            .ok_or_else(|| {
16915                crate::io::IoError::InvalidState("dataset header not yet published".into())
16916            })?;
16917        let header = self.build_dataset_header_with(index, published)?;
16918        let nlink = self.object_link_count(HardLinkTarget::Dataset(index));
16919        let format = self.dataset_header_format(index);
16920        let images = self.encode_header_in(&header, nlink, format, &placement)?;
16921        let reserved = placement.blocks();
16922        let fits = images.len() == reserved.len()
16923            && images
16924                .iter()
16925                .zip(&reserved)
16926                .all(|((_, image), &(_, size))| image.len() as u64 == size);
16927        if !fits {
16928            return Err(crate::io::IoError::InvalidState(format!(
16929                "dataset header grew from {} to {} bytes; cannot rewrite in place",
16930                reserved.iter().map(|&(_, size)| size).sum::<u64>(),
16931                images.iter().map(|(_, image)| image.len()).sum::<usize>()
16932            )));
16933        }
16934        for (addr, image) in &images {
16935            self.handle.write_at(*addr, image)?;
16936        }
16937        // Only after the bytes are down: a failed write leaves the registry
16938        // describing the header the file still holds.
16939        self.ds(index).lock().header_written(nlink);
16940        Ok(())
16941    }
16942
16943    /// Perform a full finalize for SWMR mode.
16944    ///
16945    /// This writes all dataset object headers, the root group header, and the
16946    /// superblock with SWMR flags. After this call, the file is valid for
16947    /// SWMR readers. Subsequent writes use in-place updates.
16948    pub fn finalize_for_swmr(&mut self) -> IoResult<()> {
16949        self.reject_swmr()?;
16950        // 0. Flush all chunked dataset index structures.
16951        for i in 0..self.dataset_count() {
16952            let is_indexed = {
16953                let ds = self.ds(i);
16954                let m = ds.lock();
16955                !m.deleted && m.is_chunked()
16956            };
16957            if is_indexed {
16958                self.flush_dataset(i)?;
16959            }
16960        }
16961
16962        // 1. Allocate every object header (none for a dataset deleted before
16963        // start_swmr — its storage was freed at delete time). Same three
16964        // phases as the full finalize, and for the same reason: nothing a
16965        // header names can be laid out until every object has an address.
16966        let live: Vec<usize> = (0..self.dataset_count())
16967            .filter(|&i| !self.ds(i).lock().deleted)
16968            .collect();
16969        let kept = self.supersede_headers(&live);
16970        // Before any dataset header: a sharing dataset's header names the
16971        // committed type's address.
16972        self.write_committed_datatype_headers()?;
16973        let layout = self.allocate_object_headers(&live, &kept)?;
16974
16975        // 2. Build content against those addresses.
16976        self.prepare_dense_attributes(&live)?;
16977        self.prepare_link_storage()?;
16978        self.write_reference_values()?;
16979        for (scope, _) in self.attribute_scopes(&live) {
16980            let attributes = self.object_attributes(scope)?;
16981            self.swmr_published_attrs.lock().insert(scope, attributes);
16982        }
16983
16984        // 3. Write every object header.
16985        self.write_object_headers(&layout)?;
16986        // Where each header is published and how much room it has: what
16987        // `write_dataset_header_inplace` rewrites within, and what the
16988        // closing finalize writes over, since a reader may by then hold any
16989        // of these addresses.
16990        for &(i, placement) in &layout.datasets {
16991            let ds = self.ds(i);
16992            let mut m = ds.lock();
16993            m.obj_header_written_addr = Some(placement.addr);
16994            m.obj_header_blocks = placement.blocks();
16995        }
16996        for &(gi, placement) in &layout.groups {
16997            let grp = self.grp(gi);
16998            let mut g = grp.lock();
16999            g.obj_header_written_addr = Some(placement.addr);
17000            g.obj_header_blocks = placement.blocks();
17001        }
17002        self.superseded_root_header = layout.root.blocks();
17003
17004        // 4. Write superblock with SWMR flags.
17005        self.write_superblock(FLAG_WRITE_ACCESS | FLAG_SWMR_WRITE)?;
17006        self.handle.set_eof(self.allocator.eof())?;
17007
17008        self.handle.sync_all()?;
17009        // Readers can now be following this file, so a chunk that moves must
17010        // leave its old block intact for whoever is still holding the previous
17011        // index (see `swmr_active`).
17012        self.swmr_active = true;
17013        Ok(())
17014    }
17015
17016    // ------------------------------------------------------------------
17017    // Internal helpers
17018    // ------------------------------------------------------------------
17019
17020    /// Flush every dataset's append buffer into the chunks it belongs to,
17021    /// through [`flush_append_buffer`](Self::flush_append_buffer): frames
17022    /// already in the chunk survive, and the rest of it reads back as the
17023    /// dataset's fill value (zeros when none is defined).
17024    fn flush_append_buffers(&mut self) -> IoResult<()> {
17025        for i in 0..self.dataset_count() {
17026            if self.ds(i).lock().deleted {
17027                continue;
17028            }
17029            self.flush_append_buffer(i)?;
17030        }
17031        Ok(())
17032    }
17033
17034    /// Write all object headers and the superblock, producing a complete,
17035    /// valid HDF5 file.
17036    ///
17037    /// `sync == true` issues a final `sync_all` (fsync) so the bytes are
17038    /// durable against power loss / OS crash before returning. `sync == false`
17039    /// skips that fsync: the file is still fully written to the OS and readable
17040    /// by any process, but durability is left to the OS page-cache flush. This
17041    /// is the only difference between [`close`](Self::close) (durable) and
17042    /// [`close_no_sync`](Self::close_no_sync) (fast).
17043    fn finalize(&mut self, sync: bool) -> IoResult<()> {
17044        // Flush any partial append buffers before finalizing
17045        self.flush_append_buffers()?;
17046
17047        // A SWMR session (`finalize_for_swmr` already ran, so
17048        // `root_group_addr` is `Some`) is closed by the same full finalize as
17049        // a fresh write: every object header is rebuilt over its chunk 0 and
17050        // the superblock is written with clean-close flags. A full rebuild —
17051        // rather than the in-place header rewrite used by the live
17052        // `SwmrWriter::flush` path — is required so any structural change made
17053        // after `start_swmr` is committed to the final file. A hard link, in
17054        // particular, both grows its target's header with an object
17055        // reference-count message and adds a `MSG_LINK` record to a group
17056        // header; an in-place rewrite cannot accommodate the grown header and
17057        // never re-emits group/root headers. The fall-through below already
17058        // handles datasets whose header was written by `finalize_for_swmr`
17059        // (`obj_header_written_addr.is_some()`).
17060
17061        // 0. Flush chunked dataset index structures (only modified datasets).
17062        for i in 0..self.dataset_count() {
17063            let ds = self.ds(i);
17064            {
17065                let m = ds.lock();
17066                if m.deleted {
17067                    continue;
17068                }
17069                if m.obj_header_written_addr.is_some() && !m.storage_dirty() {
17070                    continue;
17071                }
17072                let is_indexed = m.is_chunked();
17073                if !is_indexed {
17074                    continue;
17075                }
17076            }
17077            self.flush_dataset_synced(i, sync)?;
17078        }
17079
17080        // 1. Plan. Which datasets get a header (deleted datasets get none —
17081        // their storage was already freed at delete time) is settled first,
17082        // because everything the next phases lay out is laid out only for the
17083        // headers this finalize actually rewrites; and every header those
17084        // phases supersede is taken here, before the first allocation, so
17085        // the rewrite lands over it.
17086        let mut rewritten: Vec<usize> = Vec::new();
17087        // A finalize that lays the shared-message table out afresh reassigns
17088        // every heap ID in the file, so no existing header can keep its bytes:
17089        // the pointers in them name heap objects the new table does not have.
17090        let table_replaced = self.rebuilds_shared_messages();
17091        for i in 0..self.dataset_count() {
17092            // Before the slot guard: `object_link_count` re-locks every
17093            // dataset and group slot, this one included.
17094            let nlink = self.object_link_count(HardLinkTarget::Dataset(i));
17095            let ds = self.ds(i);
17096            let mut m = ds.lock();
17097            if m.deleted {
17098                continue;
17099            }
17100            // An existing dataset from append mode keeps its header — and
17101            // everything that header names — unless this session changed
17102            // what the header says.
17103            if let Some(written) = m.obj_header_written_addr {
17104                if !table_replaced && !m.header_stale_with(nlink) {
17105                    // Keep the original object header address for the root group link.
17106                    m.obj_header_addr = written;
17107                    continue;
17108                }
17109            }
17110            rewritten.push(i);
17111        }
17112        let kept = self.supersede_headers(&rewritten);
17113
17114        // 2. Allocate. Committed datatype headers go down whole: a header of
17115        // theirs holds a datatype and a reference count, so it waits on
17116        // nothing, while a dataset sharing the type and the group naming it
17117        // both store its address. They are written before the shared-message
17118        // phase opens, so a committed type reaches the file as itself.
17119        self.write_committed_datatype_headers()?;
17120        self.begin_shared_message_layout();
17121        let layout = self.allocate_object_headers(&rewritten, &kept)?;
17122
17123        // 3. Build content, with every object header's address known. Dense
17124        // attribute storage holds the attribute messages themselves — an
17125        // object reference among them is a header address; dense links and
17126        // symbol tables name header addresses; a reference dataset's elements
17127        // are header addresses. Nothing here is a fixup: each is written once,
17128        // with the value the file keeps. The shared-message table comes last:
17129        // it counts the bodies the headers will hold, and the three above are
17130        // what settle them.
17131        self.prepare_dense_attributes(&rewritten)?;
17132        self.prepare_link_storage()?;
17133        self.write_reference_values()?;
17134        self.prepare_shared_messages(&rewritten)?;
17135        self.write_superblock_extension()?;
17136
17137        // 4. Write every object header over the block phase 2 reserved for it.
17138        self.write_object_headers(&layout)?;
17139
17140        // 5. Write superblock at offset 0.
17141        self.write_superblock(0)?;
17142
17143        // 6. End the file where its address space ends (`H5FD_truncate`, which
17144        // `H5F__dest` calls on every close). Allocated-but-unwritten space at
17145        // the end would otherwise leave the file shorter than the end-of-file
17146        // address the superblock just recorded, which libhdf5 reads as a
17147        // truncated file.
17148        self.handle.set_eof(self.allocator.eof())?;
17149
17150        // Durability is opt-in per call: `close` passes `true`, `close_no_sync`
17151        // passes `false`, and `Drop` passes `true` so an un-`close`d writer is
17152        // still finalized durably by default.
17153        if sync {
17154            self.handle.sync_all()?;
17155        }
17156        Ok(())
17157    }
17158
17159    /// Take the on-disk header of every object this finalize rewrites — the
17160    /// datasets in `datasets`, every group, and the root — and hand each
17161    /// chunk-0 block to [`allocate_object_headers`](Self::allocate_object_headers)
17162    /// to be written over.
17163    ///
17164    /// Chunk 0 stays where it is: its address is what every reference in the
17165    /// file holds. The continuation blocks behind it go back to the free
17166    /// list, so the rewrite reuses them instead of growing the file on every
17167    /// open/close cycle — nothing names one but its own header. Hard links
17168    /// can alias one header under several names; the set keeps an aliased
17169    /// chain from being taken twice. The registry forgets each header here,
17170    /// so a finalize that fails later describes none the file no longer holds.
17171    fn supersede_headers(&mut self, datasets: &[usize]) -> KeptChunks {
17172        let mut kept = KeptChunks::default();
17173        let mut taken = std::collections::HashSet::new();
17174        for &i in datasets {
17175            let ds = self.ds(i);
17176            let mut m = ds.lock();
17177            let Some(old) = m.obj_header_written_addr.take() else {
17178                continue;
17179            };
17180            let blocks = std::mem::take(&mut m.obj_header_blocks);
17181            if !blocks.is_empty() && taken.insert(old) {
17182                kept.datasets.insert(i, self.keep_chunk0(blocks));
17183            }
17184        }
17185        for gi in 0..self.group_count() {
17186            let grp = self.grp(gi);
17187            let mut g = grp.lock();
17188            let Some(old) = g.obj_header_written_addr.take() else {
17189                continue;
17190            };
17191            let blocks = std::mem::take(&mut g.obj_header_blocks);
17192            if !blocks.is_empty() && taken.insert(old) {
17193                kept.groups.insert(gi, self.keep_chunk0(blocks));
17194            }
17195        }
17196        let root_blocks = std::mem::take(&mut self.superseded_root_header);
17197        if root_blocks
17198            .first()
17199            .is_some_and(|&(addr, _)| taken.insert(addr))
17200        {
17201            kept.root = Some(self.keep_chunk0(root_blocks));
17202        }
17203        kept
17204    }
17205
17206    /// Keep `blocks`' chunk 0 for a rewrite and free the continuation blocks
17207    /// behind it — never under SWMR, where a live reader may be walking them,
17208    /// the same rule `release_vlen_references` and `place_chunk` follow.
17209    fn keep_chunk0(&self, blocks: crate::io::object_header_io::HeaderBlocks) -> (u64, u64) {
17210        let mut blocks = blocks.into_iter();
17211        let chunk0 = blocks.next().expect("a written header has a chunk 0");
17212        if !self.swmr_active {
17213            for (addr, len) in blocks {
17214                self.allocator.free(addr, len, FreeSpaceClass::Metadata);
17215            }
17216        }
17217        chunk0
17218    }
17219
17220    /// Give every object header this finalize writes an address, before
17221    /// anything that names one is built.
17222    ///
17223    /// INVARIANT: from the moment this returns until the file is closed, every
17224    /// object in it has the object header address it will be found at. That is
17225    /// what lets the phase after this one say an address wherever the format
17226    /// wants one — in a link message, in a symbol table entry, in a reference
17227    /// dataset's elements, and in an attribute's value, which is the one of the
17228    /// four that cannot be revisited after its header is written.
17229    ///
17230    /// An object in `kept` is placed over the chunk-0 block it already has, so
17231    /// its address is the one every reference in the file already holds. A
17232    /// header is measured before its content is final, which is sound because
17233    /// no address changes its length: every address is a fixed-width field,
17234    /// and an object that has none yet reads as zero, which is the same width.
17235    /// The storage a header names is laid out between the two passes for the
17236    /// same reason and answers the same way — `emit_attributes` and
17237    /// `emit_links` each fall back to a size-equal placeholder message. It is
17238    /// [`write_object_headers`](Self::write_object_headers) that checks this
17239    /// held, rather than either pass assuming it.
17240    fn allocate_object_headers(
17241        &mut self,
17242        datasets: &[usize],
17243        kept: &KeptChunks,
17244    ) -> IoResult<HeaderLayout> {
17245        let mut layout = HeaderLayout {
17246            datasets: Vec::with_capacity(datasets.len()),
17247            groups: Vec::new(),
17248            root: HeaderPlacement::fresh(0, 0),
17249        };
17250        for &i in datasets {
17251            let header = self.build_dataset_header(i)?;
17252            let format = self.dataset_header_format(i);
17253            let placement = self.place_header(&header, format, kept.datasets.get(&i).copied())?;
17254            self.ds(i).lock().obj_header_addr = placement.addr;
17255            layout.datasets.push((i, placement));
17256        }
17257        for gi in 0..self.group_count() {
17258            if self.grp(gi).lock().deleted {
17259                continue;
17260            }
17261            let header = self.build_group_header(gi)?;
17262            let format = self.group_header_format(gi);
17263            let placement = self.place_header(&header, format, kept.groups.get(&gi).copied())?;
17264            self.grp(gi).lock().obj_header_addr = placement.addr;
17265            layout.groups.push((gi, placement));
17266        }
17267        let header = self.build_root_group_header()?;
17268        let format = self.header_format(self.root_track_order);
17269        let placement = self.place_header(&header, format, kept.root)?;
17270        self.root_group_addr = Some(placement.addr);
17271        layout.root = placement;
17272        Ok(layout)
17273    }
17274
17275    /// Write every object header over the blocks
17276    /// [`allocate_object_headers`](Self::allocate_object_headers) reserved for
17277    /// it.
17278    ///
17279    /// The single owner of object header writing in both finalize paths, and
17280    /// the only place a header's body meets its block: a body that does not
17281    /// fill its measurement exactly fails the finalize here rather than
17282    /// overrunning the next object or leaving a tail of the previous one, which
17283    /// is how a message whose length turns out to depend on an address would
17284    /// show up.
17285    fn write_object_headers(&mut self, layout: &HeaderLayout) -> IoResult<()> {
17286        for &(i, placement) in &layout.datasets {
17287            let rc = self.object_link_count(HardLinkTarget::Dataset(i));
17288            let header = self.build_dataset_header(i)?;
17289            let format = self.dataset_header_format(i);
17290            let what = format!("dataset '{}'", self.ds(i).lock().name);
17291            self.write_header_in(&header, rc, format, &placement, &what)?;
17292            // Only after the bytes are down: a failed write leaves the registry
17293            // describing the header the file still holds.
17294            self.ds(i).lock().header_written(rc);
17295        }
17296        for &(gi, placement) in &layout.groups {
17297            let rc = self.object_link_count(HardLinkTarget::Group(gi));
17298            let header = self.build_group_header(gi)?;
17299            let format = self.group_header_format(gi);
17300            let what = format!("group '{}'", self.grp(gi).lock().name);
17301            self.write_header_in(&header, rc, format, &placement, &what)?;
17302        }
17303        let header = self.build_root_group_header()?;
17304        let format = self.header_format(self.root_track_order);
17305        self.write_header_in(&header, 1, format, &layout.root, "the root group")
17306    }
17307
17308    /// Encode `header` into `placement` and write it, after checking each
17309    /// image against the block reserved for it.
17310    fn write_header_in(
17311        &mut self,
17312        header: &ObjectHeader,
17313        rc: u32,
17314        format: ObjectFormat,
17315        placement: &HeaderPlacement,
17316        what: &str,
17317    ) -> IoResult<()> {
17318        let images = self.encode_header_in(header, rc, format, placement)?;
17319        let reserved =
17320            std::iter::once(placement.size).chain(placement.continuation.map(|(_, s)| s));
17321        for ((addr, image), size) in images.iter().zip(reserved) {
17322            check_header_size(image, size, || what.to_string())?;
17323            self.handle.write_at(*addr, image)?;
17324        }
17325        Ok(())
17326    }
17327
17328    fn build_dataset_header(&self, index: usize) -> IoResult<ObjectHeader> {
17329        // Resolving references locks other objects' slots, so before the
17330        // slot guard below.
17331        let attributes = self.object_attributes(AttrScope::Dataset(index))?;
17332        self.build_dataset_header_with(index, attributes)
17333    }
17334
17335    /// [`build_dataset_header`](Self::build_dataset_header) carrying
17336    /// `attributes` instead of the dataset's current set.
17337    fn build_dataset_header_with(
17338        &self,
17339        index: usize,
17340        attributes: Vec<AttributeEntry>,
17341    ) -> IoResult<ObjectHeader> {
17342        // Compute the link count first: object_link_count re-locks dataset and
17343        // group slots (including this one), so it must run before we take this
17344        // dataset's slot guard — otherwise it would deadlock on the same slot.
17345        let rc = self.object_link_count(HardLinkTarget::Dataset(index));
17346        // Same reason: reading the committed type's address locks the
17347        // committed-datatype registry, which the slot guard below must not be
17348        // held across.
17349        let committed = self.ds(index).lock().committed_type;
17350        let committed_addr = committed.map(|r| match r {
17351            CommittedTypeRef::Session(ci) => self.committed_datatypes.lock()[ci].obj_header_addr,
17352            CommittedTypeRef::Preserved(addr) => addr,
17353        });
17354
17355        // Hold one slot guard for the whole header build.
17356        let ds = self.ds(index);
17357        let m = ds.lock();
17358        let mut header = ObjectHeader::new();
17359
17360        // Every message below is written in the format this dataset already
17361        // has, not the one this session would pick. libhdf5 grows a header in
17362        // place and never re-encodes a message it did not touch, so a reopen
17363        // at a newer bound leaves the version-1 dataspaces an EARLIEST-bound
17364        // creating session wrote exactly as they are. This writer has to lay
17365        // the whole header out again whenever the shared-message heap moves,
17366        // so preserving the encoding is the only way to land on the same
17367        // bytes.
17368        let format = m.read_format.unwrap_or_else(|| self.message_format());
17369        let libver = match format {
17370            ObjectFormat::Legacy => LibverBound::Earliest,
17371            ObjectFormat::Modern => self.encoding_libver(),
17372        };
17373
17374        // Dataspace message (type 0x01)
17375        let ds_msg = m.dataspace.encode_for(&self.ctx, format);
17376        let owner = ShareOwner::Header(m.obj_header_addr);
17377        let (flags, ds_msg) = self.share_message(owner, MSG_DATASPACE, 0x00, ds_msg);
17378        header.add_message(MSG_DATASPACE, flags, ds_msg);
17379
17380        // Datatype message (type 0x03). A dataset built on a committed type
17381        // stores a pointer to that object header in place of the message, and
17382        // the shared flag is what says the body is a pointer — the two are one
17383        // statement, so they are written together.
17384        match committed_addr {
17385            Some(addr) => header.add_message(
17386                MSG_DATATYPE,
17387                MSG_FLAG_CONSTANT | MSG_FLAG_SHARED,
17388                SharedMessagePointer::encode_committed(addr, &self.ctx),
17389            ),
17390            None => {
17391                let body = m.datatype.encode_at(&self.ctx, libver);
17392                let (flags, body) = if self.dataset_datatype_shareable(&m.datatype, libver) {
17393                    self.share_message(owner, MSG_DATATYPE, MSG_FLAG_CONSTANT, body)
17394                } else {
17395                    (MSG_FLAG_CONSTANT, body)
17396                };
17397                header.add_message(MSG_DATATYPE, flags, body)
17398            }
17399        }
17400
17401        // Fill Value message (type 0x05)
17402        let is_chunked = m.is_chunked();
17403        // `H5P__init_def_layout` gives each storage class its own default
17404        // allocation time: incremental for chunked and for virtual (whose
17405        // source datasets are allocated as they are written), early for
17406        // compact (the space is the header, so it exists as soon as the
17407        // dataset does), late for contiguous. An implicitly indexed dataset is
17408        // the one chunked exception, and not by default but by definition:
17409        // early allocation is a *condition* of that index
17410        // (`H5D__layout_set_latest_indexing`), so a header claiming
17411        // incremental would describe a file libhdf5 would never have chosen
17412        // this index for. A single-chunk dataset can go either way — unlike
17413        // Implicit, early allocation is not one of its selection conditions
17414        // — so its `early_alloc` flag (set only for an unfiltered dataset
17415        // created that way) is what this checks instead.
17416        let alloc_time = if m.compact.is_some()
17417            || m.implicit.is_some()
17418            || m.single_chunk.as_ref().is_some_and(|s| s.early_alloc)
17419        {
17420            1 // early
17421        } else if is_chunked || m.virtual_storage.is_some() {
17422            3 // incremental
17423        } else {
17424            2 // late
17425        };
17426        // `H5D__update_oh_info` (H5Dint.c:927-943): a variable-length
17427        // datatype with no explicit fill value forces ALLOC regardless of
17428        // the declared policy — its heap-reference encoding has no safe
17429        // all-zero "no fill" representation, so libhdf5 always writes the
17430        // (empty) fill value at allocation for such a dataset. `IFSET` is
17431        // the only declared policy this touches: an explicit `ALLOC` is
17432        // already what it forces, and upstream rejects `NEVER` for a
17433        // VL-typed dataset at `H5Dcreate` outright — this crate's
17434        // VL-typed datasets have no builder path to declare `NEVER` in the
17435        // first place, so that branch cannot be reached here.
17436        let is_vlen = matches!(
17437            m.datatype,
17438            DatatypeMessage::VarLenString { .. } | DatatypeMessage::VarLenSequence { .. }
17439        );
17440        let fill_write_time = if is_vlen && m.fill_value.is_none() && m.fill_time == FILL_TIME_IFSET
17441        {
17442            FILL_TIME_ALLOC
17443        } else {
17444            m.fill_time
17445        };
17446        let fv = if let Some(ref bytes) = m.fill_value {
17447            // User-defined fill value (fill_defined = 2).
17448            FillValueMessage {
17449                alloc_time,
17450                fill_write_time,
17451                fill_defined: 2,
17452                fill_value: Some(bytes.clone()),
17453            }
17454        } else {
17455            // No fill value of the dataset's own (fill_defined = 1, the
17456            // implicit default zero fill) — `alloc_time` above already
17457            // carries the per-layout-class default (`H5P__set_layout`,
17458            // H5Pdcpl.c:1864-1877), so this branch must use it too instead
17459            // of `FillValueMessage::default()`'s hardcoded LATE: that was
17460            // wrong for a compact (EARLY) or virtual (INCR) dataset with no
17461            // fill value, only coincidentally right for contiguous.
17462            FillValueMessage {
17463                alloc_time,
17464                fill_write_time,
17465                fill_defined: 1, // default value (zeros)
17466                fill_value: None,
17467            }
17468        };
17469        // `H5O_MSG_FLAG_CONSTANT`, as `H5D__update_oh_info` appends it
17470        // (H5Dint.c:965) — the same flag the datatype message beside it
17471        // carries (H5Dint.c:961) and the old fill value below (H5Dint.c:981).
17472        // A dataset's fill value is fixed at creation: `H5Pset_fill_value` is
17473        // a creation property, so nothing can rewrite the message in place and
17474        // libhdf5 tells the header so.
17475        let fv_msg = fv.encode_for(format);
17476        let (flags, fv_msg) = self.share_message(owner, MSG_FILL_VALUE, MSG_FLAG_CONSTANT, fv_msg);
17477        header.add_message(MSG_FILL_VALUE, flags, fv_msg);
17478
17479        // The "fill value (old)" message (type 0x04) beside the new one, for a
17480        // user-defined fill value below the v1.8 bound. `H5D__update_oh_info`
17481        // (H5Dint.c:1024-1035) appends `H5O_FILL_ID` whenever `fill_prop->buf`
17482        // is set and `use_at_least_v18` — `H5F_LOW_BOUND(file) >= V18`, which
17483        // here is exactly a non-`Legacy` message format — is false, so that a
17484        // reader that predates the new message still finds the value. The body
17485        // is the size and the bytes and nothing else: no allocation time, no
17486        // write time, no defined flag (`H5O__fill_old_encode`, H5Ofill.c:512).
17487        if matches!(format, ObjectFormat::Legacy) {
17488            if let Some(ref bytes) = m.fill_value {
17489                let mut old = Vec::with_capacity(4 + bytes.len());
17490                old.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
17491                old.extend_from_slice(bytes);
17492                let (flags, old) =
17493                    self.share_message(owner, MSG_FILL_VALUE_OLD, MSG_FLAG_CONSTANT, old);
17494                header.add_message(MSG_FILL_VALUE_OLD, flags, old);
17495            }
17496        }
17497
17498        // External Data Files message (type 0x07), before the layout message
17499        // and marked constant, exactly where `H5D__layout_oh_create` puts it.
17500        // It is what makes a reader route the dataset's I/O through the files
17501        // it names rather than through the undefined address the layout
17502        // message below still declares.
17503        if let Some(ref ext) = m.external {
17504            header.add_message(
17505                MSG_EXTERNAL_FILE_LIST,
17506                MSG_FLAG_CONSTANT,
17507                ext.message().encode(&self.ctx),
17508            );
17509        }
17510
17511        // Data Layout message (type 0x08)
17512        let layout = if let Some(ref chunked) = m.chunked {
17513            let mut layout_dims = chunked.chunk_dims.clone();
17514            layout_dims.push(m.datatype.element_size() as u64);
17515            DataLayoutMessage::chunked_v4_earray(
17516                m.layout_version,
17517                layout_dims,
17518                chunked.earray_params.clone(),
17519                chunked.ea_header_addr,
17520            )
17521        } else if let Some(ref fa) = m.fixed_array {
17522            let mut layout_dims = fa.chunk_dims.clone();
17523            layout_dims.push(m.datatype.element_size() as u64);
17524            DataLayoutMessage::chunked_v4_farray(
17525                m.layout_version,
17526                layout_dims,
17527                FixedArrayParams::default_params(),
17528                fa.fa_header_addr,
17529            )
17530        } else if let Some(ref bt2) = m.btree_v2 {
17531            let mut layout_dims = bt2.chunk_dims.clone();
17532            layout_dims.push(m.datatype.element_size() as u64);
17533            DataLayoutMessage::chunked_v4_btree_v2(
17534                m.layout_version,
17535                layout_dims,
17536                crate::format::messages::data_layout::Bt2Params {
17537                    node_size: bt2.index.node_size,
17538                    split_percent: bt2.index.split_percent,
17539                    merge_percent: bt2.index.merge_percent,
17540                },
17541                bt2.bt2_header_addr,
17542            )
17543        } else if let Some(ref imp) = m.implicit {
17544            let mut layout_dims = imp.chunk_dims.clone();
17545            layout_dims.push(m.datatype.element_size() as u64);
17546            DataLayoutMessage::chunked_v4_implicit(m.layout_version, layout_dims, imp.data_addr)
17547        } else if let Some(ref sc) = m.single_chunk {
17548            let mut layout_dims = sc.chunk_dims.clone();
17549            layout_dims.push(m.datatype.element_size() as u64);
17550            if m.filter_pipeline.is_some() {
17551                DataLayoutMessage::chunked_v4_single_filtered(
17552                    layout_dims,
17553                    sc.data_addr,
17554                    sc.nbytes,
17555                    sc.filter_mask,
17556                )
17557            } else {
17558                DataLayoutMessage::chunked_v4_single(layout_dims, sc.data_addr)
17559            }
17560        } else if let Some(ref bt1) = m.btree_v1 {
17561            // The classic index: a version-3 layout message carrying the
17562            // address of the tree's root node, which is undefined until a
17563            // chunk is written.
17564            let mut layout_dims = bt1.chunk_dims.clone();
17565            layout_dims.push(m.datatype.element_size() as u64);
17566            DataLayoutMessage::chunked_v3_btree_v1(layout_dims, bt1.root_addr)
17567        } else if let Some(ref image) = m.compact {
17568            DataLayoutMessage::compact(image.clone())
17569        } else if let Some(ref virt) = m.virtual_storage {
17570            // Version 4 always: the virtual layout class did not exist before
17571            // it, so the default virtual layout is created at version 4 and
17572            // `H5Pset_virtual` raises any lower one to it (H5Pdcpl.c),
17573            // whatever the file's library-version bounds say — which is why a
17574            // v0-superblock file can still hold one.
17575            DataLayoutMessage::virtual_layout(4, virt.heap_addr, virt.heap_index)
17576        } else {
17577            DataLayoutMessage::contiguous(m.data_addr, m.data_size)
17578        };
17579        // `H5D__layout_oh_create` (H5Dlayout.c:530-536) marks the layout
17580        // message constant only where the storage it names is certain to be
17581        // there already: allocation time is early, the class is not compact,
17582        // no filter can change a chunk's size, and the dataspace holds at
17583        // least one element. Anything else leaves the address undefined at
17584        // creation and rewrites the message when the space is allocated, so
17585        // the flag would be a lie. `H5S_GET_EXTENT_NPOINTS` is zero for a
17586        // NULL dataspace and for any extent with a zero-length dimension.
17587        let npoints: u64 = if m.dataspace.is_null() {
17588            0
17589        } else {
17590            m.dataspace.dims.iter().product()
17591        };
17592        let filtered = m
17593            .filter_pipeline
17594            .as_ref()
17595            .is_some_and(|p| !p.filters.is_empty());
17596        let layout_flags = if alloc_time == 1 && m.compact.is_none() && !filtered && npoints != 0 {
17597            MSG_FLAG_CONSTANT
17598        } else {
17599            0x00
17600        };
17601        let layout_msg = layout.encode(&self.ctx);
17602        header.add_message(MSG_DATA_LAYOUT, layout_flags, layout_msg);
17603
17604        // Filter Pipeline message (type 0x0B) -- only if filters are
17605        // configured. `H5D__layout_oh_create` appends it with
17606        // `H5O_MSG_FLAG_CONSTANT` (H5Dlayout.c:462), as does the group
17607        // pipeline for dense links (H5Gobj.c:264): the pipeline is a creation
17608        // property, and every chunk already written was filtered through it,
17609        // so it can never be rewritten in place.
17610        if let Some(ref pipeline) = m.filter_pipeline {
17611            if !pipeline.filters.is_empty() {
17612                let (flags, filter_msg) = self.share_message(
17613                    owner,
17614                    MSG_FILTER_PIPELINE,
17615                    MSG_FLAG_CONSTANT,
17616                    pipeline.encode_for(format),
17617                );
17618                header.add_message(MSG_FILTER_PIPELINE, flags, filter_msg);
17619            }
17620        }
17621
17622        // A dataset has no links, so only attribute creation order can raise
17623        // its header past version 1 (`H5O__set_version`).
17624        let format = self.header_format(TrackOrder {
17625            links: CreationOrder::default(),
17626            attrs: m.track_attr_order,
17627        });
17628
17629        // Modification time, here and not earlier: `H5D__update_oh_info` makes
17630        // this the last message it writes (H5Dint.c:1022-1026), and the
17631        // attributes below it are added by `H5A` calls that come after the
17632        // dataset exists.
17633        touch_oh(&mut header, format, m.times, true);
17634
17635        // Attribute Info (type 0x15) + attribute messages (type 0x0C).
17636        self.emit_attributes(
17637            &mut header,
17638            AttrScope::Dataset(index),
17639            &attributes,
17640            m.track_attr_order,
17641            format,
17642            owner,
17643        );
17644
17645        self.emit_refcount(&mut header, rc, format);
17646
17647        Ok(header)
17648    }
17649
17650    /// Write the object header of every committed datatype something still
17651    /// reaches, recording the address each one landed at.
17652    ///
17653    /// Runs before the dataset and group headers because both name these
17654    /// addresses — a sharing dataset in its datatype message, the parent
17655    /// group in the link. One pass is enough: the header holds a datatype
17656    /// message and at most a reference count, neither of which depends on an
17657    /// address.
17658    fn write_committed_datatype_headers(&mut self) -> IoResult<()> {
17659        // The count is bound first: a lock guard in the `for` iterator
17660        // expression would live for the whole loop body, which locks the same
17661        // registry again.
17662        let count = self.committed_datatypes.lock().len();
17663        for i in 0..count {
17664            let rc = self.committed_datatype_refcount(i);
17665            if rc == 0 {
17666                // Its name's group was deleted and no dataset shares it, so
17667                // nothing in the file could reach the header.
17668                continue;
17669            }
17670            let format = self.committed_datatype_header_format();
17671            let encoded = self
17672                .build_committed_datatype_header(i, rc, format)
17673                .encode_for(format, rc)?;
17674            let addr = self
17675                .allocator
17676                .allocate(encoded.len() as u64, FreeSpaceClass::Metadata);
17677            self.handle.write_at(addr, &encoded)?;
17678            self.committed_datatypes.lock()[i].obj_header_addr = addr;
17679        }
17680        Ok(())
17681    }
17682
17683    /// The header format a committed datatype gets.
17684    ///
17685    /// `H5T__commit` creates the header from the datatype creation property
17686    /// list (H5Tcommit.c:468), which carries no link order and, by default, no
17687    /// attribute order — so the version is the file's floor exactly as
17688    /// `H5O__set_version` computes it, and a committed datatype in a classic
17689    /// file is a version-1 header like every other object in it.
17690    fn committed_datatype_header_format(&self) -> ObjectFormat {
17691        self.header_format(TrackOrder::default())
17692    }
17693
17694    /// Build the object header for a committed datatype: the type, and the
17695    /// reference count when more than one name reaches it.
17696    fn build_committed_datatype_header(
17697        &self,
17698        index: usize,
17699        rc: u32,
17700        format: ObjectFormat,
17701    ) -> ObjectHeader {
17702        let (datatype, times) = {
17703            let reg = self.committed_datatypes.lock();
17704            (reg[index].datatype.clone(), reg[index].times)
17705        };
17706        let mut header = ObjectHeader::new();
17707        // No attributes to emit, so nothing else would apply the file-wide
17708        // floor to this header. `store_msg_crt_idx` is a property of the file,
17709        // not of the object: every header created under it records creation
17710        // indices, a committed datatype's included.
17711        header.set_attribute_creation_order(self.header_attr_order(CreationOrder::default()));
17712        // `H5T__commit` marks the message constant and unshareable: this
17713        // header is where shared datatype bodies are read *from*, so its own
17714        // message must never become a pointer into the shared-message heap.
17715        header.add_message(
17716            MSG_DATATYPE,
17717            MSG_FLAG_CONSTANT | MSG_FLAG_DONTSHARE,
17718            datatype.encode_at(&self.ctx, self.encoding_libver()),
17719        );
17720        touch_oh(&mut header, format, times, false);
17721        // Through the same owner as every other object's count: a dataset
17722        // sharing this type raises it (`H5O__shared_link_adj`, H5Oshared.c:249)
17723        // just as a second name does, and where that count is recorded is the
17724        // header version's business, not the caller's.
17725        self.emit_refcount(&mut header, rc, format);
17726        header
17727    }
17728
17729    /// Build the object header for a subgroup.
17730    fn build_group_header(&self, group_idx: usize) -> IoResult<ObjectHeader> {
17731        let mut header = ObjectHeader::new();
17732
17733        // Link Info (type 0x02) + Group Info (type 0x0A) + the links
17734        // themselves, compact or dense.
17735        // Snapshot what the header needs, then drop the slot guard: the calls
17736        // below re-lock group slots (including this one).
17737        let (track_order, times, owner) = {
17738            let grp = self.grp(group_idx);
17739            let g = grp.lock();
17740            (
17741                g.track_order,
17742                g.times,
17743                ShareOwner::Header(g.obj_header_addr),
17744            )
17745        };
17746        let attributes = self.object_attributes(AttrScope::Group(group_idx))?;
17747        touch_oh(&mut header, self.header_format(track_order), times, false);
17748
17749        let links = self.group_links(LinkScope::Group(group_idx), track_order.links);
17750        self.emit_links(
17751            &mut header,
17752            LinkScope::Group(group_idx),
17753            &links,
17754            track_order.links,
17755        );
17756
17757        // Attribute Info (type 0x15) + attributes (type 0x0C) -- e.g. NeXus
17758        // `NX_class`.
17759        let format = self.header_format(track_order);
17760        self.emit_attributes(
17761            &mut header,
17762            AttrScope::Group(group_idx),
17763            &attributes,
17764            track_order.attrs,
17765            format,
17766            owner,
17767        );
17768
17769        self.emit_refcount(
17770            &mut header,
17771            self.object_link_count(HardLinkTarget::Group(group_idx)),
17772            format,
17773        );
17774
17775        Ok(header)
17776    }
17777
17778    fn build_root_group_header(&self) -> IoResult<ObjectHeader> {
17779        let mut header = ObjectHeader::new();
17780        touch_oh(
17781            &mut header,
17782            self.header_format(self.root_track_order),
17783            self.root_times,
17784            false,
17785        );
17786
17787        // Link Info (type 0x02) + Group Info (type 0x0A) + the links
17788        // themselves, compact or dense.
17789        let links = self.group_links(LinkScope::Root, self.root_track_order.links);
17790        self.emit_links(
17791            &mut header,
17792            LinkScope::Root,
17793            &links,
17794            self.root_track_order.links,
17795        );
17796
17797        // Root-level attributes
17798        let root_attributes = self.object_attributes(AttrScope::Root)?;
17799        self.emit_attributes(
17800            &mut header,
17801            AttrScope::Root,
17802            &root_attributes,
17803            self.root_track_order.attrs,
17804            self.header_format(self.root_track_order),
17805            ShareOwner::Header(self.root_group_addr.unwrap_or(0)),
17806        );
17807
17808        Ok(header)
17809    }
17810}
17811
17812impl Drop for Hdf5Writer {
17813    fn drop(&mut self) {
17814        if !self.closed {
17815            // Best-effort finalize on drop. Drop cannot return a Result, so a
17816            // failure here is otherwise invisible: it would leave a truncated
17817            // or unflushed file on disk while the caller believes the write
17818            // succeeded. Surface it on stderr instead of swallowing it.
17819            // Callers that need to handle the error must call
17820            // `H5File::close()` explicitly, which returns the Result.
17821            if let Err(e) = self.finalize(true) {
17822                eprintln!(
17823                    "rust-hdf5: failed to finalize HDF5 file on drop: {e}. \
17824                     The file may be incomplete or corrupt; call \
17825                     H5File::close() to handle this error explicitly."
17826                );
17827            }
17828        }
17829    }
17830}
17831
17832#[cfg(test)]
17833mod tests {
17834    use super::*;
17835    use crate::format::messages::datatype::DatatypeMessage;
17836    use crate::io::reader::Hdf5Reader;
17837
17838    fn fixture(name: &str) -> std::path::PathBuf {
17839        std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
17840            .join("tests/fixtures")
17841            .join(name)
17842    }
17843
17844    /// Copy a fixture so a test that appends does not edit the checked-in file.
17845    fn fixture_copy(name: &str, tag: &str) -> std::path::PathBuf {
17846        let path = temp_path(tag);
17847        std::fs::copy(fixture(name), &path).unwrap();
17848        path
17849    }
17850
17851    fn temp_path(tag: &str) -> std::path::PathBuf {
17852        use std::sync::atomic::{AtomicU64, Ordering};
17853        static COUNTER: AtomicU64 = AtomicU64::new(0);
17854        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
17855        std::env::temp_dir().join(format!(
17856            "rust_hdf5_w_{}_{}_{}.h5",
17857            std::process::id(),
17858            tag,
17859            n
17860        ))
17861    }
17862
17863    /// A group past the link phase change keeps its links in a fractal heap
17864    /// with a v2 B-tree name index. The reopen that rewrites that group's
17865    /// header lays a fresh pair out, so both blocks the old header named must
17866    /// come back to the allocator — every block of the heap, and the index
17867    /// header with its nodes.
17868    ///
17869    /// Asserted on the free list rather than on the file size: a reopen does
17870    /// not yet carry dense links forward, so the rewritten group's links (and
17871    /// the datasets they name) are dropped, and the file size that follows
17872    /// says more about that than about this.
17873    #[test]
17874    fn a_reopen_frees_the_dense_link_storage_its_rewrite_supersedes() {
17875        let path = temp_path("dense_link_reclaim");
17876
17877        let writer = Hdf5Writer::create(&path).unwrap();
17878        writer.create_group("/", "run").unwrap();
17879        for i in 0..12 {
17880            writer
17881                .create_dataset(&format!("run/d{i:02}"), DatatypeMessage::i32_type(), &[2])
17882                .unwrap();
17883        }
17884        writer.close().unwrap();
17885
17886        let writer = Hdf5Writer::open_append(&path).unwrap();
17887        let gidx = (0..writer.group_count())
17888            .find(|&g| writer.grp(g).lock().name == "/run")
17889            .expect("the reopen registered the group");
17890        let linfo = writer
17891            .superseded_dense
17892            .lock()
17893            .as_ref()
17894            .and_then(|s| s.links.get(&LinkScope::Group(gidx)).cloned())
17895            .expect("the reopen recorded the group's dense link storage");
17896        assert_ne!(linfo.fractal_heap_address, UNDEF_ADDR);
17897        assert_ne!(linfo.name_btree_address, UNDEF_ADDR);
17898
17899        writer
17900            .release_superseded_dense_links(LinkScope::Group(gidx))
17901            .unwrap();
17902        let freed = writer.allocator.free_blocks();
17903        let covers = |addr: u64| {
17904            freed
17905                .iter()
17906                .any(|&(a, len)| addr >= a && addr < a.saturating_add(len))
17907        };
17908        assert!(covers(linfo.fractal_heap_address), "heap header: {freed:?}");
17909        assert!(covers(linfo.name_btree_address), "name index: {freed:?}");
17910
17911        // And exactly once: the entry is gone, so the finalize that follows
17912        // cannot hand the same blocks back a second time.
17913        assert!(writer
17914            .superseded_dense
17915            .lock()
17916            .as_ref()
17917            .is_none_or(|s| s.links.is_empty()));
17918        writer
17919            .release_superseded_dense_links(LinkScope::Group(gidx))
17920            .unwrap();
17921        assert_eq!(writer.allocator.free_blocks(), freed);
17922
17923        writer.close().unwrap();
17924        std::fs::remove_file(&path).ok();
17925    }
17926
17927    /// The rewrite frees what it supersedes even when the replacement is not
17928    /// dense at all. An attribute set that drops back under `max_compact`
17929    /// goes into the object header, so nothing names the old heap any more —
17930    /// and a free driven by "the new set needs dense storage" would never
17931    /// reach this one.
17932    #[test]
17933    fn a_rewrite_that_drops_out_of_dense_storage_still_frees_it() {
17934        let path = temp_path("dense_attr_to_compact");
17935        let numeric = |name: &str| {
17936            AttributeMessage::scalar_numeric(
17937                name,
17938                DatatypeMessage::i32_type(),
17939                7i32.to_le_bytes().to_vec(),
17940            )
17941        };
17942
17943        let writer = Hdf5Writer::create(&path).unwrap();
17944        for i in 0..12 {
17945            writer
17946                .add_root_attribute(numeric(&format!("a{i:02}")))
17947                .unwrap();
17948        }
17949        writer.close().unwrap();
17950
17951        let writer = Hdf5Writer::open_append(&path).unwrap();
17952        let ainfo = writer
17953            .superseded_dense
17954            .lock()
17955            .as_ref()
17956            .and_then(|s| s.attrs.get(&AttrScope::Root).cloned())
17957            .expect("the reopen recorded the root's dense attribute storage");
17958        for i in 0..10 {
17959            writer
17960                .evict_attr(AttrTarget::Root, &format!("a{i:02}"))
17961                .unwrap();
17962        }
17963        assert!(!writer.attributes_need_dense(&writer.root_attributes.lock(), ObjectFormat::Modern));
17964
17965        writer.prepare_dense_attributes(&[]).unwrap();
17966        let freed = writer.allocator.free_blocks();
17967        let covers = |addr: u64| {
17968            freed
17969                .iter()
17970                .any(|&(a, len)| addr >= a && addr < a.saturating_add(len))
17971        };
17972        assert!(covers(ainfo.fractal_heap_address), "heap header: {freed:?}");
17973        assert!(covers(ainfo.name_btree_address), "name index: {freed:?}");
17974        assert!(writer
17975            .superseded_dense
17976            .lock()
17977            .as_ref()
17978            .is_none_or(|s| s.attrs.is_empty()));
17979
17980        writer.close().unwrap();
17981        std::fs::remove_file(&path).ok();
17982    }
17983
17984    /// Deleting a reopened object supersedes its dense storage as surely as
17985    /// rewriting one does: nothing in the finalized file names the heap, so
17986    /// the delete owner frees it through the same entry.
17987    #[test]
17988    fn deleting_a_reopened_group_frees_its_dense_attribute_storage() {
17989        let path = temp_path("dense_attr_delete");
17990        let numeric = |name: &str| {
17991            AttributeMessage::scalar_numeric(
17992                name,
17993                DatatypeMessage::i32_type(),
17994                7i32.to_le_bytes().to_vec(),
17995            )
17996        };
17997
17998        let writer = Hdf5Writer::create(&path).unwrap();
17999        writer.create_group("/", "run").unwrap();
18000        for i in 0..12 {
18001            writer
18002                .set_attribute(AttrTarget::Group("/run"), numeric(&format!("a{i:02}")))
18003                .unwrap();
18004        }
18005        writer.close().unwrap();
18006
18007        let writer = Hdf5Writer::open_append(&path).unwrap();
18008        let gidx = (0..writer.group_count())
18009            .find(|&g| writer.grp(g).lock().name == "/run")
18010            .expect("the reopen registered the group");
18011        let ainfo = writer
18012            .superseded_dense
18013            .lock()
18014            .as_ref()
18015            .and_then(|s| s.attrs.get(&AttrScope::Group(gidx)).cloned())
18016            .expect("the reopen recorded the group's dense attribute storage");
18017
18018        writer.delete_group("/run").unwrap();
18019        let freed = writer.allocator.free_blocks();
18020        let covers = |addr: u64| {
18021            freed
18022                .iter()
18023                .any(|&(a, len)| addr >= a && addr < a.saturating_add(len))
18024        };
18025        assert!(covers(ainfo.fractal_heap_address), "heap header: {freed:?}");
18026        assert!(covers(ainfo.name_btree_address), "name index: {freed:?}");
18027        assert!(writer
18028            .superseded_dense
18029            .lock()
18030            .as_ref()
18031            .is_none_or(|s| s.attrs.is_empty()));
18032
18033        writer.close().unwrap();
18034        std::fs::remove_file(&path).ok();
18035    }
18036
18037    /// The charset rule is one owner shared by every vlen string writer:
18038    /// appends into an ASCII-declared dataset reject non-ASCII strings the
18039    /// same way the slice writer does, and a dataset whose elements are not
18040    /// vlen references at all is refused instead of overwritten with them.
18041    #[test]
18042    fn append_vlen_strings_checks_the_datatype_and_charset() {
18043        let path = temp_path("append_vlen_charset");
18044
18045        let writer = Hdf5Writer::create(&path).unwrap();
18046        let idx = writer
18047            .create_appendable_vlen_string_dataset("d", 4, None)
18048            .unwrap();
18049        writer.ds(idx).lock().datatype = DatatypeMessage::vlen_string_ascii();
18050        let err = writer
18051            .append_vlen_strings(idx, &["ok", "안녕"])
18052            .unwrap_err();
18053        assert!(
18054            err.to_string().contains("is not ASCII"),
18055            "unexpected error: {err}"
18056        );
18057        writer.append_vlen_strings(idx, &["ok", "fine"]).unwrap();
18058
18059        let nums = writer
18060            .create_chunked_dataset("n", DatatypeMessage::i32_type(), &[0], &[u64::MAX], &[4])
18061            .unwrap();
18062        let err = writer.append_vlen_strings(nums, &["x"]).unwrap_err();
18063        assert!(
18064            err.to_string()
18065                .contains("only for variable-length string datasets"),
18066            "unexpected error: {err}"
18067        );
18068
18069        writer.close().unwrap();
18070        std::fs::remove_file(&path).ok();
18071    }
18072
18073    /// `create_chunked_dataset` builds an extensible-array index unconditionally
18074    /// (the caller — the high-level dataset API — is the one that decides when
18075    /// two-or-more unlimited dimensions should go to a v2 B-tree instead), so
18076    /// its own guard is the last line of defense against a shape that index
18077    /// can't represent at all.
18078    #[test]
18079    fn create_chunked_dataset_rejects_two_unlimited_dimensions() {
18080        let path = temp_path("earray_two_unlimited");
18081        let writer = Hdf5Writer::create(&path).unwrap();
18082        let err = writer
18083            .create_chunked_dataset(
18084                "d",
18085                DatatypeMessage::i32_type(),
18086                &[4, 4],
18087                &[u64::MAX, u64::MAX],
18088                &[2, 2],
18089            )
18090            .unwrap_err();
18091        assert!(err.to_string().contains("at most one unlimited"), "{err}");
18092        writer.close().unwrap();
18093        std::fs::remove_file(&path).ok();
18094    }
18095
18096    /// Every creator must enter through `begin_create`; the four that used
18097    /// to bypass it could push a second dataset under an existing name and
18098    /// emit an invalid file with two same-named links.
18099    #[test]
18100    fn every_creator_rejects_an_existing_dataset_name() {
18101        let path = temp_path("create_gate");
18102
18103        let writer = Hdf5Writer::create(&path).unwrap();
18104        writer
18105            .create_dataset("d", DatatypeMessage::i32_type(), &[2])
18106            .unwrap();
18107
18108        let attempts: [(&str, IoResult<usize>); 4] = [
18109            (
18110                "vlen_string",
18111                writer.create_vlen_string_dataset("d", &["x"], 1),
18112            ),
18113            ("vlen_bytes", writer.create_vlen_bytes_dataset("d", &[b"x"])),
18114            (
18115                "vlen_string_compressed",
18116                writer.create_vlen_string_dataset_compressed(
18117                    "d",
18118                    &["x"],
18119                    1,
18120                    FilterPipeline::deflate(6),
18121                ),
18122            ),
18123            (
18124                "chunked_with_pipeline",
18125                writer.create_chunked_dataset_with_pipeline(
18126                    "d",
18127                    DatatypeMessage::i32_type(),
18128                    &[0],
18129                    &[u64::MAX],
18130                    &[4],
18131                    FilterPipeline::deflate(6),
18132                ),
18133            ),
18134        ];
18135        for (which, res) in attempts {
18136            match res {
18137                Ok(_) => panic!("{which} accepted a duplicate name"),
18138                Err(e) => assert!(
18139                    e.to_string().contains("already exists"),
18140                    "{which}: unexpected error: {e}"
18141                ),
18142            }
18143        }
18144
18145        writer.close().unwrap();
18146        std::fs::remove_file(&path).ok();
18147    }
18148
18149    /// Every creator and every kind of name meet at `ensure_name_free`.
18150    ///
18151    /// The gate's whole value is that it is one list: a creator must be
18152    /// blind neither to a name kind it does not itself make nor to one added
18153    /// after it. This crosses the two — six names, one of each kind the
18154    /// writer can put in a group, against every creator — so a creator that
18155    /// grows its own check, or a name kind that stops being on the list,
18156    /// fails here rather than in a file holding two links of one name.
18157    #[test]
18158    fn every_creator_refuses_every_kind_of_taken_name() {
18159        let path = temp_path("create_gate_matrix");
18160        let writer = Hdf5Writer::create(&path).unwrap();
18161
18162        let i32t = || DatatypeMessage::i32_type();
18163        writer.create_dataset("d", i32t(), &[2]).unwrap();
18164        writer.create_compact_dataset("c", i32t(), &[2]).unwrap();
18165        writer.create_group("/", "g").unwrap();
18166        writer.commit_datatype("t", i32t()).unwrap();
18167        writer.create_hard_link("/", "h", "d").unwrap();
18168        writer
18169            .create_symbolic_link(
18170                "/",
18171                "s",
18172                LinkTarget::Soft {
18173                    target: "/d".into(),
18174                },
18175            )
18176            .unwrap();
18177        writer
18178            .create_symbolic_link(
18179                "/",
18180                "e",
18181                LinkTarget::External {
18182                    file: "other.h5".into(),
18183                    path: "/x".into(),
18184                },
18185            )
18186            .unwrap();
18187
18188        for taken in ["d", "c", "g", "t", "h", "s", "e"] {
18189            let attempts: [(&str, IoResult<()>); 8] = [
18190                (
18191                    "dataset",
18192                    writer.create_dataset(taken, i32t(), &[2]).map(|_| ()),
18193                ),
18194                (
18195                    "compact",
18196                    writer
18197                        .create_compact_dataset(taken, i32t(), &[2])
18198                        .map(|_| ()),
18199                ),
18200                (
18201                    "chunked",
18202                    writer
18203                        .create_chunked_dataset(taken, i32t(), &[0], &[u64::MAX], &[4])
18204                        .map(|_| ()),
18205                ),
18206                (
18207                    "vlen_string",
18208                    writer
18209                        .create_vlen_string_dataset(taken, &["x"], 1)
18210                        .map(|_| ()),
18211                ),
18212                (
18213                    "committed datatype",
18214                    writer.commit_datatype(taken, i32t()).map(|_| ()),
18215                ),
18216                ("group", writer.create_group("/", taken).map(|_| ())),
18217                ("hard link", writer.create_hard_link("/", taken, "d")),
18218                (
18219                    "soft link",
18220                    writer.create_symbolic_link(
18221                        "/",
18222                        taken,
18223                        LinkTarget::Soft {
18224                            target: "/d".into(),
18225                        },
18226                    ),
18227                ),
18228            ];
18229            for (which, res) in attempts {
18230                match res {
18231                    Ok(()) => panic!("{which} accepted the taken name '{taken}'"),
18232                    Err(e) => assert!(
18233                        e.to_string().contains("already exists"),
18234                        "{which} on '{taken}': unexpected error: {e}"
18235                    ),
18236                }
18237            }
18238        }
18239
18240        writer.close().unwrap();
18241        std::fs::remove_file(&path).ok();
18242    }
18243
18244    /// The `H5T_VLEN` length field counts base elements, so an image that is
18245    /// not a whole number of them has no length that reads back as what was
18246    /// handed over; it is refused at the call rather than stored truncated.
18247    #[test]
18248    fn vlen_sequence_refuses_a_partial_element() {
18249        let path = temp_path("vlen_partial_element");
18250
18251        let writer = Hdf5Writer::create(&path).unwrap();
18252        let err = writer
18253            .create_vlen_sequence_dataset("d", DatatypeMessage::i32_type(), &[&[1u8, 2, 3, 4, 5]])
18254            .unwrap_err()
18255            .to_string();
18256        assert!(err.contains("5 bytes"), "unexpected error: {err}");
18257        assert!(err.contains("4-byte elements"), "unexpected error: {err}");
18258
18259        // The refusal is the length rule alone: the same base takes a whole
18260        // number of elements, and an empty sequence is a legal one.
18261        writer
18262            .create_vlen_sequence_dataset(
18263                "d",
18264                DatatypeMessage::i32_type(),
18265                &[&[1u8, 2, 3, 4], &[][..]],
18266            )
18267            .unwrap();
18268
18269        writer.close().unwrap();
18270        std::fs::remove_file(&path).ok();
18271    }
18272
18273    /// A corrupt file can declare a zero-length chunk dimension; the
18274    /// superseded-reference read must reject it the way `write_slice` does,
18275    /// not divide by it.
18276    #[test]
18277    fn vlen_slice_rejects_a_zero_chunk_dimension() {
18278        let path = temp_path("vlen_slice_zero_chunk");
18279
18280        let writer = Hdf5Writer::create(&path).unwrap();
18281        let idx = writer
18282            .create_appendable_vlen_string_dataset("d", 2, None)
18283            .unwrap();
18284        writer.append_vlen_strings(idx, &["a", "b"]).unwrap();
18285        writer.ds(idx).lock().chunked.as_mut().unwrap().chunk_dims[0] = 0;
18286        let err = writer.write_vlen_strings_slice(idx, 0, &["x"]).unwrap_err();
18287        assert!(
18288            err.to_string().contains("zero-length dimension"),
18289            "unexpected error: {err}"
18290        );
18291
18292        writer.ds(idx).lock().chunked.as_mut().unwrap().chunk_dims[0] = 2;
18293        writer.close().unwrap();
18294        std::fs::remove_file(&path).ok();
18295    }
18296
18297    /// A libhdf5-written collection can be 100% full — no free-space marker,
18298    /// content exactly the declared size. When a stale reference names an
18299    /// index that is not there, nothing is removed, and the collection must
18300    /// be left alone: re-encoding it at its declared size cannot fit the
18301    /// free-space marker and would fail the whole update.
18302    #[test]
18303    fn release_leaves_a_full_collection_it_removed_nothing_from() {
18304        use crate::format::global_heap::encode_vlen_reference;
18305
18306        let path = temp_path("release_full_gcol");
18307        let writer = Hdf5Writer::create(&path).unwrap();
18308
18309        // Hand-built full collection: 16-byte header + one 16+8-byte object,
18310        // declared size exactly 40, no free-space marker.
18311        let mut img = Vec::new();
18312        img.extend_from_slice(b"GCOL");
18313        img.push(1);
18314        img.extend_from_slice(&[0u8; 3]);
18315        img.extend_from_slice(&40u64.to_le_bytes());
18316        img.extend_from_slice(&1u16.to_le_bytes()); // object index 1
18317        img.extend_from_slice(&1u16.to_le_bytes()); // ref_count
18318        img.extend_from_slice(&0u32.to_le_bytes()); // reserved
18319        img.extend_from_slice(&8u64.to_le_bytes()); // data size
18320        img.extend_from_slice(b"deadbeef");
18321        assert_eq!(img.len(), 40);
18322        let addr = writer
18323            .allocator
18324            .allocate(img.len() as u64, FreeSpaceClass::RawData);
18325        writer.handle.write_at(addr, &img).unwrap();
18326
18327        // The superseded reference names index 2, which the collection does
18328        // not hold — a no-op removal.
18329        let refs = encode_vlen_reference(3, addr, 2, &writer.ctx);
18330        writer.release_vlen_references(&refs).unwrap();
18331        assert_eq!(writer.handle.read_at(addr, 40).unwrap(), img);
18332
18333        writer.close().unwrap();
18334        std::fs::remove_file(&path).ok();
18335    }
18336
18337    /// The CWFS second pass (`H5F_cwfs_find_free_heap`): an object too big
18338    /// for the listed collection's remaining free space extends the
18339    /// collection in place — the file allocation grows off the end of the
18340    /// file (`H5MF_try_extend`) and the collection's declared size and
18341    /// free-space marker grow with it (`H5HG_extend`) — instead of opening
18342    /// a second collection.
18343    #[test]
18344    fn an_oversized_vlen_insert_extends_the_listed_collection() {
18345        use crate::format::global_heap::GlobalHeapCollection;
18346
18347        let path = temp_path("cwfs_extend_tail");
18348        let writer = Hdf5Writer::create(&path).unwrap();
18349        // A small object opens a minimum-size (4096) listed collection —
18350        // the file's last allocation, so the extension grows the file end.
18351        let p1 = writer.insert_vlen_objects(&[b"hello".as_slice()]).unwrap();
18352        let big = vec![0x41u8; 5000]; // more than the ~4 KiB remaining
18353        let p2 = writer.insert_vlen_objects(&[big.as_slice()]).unwrap();
18354        assert_eq!(
18355            p2[0].0, p1[0].0,
18356            "the big object opened a second collection"
18357        );
18358
18359        // The block on disk is one grown collection holding both objects.
18360        let img = writer.handle.read_at_most(p1[0].0, 65536).unwrap();
18361        let (gcol, csize) = GlobalHeapCollection::decode(&img, &writer.ctx).unwrap();
18362        assert!(csize > 4096, "declared size did not grow: {csize}");
18363        assert_eq!(gcol.objects.len(), 2);
18364        assert_eq!(gcol.objects[1].data, big);
18365
18366        writer.close().unwrap();
18367        let bytes = std::fs::read(&path).unwrap();
18368        assert_eq!(
18369            bytes.windows(4).filter(|w| *w == b"GCOL").count(),
18370            1,
18371            "a second collection signature is in the file"
18372        );
18373        std::fs::remove_file(&path).ok();
18374    }
18375
18376    /// The non-tail counterpart: the collection is pinned away from the end
18377    /// of the file, but a released block starts right after it, so the
18378    /// extension consumes the front of that block (`H5MF_try_extend`'s
18379    /// free-section path) and the remainder stays reusable.
18380    #[test]
18381    fn extension_consumes_a_freed_block_after_the_collection() {
18382        use crate::format::global_heap::GlobalHeapCollection;
18383
18384        let path = temp_path("cwfs_extend_freed");
18385        let writer = Hdf5Writer::create(&path).unwrap();
18386        let p1 = writer.insert_vlen_objects(&[b"hello".as_slice()]).unwrap();
18387        let addr = p1[0].0;
18388        // Land a block right after the collection, pin the file end past
18389        // it, then release it: extension must use the released space.
18390        let spacer = writer.allocator.allocate(8192, FreeSpaceClass::RawData);
18391        assert_eq!(spacer, addr + 4096, "spacer not adjacent; layout changed");
18392        writer.allocator.allocate(8, FreeSpaceClass::RawData);
18393        writer.allocator.free(spacer, 8192, FreeSpaceClass::RawData);
18394
18395        let big = vec![0x42u8; 5000];
18396        let p2 = writer.insert_vlen_objects(&[big.as_slice()]).unwrap();
18397        assert_eq!(p2[0].0, addr, "the big object opened a second collection");
18398
18399        let img = writer.handle.read_at_most(addr, 65536).unwrap();
18400        let (gcol, csize) = GlobalHeapCollection::decode(&img, &writer.ctx).unwrap();
18401        assert_eq!(csize, 8192, "grew by max(size, shortfall) = 4096");
18402        assert_eq!(gcol.objects.len(), 2);
18403
18404        // The remainder of the released block is still allocatable.
18405        assert_eq!(
18406            writer.allocator.allocate(4096, FreeSpaceClass::RawData),
18407            addr + 8192,
18408            "the freed block's tail was lost"
18409        );
18410        writer.close().unwrap();
18411        std::fs::remove_file(&path).ok();
18412    }
18413
18414    /// Issue #10: a reopen-and-replace loop on a vlen string must not grow
18415    /// the file. The superseded heap objects are freed *before* the
18416    /// replacement is allocated, so each session reuses the block it just
18417    /// released even though the free list starts empty on reopen. The old
18418    /// free-after-alloc order failed this by one collection per session.
18419    #[test]
18420    fn vlen_replace_across_reopen_keeps_the_file_flat() {
18421        let path = temp_path("vlen_reopen_flat");
18422        let payload_a = "a".repeat(64 * 1024);
18423        let payload_b = "b".repeat(64 * 1024);
18424
18425        let writer = Hdf5Writer::create(&path).unwrap();
18426        writer
18427            .create_vlen_string_dataset("notes", &["initial"], 1)
18428            .unwrap();
18429        writer.close().unwrap();
18430
18431        let mut sizes = Vec::new();
18432        for i in 0..8 {
18433            let writer = Hdf5Writer::open_append(&path).unwrap();
18434            let payload = if i % 2 == 0 { &payload_a } else { &payload_b };
18435            writer
18436                .write_vlen_strings_slice(0, 0, &[payload.as_str()])
18437                .unwrap();
18438            writer.close().unwrap();
18439            sizes.push(std::fs::metadata(&path).unwrap().len());
18440        }
18441        // The first replacement grows the file once (the initial collection
18442        // cannot hold 64 KiB); every later equal-size replacement must land
18443        // in the block its own session just freed.
18444        assert_eq!(&sizes[1..], &vec![sizes[0]; 7][..], "sizes: {sizes:?}");
18445
18446        // The reused blocks still form a valid file holding the last value.
18447        let mut reader = Hdf5Reader::open(&path).unwrap();
18448        assert_eq!(
18449            reader.read_vlen_strings("notes").unwrap(),
18450            vec![payload_b.clone()]
18451        );
18452
18453        std::fs::remove_file(&path).ok();
18454    }
18455
18456    /// Replacing a vlen string attribute must release the superseded
18457    /// global-heap collection *before* the replacement's collection is
18458    /// allocated, so a reopen-replace loop lands each new value in the block
18459    /// it just freed instead of growing the file by one collection per
18460    /// session — the attribute counterpart of
18461    /// [`vlen_replace_across_reopen_keeps_the_file_flat`].
18462    #[test]
18463    fn vlen_attr_replace_across_reopen_keeps_the_file_flat() {
18464        let path = temp_path("vlen_attr_reopen_flat");
18465        let payload_a = "a".repeat(8 * 1024);
18466        let payload_b = "b".repeat(8 * 1024);
18467
18468        let writer = Hdf5Writer::create(&path).unwrap();
18469        writer
18470            .set_vlen_string_attribute(AttrTarget::Root, "note", &payload_a)
18471            .unwrap();
18472        writer.close().unwrap();
18473
18474        let mut sizes = Vec::new();
18475        for i in 0..8 {
18476            let writer = Hdf5Writer::open_append(&path).unwrap();
18477            let payload = if i % 2 == 0 { &payload_b } else { &payload_a };
18478            writer
18479                .set_vlen_string_attribute(AttrTarget::Root, "note", payload)
18480                .unwrap();
18481            writer.close().unwrap();
18482            sizes.push(std::fs::metadata(&path).unwrap().len());
18483        }
18484        assert_eq!(&sizes[1..], &vec![sizes[0]; 7][..], "sizes: {sizes:?}");
18485
18486        // The reused blocks still hold the last value.
18487        let reader = Hdf5Reader::open(&path).unwrap();
18488        let attr = reader.root_attr("note").unwrap().clone();
18489        let mut reader = reader;
18490        assert_eq!(reader.attr_string_value(&attr).unwrap(), payload_a);
18491
18492        std::fs::remove_file(&path).ok();
18493    }
18494
18495    /// A numeric attribute replacing a vlen one goes through the same list
18496    /// owner, so the superseded collection is released even though the new
18497    /// value holds no heap reference: a later same-size vlen attribute must
18498    /// land in the freed block, making the file exactly as large as one that
18499    /// never stored the replaced value.
18500    #[test]
18501    fn numeric_replacing_a_vlen_attr_releases_its_collection() {
18502        let payload = "x".repeat(8 * 1024);
18503        let numeric = || {
18504            AttributeMessage::scalar_numeric(
18505                "x",
18506                DatatypeMessage::i32_type(),
18507                7i32.to_le_bytes().to_vec(),
18508            )
18509        };
18510
18511        let path_a = temp_path("vlen_attr_cross_a");
18512        let writer = Hdf5Writer::create(&path_a).unwrap();
18513        writer
18514            .set_vlen_string_attribute(AttrTarget::Root, "x", &payload)
18515            .unwrap();
18516        writer.add_root_attribute(numeric()).unwrap();
18517        writer
18518            .set_vlen_string_attribute(AttrTarget::Root, "y", &payload)
18519            .unwrap();
18520        writer.close().unwrap();
18521
18522        // The same end state written without the replaced vlen value.
18523        let path_b = temp_path("vlen_attr_cross_b");
18524        let writer = Hdf5Writer::create(&path_b).unwrap();
18525        writer.add_root_attribute(numeric()).unwrap();
18526        writer
18527            .set_vlen_string_attribute(AttrTarget::Root, "y", &payload)
18528            .unwrap();
18529        writer.close().unwrap();
18530
18531        assert_eq!(
18532            std::fs::metadata(&path_a).unwrap().len(),
18533            std::fs::metadata(&path_b).unwrap().len()
18534        );
18535
18536        let reader = Hdf5Reader::open(&path_a).unwrap();
18537        let y = reader.root_attr("y").unwrap().clone();
18538        let mut reader = reader;
18539        assert_eq!(reader.attr_string_value(&y).unwrap(), payload);
18540
18541        std::fs::remove_file(&path_a).ok();
18542        std::fs::remove_file(&path_b).ok();
18543    }
18544
18545    /// Reopen/write/close cycles must not leak the object-header blocks
18546    /// finalize rewrites: the reopened root header, the reopened group
18547    /// header, and the modified chunked dataset's header are each freed
18548    /// before their replacements are allocated. The chunk rewrite itself is
18549    /// in place (unfiltered chunks never move), so a leak of any header
18550    /// block shows up as monotonic growth here.
18551    #[test]
18552    fn reopen_cycles_reuse_superseded_header_blocks() {
18553        let path = temp_path("header_reuse");
18554        {
18555            let writer = Hdf5Writer::create(&path).unwrap();
18556            writer.create_group("/", "g").unwrap();
18557            let idx = writer
18558                .create_chunked_dataset(
18559                    "g/data",
18560                    DatatypeMessage::i32_type(),
18561                    &[4],
18562                    &[u64::MAX],
18563                    &[4],
18564                )
18565                .unwrap();
18566            let seed: Vec<u8> = [1i32, 2, 3, 4]
18567                .iter()
18568                .flat_map(|v| v.to_le_bytes())
18569                .collect();
18570            writer.write_chunk(idx, 0, &seed).unwrap();
18571            writer.close().unwrap();
18572        }
18573
18574        let mut sizes = Vec::new();
18575        for i in 0..6i32 {
18576            let writer = Hdf5Writer::open_append(&path).unwrap();
18577            let data: Vec<u8> = [i; 4].iter().flat_map(|v| v.to_le_bytes()).collect();
18578            writer.write_chunk(0, 0, &data).unwrap();
18579            writer.close().unwrap();
18580            sizes.push(std::fs::metadata(&path).unwrap().len());
18581        }
18582        assert_eq!(&sizes[1..], &vec![sizes[0]; 5][..], "sizes: {sizes:?}");
18583
18584        // The reused header blocks still form a valid file.
18585        let mut reader = Hdf5Reader::open(&path).unwrap();
18586        let raw = reader.read_dataset_raw("g/data").unwrap();
18587        let values: Vec<i32> = raw
18588            .chunks(4)
18589            .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
18590            .collect();
18591        assert_eq!(values, vec![5, 5, 5, 5]);
18592
18593        std::fs::remove_file(&path).ok();
18594    }
18595
18596    #[test]
18597    fn create_empty_file() {
18598        let path = temp_path("empty");
18599
18600        let writer = Hdf5Writer::create(&path).unwrap();
18601        writer.close().unwrap();
18602
18603        // Verify we can read it back
18604        let reader = Hdf5Reader::open(&path).unwrap();
18605        assert!(reader.dataset_names().is_empty());
18606
18607        std::fs::remove_file(&path).ok();
18608    }
18609
18610    #[test]
18611    fn create_single_dataset() {
18612        let path = temp_path("single");
18613
18614        let writer = Hdf5Writer::create(&path).unwrap();
18615        let idx = writer
18616            .create_dataset("data", DatatypeMessage::f64_type(), &[4])
18617            .unwrap();
18618        let values: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0];
18619        let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
18620        writer.write_dataset_raw(idx, &raw).unwrap();
18621        writer.close().unwrap();
18622
18623        // Read back
18624        let mut reader = Hdf5Reader::open(&path).unwrap();
18625        assert_eq!(reader.dataset_names(), vec!["data"]);
18626        assert_eq!(reader.dataset_shape("data").unwrap(), vec![4]);
18627        let readback = reader.read_dataset_raw("data").unwrap();
18628        assert_eq!(readback, raw);
18629
18630        std::fs::remove_file(&path).ok();
18631    }
18632
18633    #[test]
18634    fn create_multiple_datasets() {
18635        let path = temp_path("multi");
18636
18637        let writer = Hdf5Writer::create(&path).unwrap();
18638
18639        let idx0 = writer
18640            .create_dataset("ints", DatatypeMessage::i32_type(), &[3])
18641            .unwrap();
18642        let i_data: Vec<u8> = [10i32, 20, 30]
18643            .iter()
18644            .flat_map(|v| v.to_le_bytes())
18645            .collect();
18646        writer.write_dataset_raw(idx0, &i_data).unwrap();
18647
18648        let idx1 = writer
18649            .create_dataset("floats", DatatypeMessage::f32_type(), &[2, 2])
18650            .unwrap();
18651        let f_data: Vec<u8> = [1.0f32, 2.0, 3.0, 4.0]
18652            .iter()
18653            .flat_map(|v| v.to_le_bytes())
18654            .collect();
18655        writer.write_dataset_raw(idx1, &f_data).unwrap();
18656
18657        writer.close().unwrap();
18658
18659        let mut reader = Hdf5Reader::open(&path).unwrap();
18660        let names = reader.dataset_names();
18661        assert!(names.contains(&"ints"));
18662        assert!(names.contains(&"floats"));
18663        assert_eq!(reader.dataset_shape("ints").unwrap(), vec![3]);
18664        assert_eq!(reader.dataset_shape("floats").unwrap(), vec![2, 2]);
18665        assert_eq!(reader.read_dataset_raw("ints").unwrap(), i_data);
18666        assert_eq!(reader.read_dataset_raw("floats").unwrap(), f_data);
18667
18668        std::fs::remove_file(&path).ok();
18669    }
18670
18671    #[test]
18672    fn data_size_mismatch() {
18673        let path = temp_path("mismatch");
18674
18675        let writer = Hdf5Writer::create(&path).unwrap();
18676        let idx = writer
18677            .create_dataset("x", DatatypeMessage::u8_type(), &[4])
18678            .unwrap();
18679        let err = writer.write_dataset_raw(idx, &[1, 2, 3]); // 3 bytes instead of 4
18680        assert!(err.is_err());
18681
18682        std::fs::remove_file(&path).ok();
18683    }
18684
18685    #[test]
18686    fn create_chunked_dataset_simple() {
18687        let path = temp_path("chunked_simple");
18688
18689        let writer = Hdf5Writer::create(&path).unwrap();
18690        let idx = writer
18691            .create_chunked_dataset(
18692                "data",
18693                DatatypeMessage::f64_type(),
18694                &[0, 4],        // start empty
18695                &[u64::MAX, 4], // unlimited first dim
18696                &[1, 4],        // chunk = [1, 4]
18697            )
18698            .unwrap();
18699
18700        // Write 3 frames (chunks)
18701        for frame in 0..3u64 {
18702            let values: Vec<f64> = (0..4).map(|i| (frame * 4 + i) as f64).collect();
18703            let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
18704            writer.write_chunk(idx, frame, &raw).unwrap();
18705        }
18706
18707        // Extend dimensions
18708        writer.extend_dataset(idx, &[3, 4]).unwrap();
18709
18710        writer.close().unwrap();
18711
18712        // Read back
18713        let mut reader = Hdf5Reader::open(&path).unwrap();
18714        assert_eq!(reader.dataset_names(), vec!["data"]);
18715        assert_eq!(reader.dataset_shape("data").unwrap(), vec![3, 4]);
18716
18717        let raw = reader.read_dataset_raw("data").unwrap();
18718        let values: Vec<f64> = raw
18719            .chunks(8)
18720            .map(|chunk| f64::from_le_bytes(chunk.try_into().unwrap()))
18721            .collect();
18722        assert_eq!(values.len(), 12);
18723        for (i, val) in values.iter().enumerate() {
18724            assert_eq!(*val, i as f64);
18725        }
18726
18727        std::fs::remove_file(&path).ok();
18728    }
18729
18730    #[test]
18731    fn chunked_dataset_many_frames() {
18732        let path = temp_path("chunked_many");
18733
18734        let writer = Hdf5Writer::create(&path).unwrap();
18735        let idx = writer
18736            .create_chunked_dataset(
18737                "frames",
18738                DatatypeMessage::i32_type(),
18739                &[0, 2],
18740                &[u64::MAX, 2],
18741                &[1, 2],
18742            )
18743            .unwrap();
18744
18745        let n_frames = 10u64;
18746        for frame in 0..n_frames {
18747            let values = [(frame * 2) as i32, (frame * 2 + 1) as i32];
18748            let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
18749            writer.write_chunk(idx, frame, &raw).unwrap();
18750        }
18751
18752        writer.extend_dataset(idx, &[n_frames, 2]).unwrap();
18753        writer.close().unwrap();
18754
18755        // Read back
18756        let mut reader = Hdf5Reader::open(&path).unwrap();
18757        assert_eq!(reader.dataset_shape("frames").unwrap(), vec![10, 2]);
18758
18759        let raw = reader.read_dataset_raw("frames").unwrap();
18760        let values: Vec<i32> = raw
18761            .chunks(4)
18762            .map(|chunk| i32::from_le_bytes(chunk.try_into().unwrap()))
18763            .collect();
18764        assert_eq!(values.len(), 20);
18765        for (i, val) in values.iter().enumerate() {
18766            assert_eq!(*val, i as i32);
18767        }
18768
18769        std::fs::remove_file(&path).ok();
18770    }
18771
18772    #[test]
18773    fn create_fixed_array_dataset_roundtrip() {
18774        let path = temp_path("fixed_array");
18775
18776        let writer = Hdf5Writer::create(&path).unwrap();
18777        let idx = writer
18778            .create_fixed_array_dataset(
18779                "grid",
18780                DatatypeMessage::i32_type(),
18781                &[4, 6], // 4x6 grid
18782                &[2, 3], // chunk = 2x3
18783            )
18784            .unwrap();
18785
18786        // Write all chunks: 2x2 = 4 chunks
18787        // chunk (0,0): rows 0-1, cols 0-2
18788        let c00: Vec<u8> = [0i32, 1, 2, 6, 7, 8]
18789            .iter()
18790            .flat_map(|v| v.to_le_bytes())
18791            .collect();
18792        writer.write_chunk_fixed_array(idx, &[0, 0], &c00).unwrap();
18793
18794        // chunk (0,1): rows 0-1, cols 3-5
18795        let c01: Vec<u8> = [3i32, 4, 5, 9, 10, 11]
18796            .iter()
18797            .flat_map(|v| v.to_le_bytes())
18798            .collect();
18799        writer.write_chunk_fixed_array(idx, &[0, 1], &c01).unwrap();
18800
18801        // chunk (1,0): rows 2-3, cols 0-2
18802        let c10: Vec<u8> = [12i32, 13, 14, 18, 19, 20]
18803            .iter()
18804            .flat_map(|v| v.to_le_bytes())
18805            .collect();
18806        writer.write_chunk_fixed_array(idx, &[1, 0], &c10).unwrap();
18807
18808        // chunk (1,1): rows 2-3, cols 3-5
18809        let c11: Vec<u8> = [15i32, 16, 17, 21, 22, 23]
18810            .iter()
18811            .flat_map(|v| v.to_le_bytes())
18812            .collect();
18813        writer.write_chunk_fixed_array(idx, &[1, 1], &c11).unwrap();
18814
18815        writer.close().unwrap();
18816
18817        // Read back
18818        let mut reader = Hdf5Reader::open(&path).unwrap();
18819        assert_eq!(reader.dataset_names(), vec!["grid"]);
18820        assert_eq!(reader.dataset_shape("grid").unwrap(), vec![4, 6]);
18821
18822        let raw = reader.read_dataset_raw("grid").unwrap();
18823        let values: Vec<i32> = raw
18824            .chunks(4)
18825            .map(|chunk| i32::from_le_bytes(chunk.try_into().unwrap()))
18826            .collect();
18827        assert_eq!(values.len(), 24);
18828        for (i, val) in values.iter().enumerate() {
18829            assert_eq!(*val, i as i32);
18830        }
18831
18832        std::fs::remove_file(&path).ok();
18833    }
18834
18835    #[test]
18836    fn fixed_array_paged_dblk_disk_size() {
18837        let ctx = FormatContext {
18838            sizeof_addr: 8,
18839            sizeof_size: 8,
18840        };
18841        // 1024 elements per page (bits=10). 3000 chunks => 3 pages.
18842        let hdr = FixedArrayHeader::new_for_chunks(&ctx, 3000);
18843        assert!(hdr.is_paged());
18844        assert_eq!(hdr.npages(), 3);
18845        // prefix: 4+1+1+8 + bitmap(1) + cksum(4) = 19
18846        // elements: 3000 * 8 = 24000 ; per-page cksum: 3 * 4 = 12
18847        assert_eq!(fixed_array_dblk_disk_size(&ctx, &hdr), 19 + 24000 + 12);
18848
18849        // Non-paged: 1000 elements. prefix(14) + 1000*8 + cksum(4).
18850        let small = FixedArrayHeader::new_for_chunks(&ctx, 1000);
18851        assert!(!small.is_paged());
18852        assert_eq!(fixed_array_dblk_disk_size(&ctx, &small), 14 + 8000 + 4);
18853    }
18854
18855    #[test]
18856    fn fixed_array_paged_encode_matches_reader_layout() {
18857        let ctx = FormatContext {
18858            sizeof_addr: 8,
18859            sizeof_size: 8,
18860        };
18861        let mut hdr = FixedArrayHeader::new_for_chunks(&ctx, 2500);
18862        hdr.data_blk_addr = 0x9000;
18863        let npages = hdr.npages() as usize; // ceil(2500/1024) = 3
18864
18865        let mut dblk = FixedArrayDataBlock::new_unfiltered(0x1000, 2500);
18866        for (i, e) in dblk.elements.iter_mut().enumerate() {
18867            *e = 0x10000 + (i as u64) * 0x100;
18868        }
18869
18870        let encoded = encode_fixed_array_dblk(&ctx, &hdr, &dblk);
18871        assert_eq!(encoded.len() as u64, fixed_array_dblk_disk_size(&ctx, &hdr));
18872
18873        // Decode the prefix and pages exactly as the reader does.
18874        let prefix = FixedArrayPagedPrefix::decode(&encoded, &ctx, npages as u64).unwrap();
18875        assert_eq!(prefix.header_addr, 0x1000);
18876        for p in 0..npages {
18877            assert!(prefix.page_initialized(p), "page {p} should be initialized");
18878        }
18879
18880        let dblk_page_nelmts = hdr.dblk_page_nelmts() as usize;
18881        let page_stride = dblk_page_nelmts * 8 + 4;
18882        let mut recovered = Vec::new();
18883        for p in 0..npages {
18884            let page_nelmts = if p + 1 == npages {
18885                2500 - p * dblk_page_nelmts
18886            } else {
18887                dblk_page_nelmts
18888            };
18889            let off = prefix.prefix_size + p * page_stride;
18890            let page_buf = &encoded[off..];
18891            let addrs = crate::format::chunk_index::fixed_array::decode_unfiltered_page(
18892                page_buf,
18893                &ctx,
18894                page_nelmts,
18895            )
18896            .unwrap();
18897            recovered.extend(addrs);
18898        }
18899        assert_eq!(recovered, dblk.elements);
18900    }
18901
18902    #[test]
18903    fn fixed_array_paged_decode_roundtrip_with_uninitialized_page() {
18904        let ctx = FormatContext {
18905            sizeof_addr: 8,
18906            sizeof_size: 8,
18907        };
18908        let hdr = FixedArrayHeader::new_for_chunks(&ctx, 2500);
18909        let npages = hdr.npages() as usize; // 3
18910        let page = hdr.dblk_page_nelmts() as usize; // 1024
18911
18912        // Populate pages 0 and 2; leave page 1 entirely undefined so its
18913        // bitmap bit stays clear on encode.
18914        let mut dblk = FixedArrayDataBlock::new_unfiltered(0x1000, 2500);
18915        for i in (0..page).chain(2 * page..2500) {
18916            dblk.elements[i] = 0x10000 + (i as u64) * 0x100;
18917        }
18918
18919        let mut encoded = encode_fixed_array_dblk(&ctx, &hdr, &dblk);
18920        let prefix = FixedArrayPagedPrefix::decode(&encoded, &ctx, npages as u64).unwrap();
18921        assert!(prefix.page_initialized(0));
18922        assert!(!prefix.page_initialized(1));
18923        assert!(prefix.page_initialized(2));
18924
18925        // Corrupt the uninitialized page's bytes the way libhdf5 leaves
18926        // them: arbitrary, no valid checksum. Decode must not look at it.
18927        let page_stride = page * 8 + 4;
18928        let p1 = prefix.prefix_size + page_stride;
18929        for b in &mut encoded[p1..p1 + page_stride] {
18930            *b = 0x5A;
18931        }
18932
18933        let decoded = decode_fixed_array_dblk(&ctx, &hdr, &encoded, 0).unwrap();
18934        assert_eq!(decoded.elements, dblk.elements);
18935        assert_eq!(decoded.header_addr, 0x1000);
18936    }
18937
18938    #[test]
18939    fn fixed_array_paged_decode_filtered_roundtrip() {
18940        let ctx = FormatContext {
18941            sizeof_addr: 8,
18942            sizeof_size: 8,
18943        };
18944        let chunk_size_len = 4usize;
18945        let hdr = FixedArrayHeader::new_for_filtered_chunks(&ctx, 1500, chunk_size_len as u8);
18946        assert!(hdr.is_paged());
18947
18948        let mut dblk = FixedArrayDataBlock::new_filtered(0x2000, 1500);
18949        for (i, e) in dblk.filtered_elements.iter_mut().enumerate() {
18950            e.address = 0x8000 + (i as u64) * 0x40;
18951            e.chunk_size = 100 + i as u64;
18952            e.filter_mask = (i % 3) as u32;
18953        }
18954
18955        let encoded = encode_fixed_array_dblk(&ctx, &hdr, &dblk);
18956        assert_eq!(encoded.len() as u64, fixed_array_dblk_disk_size(&ctx, &hdr));
18957        let decoded = decode_fixed_array_dblk(&ctx, &hdr, &encoded, chunk_size_len).unwrap();
18958        assert_eq!(decoded.filtered_elements, dblk.filtered_elements);
18959        assert_eq!(decoded.client_id, FA_CLIENT_FILT_CHUNK);
18960    }
18961
18962    #[test]
18963    fn create_fixed_array_paged_dataset_roundtrip() {
18964        let path = temp_path("fixed_array_paged");
18965
18966        // 1D dataset of 3000 elements, chunk size 1 => 3000 chunks.
18967        // 3000 > 1024 (one page) => the FA data block must be paged.
18968        let n: usize = 3000;
18969        let writer = Hdf5Writer::create(&path).unwrap();
18970        let idx = writer
18971            .create_fixed_array_dataset("paged", DatatypeMessage::i32_type(), &[n as u64], &[1])
18972            .unwrap();
18973
18974        for i in 0..n {
18975            let v = (i as i32).to_le_bytes();
18976            writer
18977                .write_chunk_fixed_array(idx, &[i as u64], &v)
18978                .unwrap();
18979        }
18980        writer.close().unwrap();
18981
18982        let mut reader = Hdf5Reader::open(&path).unwrap();
18983        assert_eq!(reader.dataset_shape("paged").unwrap(), vec![n as u64]);
18984        let raw = reader.read_dataset_raw("paged").unwrap();
18985        let values: Vec<i32> = raw
18986            .chunks(4)
18987            .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
18988            .collect();
18989        assert_eq!(values.len(), n);
18990        for (i, v) in values.iter().enumerate() {
18991            assert_eq!(*v, i as i32, "element {i}");
18992        }
18993
18994        std::fs::remove_file(&path).ok();
18995    }
18996
18997    #[cfg(feature = "deflate")]
18998    #[test]
18999    fn create_filtered_fixed_array_dataset_roundtrip() {
19000        // Small compressed fixed-shape chunked dataset: flat filtered FA.
19001        let path = temp_path("fixed_array_filt");
19002
19003        let writer = Hdf5Writer::create(&path).unwrap();
19004        let idx = writer
19005            .create_fixed_array_dataset_with_pipeline(
19006                "grid",
19007                DatatypeMessage::i32_type(),
19008                &[4, 6], // 4x6 grid
19009                &[2, 3], // chunk = 2x3 => 2x2 = 4 chunks
19010                FilterPipeline::deflate(6),
19011            )
19012            .unwrap();
19013
19014        let c00: Vec<u8> = [0i32, 1, 2, 6, 7, 8]
19015            .iter()
19016            .flat_map(|v| v.to_le_bytes())
19017            .collect();
19018        writer.write_chunk_fixed_array(idx, &[0, 0], &c00).unwrap();
19019        let c01: Vec<u8> = [3i32, 4, 5, 9, 10, 11]
19020            .iter()
19021            .flat_map(|v| v.to_le_bytes())
19022            .collect();
19023        writer.write_chunk_fixed_array(idx, &[0, 1], &c01).unwrap();
19024        let c10: Vec<u8> = [12i32, 13, 14, 18, 19, 20]
19025            .iter()
19026            .flat_map(|v| v.to_le_bytes())
19027            .collect();
19028        writer.write_chunk_fixed_array(idx, &[1, 0], &c10).unwrap();
19029        let c11: Vec<u8> = [15i32, 16, 17, 21, 22, 23]
19030            .iter()
19031            .flat_map(|v| v.to_le_bytes())
19032            .collect();
19033        writer.write_chunk_fixed_array(idx, &[1, 1], &c11).unwrap();
19034
19035        writer.close().unwrap();
19036
19037        let mut reader = Hdf5Reader::open(&path).unwrap();
19038        assert_eq!(reader.dataset_shape("grid").unwrap(), vec![4, 6]);
19039        let raw = reader.read_dataset_raw("grid").unwrap();
19040        let values: Vec<i32> = raw
19041            .chunks(4)
19042            .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
19043            .collect();
19044        assert_eq!(values.len(), 24);
19045        for (i, v) in values.iter().enumerate() {
19046            assert_eq!(*v, i as i32, "element {i}");
19047        }
19048
19049        std::fs::remove_file(&path).ok();
19050    }
19051
19052    #[cfg(feature = "deflate")]
19053    #[test]
19054    fn create_filtered_fixed_array_paged_dataset_roundtrip() {
19055        // Large compressed fixed-shape chunked dataset (>1024 chunks): the
19056        // filtered FA data block must be paged.
19057        let path = temp_path("fixed_array_filt_paged");
19058
19059        let n: usize = 3000;
19060        let writer = Hdf5Writer::create(&path).unwrap();
19061        let idx = writer
19062            .create_fixed_array_dataset_with_pipeline(
19063                "paged",
19064                DatatypeMessage::i32_type(),
19065                &[n as u64],
19066                &[1],
19067                FilterPipeline::deflate(6),
19068            )
19069            .unwrap();
19070
19071        for i in 0..n {
19072            let v = (i as i32).to_le_bytes();
19073            writer
19074                .write_chunk_fixed_array(idx, &[i as u64], &v)
19075                .unwrap();
19076        }
19077        writer.close().unwrap();
19078
19079        let mut reader = Hdf5Reader::open(&path).unwrap();
19080        assert_eq!(reader.dataset_shape("paged").unwrap(), vec![n as u64]);
19081        let raw = reader.read_dataset_raw("paged").unwrap();
19082        let values: Vec<i32> = raw
19083            .chunks(4)
19084            .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
19085            .collect();
19086        assert_eq!(values.len(), n);
19087        for (i, v) in values.iter().enumerate() {
19088            assert_eq!(*v, i as i32, "element {i}");
19089        }
19090
19091        std::fs::remove_file(&path).ok();
19092    }
19093
19094    #[test]
19095    fn filtered_fixed_array_dblk_disk_size_and_encode() {
19096        // Cross-check filtered FA data-block sizing against the encoded length,
19097        // for both flat and paged layouts.
19098        let ctx = FormatContext {
19099            sizeof_addr: 8,
19100            sizeof_size: 8,
19101        };
19102        let csl = 3u8; // chunk_size_len
19103        let elem_size = 8 + csl as usize + 4; // addr + size + filter_mask
19104
19105        // Flat: 100 chunks. prefix(14) + 100*elem_size + cksum(4).
19106        let mut flat = FixedArrayHeader::new_for_filtered_chunks(&ctx, 100, csl);
19107        flat.data_blk_addr = 0x4000;
19108        assert!(!flat.is_paged());
19109        assert_eq!(
19110            fixed_array_dblk_disk_size(&ctx, &flat),
19111            (14 + 100 * elem_size + 4) as u64
19112        );
19113        let flat_dblk = FixedArrayDataBlock::new_filtered(0x1000, 100);
19114        assert_eq!(
19115            encode_fixed_array_dblk(&ctx, &flat, &flat_dblk).len() as u64,
19116            fixed_array_dblk_disk_size(&ctx, &flat)
19117        );
19118
19119        // Paged: 2500 chunks => 3 pages. prefix(4+1+1+8+1+4=19)
19120        // + 2500*elem_size + 3*cksum(4).
19121        let mut paged = FixedArrayHeader::new_for_filtered_chunks(&ctx, 2500, csl);
19122        paged.data_blk_addr = 0x9000;
19123        assert!(paged.is_paged());
19124        assert_eq!(paged.npages(), 3);
19125        assert_eq!(
19126            fixed_array_dblk_disk_size(&ctx, &paged),
19127            (19 + 2500 * elem_size + 12) as u64
19128        );
19129        let mut paged_dblk = FixedArrayDataBlock::new_filtered(0x1000, 2500);
19130        for (i, e) in paged_dblk.filtered_elements.iter_mut().enumerate() {
19131            e.address = 0x10000 + (i as u64) * 0x100;
19132            e.chunk_size = (i % 200) as u64;
19133        }
19134        let encoded = encode_fixed_array_dblk(&ctx, &paged, &paged_dblk);
19135        assert_eq!(
19136            encoded.len() as u64,
19137            fixed_array_dblk_disk_size(&ctx, &paged)
19138        );
19139
19140        // Decode the paged prefix + pages as the reader does.
19141        let npages = paged.npages() as usize;
19142        let prefix = FixedArrayPagedPrefix::decode(&encoded, &ctx, npages as u64).unwrap();
19143        for p in 0..npages {
19144            assert!(prefix.page_initialized(p), "page {p}");
19145        }
19146        let dblk_page_nelmts = paged.dblk_page_nelmts() as usize;
19147        let page_stride = dblk_page_nelmts * elem_size + 4;
19148        let mut recovered = Vec::new();
19149        for p in 0..npages {
19150            let page_nelmts = if p + 1 == npages {
19151                2500 - p * dblk_page_nelmts
19152            } else {
19153                dblk_page_nelmts
19154            };
19155            let off = prefix.prefix_size + p * page_stride;
19156            let elems = crate::format::chunk_index::fixed_array::decode_filtered_page(
19157                &encoded[off..],
19158                &ctx,
19159                page_nelmts,
19160                csl as usize,
19161            )
19162            .unwrap();
19163            recovered.extend(elems);
19164        }
19165        assert_eq!(recovered, paged_dblk.filtered_elements);
19166    }
19167
19168    #[test]
19169    fn create_btree_v2_dataset_roundtrip() {
19170        let path = temp_path("btree_v2");
19171
19172        let writer = Hdf5Writer::create(&path).unwrap();
19173        let idx = writer
19174            .create_btree_v2_dataset(
19175                "data",
19176                DatatypeMessage::f64_type(),
19177                &[0, 0],               // start empty
19178                &[u64::MAX, u64::MAX], // both dims unlimited
19179                &[2, 3],               // chunk = 2x3
19180            )
19181            .unwrap();
19182
19183        // Write chunks for a 4x6 dataset
19184        // chunk (0,0)
19185        let c00: Vec<u8> = [0.0f64, 1.0, 2.0, 6.0, 7.0, 8.0]
19186            .iter()
19187            .flat_map(|v| v.to_le_bytes())
19188            .collect();
19189        writer.write_chunk_btree_v2(idx, &[0, 0], &c00).unwrap();
19190
19191        // chunk (0,1)
19192        let c01: Vec<u8> = [3.0f64, 4.0, 5.0, 9.0, 10.0, 11.0]
19193            .iter()
19194            .flat_map(|v| v.to_le_bytes())
19195            .collect();
19196        writer.write_chunk_btree_v2(idx, &[0, 1], &c01).unwrap();
19197
19198        // chunk (1,0)
19199        let c10: Vec<u8> = [12.0f64, 13.0, 14.0, 18.0, 19.0, 20.0]
19200            .iter()
19201            .flat_map(|v| v.to_le_bytes())
19202            .collect();
19203        writer.write_chunk_btree_v2(idx, &[1, 0], &c10).unwrap();
19204
19205        // chunk (1,1)
19206        let c11: Vec<u8> = [15.0f64, 16.0, 17.0, 21.0, 22.0, 23.0]
19207            .iter()
19208            .flat_map(|v| v.to_le_bytes())
19209            .collect();
19210        writer.write_chunk_btree_v2(idx, &[1, 1], &c11).unwrap();
19211
19212        writer.extend_dataset(idx, &[4, 6]).unwrap();
19213        writer.close().unwrap();
19214
19215        // Read back
19216        let mut reader = Hdf5Reader::open(&path).unwrap();
19217        assert_eq!(reader.dataset_names(), vec!["data"]);
19218        assert_eq!(reader.dataset_shape("data").unwrap(), vec![4, 6]);
19219
19220        let raw = reader.read_dataset_raw("data").unwrap();
19221        let values: Vec<f64> = raw
19222            .chunks(8)
19223            .map(|chunk| f64::from_le_bytes(chunk.try_into().unwrap()))
19224            .collect();
19225        assert_eq!(values.len(), 24);
19226        for (i, val) in values.iter().enumerate() {
19227            assert_eq!(*val, i as f64);
19228        }
19229
19230        std::fs::remove_file(&path).ok();
19231    }
19232
19233    /// Bytes one chunk of [`btree_v2_flush_probe`]'s dataset occupies — an
19234    /// f64 element, so the allocator's alignment neither pads nor merges it and
19235    /// the file's growth is exactly the bytes asked for.
19236    const BT2_PROBE_CHUNK: u64 = 8;
19237
19238    /// Write chunks of a 1x1-chunked 2-D BT2 dataset, flushing at each batch
19239    /// boundary, and report `(node addresses, file length)` after every flush.
19240    /// Chunks are addressed down column 0 so the record count — and hence the
19241    /// tree's shape — grows one record at a time.
19242    fn btree_v2_flush_probe(path: &std::path::Path, batches: &[u64]) -> Vec<(Vec<u64>, u64)> {
19243        let writer = Hdf5Writer::create(path).unwrap();
19244        let idx = writer
19245            .create_btree_v2_dataset(
19246                "data",
19247                DatatypeMessage::f64_type(),
19248                &[0, 0],
19249                &[u64::MAX, u64::MAX],
19250                &[1, 1],
19251            )
19252            .unwrap();
19253        let mut written = 0u64;
19254        let mut out = Vec::new();
19255        for &upto in batches {
19256            while written < upto {
19257                writer
19258                    .write_chunk_btree_v2(idx, &[written, 0], &(written as f64).to_le_bytes())
19259                    .unwrap();
19260                written += 1;
19261            }
19262            writer.flush_dataset(idx).unwrap();
19263            let addrs = writer
19264                .ds(idx)
19265                .lock()
19266                .btree_v2
19267                .as_ref()
19268                .unwrap()
19269                .node_addrs
19270                .clone();
19271            out.push((addrs, std::fs::metadata(path).unwrap().len()));
19272        }
19273        writer.extend_dataset(idx, &[written.max(1), 1]).unwrap();
19274        writer.close().unwrap();
19275        out
19276    }
19277
19278    /// The node pool tracks the tree in both directions. Dropping records is
19279    /// what a removal path would do — [`Bt2ChunkIndex`] has none today, so the
19280    /// test drops them itself — and the flush that follows must hand the blocks
19281    /// its smaller tree no longer needs back to the allocator instead of
19282    /// leaving them recorded and unreachable.
19283    #[test]
19284    fn a_btree_v2_flush_frees_the_node_blocks_its_tree_gave_up() {
19285        use crate::format::chunk_index::btree_v2::BT2_NODE_SIZE;
19286
19287        let path = temp_path("bt2_node_shrink");
19288        let writer = Hdf5Writer::create(&path).unwrap();
19289        let idx = writer
19290            .create_btree_v2_dataset(
19291                "data",
19292                DatatypeMessage::f64_type(),
19293                &[0, 0],
19294                &[u64::MAX, u64::MAX],
19295                &[1, 1],
19296            )
19297            .unwrap();
19298        // 85 records is one past a leaf, so the tree is two leaves and a root.
19299        for i in 0..85u64 {
19300            writer
19301                .write_chunk_btree_v2(idx, &[i, 0], &(i as f64).to_le_bytes())
19302                .unwrap();
19303        }
19304        writer.flush_dataset(idx).unwrap();
19305        let grown = writer
19306            .ds(idx)
19307            .lock()
19308            .btree_v2
19309            .as_ref()
19310            .unwrap()
19311            .node_addrs
19312            .clone();
19313        assert_eq!(grown.len(), 3, "expected two leaves and a root");
19314
19315        // Back to 84 records: one leaf, so two of the three blocks are surplus.
19316        writer
19317            .ds(idx)
19318            .lock()
19319            .btree_v2
19320            .as_mut()
19321            .unwrap()
19322            .index
19323            .records
19324            .truncate(84);
19325        writer.flush_dataset(idx).unwrap();
19326        let shrunk = writer
19327            .ds(idx)
19328            .lock()
19329            .btree_v2
19330            .as_ref()
19331            .unwrap()
19332            .node_addrs
19333            .clone();
19334        assert_eq!(
19335            shrunk,
19336            grown[..1],
19337            "the pool still records the surplus blocks"
19338        );
19339
19340        // The surplus went back to the allocator, not on the floor: the next
19341        // node-sized allocation lands inside the region the two blocks covered.
19342        let reused = writer
19343            .allocator
19344            .allocate(BT2_NODE_SIZE as u64, FreeSpaceClass::Metadata);
19345        assert!(
19346            (grown[1]..grown[1] + 2 * BT2_NODE_SIZE as u64).contains(&reused),
19347            "a node block allocated at {reused:#x}, outside the freed \
19348             [{:#x}, {:#x}) the flush gave up",
19349            grown[1],
19350            grown[1] + 2 * BT2_NODE_SIZE as u64
19351        );
19352
19353        writer.extend_dataset(idx, &[85, 1]).unwrap();
19354        writer.close().unwrap();
19355        std::fs::remove_file(&path).ok();
19356    }
19357
19358    /// A v2 B-tree whose header declares a non-default node size — libhdf5
19359    /// built with a different `H5D_BT2_NODE_SIZE`, or any other writer —
19360    /// reopens for append: the reconstruction adopts the header's node_size,
19361    /// split and merge instead of refusing everything but 2048, and the next
19362    /// flush re-serializes at that size (upstream allocates every node at
19363    /// `hdr->node_size`, H5B2leaf.c / H5B2internal.c).
19364    #[test]
19365    fn a_btree_v2_with_a_foreign_node_size_reopens_and_grows() {
19366        let path = temp_path("bt2_foreign_node_size");
19367        {
19368            let writer = Hdf5Writer::create(&path).unwrap();
19369            let idx = writer
19370                .create_btree_v2_dataset(
19371                    "data",
19372                    DatatypeMessage::f64_type(),
19373                    &[0, 0],
19374                    &[u64::MAX, u64::MAX],
19375                    &[1, 1],
19376                )
19377                .unwrap();
19378            // Act as a foreign writer: 512-byte nodes, non-default tuning.
19379            // record_size 24 => a 512-byte leaf holds 20 records, so 85
19380            // records make a depth-1 tree of 512-byte blocks.
19381            {
19382                let ds = writer.ds(idx);
19383                let mut m = ds.lock();
19384                let index = &mut m.btree_v2.as_mut().unwrap().index;
19385                index.node_size = 512;
19386                index.split_percent = 90;
19387                index.merge_percent = 30;
19388            }
19389            for i in 0..85u64 {
19390                writer
19391                    .write_chunk_btree_v2(idx, &[i, 0], &(i as f64).to_le_bytes())
19392                    .unwrap();
19393            }
19394            writer.extend_dataset(idx, &[85, 1]).unwrap();
19395            writer.close().unwrap();
19396        }
19397        {
19398            let writer = Hdf5Writer::open_append(&path).unwrap();
19399            let idx = writer.dataset_index("data").unwrap();
19400            {
19401                let ds = writer.ds(idx);
19402                let m = ds.lock();
19403                let index = &m.btree_v2.as_ref().unwrap().index;
19404                assert_eq!(index.node_size, 512, "header node_size not adopted");
19405                assert_eq!(index.split_percent, 90);
19406                assert_eq!(index.merge_percent, 30);
19407                assert_eq!(index.records.len(), 85, "records not walked back");
19408            }
19409            for i in 85..115u64 {
19410                writer
19411                    .write_chunk_btree_v2(idx, &[i, 0], &(i as f64).to_le_bytes())
19412                    .unwrap();
19413            }
19414            writer.extend_dataset(idx, &[115, 1]).unwrap();
19415            writer.close().unwrap();
19416        }
19417
19418        let mut reader = Hdf5Reader::open(&path).unwrap();
19419        let raw = reader.read_dataset_raw("data").unwrap();
19420        let values: Vec<f64> = raw
19421            .chunks(8)
19422            .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
19423            .collect();
19424        assert_eq!(values.len(), 115);
19425        for (i, v) in values.iter().enumerate() {
19426            assert_eq!(*v, i as f64, "element {i}");
19427        }
19428        std::fs::remove_file(&path).ok();
19429    }
19430
19431    /// A node's record count falls as well as rises: the tree's first leaf goes
19432    /// from a full 84 records to 42 when 85 records force it to split. The node
19433    /// image is padded to the whole block so re-serializing overwrites the
19434    /// block, not a prefix of it — otherwise that leaf keeps the tail of its
19435    /// 84-record self, stale records sitting in a live node block.
19436    #[test]
19437    fn a_shrinking_btree_v2_node_leaves_no_stale_records_behind() {
19438        use crate::format::chunk_index::btree_v2::{Bt2ChunkIndex, BT2_NODE_SIZE};
19439
19440        let path = temp_path("bt2_node_blocks");
19441        let probe = btree_v2_flush_probe(&path, &[84, 85]);
19442        let node0 = probe.last().unwrap().0[0];
19443
19444        // What the first leaf holds once the tree has split.
19445        let ctx = FormatContext {
19446            sizeof_addr: 8,
19447            sizeof_size: 8,
19448        };
19449        let mut index = Bt2ChunkIndex::new_unfiltered(2);
19450        for i in 0..85u64 {
19451            index.insert(vec![i, 0], 0);
19452        }
19453        let tree = index.build_tree(&ctx);
19454        assert!(
19455            tree.nodes[0].num_records < 84,
19456            "this test needs the first leaf to shrink, got {}",
19457            tree.nodes[0].num_records
19458        );
19459        // signature(4) + version(1) + type(1) + records + checksum(4)
19460        let used = 10 + tree.nodes[0].num_records as usize * tree.record_size as usize;
19461
19462        let bytes = std::fs::read(&path).unwrap();
19463        let block = &bytes[node0 as usize..node0 as usize + BT2_NODE_SIZE as usize];
19464        assert!(
19465            block[used..].iter().all(|&b| b == 0),
19466            "leaf block at {node0:#x} still holds {} bytes of its previous, larger image",
19467            block[used..].iter().rposition(|&b| b != 0).unwrap_or(0) + 1
19468        );
19469        std::fs::remove_file(&path).ok();
19470    }
19471
19472    /// The node pool is the single owner of the tree's block addresses: a flush
19473    /// reuses every block already in it and allocates only the shortfall. So
19474    /// re-flushing an unchanged index must cost nothing, and a flush that grows
19475    /// the tree must cost exactly the blocks it added — anything more means a
19476    /// block was stranded.
19477    #[test]
19478    fn a_btree_v2_flush_allocates_only_the_node_blocks_it_adds() {
19479        use crate::format::chunk_index::btree_v2::BT2_NODE_SIZE;
19480
19481        let path = temp_path("bt2_pool_growth");
19482        // Re-flush at 84 (still one leaf), then cross into a three-node depth-1
19483        // tree, then keep growing.
19484        let batches = [84u64, 84, 85, 200, 200];
19485        let probe = btree_v2_flush_probe(&path, &batches);
19486        for i in 1..probe.len() {
19487            let (prev_addrs, prev_len) = &probe[i - 1];
19488            let (addrs, len) = &probe[i];
19489            assert!(
19490                addrs.starts_with(prev_addrs),
19491                "flush {i} moved a node block instead of reusing it"
19492            );
19493            let new_blocks = (addrs.len() - prev_addrs.len()) as u64 * BT2_NODE_SIZE as u64;
19494            let new_chunks = (batches[i] - batches[i - 1]) * BT2_PROBE_CHUNK;
19495            assert_eq!(
19496                len - prev_len,
19497                new_blocks + new_chunks,
19498                "flush {i} grew the file by more than the blocks it added"
19499            );
19500        }
19501        // The unchanged re-flushes must be free.
19502        assert_eq!(probe[1].1, probe[0].1);
19503        assert_eq!(probe[4].1, probe[3].1);
19504        std::fs::remove_file(&path).ok();
19505    }
19506
19507    #[cfg(feature = "parallel")]
19508    #[test]
19509    fn parallel_batch_write_roundtrip() {
19510        let path = temp_path("parallel_batch");
19511
19512        let writer = Hdf5Writer::create(&path).unwrap();
19513        let idx = writer
19514            .create_chunked_dataset(
19515                "data",
19516                DatatypeMessage::i32_type(),
19517                &[0, 4],
19518                &[u64::MAX, 4],
19519                &[1, 4],
19520            )
19521            .unwrap();
19522
19523        // Prepare chunks
19524        let chunks_data: Vec<(u64, Vec<u8>)> = (0..8u64)
19525            .map(|frame| {
19526                let values: Vec<i32> = (0..4).map(|i| (frame * 4 + i) as i32).collect();
19527                let raw: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
19528                (frame, raw)
19529            })
19530            .collect();
19531
19532        let batch: Vec<(u64, &[u8])> = chunks_data
19533            .iter()
19534            .map(|(idx, data)| (*idx, data.as_slice()))
19535            .collect();
19536
19537        writer.write_chunks_batch(idx, &batch).unwrap();
19538        writer.extend_dataset(idx, &[8, 4]).unwrap();
19539        writer.close().unwrap();
19540
19541        // Read back
19542        let mut reader = Hdf5Reader::open(&path).unwrap();
19543        assert_eq!(reader.dataset_shape("data").unwrap(), vec![8, 4]);
19544        let raw = reader.read_dataset_raw("data").unwrap();
19545        let values: Vec<i32> = raw
19546            .chunks(4)
19547            .map(|chunk| i32::from_le_bytes(chunk.try_into().unwrap()))
19548            .collect();
19549        assert_eq!(values.len(), 32);
19550        for (i, val) in values.iter().enumerate() {
19551            assert_eq!(*val, i as i32);
19552        }
19553
19554        std::fs::remove_file(&path).ok();
19555    }
19556
19557    #[test]
19558    fn swmr_writer_append_frames() {
19559        use crate::io::swmr::SwmrWriter;
19560
19561        // Per-call unique path so concurrent cargo invocations and
19562        // kernel-side flock release races cannot collide.
19563        use std::sync::atomic::{AtomicU64, Ordering};
19564        static COUNTER: AtomicU64 = AtomicU64::new(0);
19565        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19566        let path = std::env::temp_dir().join(format!(
19567            "rust_hdf5_swmr_append_{}_{}.h5",
19568            std::process::id(),
19569            n
19570        ));
19571
19572        let mut swmr = SwmrWriter::create(&path).unwrap();
19573        let idx = swmr
19574            .create_streaming_dataset("detector", DatatypeMessage::u16_type(), &[4, 4])
19575            .unwrap();
19576
19577        swmr.start_swmr().unwrap();
19578
19579        // Append 5 frames
19580        for frame in 0..5u16 {
19581            let data: Vec<u16> = (0..16).map(|i| frame * 16 + i).collect();
19582            let raw: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
19583            swmr.append_frame(idx, &raw).unwrap();
19584        }
19585
19586        swmr.flush().unwrap();
19587        swmr.close().unwrap();
19588
19589        // Read back
19590        let mut reader = Hdf5Reader::open(&path).unwrap();
19591        assert_eq!(reader.dataset_shape("detector").unwrap(), vec![5, 4, 4]);
19592
19593        let raw = reader.read_dataset_raw("detector").unwrap();
19594        let values: Vec<u16> = raw
19595            .chunks(2)
19596            .map(|chunk| u16::from_le_bytes(chunk.try_into().unwrap()))
19597            .collect();
19598        assert_eq!(values.len(), 80); // 5 * 4 * 4
19599                                      // Verify first frame
19600        for (i, val) in values.iter().enumerate().take(16) {
19601            assert_eq!(*val, i as u16);
19602        }
19603        // Verify last frame
19604        for (i, val) in values[64..80].iter().enumerate() {
19605            assert_eq!(*val, 4 * 16 + i as u16);
19606        }
19607
19608        std::fs::remove_file(&path).ok();
19609    }
19610
19611    #[test]
19612    fn swmr_writer_tiled_frames() {
19613        use crate::io::swmr::SwmrWriter;
19614        use std::sync::atomic::{AtomicU64, Ordering};
19615        static COUNTER: AtomicU64 = AtomicU64::new(0);
19616        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19617        let path = std::env::temp_dir().join(format!(
19618            "rust_hdf5_swmr_tiled_{}_{}.h5",
19619            std::process::id(),
19620            n
19621        ));
19622
19623        let mut swmr = SwmrWriter::create(&path).unwrap();
19624        // 4x4 frames, tiled into 2x2 chunks -> 4 chunks per frame.
19625        let idx = swmr
19626            .create_streaming_dataset_tiled("det", DatatypeMessage::u16_type(), &[4, 4], &[2, 2])
19627            .unwrap();
19628        swmr.start_swmr().unwrap();
19629
19630        for frame in 0..3u16 {
19631            let data: Vec<u16> = (0..16).map(|i| frame * 100 + i).collect();
19632            let raw: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
19633            swmr.append_frame(idx, &raw).unwrap();
19634        }
19635        swmr.flush().unwrap();
19636        swmr.close().unwrap();
19637
19638        let mut reader = Hdf5Reader::open(&path).unwrap();
19639        assert_eq!(reader.dataset_shape("det").unwrap(), vec![3, 4, 4]);
19640        let raw = reader.read_dataset_raw("det").unwrap();
19641        let values: Vec<u16> = raw
19642            .chunks(2)
19643            .map(|c| u16::from_le_bytes(c.try_into().unwrap()))
19644            .collect();
19645        assert_eq!(values.len(), 48);
19646        // Every element must survive the frame -> tile split and the
19647        // tile -> frame reassembly on read.
19648        for frame in 0..3u16 {
19649            for i in 0..16usize {
19650                assert_eq!(values[frame as usize * 16 + i], frame * 100 + i as u16);
19651            }
19652        }
19653        std::fs::remove_file(&path).ok();
19654    }
19655
19656    /// A chunk tile larger than the frame is geometry libhdf5 refuses to
19657    /// create (`H5D__chunk_construct`: chunk must not exceed a fixed maximum
19658    /// dimension), so no libhdf5-based writer — including the NDFileHDF5
19659    /// tiling controls this API mirrors — can produce such a file. Until
19660    /// 0.4.1 we accepted it and zero-padded the frame up to the tile; now
19661    /// the create is rejected like every other creator's.
19662    #[test]
19663    fn swmr_writer_tiled_chunk_larger_than_frame_is_rejected() {
19664        use crate::io::swmr::SwmrWriter;
19665        use std::sync::atomic::{AtomicU64, Ordering};
19666        static COUNTER: AtomicU64 = AtomicU64::new(0);
19667        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19668        let path = std::env::temp_dir().join(format!(
19669            "rust_hdf5_swmr_bigchunk_{}_{}.h5",
19670            std::process::id(),
19671            n
19672        ));
19673
19674        let mut swmr = SwmrWriter::create(&path).unwrap();
19675        let err = swmr
19676            .create_streaming_dataset_tiled("det", DatatypeMessage::u16_type(), &[3, 3], &[8, 8])
19677            .unwrap_err();
19678        assert!(
19679            err.to_string().contains("maximum dimension size"),
19680            "unexpected error: {err}"
19681        );
19682        swmr.close().unwrap();
19683        std::fs::remove_file(&path).ok();
19684    }
19685
19686    #[test]
19687    fn swmr_writer_multi_frame_chunks() {
19688        use crate::io::swmr::SwmrWriter;
19689        use std::sync::atomic::{AtomicU64, Ordering};
19690        static COUNTER: AtomicU64 = AtomicU64::new(0);
19691        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19692        let path = std::env::temp_dir().join(format!(
19693            "rust_hdf5_swmr_mfc_{}_{}.h5",
19694            std::process::id(),
19695            n
19696        ));
19697
19698        // 3x3 frames, chunk = 4 frames x full frame. 10 frames -> 3 bands
19699        // of 4, 4, 2 (the last band partial).
19700        let mut swmr = SwmrWriter::create(&path).unwrap();
19701        let idx = swmr
19702            .create_streaming_dataset_chunked(
19703                "det",
19704                DatatypeMessage::u16_type(),
19705                &[3, 3],
19706                &[4, 3, 3],
19707            )
19708            .unwrap();
19709        swmr.start_swmr().unwrap();
19710        for frame in 0..10u16 {
19711            let data: Vec<u16> = (0..9).map(|i| frame * 100 + i).collect();
19712            let raw: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
19713            swmr.append_frame(idx, &raw).unwrap();
19714        }
19715        swmr.flush().unwrap();
19716        swmr.close().unwrap();
19717
19718        let mut reader = Hdf5Reader::open(&path).unwrap();
19719        // The partial last band must not over-extend the frame count.
19720        assert_eq!(reader.dataset_shape("det").unwrap(), vec![10, 3, 3]);
19721        let raw = reader.read_dataset_raw("det").unwrap();
19722        let values: Vec<u16> = raw
19723            .chunks(2)
19724            .map(|c| u16::from_le_bytes(c.try_into().unwrap()))
19725            .collect();
19726        assert_eq!(values.len(), 90);
19727        for frame in 0..10u16 {
19728            for i in 0..9usize {
19729                assert_eq!(values[frame as usize * 9 + i], frame * 100 + i as u16);
19730            }
19731        }
19732        std::fs::remove_file(&path).ok();
19733    }
19734
19735    #[test]
19736    fn swmr_writer_multi_frame_tiled_chunks() {
19737        use crate::io::swmr::SwmrWriter;
19738        use std::sync::atomic::{AtomicU64, Ordering};
19739        static COUNTER: AtomicU64 = AtomicU64::new(0);
19740        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19741        let path = std::env::temp_dir().join(format!(
19742            "rust_hdf5_swmr_mftc_{}_{}.h5",
19743            std::process::id(),
19744            n
19745        ));
19746
19747        // 4x4 frames, chunk = 2 frames x 2x2 tiles. 5 frames -> bands of
19748        // 2, 2, 1; every frame is also split into a 2x2 tile grid.
19749        let mut swmr = SwmrWriter::create(&path).unwrap();
19750        let idx = swmr
19751            .create_streaming_dataset_chunked(
19752                "det",
19753                DatatypeMessage::u16_type(),
19754                &[4, 4],
19755                &[2, 2, 2],
19756            )
19757            .unwrap();
19758        swmr.start_swmr().unwrap();
19759        for frame in 0..5u16 {
19760            let data: Vec<u16> = (0..16).map(|i| frame * 100 + i).collect();
19761            let raw: Vec<u8> = data.iter().flat_map(|v| v.to_le_bytes()).collect();
19762            swmr.append_frame(idx, &raw).unwrap();
19763        }
19764        swmr.flush().unwrap();
19765        swmr.close().unwrap();
19766
19767        let mut reader = Hdf5Reader::open(&path).unwrap();
19768        assert_eq!(reader.dataset_shape("det").unwrap(), vec![5, 4, 4]);
19769        let raw = reader.read_dataset_raw("det").unwrap();
19770        let values: Vec<u16> = raw
19771            .chunks(2)
19772            .map(|c| u16::from_le_bytes(c.try_into().unwrap()))
19773            .collect();
19774        assert_eq!(values.len(), 80);
19775        for frame in 0..5u16 {
19776            for i in 0..16usize {
19777                assert_eq!(values[frame as usize * 16 + i], frame * 100 + i as u16);
19778            }
19779        }
19780        std::fs::remove_file(&path).ok();
19781    }
19782
19783    #[cfg(feature = "deflate")]
19784    #[test]
19785    fn swmr_writer_compressed_frames() {
19786        use crate::io::swmr::SwmrWriter;
19787        use std::sync::atomic::{AtomicU64, Ordering};
19788        static COUNTER: AtomicU64 = AtomicU64::new(0);
19789        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
19790        let path = std::env::temp_dir().join(format!(
19791            "rust_hdf5_swmr_comp_{}_{}.h5",
19792            std::process::id(),
19793            n
19794        ));
19795
19796        let mut swmr = SwmrWriter::create(&path).unwrap();
19797        let pipeline = crate::format::messages::filter::FilterPipeline::deflate(4);
19798        let idx = swmr
19799            .create_streaming_dataset_compressed(
19800                "detector",
19801                DatatypeMessage::i32_type(),
19802                &[8],
19803                pipeline,
19804            )
19805            .unwrap();
19806        swmr.start_swmr().unwrap();
19807
19808        for frame in 0..40i32 {
19809            let raw: Vec<u8> = (0..8).flat_map(|i| (frame * 8 + i).to_le_bytes()).collect();
19810            swmr.append_frame(idx, &raw).unwrap();
19811            if frame % 7 == 0 {
19812                swmr.flush().unwrap();
19813            }
19814        }
19815        swmr.flush().unwrap();
19816        swmr.close().unwrap();
19817
19818        let mut reader = Hdf5Reader::open(&path).unwrap();
19819        assert_eq!(reader.dataset_shape("detector").unwrap(), vec![40, 8]);
19820        let raw = reader.read_dataset_raw("detector").unwrap();
19821        let values: Vec<i32> = raw
19822            .chunks(4)
19823            .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
19824            .collect();
19825        assert_eq!(values, (0..320).collect::<Vec<i32>>());
19826
19827        std::fs::remove_file(&path).ok();
19828    }
19829
19830    #[test]
19831    fn group_hierarchy_writer_reader() {
19832        let path = temp_path("group_hierarchy");
19833
19834        let writer = Hdf5Writer::create(&path).unwrap();
19835
19836        // Create groups
19837        let g0 = writer.create_group("/", "group1").unwrap();
19838        let g1 = writer.create_group("/group1", "sub").unwrap();
19839        assert_eq!(g0, 0);
19840        assert_eq!(g1, 1);
19841
19842        // Create datasets
19843        let ds_root = writer
19844            .create_dataset("root_data", DatatypeMessage::f64_type(), &[2])
19845            .unwrap();
19846        let raw_root: Vec<u8> = [1.0f64, 2.0].iter().flat_map(|v| v.to_le_bytes()).collect();
19847        writer.write_dataset_raw(ds_root, &raw_root).unwrap();
19848
19849        let ds_g0 = writer
19850            .create_dataset("group1/data", DatatypeMessage::i32_type(), &[3])
19851            .unwrap();
19852        let raw_g0: Vec<u8> = [10i32, 20, 30]
19853            .iter()
19854            .flat_map(|v| v.to_le_bytes())
19855            .collect();
19856        writer.write_dataset_raw(ds_g0, &raw_g0).unwrap();
19857
19858        let ds_g1 = writer
19859            .create_dataset("group1/sub/values", DatatypeMessage::u8_type(), &[4])
19860            .unwrap();
19861        writer.write_dataset_raw(ds_g1, &[1u8, 2, 3, 4]).unwrap();
19862
19863        writer.close().unwrap();
19864
19865        // Read back
19866        let mut reader = Hdf5Reader::open(&path).unwrap();
19867        let names = reader.dataset_names();
19868        assert!(names.contains(&"root_data"), "names: {:?}", names);
19869        assert!(names.contains(&"group1/data"), "names: {:?}", names);
19870        assert!(names.contains(&"group1/sub/values"), "names: {:?}", names);
19871
19872        let raw = reader.read_dataset_raw("root_data").unwrap();
19873        let vals: Vec<f64> = raw
19874            .chunks(8)
19875            .map(|c| f64::from_le_bytes(c.try_into().unwrap()))
19876            .collect();
19877        assert_eq!(vals, vec![1.0, 2.0]);
19878
19879        let raw = reader.read_dataset_raw("group1/data").unwrap();
19880        let vals: Vec<i32> = raw
19881            .chunks(4)
19882            .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
19883            .collect();
19884        assert_eq!(vals, vec![10, 20, 30]);
19885
19886        let raw = reader.read_dataset_raw("group1/sub/values").unwrap();
19887        assert_eq!(raw, vec![1, 2, 3, 4]);
19888
19889        std::fs::remove_file(&path).ok();
19890    }
19891
19892    /// libhdf5 (`H5D__chunk_construct`) rejects a chunk dimension that
19893    /// exceeds a fixed maximum dimension. Before this check, such a dataset
19894    /// was created and appends landed rows at the chunk stride instead of
19895    /// the row stride, reading back [1, 2, 0, 0] for [1, 2, 3, 4].
19896    #[test]
19897    fn create_rejects_a_chunk_wider_than_a_fixed_max_dimension() {
19898        let path = temp_path("chunk_wider_than_max");
19899
19900        let writer = Hdf5Writer::create(&path).unwrap();
19901        let err = writer
19902            .create_chunked_dataset(
19903                "data",
19904                DatatypeMessage::f64_type(),
19905                &[0, 2],
19906                &[u64::MAX, 2],
19907                &[2, 4],
19908            )
19909            .unwrap_err();
19910        assert!(
19911            err.to_string().contains("maximum dimension size"),
19912            "unexpected error: {err}"
19913        );
19914
19915        // The fixed-array creators derive the maximum from the fixed dims.
19916        let err = writer
19917            .create_fixed_array_dataset("fa", DatatypeMessage::f64_type(), &[3], &[5])
19918            .unwrap_err();
19919        assert!(
19920            err.to_string().contains("maximum dimension size"),
19921            "unexpected error: {err}"
19922        );
19923
19924        writer.close().unwrap();
19925        std::fs::remove_file(&path).ok();
19926    }
19927
19928    /// libhdf5 exempts a dimension whose *current* size is zero from the
19929    /// chunk-vs-maximum check (`curr_dims[u] &&` in `H5D__chunk_construct`),
19930    /// and rejects a zero chunk dimension on every path.
19931    #[test]
19932    fn create_mirrors_the_libhdf5_chunk_geometry_exemptions() {
19933        let path = temp_path("chunk_geometry_exemptions");
19934
19935        let writer = Hdf5Writer::create(&path).unwrap();
19936        // dims[1] == 0: chunk 4 > max 2 is allowed, as libhdf5 allows it.
19937        writer
19938            .create_chunked_dataset(
19939                "exempt",
19940                DatatypeMessage::f64_type(),
19941                &[0, 0],
19942                &[u64::MAX, 2],
19943                &[2, 4],
19944            )
19945            .unwrap();
19946
19947        let err = writer
19948            .create_chunked_dataset("zero", DatatypeMessage::f64_type(), &[0], &[u64::MAX], &[0])
19949            .unwrap_err();
19950        assert!(
19951            err.to_string().contains("chunk dimension 0 is zero"),
19952            "unexpected error: {err}"
19953        );
19954
19955        writer.close().unwrap();
19956        std::fs::remove_file(&path).ok();
19957    }
19958
19959    /// A rank-0 chunk is refused on both index paths, as `H5Pset_chunk`
19960    /// refuses it: written anyway, the dataset was gone when the file
19961    /// reopened.
19962    #[test]
19963    fn create_refuses_a_rank_0_chunk() {
19964        let path = temp_path("rank0_chunk");
19965
19966        let writer = Hdf5Writer::create(&path).unwrap();
19967        let errs = [
19968            writer
19969                .create_chunked_dataset("ea", DatatypeMessage::f64_type(), &[], &[], &[])
19970                .unwrap_err(),
19971            writer
19972                .create_fixed_array_dataset("fa", DatatypeMessage::f64_type(), &[], &[])
19973                .unwrap_err(),
19974        ];
19975        for err in errs {
19976            assert!(
19977                err.to_string()
19978                    .contains("chunk dimensionality must be positive"),
19979                "unexpected error: {err}"
19980            );
19981        }
19982
19983        writer.close().unwrap();
19984        std::fs::remove_file(&path).ok();
19985    }
19986
19987    /// A file written by 0.4.0 can carry a chunk row wider than the frame
19988    /// row — create now rejects that geometry, but reopened files keep it.
19989    /// Appends must scatter frames at the chunk stride, not pack them at
19990    /// the frame stride (which read back `[1, 2, 0, 0]` for `[1, 2, 3, 4]`).
19991    /// The wide shape is simulated by widening the registered chunk dims
19992    /// after create, which also lands in the layout message at close.
19993    #[test]
19994    fn append_scatters_into_a_legacy_wider_than_row_chunk() {
19995        let path = temp_path("legacy_wide_chunk_append");
19996
19997        let writer = Hdf5Writer::create(&path).unwrap();
19998        let idx = writer
19999            .create_chunked_dataset(
20000                "data",
20001                DatatypeMessage::i32_type(),
20002                &[0, 2],
20003                &[u64::MAX, 2],
20004                &[2, 2],
20005            )
20006            .unwrap();
20007        writer.ds(idx).lock().chunked.as_mut().unwrap().chunk_dims = vec![2, 4];
20008
20009        let frames: Vec<u8> = [1i32, 2, 3, 4]
20010            .iter()
20011            .flat_map(|v| v.to_le_bytes())
20012            .collect();
20013        writer.write_append_frames(idx, 0, 2, &frames).unwrap();
20014        writer.extend_dataset(idx, &[2, 2]).unwrap();
20015        writer.close().unwrap();
20016
20017        let mut reader = Hdf5Reader::open(&path).unwrap();
20018        assert_eq!(reader.dataset_shape("data").unwrap(), vec![2, 2]);
20019        let raw = reader.read_dataset_raw("data").unwrap();
20020        let values: Vec<i32> = raw
20021            .chunks(4)
20022            .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
20023            .collect();
20024        assert_eq!(values, vec![1, 2, 3, 4]);
20025        std::fs::remove_file(&path).ok();
20026    }
20027
20028    /// The compressed vlen creator sizes its chunked layout from a
20029    /// caller-supplied chunk size; it goes through the same geometry
20030    /// validation as every other creator (empty inputs are exempt because
20031    /// their current size is zero).
20032    #[test]
20033    #[cfg(feature = "deflate")]
20034    fn compressed_vlen_create_validates_its_chunk_size() {
20035        use crate::format::messages::filter::FilterPipeline;
20036        let path = temp_path("vlen_compressed_chunk");
20037
20038        let writer = Hdf5Writer::create(&path).unwrap();
20039        let err = writer
20040            .create_vlen_string_dataset_compressed(
20041                "texts",
20042                &["a", "b", "c"],
20043                100,
20044                FilterPipeline::deflate(6),
20045            )
20046            .unwrap_err();
20047        assert!(
20048            err.to_string().contains("maximum dimension size"),
20049            "unexpected error: {err}"
20050        );
20051
20052        writer
20053            .create_vlen_string_dataset_compressed("empty", &[], 16, FilterPipeline::deflate(6))
20054            .unwrap();
20055
20056        writer.close().unwrap();
20057        std::fs::remove_file(&path).ok();
20058    }
20059
20060    /// `set_libver_latest` moves *filtered* chunked datasets to layout v5 with
20061    /// fixed 8-byte chunk-size fields; unfiltered chunked and pre-opt-in
20062    /// datasets keep v4 with the derived width, matching libhdf5's
20063    /// `version_perf` rule (only the filtered index arms bump to 5).
20064    #[cfg(feature = "deflate")]
20065    #[test]
20066    fn libver_latest_selects_v5_for_filtered_chunks_only() {
20067        let path = temp_path("libver_v5_select");
20068
20069        let mut writer = Hdf5Writer::create(&path).unwrap();
20070        let before = writer
20071            .create_chunked_dataset_with_pipeline(
20072                "d4",
20073                DatatypeMessage::i32_type(),
20074                &[0],
20075                &[u64::MAX],
20076                &[16],
20077                FilterPipeline::deflate(4),
20078            )
20079            .unwrap();
20080        writer.set_libver_latest(true).unwrap();
20081        let ea5 = writer
20082            .create_chunked_dataset_with_pipeline(
20083                "ea5",
20084                DatatypeMessage::i32_type(),
20085                &[0],
20086                &[u64::MAX],
20087                &[16],
20088                FilterPipeline::deflate(4),
20089            )
20090            .unwrap();
20091        let plain = writer
20092            .create_chunked_dataset(
20093                "plain",
20094                DatatypeMessage::i32_type(),
20095                &[0],
20096                &[u64::MAX],
20097                &[16],
20098            )
20099            .unwrap();
20100        let fa5 = writer
20101            .create_fixed_array_dataset_with_pipeline(
20102                "fa5",
20103                DatatypeMessage::i32_type(),
20104                &[4, 6],
20105                &[2, 3],
20106                FilterPipeline::deflate(6),
20107            )
20108            .unwrap();
20109        let bt5 = writer
20110            .create_btree_v2_dataset_with_pipeline(
20111                "bt5",
20112                DatatypeMessage::i32_type(),
20113                &[0, 0],
20114                &[u64::MAX, u64::MAX],
20115                &[2, 3],
20116                FilterPipeline::deflate(6),
20117            )
20118            .unwrap();
20119
20120        {
20121            let d4 = writer.ds(before);
20122            let d4 = d4.lock();
20123            assert_eq!(d4.layout_version, 4);
20124            assert_eq!(
20125                d4.chunked.as_ref().unwrap().chunk_size_len,
20126                compute_chunk_size_len(16 * 4)
20127            );
20128            let e5 = writer.ds(ea5);
20129            let e5 = e5.lock();
20130            assert_eq!(e5.layout_version, 5);
20131            assert_eq!(e5.chunked.as_ref().unwrap().chunk_size_len, 8);
20132            assert_eq!(writer.ds(plain).lock().layout_version, 4);
20133            assert_eq!(writer.ds(fa5).lock().layout_version, 5);
20134            assert_eq!(writer.ds(bt5).lock().layout_version, 5);
20135        }
20136
20137        // Write through the FA and BT2 v5 indexes so their 8-byte chunk-size
20138        // fields are exercised end to end, not just selected.
20139        for (coords, vals) in [
20140            ([0u64, 0], [0i32, 1, 2, 6, 7, 8]),
20141            ([0, 1], [3, 4, 5, 9, 10, 11]),
20142            ([1, 0], [12, 13, 14, 18, 19, 20]),
20143            ([1, 1], [15, 16, 17, 21, 22, 23]),
20144        ] {
20145            let bytes: Vec<u8> = vals.iter().flat_map(|v| v.to_le_bytes()).collect();
20146            writer
20147                .write_chunk_fixed_array(fa5, &coords, &bytes)
20148                .unwrap();
20149            writer.write_chunk_btree_v2(bt5, &coords, &bytes).unwrap();
20150        }
20151        writer.extend_dataset(bt5, &[4, 6]).unwrap();
20152        writer.close().unwrap();
20153
20154        let mut reader = Hdf5Reader::open(&path).unwrap();
20155        for name in ["fa5", "bt5"] {
20156            let raw = reader.read_dataset_raw(name).unwrap();
20157            let values: Vec<i32> = raw
20158                .chunks(4)
20159                .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
20160                .collect();
20161            assert_eq!(values, (0..24).collect::<Vec<i32>>(), "dataset {name}");
20162        }
20163
20164        std::fs::remove_file(&path).ok();
20165    }
20166
20167    /// A v5 file reopened for append must stay v5: the decode → `DatasetInfo`
20168    /// → finalize path carries the version through, so the re-encoded layout
20169    /// message matches the 8-byte size fields the filtered index was built
20170    /// with. A silent v4 downgrade here would make libhdf5 derive a narrower
20171    /// field width than the index uses.
20172    #[cfg(feature = "deflate")]
20173    #[test]
20174    fn v5_layout_survives_reopen_and_append() {
20175        let path = temp_path("libver_v5_reopen");
20176        let chunk: usize = 8;
20177
20178        let mut writer = Hdf5Writer::create(&path).unwrap();
20179        writer.set_libver_latest(true).unwrap();
20180        let idx = writer
20181            .create_chunked_dataset_with_pipeline(
20182                "d",
20183                DatatypeMessage::i32_type(),
20184                &[0],
20185                &[u64::MAX],
20186                &[chunk as u64],
20187                FilterPipeline::deflate(4),
20188            )
20189            .unwrap();
20190        for c in 0..2u64 {
20191            let data: Vec<u8> = (0..chunk as i32)
20192                .flat_map(|i| (c as i32 * chunk as i32 + i).to_le_bytes())
20193                .collect();
20194            writer.write_chunk(idx, c, &data).unwrap();
20195        }
20196        writer.extend_dataset(idx, &[2 * chunk as u64]).unwrap();
20197        writer.close().unwrap();
20198
20199        // Reopen: the decoded layout version must be preserved, and appends
20200        // must keep working against the 8-byte-size-field index.
20201        let writer = Hdf5Writer::open_append(&path).unwrap();
20202        assert_eq!(writer.ds(0).lock().layout_version, 5);
20203        for c in 2..4u64 {
20204            let data: Vec<u8> = (0..chunk as i32)
20205                .flat_map(|i| (c as i32 * chunk as i32 + i).to_le_bytes())
20206                .collect();
20207            writer.write_chunk(0, c, &data).unwrap();
20208        }
20209        writer.extend_dataset(0, &[4 * chunk as u64]).unwrap();
20210        writer.close().unwrap();
20211
20212        // Still v5 after the second finalize, and fully readable.
20213        let writer = Hdf5Writer::open_append(&path).unwrap();
20214        assert_eq!(writer.ds(0).lock().layout_version, 5);
20215        writer.close().unwrap();
20216
20217        let mut reader = Hdf5Reader::open(&path).unwrap();
20218        let raw = reader.read_dataset_raw("d").unwrap();
20219        let values: Vec<i32> = raw
20220            .chunks(4)
20221            .map(|c| i32::from_le_bytes(c.try_into().unwrap()))
20222            .collect();
20223        assert_eq!(values, (0..4 * chunk as i32).collect::<Vec<i32>>());
20224
20225        std::fs::remove_file(&path).ok();
20226    }
20227
20228    /// A chunk strictly larger than `u32::MAX` bytes forces layout v5 with no
20229    /// opt-in — v4's size field cannot represent it — while a chunk of exactly
20230    /// `u32::MAX` bytes stays v4, matching libhdf5's `version_req` boundary
20231    /// (`> 0xffffffff`, filtered or not).
20232    #[test]
20233    fn oversized_chunk_forces_v5_without_opt_in() {
20234        let path = temp_path("libver_4gib_force");
20235
20236        let writer = Hdf5Writer::create(&path).unwrap();
20237        let at_limit = writer
20238            .create_chunked_dataset_with_pipeline(
20239                "at_limit",
20240                DatatypeMessage::u8_type(),
20241                &[0],
20242                &[u64::MAX],
20243                &[u32::MAX as u64],
20244                FilterPipeline::deflate(4),
20245            )
20246            .unwrap();
20247        let over = writer
20248            .create_chunked_dataset_with_pipeline(
20249                "over",
20250                DatatypeMessage::u8_type(),
20251                &[0],
20252                &[u64::MAX],
20253                &[u32::MAX as u64 + 1],
20254                FilterPipeline::deflate(4),
20255            )
20256            .unwrap();
20257        let over_unfiltered = writer
20258            .create_chunked_dataset(
20259                "over_plain",
20260                DatatypeMessage::u8_type(),
20261                &[0],
20262                &[u64::MAX],
20263                &[u32::MAX as u64 + 1],
20264            )
20265            .unwrap();
20266
20267        assert_eq!(writer.ds(at_limit).lock().layout_version, 4);
20268        {
20269            let ds = writer.ds(over);
20270            let ds = ds.lock();
20271            assert_eq!(ds.layout_version, 5);
20272            assert_eq!(ds.chunked.as_ref().unwrap().chunk_size_len, 8);
20273        }
20274        assert_eq!(writer.ds(over_unfiltered).lock().layout_version, 5);
20275        writer.close().unwrap();
20276        std::fs::remove_file(&path).ok();
20277    }
20278
20279    /// SWMR reaches version 3 on its own, without a chunked dataset to raise
20280    /// the bound — through the flags `finalize_for_swmr` passes, and then
20281    /// through `swmr_active` for every superblock written after it. Only a
20282    /// file with nothing else newer in it can tell the two arms apart, and
20283    /// the public SWMR API always creates a chunked streaming dataset.
20284    #[test]
20285    fn swmr_reaches_version_3_with_no_chunked_dataset_in_the_file() {
20286        let path = temp_path("swmr_superblock");
20287
20288        let mut writer = Hdf5Writer::create(&path).unwrap();
20289        writer
20290            .create_dataset("d", DatatypeMessage::i32_type(), &[2])
20291            .unwrap();
20292        assert_eq!(writer.superblock_version_for(0), SUPERBLOCK_V2);
20293
20294        writer.finalize_for_swmr().unwrap();
20295        // What `start_swmr` does after finalizing, and what lets a second
20296        // handle read the file while this writer lives — the writer's
20297        // exclusive lock is mandatory on Windows.
20298        writer.handle().release_lock().unwrap();
20299        assert_eq!(std::fs::read(&path).unwrap()[8], SUPERBLOCK_V3);
20300
20301        // The close-time finalize carries no SWMR flag; the file is still an
20302        // SWMR file and must not be handed back a version older than the one
20303        // its readers attached to.
20304        writer.close().unwrap();
20305        assert_eq!(std::fs::read(&path).unwrap()[8], SUPERBLOCK_V3);
20306        std::fs::remove_file(&path).ok();
20307    }
20308
20309    /// A named bound below `H5F_LIBVER_V110` refuses the session instead —
20310    /// the two checks `H5F__start_swmr_write` opens with, a version-3
20311    /// superblock (H5Fint.c:3814) and a low bound of at least V110
20312    /// (H5Fint.c:3818). Naming no bound at all is what the test above does,
20313    /// and that file is free to become version 3.
20314    #[test]
20315    fn a_named_bound_below_v110_refuses_an_swmr_session() {
20316        for bound in [LibverBound::Earliest, LibverBound::V18] {
20317            let path = temp_path(&format!("swmr_refused_{bound:?}"));
20318            let mut writer = Hdf5Writer::create_with_options(
20319                &path,
20320                FileCreateOptions {
20321                    libver: Some(bound),
20322                    ..Default::default()
20323                },
20324            )
20325            .unwrap();
20326            writer
20327                .create_dataset("d", DatatypeMessage::i32_type(), &[2])
20328                .unwrap();
20329
20330            let err = writer.finalize_for_swmr().unwrap_err().to_string();
20331            assert!(err.contains("SWMR"), "{bound:?}: {err}");
20332            assert!(err.contains("H5F_LIBVER_V110"), "{bound:?}: {err}");
20333
20334            // Refused, not half-done: nothing was published, and the close
20335            // writes the file the bound asked for.
20336            writer.close().unwrap();
20337            let version = std::fs::read(&path).unwrap()[8];
20338            assert_eq!(version, bound.superblock_version(), "{bound:?}");
20339            std::fs::remove_file(&path).ok();
20340        }
20341    }
20342
20343    /// A dataset header the SWMR publish could not fit into the chunk 0 it
20344    /// already had chains into a continuation block, and the in-place rewrite
20345    /// goes back over both: chunk 0 stays at the address the file's readers
20346    /// hold, and the continuation chunk at the one chunk 0 names.
20347    #[test]
20348    fn inplace_rewrite_goes_over_a_chained_header() {
20349        let path = temp_path("inplace_rewrite_chained");
20350        let writer = Hdf5Writer::create_with_options(
20351            &path,
20352            FileCreateOptions {
20353                libver: Some(LibverBound::V110),
20354                ..Default::default()
20355            },
20356        )
20357        .unwrap();
20358        writer
20359            .create_chunked_dataset("d", DatatypeMessage::i32_type(), &[0], &[u64::MAX], &[4])
20360            .unwrap();
20361        writer.close().unwrap();
20362
20363        let mut writer = Hdf5Writer::open_append(&path).unwrap();
20364        let idx = 0;
20365        let published = writer.ds(idx).lock().obj_header_written_addr.unwrap();
20366        for i in 0..4 {
20367            writer
20368                .add_dataset_attribute(
20369                    idx,
20370                    AttributeMessage::array_numeric(
20371                        &format!("wide{i}"),
20372                        DatatypeMessage::f64_type(),
20373                        &[32],
20374                        vec![0u8; 256],
20375                    ),
20376                )
20377                .unwrap();
20378        }
20379        writer.finalize_for_swmr().unwrap();
20380        let blocks = writer.ds(idx).lock().obj_header_blocks.clone();
20381        assert_eq!(blocks.len(), 2, "chunk 0 and a continuation: {blocks:?}");
20382        assert_eq!(blocks[0].0, published, "chunk 0 stayed where it was");
20383
20384        writer.write_dataset_header_inplace(idx).unwrap();
20385        assert_eq!(writer.ds(idx).lock().obj_header_blocks, blocks);
20386        writer.close().unwrap();
20387
20388        // The closing finalize wrote over the same chunk 0, and the chained
20389        // header reads back whole.
20390        let writer = Hdf5Writer::open_append(&path).unwrap();
20391        assert_eq!(writer.ds(0).lock().obj_header_written_addr, Some(published));
20392        assert_eq!(writer.ds(0).lock().attributes.len(), 4);
20393        std::fs::remove_file(&path).ok();
20394    }
20395
20396    /// `H5F__start_swmr_write` refuses a low bound below `H5F_LIBVER_V110`
20397    /// (H5Fint.c:3818) on a reopened file as on a created one, now that the
20398    /// superblock no longer raises it: an SWMR reader follows the v1.10 chunk
20399    /// indexes, which a lower bound's layout version cannot name. No bound
20400    /// named passes, the default's layout row being `V110`'s.
20401    #[test]
20402    fn swmr_on_a_reopened_file_refuses_a_named_bound_below_v110() {
20403        let path = temp_path("swmr_reopen_bound");
20404        let writer = Hdf5Writer::create_with_options(
20405            &path,
20406            FileCreateOptions {
20407                libver: Some(LibverBound::V110),
20408                ..Default::default()
20409            },
20410        )
20411        .unwrap();
20412        writer
20413            .create_dataset("d", DatatypeMessage::i32_type(), &[2])
20414            .unwrap();
20415        writer.close().unwrap();
20416        assert_eq!(std::fs::read(&path).unwrap()[8], SUPERBLOCK_V3);
20417
20418        for bound in [LibverBound::Earliest, LibverBound::V18] {
20419            let mut writer = Hdf5Writer::open_append(&path).unwrap();
20420            writer.set_libver_bound(bound).unwrap();
20421            let err = writer.finalize_for_swmr().unwrap_err().to_string();
20422            assert!(err.contains("H5F_LIBVER_V110"), "{bound:?}: {err}");
20423            writer.close().unwrap();
20424        }
20425        let mut writer = Hdf5Writer::open_append(&path).unwrap();
20426        writer.finalize_for_swmr().unwrap();
20427        writer.close().unwrap();
20428        std::fs::remove_file(&path).ok();
20429    }
20430
20431    /// After every writer of a dataset object header, `nlink_written` is the
20432    /// count that writer encoded.
20433    ///
20434    /// `header_stale_with` is the one authority for "does the on-disk header
20435    /// still describe this dataset?", and it reads `nlink_written`; the three
20436    /// writers — `finalize`, `finalize_for_swmr` and
20437    /// `write_dataset_header_inplace` — therefore all record through
20438    /// `DatasetInfo::header_written`. This walks the SWMR sequence, where the
20439    /// in-place writer is the one that could drift, and pins why it does not:
20440    /// a name added after the publish grows the header past the block it was
20441    /// published into, so the rewrite is refused rather than half-applied and
20442    /// the count on disk stays the one the registry names.
20443    #[test]
20444    fn every_dataset_header_write_records_its_link_count() {
20445        let path = temp_path("header_write_records_nlink");
20446        let writer = Hdf5Writer::create(&path).unwrap();
20447        let idx = writer
20448            .create_chunked_dataset("d", DatatypeMessage::i32_type(), &[0], &[u64::MAX], &[4])
20449            .unwrap();
20450        let mut writer = writer;
20451        writer.finalize_for_swmr().unwrap();
20452        assert_eq!(
20453            writer.ds(idx).lock().nlink_written,
20454            1,
20455            "the SWMR publish put one name in the header"
20456        );
20457        writer.write_dataset_header_inplace(idx).unwrap();
20458        assert_eq!(writer.ds(idx).lock().nlink_written, 1);
20459
20460        // A second name after the publish: the reference-count message it
20461        // adds does not fit the published block.
20462        writer.create_hard_link("/", "alias", "d").unwrap();
20463        assert_eq!(writer.object_link_count(HardLinkTarget::Dataset(idx)), 2);
20464        let grew = writer
20465            .write_dataset_header_inplace(idx)
20466            .unwrap_err()
20467            .to_string();
20468        assert!(
20469            grew.contains("cannot rewrite in place"),
20470            "a header that outgrew its block must be refused: {grew}"
20471        );
20472        assert_eq!(
20473            writer.ds(idx).lock().nlink_written,
20474            1,
20475            "a refused rewrite leaves the registry describing the header the file holds"
20476        );
20477
20478        // The close-time finalize is the writer that commits the second name,
20479        // and a reopen reads the same count back off the link graph.
20480        writer.close().unwrap();
20481        let writer = Hdf5Writer::open_append(&path).unwrap();
20482        assert_eq!(
20483            writer.ds(0).lock().nlink_written,
20484            2,
20485            "finalize wrote two names and the reopen reads two"
20486        );
20487        writer.close().unwrap();
20488        std::fs::remove_file(&path).ok();
20489    }
20490
20491    /// `H5D__chunk_set_info`'s `version_req` (H5Dchunk.c:909, :936): version 5
20492    /// is required for a chunk over 4 GiB — the version-4 layout message's
20493    /// stored-size field is 32 bits and cannot record one — and
20494    /// `LAYOUT_VERSION_DEFAULT` (3, `H5O_LAYOUT_VERSION_DEFAULT`) is the floor
20495    /// for everything at or under that limit. Pure arithmetic on the byte
20496    /// count: no chunk is ever allocated.
20497    #[test]
20498    fn required_chunk_layout_version_pins_5_past_4_gib() {
20499        assert_eq!(
20500            Hdf5Writer::required_chunk_layout_version(u32::MAX as u64),
20501            LAYOUT_VERSION_DEFAULT
20502        );
20503        assert_eq!(
20504            Hdf5Writer::required_chunk_layout_version(u32::MAX as u64 + 1),
20505            5
20506        );
20507    }
20508
20509    /// `H5D__chunk_set_info`'s index-selection gate (H5Dchunk.c:936): a chunk
20510    /// over 4 GiB reaches the v1.10 chunk indexes even under a bound whose
20511    /// `H5O_layout_ver_bounds` row (`LibverBound::layout_version`) is below
20512    /// 4 — `V18` (row 3) and `Earliest` (row 1) both normally keep an
20513    /// ordinary chunk on the version-1 B-tree, but
20514    /// `required_chunk_layout_version`'s own escape to 5 overrides that row
20515    /// for this one chunk. The default bound (`V110`, row 4) already crosses
20516    /// the threshold on its own, so it is asserted only as the baseline, not
20517    /// as a distinguishing case for the escape.
20518    #[test]
20519    fn uses_v110_chunk_indexing_escapes_past_4_gib_at_every_bound() {
20520        let over_4gib = u32::MAX as u64 + 1;
20521        let small = 1024u64;
20522
20523        let path = temp_path("uses_v110_default");
20524        let writer = Hdf5Writer::create(&path).unwrap();
20525        assert!(writer.uses_v110_chunk_indexing(small));
20526        assert!(writer.uses_v110_chunk_indexing(over_4gib));
20527        writer.close().unwrap();
20528        std::fs::remove_file(&path).ok();
20529
20530        let path = temp_path("uses_v110_v18");
20531        let mut writer = Hdf5Writer::create(&path).unwrap();
20532        writer.set_libver_bound(LibverBound::V18).unwrap();
20533        assert!(
20534            !writer.uses_v110_chunk_indexing(small),
20535            "V18's layout row (3) stays below the v1.10 gate for an ordinary chunk"
20536        );
20537        assert!(
20538            writer.uses_v110_chunk_indexing(over_4gib),
20539            "the >4 GiB escape reaches v1.10 indexing despite V18's row"
20540        );
20541        writer.close().unwrap();
20542        std::fs::remove_file(&path).ok();
20543
20544        let path = temp_path("uses_v110_earliest");
20545        let mut writer = Hdf5Writer::create(&path).unwrap();
20546        writer.set_libver_bound(LibverBound::Earliest).unwrap();
20547        assert!(
20548            !writer.uses_v110_chunk_indexing(small),
20549            "Earliest's layout row (1) stays below the v1.10 gate for an ordinary chunk"
20550        );
20551        assert!(
20552            writer.uses_v110_chunk_indexing(over_4gib),
20553            "the >4 GiB escape reaches v1.10 indexing despite Earliest's row"
20554        );
20555        writer.close().unwrap();
20556        std::fs::remove_file(&path).ok();
20557    }
20558
20559    /// `H5D__chunk_set_info`'s closing `MAX3` (H5Dchunk.c:1046): the same
20560    /// escape pins the layout message itself at version 5 for a chunk over
20561    /// 4 GiB regardless of bound — `required_chunk_layout_version` dominates
20562    /// the max chain ahead of both the bound-derived preference and
20563    /// `LAYOUT_VERSION_DEFAULT`.
20564    #[test]
20565    fn chunk_layout_version_pins_5_past_4_gib_at_every_bound() {
20566        let over_4gib = u32::MAX as u64 + 1;
20567        let small = 1024u64;
20568
20569        let path = temp_path("chunk_ver_default");
20570        let writer = Hdf5Writer::create(&path).unwrap();
20571        assert_eq!(writer.chunk_layout_version(false, small), 4);
20572        assert_eq!(writer.chunk_layout_version(false, over_4gib), 5);
20573        writer.close().unwrap();
20574        std::fs::remove_file(&path).ok();
20575
20576        let path = temp_path("chunk_ver_v18");
20577        let mut writer = Hdf5Writer::create(&path).unwrap();
20578        writer.set_libver_bound(LibverBound::V18).unwrap();
20579        assert_eq!(writer.chunk_layout_version(false, small), 3);
20580        assert_eq!(writer.chunk_layout_version(false, over_4gib), 5);
20581        writer.close().unwrap();
20582        std::fs::remove_file(&path).ok();
20583
20584        let path = temp_path("chunk_ver_earliest");
20585        let mut writer = Hdf5Writer::create(&path).unwrap();
20586        writer.set_libver_bound(LibverBound::Earliest).unwrap();
20587        assert_eq!(
20588            writer.chunk_layout_version(false, small),
20589            LAYOUT_VERSION_DEFAULT
20590        );
20591        assert_eq!(writer.chunk_layout_version(false, over_4gib), 5);
20592        writer.close().unwrap();
20593        std::fs::remove_file(&path).ok();
20594    }
20595    /// `fsm_persist.h5` persists two managers — metadata and raw data. The
20596    /// reopen reads both, hands their merged sections to the allocator, and
20597    /// claims the four blocks the managers themselves occupy.
20598    #[test]
20599    fn a_persisting_file_reopens_with_its_free_sections() {
20600        let path = fixture_copy("fsm_persist.h5", "fsm_read");
20601        let writer = Hdf5Writer::open_append(&path).unwrap();
20602        let fs = writer.free_space.as_deref().expect("managers were read");
20603
20604        assert!(fs.info.persist);
20605        assert_eq!(fs.info.strategy, FileSpaceStrategy::FsmAggr);
20606        assert_eq!(fs.info.threshold, 1);
20607
20608        let sections = writer.allocator.free_blocks();
20609        // h5stat -S reports 1910 bytes of tracked free space for this file.
20610        assert_eq!(sections.iter().map(|s| s.1).sum::<u64>(), 1910);
20611        // Address-ordered, and no two sections touch: what the two managers
20612        // held separately came out coalesced.
20613        for w in sections.windows(2) {
20614            assert!(w[0].0 + w[0].1 < w[1].0, "{sections:?}");
20615        }
20616        // Two headers plus the two sections blocks they name.
20617        assert_eq!(fs.superseded.len(), 4);
20618        for &(addr, len) in &fs.superseded {
20619            assert!(len > 0);
20620            assert!(
20621                !sections
20622                    .iter()
20623                    .any(|&(a, l)| addr < a + l && a < addr + len),
20624                "manager block {addr:#x}+{len} sits in a free section"
20625            );
20626        }
20627        drop(writer);
20628        let _ = std::fs::remove_file(&path);
20629    }
20630
20631    /// A file created with non-default file-space properties carries the
20632    /// message that declares them, and one created to persist gets real
20633    /// managers as soon as anything is freed.
20634    #[test]
20635    fn a_created_file_declares_the_strategy_it_was_made_with() {
20636        let path = temp_path("fsm_create");
20637        {
20638            let w = Hdf5Writer::create_with_options(
20639                &path,
20640                FileCreateOptions {
20641                    file_space: FileSpaceConfig::new(FileSpaceStrategy::FsmAggr, true, 1),
20642                    ..Default::default()
20643                },
20644            )
20645            .unwrap();
20646            let i = w
20647                .create_dataset("keep", DatatypeMessage::i32_type(), &[8])
20648                .unwrap();
20649            w.write_dataset_raw(i, &[0u8; 32]).unwrap();
20650            w.close().unwrap();
20651        }
20652
20653        let info = read_only_append(&path)
20654            .free_space
20655            .as_deref()
20656            .expect("the created file declares a strategy")
20657            .info
20658            .clone();
20659        assert_eq!(info.strategy, FileSpaceStrategy::FsmAggr);
20660        assert!(info.persist);
20661        assert_eq!(info.threshold, 1);
20662        assert_eq!(info.page_size, 4096);
20663        // The alignment fragments the creation left behind are the file's
20664        // first free space, so the metadata manager already has an address
20665        // and the raw-data one, which nothing freed into, does not.
20666        assert_ne!(info.fs_addr[0], UNDEF_ADDR);
20667        assert!(info.fs_addr.iter().skip(1).all(|&a| a == UNDEF_ADDR));
20668
20669        // An append supersedes the root header and the extension, and that
20670        // freed space is what the managers now record.
20671        append_one(&path, "added", false);
20672        assert!(
20673            tracked_free_space(&path) > 0,
20674            "the append recorded no free space"
20675        );
20676        let _ = std::fs::remove_file(&path);
20677    }
20678
20679    /// The two strategies without managers, and the default. All three are
20680    /// `H5Pset_file_space_strategy` settings; only the default leaves the file
20681    /// without the message.
20682    #[test]
20683    fn a_strategy_without_managers_still_declares_itself() {
20684        for (strategy, persist) in [
20685            (FileSpaceStrategy::Aggr, true),
20686            (FileSpaceStrategy::None, false),
20687        ] {
20688            let path = temp_path("fsm_nomgr");
20689            {
20690                let w = Hdf5Writer::create_with_options(
20691                    &path,
20692                    FileCreateOptions {
20693                        file_space: FileSpaceConfig::new(strategy, persist, 7),
20694                        ..Default::default()
20695                    },
20696                )
20697                .unwrap();
20698                w.create_dataset("d", DatatypeMessage::f64_type(), &[4])
20699                    .unwrap();
20700                w.close().unwrap();
20701            }
20702            // Read through the reader, not the writer: a reopen only builds
20703            // free-space state for a file it will rewrite managers for, and
20704            // these two have none.
20705            let info = declared_file_space(&path).expect("the strategy is declared");
20706            assert_eq!(info.strategy, strategy);
20707            // `H5P__set_file_space_strategy` stores neither for a strategy
20708            // that has no managers, so both keep the library defaults.
20709            assert!(!info.persist);
20710            assert_eq!(info.threshold, 1);
20711            let _ = std::fs::remove_file(&path);
20712        }
20713    }
20714
20715    /// The library defaults are what a file says by saying nothing.
20716    #[test]
20717    fn the_default_strategy_writes_no_message() {
20718        let path = temp_path("fsm_default");
20719        {
20720            let w = Hdf5Writer::create_with_options(
20721                &path,
20722                FileCreateOptions {
20723                    file_space: FileSpaceConfig::new(FileSpaceStrategy::FsmAggr, false, 1),
20724                    ..Default::default()
20725                },
20726            )
20727            .unwrap();
20728            w.create_dataset("d", DatatypeMessage::f64_type(), &[4])
20729                .unwrap();
20730            w.close().unwrap();
20731        }
20732        assert!(declared_file_space(&path).is_none());
20733        let _ = std::fs::remove_file(&path);
20734    }
20735
20736    /// The file-space info message a file carries, read back the way any
20737    /// reader sees it.
20738    fn declared_file_space(path: &std::path::Path) -> Option<FileSpaceInfoMessage> {
20739        crate::io::reader::Hdf5Reader::open(path)
20740            .unwrap()
20741            .superblock_extension()
20742            .file_space_info
20743            .clone()
20744    }
20745
20746    /// A created paged file is laid out on its page grid: the superblock takes
20747    /// the whole of page zero and the rest of that page is the metadata
20748    /// manager's first section, which is what `H5MF__alloc_pagefs` gives
20749    /// `H5F__super_init`'s `H5MF_alloc(f, H5FD_MEM_SUPER, ...)`.
20750    #[test]
20751    fn a_created_paged_file_lays_its_pages_out() {
20752        let path = temp_path("fsm_paged_created");
20753        {
20754            let w = Hdf5Writer::create_with_options(
20755                &path,
20756                FileCreateOptions {
20757                    file_space: FileSpaceConfig::new(FileSpaceStrategy::Page, true, 1),
20758                    ..Default::default()
20759                },
20760            )
20761            .unwrap();
20762            let i = w
20763                .create_dataset("keep", DatatypeMessage::i32_type(), &[8])
20764                .unwrap();
20765            w.write_dataset_raw(i, &[0u8; 32]).unwrap();
20766            w.close().unwrap();
20767        }
20768        let info = read_only_append(&path)
20769            .free_space
20770            .as_deref()
20771            .expect("the created file declares a strategy")
20772            .info
20773            .clone();
20774        assert_eq!(info.strategy, FileSpaceStrategy::Page);
20775        assert!(info.persist);
20776        assert_eq!(info.page_size, 4096);
20777        assert_eq!(
20778            std::fs::metadata(&path).unwrap().len() % info.page_size,
20779            0,
20780            "a paged file ends on a page boundary"
20781        );
20782        let _ = std::fs::remove_file(&path);
20783    }
20784
20785    /// A userblock has to be a whole number of pages, or every page boundary
20786    /// after it is off the file's own grid — `H5F__super_init` refuses one
20787    /// that is not (H5Fsuper.c:1182-1192).
20788    #[test]
20789    fn a_paged_file_refuses_a_userblock_smaller_than_its_page() {
20790        let path = temp_path("fsm_paged_userblock");
20791        let Err(err) = Hdf5Writer::create_with_options(
20792            &path,
20793            FileCreateOptions {
20794                file_space: FileSpaceConfig::new(FileSpaceStrategy::Page, true, 1),
20795                userblock: 512,
20796                ..Default::default()
20797            },
20798        ) else {
20799            panic!("a 512-byte userblock was accepted on a 4096-byte page");
20800        };
20801        assert!(
20802            format!("{err}").contains("multiple of its 4096-byte"),
20803            "{err}"
20804        );
20805        let _ = std::fs::remove_file(&path);
20806    }
20807
20808    /// A page size the builder names is the page the file is actually laid
20809    /// out in, not just a number the message repeats: every allocation is
20810    /// shaped by it and the file ends on one of its boundaries.
20811    #[test]
20812    fn a_file_created_at_a_non_default_page_size_allocates_by_it() {
20813        let path = temp_path("fsm_page_size_8k");
20814        {
20815            let w = Hdf5Writer::create_with_options(
20816                &path,
20817                FileCreateOptions {
20818                    file_space: FileSpaceConfig::new(FileSpaceStrategy::Page, true, 1)
20819                        .with_page_size(8192),
20820                    ..Default::default()
20821                },
20822            )
20823            .unwrap();
20824            let i = w
20825                .create_dataset("keep", DatatypeMessage::i32_type(), &[8])
20826                .unwrap();
20827            w.write_dataset_raw(i, &[0u8; 32]).unwrap();
20828            w.close().unwrap();
20829        }
20830        let info = read_only_append(&path)
20831            .free_space
20832            .as_deref()
20833            .expect("the created file declares a strategy")
20834            .info
20835            .clone();
20836        assert_eq!(info.page_size, 8192);
20837        assert_eq!(
20838            std::fs::metadata(&path).unwrap().len() % 8192,
20839            0,
20840            "the file ends on one of the pages it was created with"
20841        );
20842        let _ = std::fs::remove_file(&path);
20843    }
20844
20845    /// The page size is the fourth of the four properties `H5F__super_init`
20846    /// compares against the library defaults (H5Fsuper.c:1092-1097), so
20847    /// naming it is on its own enough to give a file the message — under the
20848    /// default strategy, which allocates without it.
20849    #[test]
20850    fn a_non_default_page_size_alone_gives_the_file_a_message() {
20851        let path = temp_path("fsm_page_size_only");
20852        {
20853            let w = Hdf5Writer::create_with_options(
20854                &path,
20855                FileCreateOptions {
20856                    file_space: FileSpaceConfig::default().with_page_size(1024),
20857                    ..Default::default()
20858                },
20859            )
20860            .unwrap();
20861            w.close().unwrap();
20862        }
20863        let info = declared_file_space(&path)
20864            .expect("a file naming only a page size still carries the message");
20865        assert_eq!(info.strategy, FileSpaceStrategy::FsmAggr);
20866        assert!(!info.persist);
20867        assert_eq!(info.page_size, 1024);
20868        let _ = std::fs::remove_file(&path);
20869    }
20870
20871    /// `H5Pset_file_space_page_size` refuses anything below 512 or above
20872    /// 1 GiB (H5Pfcpl.c:1389-1393), and nothing between: no power of two is
20873    /// required, so a size the bounds admit is one the file may carry.
20874    #[test]
20875    fn a_page_size_outside_the_library_bounds_is_refused() {
20876        for size in [0, 1, 511, PAGE_SIZE_MAX + 1] {
20877            let path = temp_path(&format!("fsm_page_size_bad_{size}"));
20878            let Err(err) = Hdf5Writer::create_with_options(
20879                &path,
20880                FileCreateOptions {
20881                    file_space: FileSpaceConfig::new(FileSpaceStrategy::Page, true, 1)
20882                        .with_page_size(size),
20883                    ..Default::default()
20884                },
20885            ) else {
20886                panic!("a {size}-byte file-space page was accepted");
20887            };
20888            assert!(
20889                format!("{err}").contains("between 512 bytes and 1073741824"),
20890                "{err}"
20891            );
20892            let _ = std::fs::remove_file(&path);
20893        }
20894        let path = temp_path("fsm_page_size_odd");
20895        let w = Hdf5Writer::create_with_options(
20896            &path,
20897            FileCreateOptions {
20898                file_space: FileSpaceConfig::new(FileSpaceStrategy::Page, true, 1)
20899                    .with_page_size(513),
20900                ..Default::default()
20901            },
20902        )
20903        .expect("513 is inside the bounds, and no power of two is required");
20904        w.close().unwrap();
20905        let _ = std::fs::remove_file(&path);
20906    }
20907
20908    /// A paged file's managers are read on reopen, the same as any other
20909    /// file's: paged aggregation changes which manager a request maps to, not
20910    /// whether the file has managers to rewrite.
20911    #[test]
20912    fn a_paged_file_reports_the_managers_it_persists() {
20913        let path = fixture_copy("fsm_persist_page.h5", "fsm_read_paged");
20914        let writer = Hdf5Writer::open_append(&path).unwrap();
20915        let fs = writer.free_space.as_deref().expect("no managers read");
20916        assert_eq!(fs.info.strategy, FileSpaceStrategy::Page);
20917        assert!(
20918            !writer.allocator.free_extents().is_empty(),
20919            "the sections the file records were not put back in circulation"
20920        );
20921        drop(writer);
20922        let _ = std::fs::remove_file(&path);
20923    }
20924
20925    /// A file with no file-space info message at all — every file this crate
20926    /// creates — has nothing to read and nothing to write back.
20927    #[test]
20928    fn a_file_without_a_strategy_has_no_managers() {
20929        let path = temp_path("fsm_none");
20930        {
20931            let w = Hdf5Writer::create(&path).unwrap();
20932            w.create_dataset("d", DatatypeMessage::f64_type(), &[4])
20933                .unwrap();
20934            w.close().unwrap();
20935        }
20936        let writer = Hdf5Writer::open_append(&path).unwrap();
20937        assert!(writer.free_space.is_none());
20938        drop(writer);
20939        let _ = std::fs::remove_file(&path);
20940    }
20941    /// Sum of the sections the managers a file names actually hold — what
20942    /// `h5stat -S` prints as "Amount of tracked free space", read back through
20943    /// this crate's own decoder so a test can assert on it. A reopen seeds the
20944    /// allocator with exactly those sections, so its free list is the number.
20945    fn tracked_free_space(path: &std::path::Path) -> u64 {
20946        read_only_append(path)
20947            .allocator
20948            .free_blocks()
20949            .iter()
20950            .map(|b| b.1)
20951            .sum()
20952    }
20953
20954    /// Open for append and mark the writer closed, so dropping it releases the
20955    /// file lock instead of finalizing and rewriting what is being inspected.
20956    fn read_only_append(path: &std::path::Path) -> Hdf5Writer {
20957        let mut w = Hdf5Writer::open_append(path).unwrap();
20958        w.closed = true;
20959        w
20960    }
20961
20962    /// Add one small dataset, the smallest append that still rewrites the root
20963    /// header, the superblock extension and — on a persisting file — the
20964    /// free-space manager.
20965    fn append_one(path: &std::path::Path, name: &str, disable_managers: bool) {
20966        let mut w = Hdf5Writer::open_append(path).unwrap();
20967        if disable_managers {
20968            // Both halves of the change, so the control is the file as this
20969            // crate wrote it before: the session neither allocates from the
20970            // recorded sections nor writes any back.
20971            w.free_space = None;
20972            w.allocator.reset_free_list(&[]);
20973        }
20974        let i = w
20975            .create_dataset(name, DatatypeMessage::i32_type(), &[8])
20976            .unwrap();
20977        w.write_dataset_raw(
20978            i,
20979            &(0..8i32).flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>(),
20980        )
20981        .unwrap();
20982        w.close().unwrap();
20983    }
20984
20985    /// The block list a reopen carries for the superblock extension covers
20986    /// every chunk of the header, not just the first. The fixture's extension
20987    /// is a two-chunk header — libhdf5 put the file-space info message in a
20988    /// continuation — and freeing chunk zero alone left the continuation
20989    /// allocated with nothing naming it.
20990    #[test]
20991    fn a_reopen_carries_every_chunk_of_the_superblock_extension() {
20992        let path = fixture_copy("fsm_persist.h5", "fsm_ext_chunks");
20993        let blocks = read_only_append(&path).extension.superseded.clone();
20994        assert!(
20995            blocks.len() > 1,
20996            "the fixture's extension is one chunk, so this proves nothing: {blocks:?}"
20997        );
20998        let _ = std::fs::remove_file(&path);
20999    }
21000
21001    /// An append on a persisting file both spends and records the space its
21002    /// managers track: the new dataset comes out of the sections the file
21003    /// already had, and what the rewrite frees goes back into them.
21004    #[test]
21005    fn an_append_reuses_and_records_the_space_the_managers_track() {
21006        let path = fixture_copy("fsm_persist.h5", "fsm_write");
21007        let original = std::fs::metadata(&path).unwrap().len();
21008        let before = tracked_free_space(&path);
21009        assert_eq!(before, 1910, "the fixture's own managers");
21010
21011        append_one(&path, "added", false);
21012        let size = std::fs::metadata(&path).unwrap().len();
21013        let tracked = tracked_free_space(&path);
21014
21015        // Negative control: the same append with both halves of this off — no
21016        // allocating out of the recorded sections and no writing any back —
21017        // which is what this crate did before it read free space at all.
21018        let control = fixture_copy("fsm_persist.h5", "fsm_write_control");
21019        append_one(&control, "added", true);
21020        let control_size = std::fs::metadata(&control).unwrap().len();
21021        assert_eq!(
21022            tracked_free_space(&control),
21023            before,
21024            "with the manager rewrite disabled the number must not move"
21025        );
21026
21027        // The new dataset's raw data comes out of the raw-data sections the
21028        // file already recorded, so the append grows the file by less than the
21029        // same append with the reuse off. It does not stop the growth:
21030        // `H5MF_alloc` asks one manager and no other, and of this fixture's
21031        // 1910 free bytes 1848 are raw-data ones, so the metadata the append
21032        // writes still comes from the end of the file.
21033        assert!(
21034            size < control_size,
21035            "the append took nothing from the {before} bytes free: \
21036             {original} grew to {size}, the control to {control_size}"
21037        );
21038        assert!(
21039            control_size > original,
21040            "the control has to grow or it proves nothing"
21041        );
21042        // Space no manager and no object claims — `h5stat -S`'s "unaccounted
21043        // space" — is what the leak was, and it is smaller now.
21044        assert!(
21045            size - tracked < control_size - before,
21046            "unaccounted space went from {} to {}",
21047            control_size - before,
21048            size - tracked
21049        );
21050
21051        for p in [&path, &control] {
21052            let _ = std::fs::remove_file(p);
21053        }
21054    }
21055
21056    /// The set the writer holds free when it finishes is exactly the set the
21057    /// manager it just wrote records — the invariant that makes the on-disk
21058    /// managers a faithful account of the file's free space.
21059    #[test]
21060    fn the_manager_records_the_free_list_the_close_ends_with() {
21061        let path = fixture_copy("fsm_persist.h5", "fsm_roundtrip");
21062        let internal = {
21063            let mut w = Hdf5Writer::open_append(&path).unwrap();
21064            let i = w
21065                .create_dataset("added", DatatypeMessage::i32_type(), &[8])
21066                .unwrap();
21067            w.write_dataset_raw(i, &[0u8; 32]).unwrap();
21068            w.finalize(true).unwrap();
21069            let blocks = w.allocator.free_extents();
21070            w.closed = true;
21071            blocks
21072        };
21073        assert!(!internal.is_empty(), "the append freed nothing");
21074
21075        // Classes included: a section read back out of the wrong manager is a
21076        // section libhdf5 would offer to the wrong kind of allocation.
21077        let reread = {
21078            let w = read_only_append(&path);
21079            assert!(w.free_space.is_some(), "managers were written");
21080            w.allocator.free_extents()
21081        };
21082        assert_eq!(internal, reread);
21083        let _ = std::fs::remove_file(&path);
21084    }
21085
21086    /// The paged half of
21087    /// [`the_manager_records_the_free_list_the_close_ends_with`]: a paged
21088    /// file's sections carry a page and a class as well as an address, and a
21089    /// section written into the wrong manager or split across a page boundary
21090    /// would come back different.
21091    #[test]
21092    fn the_manager_records_the_free_list_a_paged_close_ends_with() {
21093        let path = fixture_copy("fsm_persist_page.h5", "fsm_paged_roundtrip");
21094        let internal = {
21095            let mut w = Hdf5Writer::open_append(&path).unwrap();
21096            let i = w
21097                .create_dataset("added", DatatypeMessage::i32_type(), &[8])
21098                .unwrap();
21099            w.write_dataset_raw(i, &[0u8; 32]).unwrap();
21100            w.finalize(true).unwrap();
21101            let blocks = w.allocator.free_extents();
21102            w.closed = true;
21103            blocks
21104        };
21105        assert!(!internal.is_empty(), "the append freed nothing");
21106
21107        let reread = {
21108            let w = read_only_append(&path);
21109            assert!(w.free_space.is_some(), "managers were written");
21110            w.allocator.free_extents()
21111        };
21112        assert_eq!(internal, reread);
21113        let _ = std::fs::remove_file(&path);
21114    }
21115
21116    /// Negative control for the paged managers: with the read and the rewrite
21117    /// both off — the file as this crate handled a paged file before — the
21118    /// space the append frees is recorded nowhere, and the number this crate
21119    /// reads back is the fixture's own.
21120    #[test]
21121    fn a_paged_append_records_nothing_without_the_manager_rewrite() {
21122        let path = fixture_copy("fsm_persist_page.h5", "fsm_paged_measured");
21123        let control = fixture_copy("fsm_persist_page.h5", "fsm_paged_control");
21124        let before = tracked_free_space(&path);
21125        let original = std::fs::metadata(&path).unwrap().len();
21126
21127        append_one(&path, "added", false);
21128        append_one(&control, "added", true);
21129
21130        assert_eq!(
21131            tracked_free_space(&control),
21132            before,
21133            "the control moved the number it is there to hold still"
21134        );
21135        assert_eq!(
21136            std::fs::metadata(&path).unwrap().len(),
21137            original,
21138            "the append grew a paged file with {before} bytes recorded free"
21139        );
21140        assert!(
21141            std::fs::metadata(&control).unwrap().len() > original,
21142            "the control has to grow or it proves nothing"
21143        );
21144        assert_ne!(
21145            tracked_free_space(&path),
21146            before,
21147            "the managers came back holding what the fixture wrote"
21148        );
21149        for p in [&path, &control] {
21150            let _ = std::fs::remove_file(p);
21151        }
21152    }
21153
21154    /// A block released from a dataset's raw data is recorded by the manager
21155    /// `H5MF_ALLOC_TO_FS_AGGR_TYPE` maps `H5FD_MEM_DRAW` to, and nothing else
21156    /// is: the dichotomy the sec2 driver installs is what decides, and the two
21157    /// managers it collapses to are the file-space info message's slots 0 and
21158    /// 2.
21159    #[test]
21160    fn a_released_raw_block_lands_in_the_raw_data_manager() {
21161        let path = temp_path("fsm_dichotomy");
21162        {
21163            let w = Hdf5Writer::create_with_options(
21164                &path,
21165                FileCreateOptions {
21166                    file_space: FileSpaceConfig::new(FileSpaceStrategy::FsmAggr, true, 1),
21167                    ..Default::default()
21168                },
21169            )
21170            .unwrap();
21171            let i = w
21172                .create_dataset("bulk", DatatypeMessage::i32_type(), &[256])
21173                .unwrap();
21174            w.write_dataset_raw(i, &vec![0u8; 1024]).unwrap();
21175            w.create_dataset("keep", DatatypeMessage::i32_type(), &[8])
21176                .unwrap();
21177            w.close().unwrap();
21178        }
21179        let (raw_addr, raw_len) = {
21180            let w = read_only_append(&path);
21181            let i = w.dataset_index("bulk").unwrap();
21182            let ds = w.ds(i);
21183            let m = ds.lock();
21184            (m.data_addr, m.data_size)
21185        };
21186        assert!(raw_len >= 1024, "the raw block is {raw_len} bytes");
21187        {
21188            let w = Hdf5Writer::open_append(&path).unwrap();
21189            w.delete_dataset("bulk").unwrap();
21190            w.close().unwrap();
21191        }
21192
21193        let mut w = read_only_append(&path);
21194        let info = w
21195            .free_space
21196            .as_deref()
21197            .expect("the file persists managers")
21198            .info
21199            .clone();
21200        assert_ne!(info.fs_addr[0], UNDEF_ADDR, "no metadata manager");
21201        assert_ne!(info.fs_addr[2], UNDEF_ADDR, "no raw-data manager");
21202        for (slot, &addr) in info.fs_addr.iter().enumerate() {
21203            if slot != 0 && slot != 2 {
21204                assert_eq!(addr, UNDEF_ADDR, "slot {slot} names a manager");
21205            }
21206        }
21207
21208        let found = crate::io::free_space_io::read_managers(&mut w.handle, &w.ctx, &info).unwrap();
21209        let inside = |b: &FreeBlock| b.addr >= raw_addr && b.addr + b.len <= raw_addr + raw_len;
21210        let raw: Vec<&FreeBlock> = found
21211            .sections
21212            .iter()
21213            .filter(|b| b.manager == FreeSpaceManager::RawData)
21214            .collect();
21215        assert!(
21216            !raw.is_empty(),
21217            "the deleted dataset's bytes were not recorded"
21218        );
21219        assert!(
21220            raw.iter().all(|b| inside(b)),
21221            "a raw-data section is outside the deleted dataset's block: {raw:?}"
21222        );
21223        assert!(
21224            found
21225                .sections
21226                .iter()
21227                .filter(|b| b.manager == FreeSpaceManager::Metadata)
21228                .all(|b| !inside(b)),
21229            "raw-data bytes were recorded by the metadata manager"
21230        );
21231        drop(w);
21232        let _ = std::fs::remove_file(&path);
21233    }
21234
21235    /// A reopened paged file's managers are this writer's to rewrite, and the
21236    /// three the sec2 driver can reach are the only ones it names.
21237    ///
21238    /// `H5MF__alloc_to_fs_type` (H5MF.c:265) sends a request of at least one
21239    /// page to `H5F_MEM_PAGE_GENERIC` unless the driver declares
21240    /// `H5FD_FEAT_PAGED_AGGR`, which only the multi and split drivers do, so a
21241    /// sec2 file has the dichotomy's two small managers and that one large
21242    /// one: message slots 0, 2 and 6.
21243    #[test]
21244    fn a_paged_file_names_only_the_managers_sec2_can_reach() {
21245        let path = fixture_copy("fsm_persist_page.h5", "fsm_write_paged");
21246        assert!(
21247            read_only_append(&path).free_space.is_some(),
21248            "the paged fixture's managers were not read"
21249        );
21250        append_one(&path, "added", false);
21251
21252        let mut w = read_only_append(&path);
21253        let info = w
21254            .free_space
21255            .as_deref()
21256            .expect("the file persists managers")
21257            .info
21258            .clone();
21259        assert_eq!(info.strategy, FileSpaceStrategy::Page);
21260        for (slot, &addr) in info.fs_addr.iter().enumerate() {
21261            if !matches!(slot, 0 | 2 | 6) {
21262                assert_eq!(addr, UNDEF_ADDR, "slot {slot} names a manager");
21263            }
21264        }
21265        assert!(
21266            info.fs_addr.iter().any(|&a| a != UNDEF_ADDR),
21267            "the rewritten file records nothing free"
21268        );
21269        crate::io::free_space_io::read_managers(&mut w.handle, &w.ctx, &info).unwrap();
21270        drop(w);
21271        let _ = std::fs::remove_file(&path);
21272    }
21273
21274    /// Every section a paged file records sits inside one page, and the pages
21275    /// its small managers use are pages of their own kind — the invariant
21276    /// `H5MF__alloc_pagefs` maintains by giving each small request a whole
21277    /// page of its class and recording the rest of it in that class's manager.
21278    #[test]
21279    fn a_paged_files_small_sections_stay_inside_one_page_of_one_kind() {
21280        let path = fixture_copy("fsm_persist_page.h5", "fsm_paged_pages");
21281        append_one(&path, "added", false);
21282
21283        let mut w = read_only_append(&path);
21284        let info = w
21285            .free_space
21286            .as_deref()
21287            .expect("the file persists managers")
21288            .info
21289            .clone();
21290        let page = info.page_size;
21291        let found = crate::io::free_space_io::read_managers(&mut w.handle, &w.ctx, &info).unwrap();
21292        let mut kind_of_page: std::collections::HashMap<u64, FreeSpaceManager> =
21293            std::collections::HashMap::new();
21294        for section in &found.sections {
21295            if section.manager == FreeSpaceManager::Large {
21296                continue;
21297            }
21298            assert_eq!(
21299                section.addr / page,
21300                (section.addr + section.len - 1) / page,
21301                "the section at {:#x} crosses a page boundary",
21302                section.addr
21303            );
21304            let owner = kind_of_page
21305                .entry(section.addr / page)
21306                .or_insert(section.manager);
21307            assert_eq!(
21308                *owner,
21309                section.manager,
21310                "page {} holds sections of two kinds",
21311                section.addr / page
21312            );
21313        }
21314        drop(w);
21315        let _ = std::fs::remove_file(&path);
21316    }
21317}