Skip to main content

hdf5_pure/
reader.rs

1//! Reading API: File, Dataset, and Group handles for reading HDF5 files.
2
3use std::borrow::Cow;
4use std::collections::HashMap;
5use std::io::{Read, Seek, SeekFrom};
6use std::num::{NonZeroU64, NonZeroUsize};
7use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
8use std::sync::{Arc, Mutex, PoisonError, RwLock};
9
10use crate::access_mode::AccessMode;
11use crate::address::{BaseAddress, StoredAddress};
12use crate::edit::{
13    AppendBuilder, AppendGeometry, AppendTarget, EditBacking, MemoryStrategy, SpaceAccounting,
14    StagedChild, StagedKind, StagedMeta, StagedObject, SyncPolicy, WriteEngine,
15};
16use crate::element::H5Element;
17use crate::type_builders::{DatasetBuilder, VL_REF_SIZE};
18
19use crate::appender::BufferedAppender;
20use crate::attribute::{extract_attributes_full, extract_attributes_full_from_source};
21use crate::chunk_cache::{CachePass, ChunkCache, ChunkCacheConfig, ChunkCacheStats};
22use crate::compound::CompoundType;
23use crate::convert::Narrow;
24use crate::data_layout::DataLayout;
25use crate::data_read;
26use crate::dataspace::{Dataspace, MaxExtent};
27use crate::datatype::{Datatype, ReferenceType};
28use crate::error::{Error, FormatError};
29use crate::file_create_properties::FileCreateProperties;
30use crate::file_lock::{self, FileLocking, OpenIntent, OpenTarget, WriteMarkPolicy};
31use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy};
32use crate::fill_value::FillPattern;
33use crate::filter_pipeline::FilterPipeline;
34use crate::free_space_manager;
35use crate::group_v1::GroupEntry;
36use crate::group_v2::{self, ChildLookup, is_group};
37use crate::layout_info::{Chunk, ChunkIndex, Filter, Layout};
38use crate::libver::LibVer;
39use crate::message_type::MessageType;
40use crate::object_header::ObjectHeader;
41use crate::object_path::{LinkName, LinkNameBuf, ObjectPath, ObjectPathBuf};
42use crate::read_spec::RawReadSpec;
43use crate::shared_message::{self, BufferedResolver, SharedResolver, SourceResolver};
44use crate::signature;
45use crate::source::{
46    BaseOffsetSource, BytesSource, MetadataCacheConfig, MetadataCacheStats, MetadataCachingSource,
47    ReadSeekSource, Source, ValidatedSource, frame,
48};
49use crate::superblock::Superblock;
50use crate::vl_data::{self, VlenStringReadOptions};
51
52use crate::types::{AttrValue, DType, attrs_to_map, classify_datatype};
53
54// ---------------------------------------------------------------------------
55// File
56// ---------------------------------------------------------------------------
57
58/// Backing store for a [`File`]: either the whole file buffered in memory, or a
59/// lazy [`Source`] that reads regions on demand (see [`File::open_streaming`]).
60enum Backend {
61    InMemory(Vec<u8>),
62    Streaming(Box<dyn Source + Send + Sync>),
63    /// A read-write file opened with [`File::open_rw`]: a [`WriteEngine`] (exclusive OS lock + staged
64    /// edit queues + append geometry cache) behind a lock, so owned handles can
65    /// both read and mutate in place. Handle write methods route to the engine,
66    /// and `File::commit` applies staged structural edits.
67    ///
68    /// Either backing — a whole-file mirror, or positioned I/O against the
69    /// handle — appears here as the same `WriteEngine`; which one an open
70    /// resolved to is the engine's own business rather than the backend's
71    /// (issue #198). Reads
72    /// borrow the mirror's slice when there is one and go through the image's
73    /// `Source` otherwise; see [`with_engine`](FileInner::with_engine). Boxed to
74    /// keep the `Backend` enum small (a `WriteEngine` is far larger than the
75    /// other variants).
76    Edit(Box<Mutex<WriteEngine>>),
77}
78
79/// What an operation through a file's write session can do to it, which is what
80/// decides how much of an object handle's memo it invalidates — and, with it,
81/// which edit surface the operation is using.
82///
83/// One variant carries both because they coincide: the *staged* surface is the
84/// one that commits, and a commit is the only thing that moves an object header,
85/// so [`Relocating`](Self::Relocating) is exactly the surface a SWMR writer
86/// refuses. An operation off that surface that could relocate would break the
87/// pairing and would have to say which it was separately.
88#[derive(Clone, Copy)]
89enum Change {
90    /// Rewrites object headers and can move them: a commit. The staging of one
91    /// counts too, though it writes nothing — a pending edit is a header move
92    /// this session has not made yet, and an address is worth no more against
93    /// one than against the commit that will apply it. Invalidates a handle's
94    /// address as well as the header it parsed.
95    ///
96    /// This is the staged surface, which a SWMR writer refuses.
97    Relocating,
98    /// Changes bytes without moving any object header: an immediate
99    /// [`Dataset::append`], which rewrites the dataset's dimension where its
100    /// header stands, and the free-space and status-flag bookkeeping a session's
101    /// teardown rewrites where it stands. Invalidates the parsed header alone,
102    /// which is what lets a handle reached by object reference — the one kind
103    /// with no name to look itself up by — go on appending.
104    InPlace,
105    /// Changes nothing a handle can observe: a durability barrier over writes
106    /// the operations that made them already accounted for, or a question whose
107    /// answer the engine caches. Invalidates no memo.
108    ///
109    /// Distinct from not going through the gate at all, which is what a *read*
110    /// does: these still need the write session, and still need the file to be
111    /// open for writing and unsealed.
112    Nothing,
113}
114
115/// Where an object handle's header sits, and what that answer holds as of.
116///
117/// A handle names its object — by path, or by the address a reference gave it —
118/// and this is a memo of what that name last resolved to. See
119/// [`FileInner::locate`], which takes one, and the two counters it reads.
120#[derive(Clone, Copy)]
121struct Resolution {
122    content_revision: u64,
123    address_revision: u64,
124    address: u64,
125}
126
127/// The pair of revisions read *before* a resolution is worked out, which the
128/// answer is then labelled with.
129///
130/// Splitting the reads from the address is what keeps the order right at every
131/// call site: there is no way to label an address with a revision taken after
132/// it, which would claim a freshness the address does not have. See
133/// [`FileInner::locate`].
134#[derive(Clone, Copy)]
135struct Revisions {
136    content: u64,
137    address: u64,
138}
139
140impl Revisions {
141    /// Label `address` as worked out at these revisions.
142    const fn at(self, address: u64) -> Resolution {
143        Resolution {
144            content_revision: self.content,
145            address_revision: self.address,
146            address,
147        }
148    }
149}
150
151/// Where a [`Dataset`] or [`Group`] handle stands with respect to the staged
152/// set, worked out by [`FileInner::staged_standing`].
153///
154/// A handle names its object by path, and a path can mean an object in the
155/// file, an object this session has staged, or — for a handle *born* onto a
156/// staged creation — nothing at all, once that creation is withdrawn. The third
157/// is why the handle carries a mark of its own: without one, a withdrawal is
158/// indistinguishable from a commit, and the handle would silently start
159/// answering for whatever the file holds at the path, which in the case that
160/// produces it is the object the session is deleting.
161#[derive(Clone, Copy, PartialEq, Eq, Debug)]
162enum Standing {
163    /// Resolve the path against the file, as a handle opened by name does.
164    Live,
165    /// A creation this session staged owns the path and no commit has written
166    /// it: [`Error::NotCommitted`] for anything needing its bytes.
167    Pending,
168    /// This handle was made onto a staged creation that has since been
169    /// withdrawn: [`Error::StagingWithdrawn`].
170    Withdrawn,
171}
172
173/// A borrowed `Source` view over a [`File`]'s backend, used by the
174/// streaming-capable read paths so one call site serves both backends.
175pub(crate) enum SourceView<'a> {
176    Mem(&'a [u8]),
177    Stream(&'a (dyn Source + Send + Sync)),
178}
179
180impl Source for SourceView<'_> {
181    fn len(&self) -> u64 {
182        match self {
183            SourceView::Mem(b) => b.len() as u64,
184            SourceView::Stream(s) => s.len(),
185        }
186    }
187    fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
188        match self {
189            SourceView::Mem(b) => BytesSource::new(*b).read_at(offset, buf),
190            SourceView::Stream(s) => s.read_at(offset, buf),
191        }
192    }
193
194    fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
195        match self {
196            SourceView::Mem(b) => BytesSource::new(*b).read_metadata_at(offset, len),
197            SourceView::Stream(s) => s.read_metadata_at(offset, len),
198        }
199    }
200
201    fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
202        match self {
203            // A whole-file buffer is already the cache, and holds no second one.
204            SourceView::Mem(_) => None,
205            SourceView::Stream(s) => s.metadata_cache_stats(),
206        }
207    }
208
209    fn reset_metadata_cache_stats(&self) {
210        match self {
211            SourceView::Mem(_) => {}
212            SourceView::Stream(s) => s.reset_metadata_cache_stats(),
213        }
214    }
215}
216
217/// File-access properties applied when opening an HDF5 file.
218///
219/// This is the `hdf5-pure` analogue of an HDF5 **file access property list**
220/// (`fapl`): one value carrying every access-time setting, built once and passed
221/// to whichever open a caller reaches for, exactly as a `fapl` is handed to
222/// `H5Fopen`. Every `*_with_options` constructor on [`File`] accepts it, so a
223/// read path and a read-write path can share one configuration.
224///
225/// The `Properties` suffix means the type stands in for one whole HDF5 property
226/// list, so every setting on it has a C counterpart to look up. It is a stand-in
227/// and not a port: a plain `Copy` value, with no handle to create or close, no
228/// runtime property registry, and no setter that can fail. `fapl` and each
229/// `H5Pset_*` it models are doc aliases, so a search for either lands here.
230///
231/// - The metadata cache (`H5Pset_mdc_config`) applies to the streaming and
232///   bounded backends; an in-memory open already holds the whole file in one
233///   buffer.
234/// - The chunk cache (`H5Pset_cache`) is the file-wide default for datasets
235///   opened from any backend, overridable per dataset with
236///   [`DatasetAccessProperties`].
237/// - The locking policy (`H5Pset_file_locking`) applies to the read-write opens.
238///   Readers and the SWMR writer take no lock by design, so they ignore it.
239/// - The write-mark policy applies to the read-only opens, and has no C
240///   counterpart: `H5Fopen` refuses a file marked open for write with no
241///   override, where [`with_write_mark_policy`](Self::with_write_mark_policy)
242///   can admit a snapshot read of one.
243///
244/// # Supported properties
245///
246/// Selected through the [`File`] open-mode constructor, with memory budgets and locking set through
247/// [`FileAccessProperties`](Self), [`DatasetAccessProperties`] and [`FileLocking`]. The [status
248/// legend](crate::FileCreateProperties#status-legend) explains the status column.
249///
250/// | HDF5 property / driver | `hdf5-pure` | Status | Behavior |
251/// |---|---|---|---|
252/// | `H5Fopen(RDONLY)`, default sec2 | [`File::open`](crate::File::open) | **Genuine (read-only)** | Whole-file buffered read, without a lock. |
253/// | positioned / on-demand reads | [`File::open_streaming`](crate::File::open_streaming) | **Genuine (read-only, bounded)** | Fetches metadata and chunks on demand, with peak memory near one chunk. |
254/// | user-supplied read driver (cf. `H5Pset_driver`) | [`File::from_source`](crate::File::from_source) / [`from_source_with_options`](crate::File::from_source_with_options), over the [`Source`] trait | **Genuine (read-only, bounded)** | The same on-demand reads over bytes that are not a path: an object store addressed by range request, or a sandboxed guest handed byte ranges by its host. A read that comes back short is rejected. |
255/// | `H5Pset_fapl_core` / `H5Pset_file_image` | [`File::from_bytes`](crate::File::from_bytes) / [`FileBuilder::finish`](crate::FileBuilder::finish) | **Genuine** | Read an in-memory file image, or build one into a `Vec<u8>`. |
256/// | `H5Fopen(RDWR)` | [`File::open_rw`](crate::File::open_rw) / [`open_rw_with_options`](crate::File::open_rw_with_options) | **Genuine (read-write)** | Reads, appends, and staged edits with [`commit`](crate::File::commit). Holds a latest-format file with no userblock in bounded memory and any other file in a whole-file mirror, picked from the file. Commits to a paged file through a page-aware tail, and rejects a paged file without persisted free space at open. |
257/// | `H5Fopen(RDWR)`, bounded memory | [`File::open_rw_with_options`](crate::File::open_rw_with_options) + [`MemoryStrategy::Bounded`] | **Genuine (read-write, bounded)** | [`File::open_rw`](crate::File::open_rw) picks the bounded backing itself. Stating `Bounded` makes it strict: a file the bounded engine cannot edit is rejected, and never mirrored. |
258/// | no C counterpart | [`with_memory_strategy`](Self::with_memory_strategy), [`MemoryStrategy`] | **Behavioral** | How much memory a read-write open may spend holding the file, overriding the dispatch above. `Bounded` rejects a file the bounded engine cannot edit, a pre-v2 superblock or a userblock. `Auto`, which is what [`File::open_rw`](crate::File::open_rw) uses unset, falls back to the mirror, and `Mirrored` always mirrors. A paged file without persisted free space is rejected under both preferences, because the mirror cannot commit it either. [`File::edit_backing`](crate::File::edit_backing) reports which backend an open resolved to, as an [`EditBacking`] (`Bounded` or `Mirrored`), a separate type because `Auto` is a preference between the two and never an outcome. The C library has no analogue: `H5Fopen` picks its own caching with no caller-visible memory contract. |
259/// | `H5F_ACC_SWMR_READ` / `H5F_ACC_SWMR_WRITE` | [`File::open_swmr`](crate::File::open_swmr) / [`open_swmr_writer`](crate::File::open_swmr_writer) (`*_with_options`) | **Genuine** | No OS lock. The writer raises the superblock SWMR-write flag (a v3 superblock is required, as in the C library) and appends alone. It always mirrors, so it accepts `MemoryStrategy::Auto` and `Mirrored` and rejects an explicit `Bounded`. |
260/// | superblock status flags (`h5clear -s`) | checked by [`File::open`](crate::File::open), [`open_streaming`](crate::File::open_streaming), [`from_source`](crate::File::from_source), [`open_rw`](crate::File::open_rw), [`open_swmr`](crate::File::open_swmr) and [`open_swmr_writer`](crate::File::open_swmr_writer), and cleared by [`File::clear_swmr_flag`](crate::File::clear_swmr_flag) | **Genuine** | A file the byte marks as held by a writer is rejected with [`Error::FileMarkedInUse`], as `H5Fopen` rejects it. [`File::open_swmr`](crate::File::open_swmr) follows such a file, and rejects a half-set mark alone. Version-3 superblocks only, which is where the C library checks. [`File::from_bytes`](crate::File::from_bytes) does not consult the byte, and neither, therefore, does [`mat::from_file`](crate::mat::from_file), so a caller holding the bytes can still read a flagged file, at the cost of a copy of it. |
261/// | no C counterpart | [`with_write_mark_policy`](Self::with_write_mark_policy), [`WriteMarkPolicy`] | **Behavioral** | Whether a read-only open proceeds past a file marked open for write by a non-SWMR writer, bit 0 alone, which is what a page-buffered session raises and what no SWMR reader can follow, so such a file is otherwise readable only by copying it through [`File::from_bytes`](crate::File::from_bytes). `AllowSnapshot` reads it as it stands, on the caller's assertion that the writer has flushed ([`File::sync`](crate::File::sync), or a clean close), which the byte cannot record and this crate therefore cannot check. It admits nothing else: a SWMR pair belongs to [`File::open_swmr`](crate::File::open_swmr), and [`File::open_rw`](crate::File::open_rw) and [`File::open_swmr_writer`](crate::File::open_swmr_writer) are rejected whatever it says. The default `Refuse` is what `H5Fopen` does, and `H5Fopen` offers no override at all: `h5clear` is its one way through, and it edits the file. |
262/// | `H5Pset_file_locking` + `HDF5_USE_FILE_LOCKING` | [`with_locking`](Self::with_locking), [`FileLocking`] | **Genuine** | Exclusive advisory lock on both read-write paths, mirror and bounded, taken non-blocking, so a lock another process holds is [`Error::FileLocked`]. The environment-variable override recognizes the same values as the C library. Readers and the SWMR writer run without a lock by design and ignore the setting. |
263/// | `H5Fget_libver_bounds` (read) | [`File::libver_bound`](crate::File::libver_bound) | **Read-only** | Reports the low library-version bound the superblock version implies. |
264/// | `H5Pset_cache` (`rdcc`) / `H5Pset_chunk_cache` (`dapl`) | [`with_chunk_cache`](Self::with_chunk_cache), [`DatasetAccessProperties::with_chunk_cache`](crate::DatasetAccessProperties::with_chunk_cache) | **Genuine (all backends)** | A cache of decompressed chunks and parsed indexes, 1 MiB in 16 slots by default. `rdcc_nslots` and `rdcc_nbytes` map directly. There is no write coalescing, since a mutation clears the cache, and no `rdcc_w0`, for the reason below. [`Dataset::chunk_cache_stats`](crate::Dataset::chunk_cache_stats) reports what it did. |
265/// | `H5Pset_mdc_config` | [`with_metadata_cache`](Self::with_metadata_cache), [`MetadataCacheConfig`] | **Behavioral (partial)** | A byte budget for a metadata-read LRU on the streaming and bounded backends, off by default. [`File::metadata_cache_stats`](crate::File::metadata_cache_stats) reports what it did (`H5Fget_mdc_hit_rate` and `H5Fget_mdc_size`) and [`File::reset_metadata_cache_stats`](crate::File::reset_metadata_cache_stats) clears the counters (`H5Freset_mdc_hit_rate_stats`). Of `H5AC_cache_config_t` this is `max_size`, and [the metadata cache](#the-metadata-cache-h5pset_mdc_config) below covers what the other 29 fields would mean here. |
266/// | no C counterpart | [`with_sync_policy`](Self::with_sync_policy), [`SyncPolicy`], [`File::sync`](crate::File::sync) | **Behavioral** | Who issues the `fsync`s. The default `Always` forces durability at every point the write paths define one. `OnClose` issues none during the session, leaving the cadence to the application through [`File::sync`](crate::File::sync), and one at `close` or drop, which write past the point any caller could order them. Either way, writes reach the operating system by the time the operation making them returns, unless [`with_page_buffer_size`](Self::with_page_buffer_size) is set, which is off by default and requires `OnClose`. What moves to the caller is therefore power-loss durability within the session, and same-machine visibility stays where it was. [Choosing the fsync cadence](crate::_guide::editing#choosing-the-fsync-cadence) has the cost in full. The C library has no property for this because it never `fsync`s at all: the default `sec2` driver installs no flush callback, so `H5Fflush` drains libhdf5's caches with `write` and stops. |
267/// | `H5Pset_page_buffer_size` | [`with_page_buffer_size`](Self::with_page_buffer_size) | **Genuine** | A write-back page buffer for a read-write session: dirty pages live across the ordering barriers inside a commit or append, up to the byte budget, then flush whole. It requires a budget of at least the page it merges within (the file's own when `Page`, and the format's 4 KiB default otherwise), a version-3 superblock, `SyncPolicy::OnClose`, and, on a `Page` file, persisted free space. A request that misses one of those is rejected, and the setting is never quietly ignored. The SWMR writer and any [`File::create_with_options`](crate::File::create_with_options) pair that could not be reopened are rejected too. A `Page` file is not required, where `H5PB_create` requires one: the C page buffer is a page cache whose per-kind reservations count pages the paged allocator segregates, while this is a write gatherer that needs a window to merge within. A budget below 1 MiB is accepted, as it is in C, and it costs something here: `H5PB_write` bypasses the C buffer for any I/O of a page or more, while nothing bypasses this one, so a smaller budget is where a long run is flushed and restarted (one 4 MiB append: 10 writes at 1 MiB, 1,094 at 4 KiB). A sub-page budget is rejected, where `H5Fopen` rounds it up silently. It reorders publish points ahead of the content they identify, so the session raises superblock status-flag bit 0 (`H5F_SUPER_WRITE_ACCESS`) for its lifetime: a writer that dies mid-flush leaves a file this crate, `H5Fopen` and h5py all reject, and never one that reads clean and returns fill values or a deleted object's bytes. The C library's page buffer reorders the same way and ships no such mark. The budget is the one field modeled, and `min_meta_perc` and `min_raw_perc` are not: the buffer does not evict, so it has no victim to choose. Off by default, which still leaves the write gathering every read-write session does. |
268/// | `H5Pset_fapl_family` / `split` / `multi` / `mpio` / `direct` / `ros3` / `log` | none | **Unsupported** | Only two implicit drivers exist: an in-memory buffer and `Read + Seek` positioned I/O. A multi-file, parallel, or remote-object file is not opened. |
269///
270/// ## The metadata cache (`H5Pset_mdc_config`)
271///
272/// `H5AC_cache_config_t` is 30 fields (version 1, checked against HDF5 2.1.0).
273/// [`MetadataCacheConfig`] models one of them, `max_size`, as `max_bytes`, plus a `max_entry_bytes`
274/// cap that has no C counterpart. That ratio invites the reading that 29 things are missing. This
275/// cache holds byte ranges of a file being read, where the C cache holds parsed entries of a file
276/// being written, and each group of fields would mean this here:
277///
278/// | Group | Fields | Applies here? |
279/// |---|---|---|
280/// | `max_size` | 1 | **Modeled**, as [`MetadataCacheConfig::max_bytes`](crate::MetadataCacheConfig::max_bytes). |
281/// | Adaptive resize: `min_size`, `epoch_length`, `incr_mode` + 4, `flash_incr_mode` + 2, `decr_mode` + 7 | 18 | Not modeled: they are one feature, and not 18 separate settings. See below. |
282/// | Dirty-data and parallel: `min_clean_fraction`, `dirty_bytes_threshold`, `metadata_write_strategy` | 3 | **Never.** This cache holds bytes read alone, so there is no dirty entry to keep clean, flush, or write. A read-write session drops the ranges its writes overlap, which [`MetadataCacheStats::invalidations`](crate::MetadataCacheStats::invalidations) counts. |
283/// | Diagnostics: `version`, `rpt_fcn_enabled`, `open_trace_file`, `close_trace_file`, `trace_file_name` | 5 | No. `version` is C ABI evolution, and the rest are a trace-file facility this crate has no counterpart to. [`File::metadata_cache_stats`](crate::File::metadata_cache_stats) reports what they were for. |
284/// | Initial sizing: `set_initial_size`, `initial_size` | 2 | No effect to have. There is no arena allocated up front: entries are allocated as reads admit them, so the initial size is zero and the maximum is the only bound. |
285/// | `evictions_enabled` | 1 | Expressible already, as a budget larger than the metadata a session reads. A separate flag would be a second way to say it, and one that silently uncaps memory. |
286///
287/// Adaptive resize is the one substantive gap, and leaving it out is deliberate. The algorithm
288/// grows the cache when an epoch's hit rate falls below `lower_hr_threshold` and shrinks it when
289/// the rate rises above `upper_hr_threshold` or entries age out, so that a caller does not have to
290/// pick a size. Picking one is cheap here: the store is indexed and not scanned
291/// ([#367](https://github.com/CramBL/hdf5-pure/issues/367)), so a hit costs about the same at
292/// 65,000 entries as at 1,000, 161 nanoseconds against 136, measured, and an over-generous budget
293/// costs memory and nothing else. The advice the algorithm would arrive at is one line, set the
294/// budget generously, and [`File::metadata_cache_stats`](crate::File::metadata_cache_stats)
295/// confirms it landed. Note also the direction the algorithm runs in: a low hit rate makes it hold
296/// more.
297///
298/// ## The chunk cache (`rdcc`)
299///
300/// [`ChunkCacheConfig`] models `rdcc_nslots` and `rdcc_nbytes` directly. The third field,
301/// `rdcc_w0`, is not modeled, and the reason is what it discriminates, and not a gap in the
302/// eviction code.
303///
304/// In `H5Dchunk.c`, `w0` is a head start measured in entries: `w[0] = (int)(rdcc->nused *
305/// rdcc->w0)`, counted down one per step of `H5D__chunk_cache_prune`. Two methods walk the LRU list
306/// from its least-recently-used end. Method 0 preempts the entries that are not partially consumed.
307/// Method 1 preempts anything unlocked, and is not introduced until the head start reaches zero. So
308/// `w0` sets how far the selective rule gets to run alone, and its discriminator is
309/// `H5D_rdcc_ent_t::rd_count` and `wr_count`, per-entry counts of bytes remaining, initialized to
310/// the chunk size and decremented by `naccessed` in `H5D__chunk_unlock`. `w0` decides exactly one
311/// thing: whether to drop a chunk the caller consumed in full ahead of one it consumed in part.
312///
313/// Nothing here turns on that decision, for two separate reasons.
314///
315/// | C field | Applies here? |
316/// |---|---|
317/// | `wr_count` | **Never.** This cache holds decompressed data read from the file. There is no dirty chunk to write back. A commit in the session drops what it may have made stale, which [`ChunkCacheStats::invalidations`](crate::ChunkCacheStats::invalidations) counts. |
318/// | `rd_count` | **Not recorded.** A slot records that it holds a chunk, and never how much of that chunk a caller took. The class `w0` sorts by therefore does not exist here to be sorted. Partial consumption is the common case: an edge chunk of a dataset whose extent is not a multiple of its chunk extent is stored full-size and read in part, which is `rd_count > 0` in C too. |
319///
320/// The second reason is that on the path where a scan would thrash, there is no prune to order at
321/// all. A whole read visits each of its chunks exactly once, so it fills the cache and then stops
322/// offering, and it hands back none of the chunks it has already placed for chunks it will never
323/// read again. That removes the problem `w0` exists to soften.
324///
325/// Which figure reports a too-small budget therefore depends on how the dataset is read, and each
326/// is structurally zero on the other's path:
327///
328/// | read | admission | budget signal |
329/// |---|---|---|
330/// | whole ([`read_f64`](crate::Dataset::read_f64) and its siblings) | fills the cache, then stops offering | `rejections`, with `evictions` at 0 |
331/// | row window ([`read_raw_rows`](crate::Dataset::read_raw_rows), `read_*_rows`) | plain LRU, since the chunk its successor needs is the one it finished on | `evictions`, with `rejections` at 0 |
332///
333/// A row window is the one path that does evict, so it is the only place a `w0` analogue could ever
334/// pick a different victim. It would not pick a better one: the entry the next window needs is the
335/// one this read finished on, which is the most recently used and the last thing an LRU rule gives
336/// up, while the partially-consumed entries `w0` would additionally protect are the leading
337/// boundary chunks that a forward sweep never revisits. That is an argument about the access
338/// patterns these readers generate, and not a proof for an arbitrary one.
339#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
340#[doc(alias = "fapl")]
341pub struct FileAccessProperties {
342    metadata_cache: MetadataCacheConfig,
343    chunk_cache: ChunkCacheConfig,
344    locking: FileLocking,
345    memory_strategy: Option<MemoryStrategy>,
346    libver_bounds: Option<(LibVer, LibVer)>,
347    sync_policy: SyncPolicy,
348    page_buffer_size: usize,
349    write_mark_policy: WriteMarkPolicy,
350}
351
352impl FileAccessProperties {
353    /// A value carrying the crate's default access behavior.
354    pub const fn new() -> Self {
355        Self {
356            metadata_cache: MetadataCacheConfig::disabled(),
357            chunk_cache: ChunkCacheConfig::new(),
358            locking: FileLocking::Enabled,
359            memory_strategy: None,
360            libver_bounds: None,
361            sync_policy: SyncPolicy::Always,
362            page_buffer_size: 0,
363            write_mark_policy: WriteMarkPolicy::Refuse,
364        }
365    }
366
367    /// Configure the bounded streaming metadata cache.
368    #[doc(alias = "H5Pset_mdc_config")]
369    pub const fn with_metadata_cache(mut self, metadata_cache: MetadataCacheConfig) -> Self {
370        self.metadata_cache = metadata_cache;
371        self
372    }
373
374    /// Configure the per-dataset raw chunk cache used by datasets opened from
375    /// this file. This is the `H5Pset_cache`-style file-wide default.
376    #[doc(alias = "H5Pset_cache")]
377    pub const fn with_chunk_cache(mut self, chunk_cache: ChunkCacheConfig) -> Self {
378        self.chunk_cache = chunk_cache;
379        self
380    }
381
382    /// Set the OS advisory file-locking policy for the read-write opens.
383    ///
384    /// Defaults to [`FileLocking::Enabled`]. Use [`FileLocking::Disabled`] only
385    /// when an external mechanism already guarantees single-writer access, or
386    /// [`FileLocking::BestEffort`] on a filesystem (such as some network mounts)
387    /// where the OS lock is unavailable. Setting `HDF5_USE_FILE_LOCKING` in the
388    /// environment overrides this, as in the C library.
389    ///
390    /// Readers and [`File::open_swmr_writer`] take no lock by design and ignore
391    /// this.
392    #[doc(alias = "H5Pset_file_locking")]
393    pub const fn with_locking(mut self, locking: FileLocking) -> Self {
394        self.locking = locking;
395        self
396    }
397
398    /// Set how much memory a read-write open may use to hold the file.
399    ///
400    /// Unset by default, which lets the entry point choose:
401    /// [`File::open_rw`] uses [`MemoryStrategy::Auto`], preferring the bounded
402    /// engine and falling back to the whole-file mirror for a file it cannot
403    /// edit. Setting this overrides that default, in either direction, so
404    /// [`MemoryStrategy::Bounded`] refuses such a file rather than quietly
405    /// spending `O(file size)` memory on a caller who asked not to;
406    /// [`MemoryStrategy::Mirrored`] takes
407    /// the whole-file mirror unconditionally, as `open_rw` did before it learned
408    /// to dispatch.
409    ///
410    /// The read-only opens ignore this: they build no editing session at all, and
411    /// their own names say what memory they spend. [`File::open_swmr_writer`]
412    /// does build one, and always mirrors: it accepts
413    /// [`MemoryStrategy::Auto`] and [`MemoryStrategy::Mirrored`], both of which
414    /// the mirror satisfies, and refuses an explicit [`MemoryStrategy::Bounded`]
415    /// with [`Error::EditUnsupported`] rather than quietly not honoring it. Ask a
416    /// `File` which backend it resolved to with [`File::edit_backing`].
417    pub const fn with_memory_strategy(mut self, memory_strategy: MemoryStrategy) -> Self {
418        self.memory_strategy = Some(memory_strategy);
419        self
420    }
421
422    /// Constrain the on-disk format an editing session may write, mirroring
423    /// HDF5's `H5Pset_libver_bounds` — which the C library classes as a *file
424    /// access* property for exactly this reason: it governs what a later write
425    /// to an existing file is allowed to add.
426    ///
427    /// Unset by default, which keeps [`File::open_rw`] adding whatever the
428    /// content needs. That default is what lets a file the C library wrote under
429    /// its own bounds be edited at all, but it means a session can add content
430    /// only a newer library can read *without changing the superblock*, and the
431    /// caller has no way to see it happen: adding a chunked, filtered, or
432    /// resizable dataset to an HDF5 1.8 file needs the version 4 data-layout
433    /// message and a 1.10 chunk index, since this crate does not write the
434    /// version 1 B-tree index that 1.8 used.
435    ///
436    /// Setting a `high` below [`LibVer::V110`] refuses that addition with
437    /// [`FormatError::LibverTooOldForContent`]
438    /// at [`File::commit`] instead — the same refusal
439    /// [`FileBuilder::with_libver_bounds`](crate::FileBuilder::with_libver_bounds)
440    /// gives when writing a whole file, so a `.mat` bounded to 1.8 for MATLAB
441    /// stays loadable by MATLAB after an edit.
442    ///
443    /// `low` only rules formats out — as in the C library it licenses newer
444    /// encodings without requiring them — so a lower bound of [`LibVer::V112`],
445    /// [`LibVer::V114`] or [`LibVer::LATEST`] leaves the session writing the 1.10
446    /// format rather than failing, provided `high` reaches it. An inverted range
447    /// such as `V114..=V110` is refused with
448    /// [`FormatError::LibverBoundsUnsatisfiable`].
449    ///
450    /// The read-only opens ignore this: they write nothing. [`File::open_swmr_writer`]
451    /// requires a version 3 superblock, so it refuses a `high` below
452    /// [`LibVer::V110`] up front rather than accepting a bound it cannot honor.
453    #[doc(alias = "H5Pset_libver_bounds")]
454    pub const fn with_libver_bounds(mut self, low: LibVer, high: LibVer) -> Self {
455        self.libver_bounds = Some((low, high));
456        self
457    }
458
459    /// Choose who owns this session's `fsync` cadence — this crate, or the
460    /// application through [`File::sync`].
461    ///
462    /// Defaults to [`SyncPolicy::Always`]: every commit and every immediate
463    /// [`Dataset::append`](crate::Dataset::append) forces its writes to durable
464    /// storage before returning. [`SyncPolicy::OnClose`] issues no `fsync` at all,
465    /// which is what the reference C library does; the writes still reach the
466    /// operating system by the time the operation making them returns, so only
467    /// power-loss durability moves to the caller. [`with_page_buffer_size`](Self::with_page_buffer_size),
468    /// off by default, is the one setting that changes that — and it requires
469    /// this policy.
470    ///
471    /// The read-only opens ignore this: they write nothing.
472    pub const fn with_sync_policy(mut self, sync_policy: SyncPolicy) -> Self {
473        self.sync_policy = sync_policy;
474        self
475    }
476
477    /// Let a read-write session's writes accumulate in a page buffer of
478    /// `bytes`, so repeated small updates landing in the same page cost one
479    /// write rather than one each.
480    ///
481    /// Defaults to `0`, which is off — as `H5Pset_page_buffer_size` defaults to
482    /// off — and leaves the gathering every read-write session already does: one
483    /// write per dirty page per *ordering barrier*, so a commit or an append
484    /// still reaches the operating system in full before it returns. What this
485    /// buys on top is letting a dirty page survive those barriers, which is where
486    /// a workload of many small appends into a few pages does most of its
487    /// repeating. Measured on a paged file, 32 chunk appends into eight datasets
488    /// followed by a commit: **188 writes with the default gathering and 5 with a
489    /// page buffer**, of which two are the mark below going up and coming down.
490    /// The appends issue nothing at all until the session ends.
491    ///
492    /// **It pays off over a long session, and costs on a short one.** The crash
493    /// mark below is two `fsync`s per session whatever the session then does, so
494    /// there is a break-even: measured on an Apple M1 Max (APFS) with 256-byte
495    /// appends into eight datasets, 400 appends ran 0.75x — slower — 800 broke
496    /// even, and 6,400 ran 1.64x. The ratio climbs with session length, because
497    /// the same pages are re-dirtied more often, and narrows to about 1.1x once
498    /// 64 KiB payloads rather than metadata churn dominate. One host's numbers,
499    /// and the short end is noisy; re-measure on the one that matters with
500    /// `cargo bench --bench hot_paths -- page_buffer`, which runs both sides of
501    /// the crossing.
502    ///
503    /// # What it costs, and what pays for it
504    ///
505    /// Gathered writes go out in address order, and every publish point sits
506    /// below the content it reaches, so all of them are issued first. A write
507    /// that fails, or a process that dies, mid-flush can therefore leave a file
508    /// whose superblock, dataset length or object header names bytes that never
509    /// arrived — and two of those read back **clean**, as fill values or as a
510    /// deleted object's data, with every checksum verifying. That is what a
511    /// write-back page buffer is, rather than a fault in this one:
512    /// `H5Pset_page_buffer_size` reorders the same way and makes no
513    /// crash-consistency claim either.
514    ///
515    /// So this session raises superblock status-flag bit 0
516    /// (`H5F_SUPER_WRITE_ACCESS`) for its whole life, `fsync`ed once at open and
517    /// cleared on a clean [`File::close`] or drop — the mark the reference C
518    /// library raises for *any* writer. A session that dies with pages in memory
519    /// leaves that byte standing, and a file carrying it is refused by this
520    /// crate, by `H5Fopen` and by h5py alike, with
521    /// [`Error::FileMarkedInUse`]. The silent
522    /// wrong answer becomes a refusal, and
523    /// [`File::clear_swmr_flag`](crate::File::clear_swmr_flag) — the `h5clear -s`
524    /// equivalent — is how to look at such a file anyway, knowing what it may
525    /// hold. A completed commit's bytes may also still be in this process's
526    /// memory when it returns.
527    ///
528    /// # Refusals
529    ///
530    /// Four, each refused with
531    /// [`Error::EditUnsupported`]:
532    ///
533    /// - a budget below the page the session merges within: the file's own
534    ///   file-space page size when it was created with
535    ///   [`FileSpaceStrategy::Page`], and the
536    ///   format's 4 KiB default otherwise. A buffer that cannot hold one page
537    ///   drains on every page it touches;
538    /// - a **paged** file whose free space is not persisted, which can be neither
539    ///   committed to nor appended to, so the buffer would hold nothing while its
540    ///   mark blocked every reader;
541    /// - a superblock older than version 3, whose status-flags byte no library
542    ///   reads back, so the mark above would announce nothing;
543    /// - [`SyncPolicy::Always`], the default, where
544    ///   every barrier is an `fsync` that flushes the buffer on its way out — so
545    ///   it would hold nothing while still costing the mark. Pair this with
546    ///   [`with_sync_policy(SyncPolicy::OnClose)`](Self::with_sync_policy).
547    ///
548    /// [`File::create_with_options`] refuses a creation/access pair it could not
549    /// then reopen with, rather than writing the file first, and
550    /// [`File::open_swmr_writer`] refuses a page buffer outright: its readers
551    /// observe the order its writes become visible in, which is exactly what a
552    /// buffer coalesces away.
553    ///
554    /// # Choosing a budget
555    ///
556    /// Any budget of at least one page is honored. One below 1 MiB is an explicit
557    /// request for less resident memory, not a mistake — a writer inside a tight
558    /// memory cap can ask for 256 KiB and get it — and what it buys that memory
559    /// with is writes: the budget is the point at which everything held is
560    /// flushed, so a long contiguous run is flushed and restarted once per
561    /// budget's worth of it. Writes issued on a 4 KiB-paged file:
562    ///
563    /// | workload | unset | 4 KiB | 64 KiB | 1 MiB |
564    /// | --- | --- | --- | --- | --- |
565    /// | 32 chunk appends into 8 datasets, then a commit | 188 | 25 | 4 | 4 |
566    /// | one 4 MiB append | 131 | 1,094 | 74 | 10 |
567    ///
568    /// On the scattered workload this property exists for, a small budget costs
569    /// little; on the long run 64 KiB is seven times the writes of 1 MiB.
570    ///
571    /// The memory comparison against leaving this unset is not the one the table
572    /// suggests. A session that sets nothing already gathers up to 1 MiB of dirty
573    /// bytes **per operation**, and releases it at every ordering barrier; a page
574    /// buffer holds its budget **across** operations, until the budget is spent,
575    /// an `fsync`, or [`File::close`]. So 1 MiB here trades a per-operation peak
576    /// for a continuous residency of the same size, and a budget below 1 MiB
577    /// lowers both.
578    ///
579    /// # How this differs from `H5Pset_page_buffer_size`
580    ///
581    /// **A paged file is not required, where the C library requires one.**
582    /// `H5PB_create` refuses an unpaged file because the C page buffer is a page
583    /// *cache*, and its `min_meta_perc` / `min_raw_perc` reservations are counted
584    /// in pages that the paged allocator keeps segregated by kind. This is a
585    /// write gatherer: it merges runs within a page-sized window and flushes
586    /// whole, so a window is all it needs, and an unpaged file gets the same
587    /// 4 KiB one that every read-write session already gathers under. Since
588    /// unpaged is the default strategy, requiring `Page` put this property out of
589    /// reach of most files for no reason this implementation had.
590    ///
591    /// **A small budget costs writes here, where it costs none in C.**
592    /// `H5PB_write` sends any I/O of a page or more straight to the driver, so a
593    /// small `page_buf_size` there caps memory without throttling a long write.
594    /// Nothing bypasses this buffer — the budget is the point at which everything
595    /// held is flushed — so a small one turns a single long run into repeated
596    /// flushes. Both libraries accept the budget; only this one charges for it.
597    /// See [Choosing a budget](#choosing-a-budget) for what it charges.
598    ///
599    /// **A sub-page budget is refused rather than rounded.** `H5Fopen` rounds it
600    /// up to one page silently, and `H5Fcreate` refuses it. A property quietly
601    /// ignored is worse than one refused.
602    ///
603    /// The read-only opens ignore this setting; they write nothing.
604    ///
605    /// Only the budget of `H5Pset_page_buffer_size` is modeled; its
606    /// `min_meta_perc` / `min_raw_perc` reservations are not, since this buffer
607    /// does not evict — it flushes whole.
608    #[doc(alias = "H5Pset_page_buffer_size")]
609    pub const fn with_page_buffer_size(mut self, bytes: usize) -> Self {
610        self.page_buffer_size = bytes;
611        self
612    }
613
614    /// Let a read-only open proceed past a superblock marked open for write by a
615    /// writer that is not a SWMR writer — status-flag bit 0 alone, which is what
616    /// [`with_page_buffer_size`](Self::with_page_buffer_size) raises for a
617    /// session's whole life.
618    ///
619    /// Defaults to [`WriteMarkPolicy::Refuse`], which is what `H5Fopen` does with
620    /// the same byte: [`File::open`], [`File::open_streaming`] and
621    /// [`File::from_source`] all report [`Error::FileMarkedInUse`].
622    /// [`WriteMarkPolicy::AllowSnapshot`] reads the file as it stands instead,
623    /// through whichever of those opens is passed these properties.
624    ///
625    /// # What the caller is asserting
626    ///
627    /// That the writer has flushed: it called [`File::sync`], or it stopped
628    /// after a flush and the mark stands only because nothing cleared it (a
629    /// clean [`File::close`] takes the mark down, and leaves nothing to opt past).
630    /// The mark is durable and says nothing about *when* — a live writer
631    /// mid-operation and one that exited without closing carry the same byte —
632    /// so this crate cannot check the assertion, and passing this value is how a
633    /// caller states it. It is exactly true for a writer under
634    /// [`SyncPolicy::OnClose`] that syncs at the
635    /// points it wants readable, and it is what the mark exists to guard against
636    /// when it is false: a page-buffered session's publish points are written
637    /// before the content they name, so a snapshot taken mid-flush can show a
638    /// dataset that reads clean and returns fill values, with every checksum
639    /// verifying.
640    ///
641    /// The snapshot is of the bytes on disk at open. A buffered open takes it
642    /// whole; a streaming open reads regions on demand, so a writer that carries
643    /// on writing can move bytes under it — reach for
644    /// [`File::open_with_options`] when the writer may continue, and for
645    /// [`File::open_streaming_with_options`] when it has stopped and the file is
646    /// too large to buffer.
647    ///
648    /// # What it does not unlock
649    ///
650    /// - a **SWMR pair** (both bits): that file has a reader of its own, and
651    ///   [`File::open_swmr`] follows it — including across the writer's later
652    ///   appends, which a snapshot cannot;
653    /// - [`File::open_rw`] and [`File::open_swmr_writer`], which are refused
654    ///   whatever this says. A second writer must not join a file a writer
655    ///   already holds;
656    /// - the OS advisory lock, a separate guard with its own policy
657    ///   ([`with_locking`](Self::with_locking)).
658    ///
659    /// A file left marked by a writer that *crashed* is a different question,
660    /// and this is not the answer to it: it reads such a file as willingly as a
661    /// flushed one, and leaves the mark standing for the next reader to meet.
662    /// [`File::clear_swmr_flag`] — the `h5clear -s` equivalent — is the recovery
663    /// there, and it records the decision by clearing the byte.
664    ///
665    /// The C library offers no counterpart: `H5Fopen` refuses the byte with no
666    /// override, and `h5clear` is its only way through. This is the narrower one,
667    /// since it changes nothing on disk.
668    pub const fn with_write_mark_policy(mut self, policy: WriteMarkPolicy) -> Self {
669        self.write_mark_policy = policy;
670        self
671    }
672
673    /// Return the configured streaming metadata cache.
674    pub const fn metadata_cache(&self) -> MetadataCacheConfig {
675        self.metadata_cache
676    }
677
678    /// Return the configured library-version bounds, or `None` when an editing
679    /// session may write whatever its content needs.
680    pub const fn libver_bounds(&self) -> Option<(LibVer, LibVer)> {
681        self.libver_bounds
682    }
683
684    /// Return the configured per-dataset chunk cache.
685    pub const fn chunk_cache(&self) -> ChunkCacheConfig {
686        self.chunk_cache
687    }
688
689    /// Return the configured file-locking policy.
690    pub const fn locking(&self) -> FileLocking {
691        self.locking
692    }
693
694    /// Return the configured memory strategy, or `None` when none was asked for
695    /// and the entry point's own default applies. This is what was *requested*;
696    /// for which backend an open resolved to, see [`File::edit_backing`].
697    ///
698    /// The `Option` distinguishes "no preference stated" from an explicit
699    /// [`MemoryStrategy::Auto`], which is what lets an entry point supply its own
700    /// default without overriding a caller who asked for one; `None` resolves to
701    /// [`MemoryStrategy::Auto`], the only default any entry point now supplies
702    /// rather than as a second break on this accessor.
703    pub const fn memory_strategy(&self) -> Option<MemoryStrategy> {
704        self.memory_strategy
705    }
706
707    /// Return the configured `fsync` policy.
708    pub const fn sync_policy(&self) -> SyncPolicy {
709        self.sync_policy
710    }
711
712    /// Return the configured page-buffer budget in bytes; `0` when none was
713    /// asked for.
714    pub const fn page_buffer_size(&self) -> usize {
715        self.page_buffer_size
716    }
717
718    /// Return the configured write-mark policy.
719    pub const fn write_mark_policy(&self) -> WriteMarkPolicy {
720        self.write_mark_policy
721    }
722}
723
724/// Dataset-access properties applied when opening a single dataset.
725///
726/// This is the `hdf5-pure` analogue of an HDF5 **dataset access property list**
727/// (`dapl`). Its chunk cache corresponds to `H5Pset_chunk_cache`: it overrides,
728/// for this one dataset, the file-wide chunk-cache default configured with
729/// [`FileAccessProperties::with_chunk_cache`] (the `H5Pset_cache` analogue). When
730/// left unset, the dataset inherits that file-wide default — matching the `dapl`
731/// default sentinels (`H5D_CHUNK_CACHE_*_DEFAULT`), which also mean "use the
732/// file's setting".
733///
734/// The `Properties` suffix means the type stands in for one whole HDF5 property
735/// list, so every setting on it has a C counterpart to look up. It is a stand-in
736/// and not a port: a plain `Copy` value, with no handle to create or close, no
737/// runtime property registry, and no setter that can fail. `dapl` and each
738/// `H5Pset_*` it models are doc aliases, so a search for either lands here.
739/// The chunk cache is the one `dapl` property modeled, and
740/// [Supported properties](#supported-properties) has the rest.
741///
742/// [`ChunkCacheConfig`] maps `H5Pset_chunk_cache`'s `rdcc_nslots` and
743/// `rdcc_nbytes`; its `rdcc_w0` preemption policy is not modeled, for the reason
744/// on [`ChunkCacheConfig::from_h5p_cache`].
745///
746/// Pass it to [`File::dataset_with_options`] or [`Group::dataset_with_options`].
747///
748/// # Supported properties
749///
750/// Passed to [`File::dataset_with_options`](crate::File::dataset_with_options) /
751/// [`Group::dataset_with_options`](crate::Group::dataset_with_options) as a
752/// [`DatasetAccessProperties`](Self), overriding the file-wide access defaults for one dataset. The
753/// status column is explained by the [status legend](crate::FileCreateProperties#status-legend).
754///
755/// | HDF5 property (C API) | `hdf5-pure` | Status | Behavior |
756/// |---|---|---|---|
757/// | `H5Pset_chunk_cache` | [`with_chunk_cache`](Self::with_chunk_cache) | **Genuine** | Overrides the file-wide chunk cache for this dataset only. Unset means inherit, matching the `H5D_CHUNK_CACHE_*_DEFAULT` sentinels. `rdcc_nslots` and `rdcc_nbytes` map directly, and `rdcc_w0` is not modeled, for the reason under [The chunk cache](crate::FileAccessProperties#the-chunk-cache-rdcc). |
758/// | `H5Pset_efile_prefix` / `H5Pset_virtual_prefix` | none | **Unsupported** | External and virtual datasets are not resolved, so there is no prefix to set. |
759/// | `H5Pset_virtual_view` / `H5Pset_virtual_printf_gap` | none | **Unsupported** | Virtual datasets are not supported. |
760/// | `H5Pset_append_flush` | none | **Unsupported** | There is no append callback and no per-boundary flush, and [`Dataset::append`](crate::Dataset::append) flushes on its own schedule. |
761#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
762#[doc(alias = "dapl")]
763pub struct DatasetAccessProperties {
764    chunk_cache: Option<ChunkCacheConfig>,
765}
766
767impl DatasetAccessProperties {
768    /// A value that inherits every file-wide access default.
769    pub const fn new() -> Self {
770        Self { chunk_cache: None }
771    }
772
773    /// Override the raw chunk cache for this one dataset, ignoring the file-wide
774    /// default. This is the `H5Pset_chunk_cache` analogue.
775    #[doc(alias = "H5Pset_chunk_cache")]
776    pub const fn with_chunk_cache(mut self, chunk_cache: ChunkCacheConfig) -> Self {
777        self.chunk_cache = Some(chunk_cache);
778        self
779    }
780
781    /// Return the chunk-cache override, or `None` when the dataset inherits the
782    /// file-wide default.
783    pub const fn chunk_cache(&self) -> Option<ChunkCacheConfig> {
784        self.chunk_cache
785    }
786
787    /// Resolve the effective chunk-cache config: the per-dataset override if one
788    /// was set, otherwise the file-wide `default`.
789    const fn resolved_chunk_cache(&self, default: ChunkCacheConfig) -> ChunkCacheConfig {
790        match self.chunk_cache {
791            Some(config) => config,
792            None => default,
793        }
794    }
795}
796
797/// Test whether a file looks like an HDF5 file, without reading it whole.
798///
799/// This is the spelling of the C library's `H5Fis_accessible` /
800/// `H5Fis_hdf5`: it opens the file and scans only the 8-byte candidate windows
801/// where the HDF5 signature is permitted (offsets 0, 512, 1024, 2048, …), so it
802/// never buffers the whole file. Returns:
803///
804/// - `Ok(true)` — the HDF5 signature was found,
805/// - `Ok(false)` — the file opened but has no HDF5 signature,
806/// - `Err(..)` — the file could not be opened (missing, permissions, …).
807///
808/// It validates only the signature, not the rest of the format; a truncated or
809/// corrupt file past the signature still reports `true`. Use [`File::open`] to
810/// fully parse and validate.
811pub fn is_hdf5<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<bool> {
812    let handle = std::fs::File::open(path)?;
813    let source = ReadSeekSource::new(handle).map_err(std::io::Error::other)?;
814    match signature::find_signature_in(&source) {
815        Ok(_) => Ok(true),
816        Err(FormatError::SignatureNotFound) => Ok(false),
817        Err(e) => Err(std::io::Error::other(e)),
818    }
819}
820
821/// Test whether an in-memory buffer begins (at a permitted offset) with the
822/// HDF5 signature. The buffer-backed counterpart of [`is_hdf5`].
823pub fn is_hdf5_bytes(data: &[u8]) -> bool {
824    signature::find_signature(data).is_ok()
825}
826
827/// An open HDF5 file for reading.
828struct FileInner {
829    backend: Backend,
830    /// The mode every object header parse of this file is made under:
831    /// [`AccessMode::ReadWrite`] for an editing session and for a repack that parses
832    /// its source as a writer does, [`AccessMode::ReadOnly`] for every other open.
833    /// Fixed at the open, so the backend a later [`File::refresh`] swaps in cannot
834    /// change it.
835    access_mode: AccessMode,
836    superblock: Superblock,
837    /// Byte offset to add to all relative addresses (= original base_address).
838    addr_offset: BaseAddress,
839    /// Live file handle, retained only when the file was opened with
840    /// [`File::open_swmr`] so [`File::refresh`] can re-read appended data.
841    handle: Option<std::fs::File>,
842    /// File Space Info parsed from the superblock extension, if the file records
843    /// one. Best-effort: a malformed or unreadable extension leaves this `None`
844    /// rather than failing the open.
845    file_space_info: Option<FileSpaceInfo>,
846    /// The shared object header message (SOHM) table, if the superblock
847    /// extension records one. `None` for almost every file: no common producer
848    /// enables shared-message indexes. Best-effort like `file_space_info`, so a
849    /// file whose table cannot be read still opens and still reads every object
850    /// that shares nothing; what fails is following a reference into the heap,
851    /// with [`FormatError::UnsupportedSohmReference`].
852    ///
853    /// Boxed because it is absent on essentially every file, and this struct is
854    /// allocated once per open: one pointer costs less here than the table
855    /// inline, and the absent case allocates nothing at all.
856    sohm_table: Option<Box<crate::sohm::SohmTable>>,
857    access_properties: FileAccessProperties,
858    /// Set by [`File::close`] to seal a read-write file: after it, a write
859    /// through any surviving [`Dataset`]/[`Group`] handle or [`File`] clone
860    /// returns [`Error::FileClosed`]. Reads still work. Only ever set on a
861    /// `Backend::Edit` file.
862    closed: AtomicBool,
863    /// How many times a write session has been given the chance to change this
864    /// file's bytes, counted so an owned [`Dataset`] handle can tell that the
865    /// object header it parsed no longer says what the file says.
866    ///
867    /// Advanced by every operation that reaches the write engine; see
868    /// [`FileInner::with_engine_mut`], which is the only thing that advances
869    /// either counter. Never advances for a read-only or streaming file, whose
870    /// bytes cannot move under a handle at all.
871    content_revision: AtomicU64,
872    /// How many times a write session has been given the chance to *move* an
873    /// object header, which is the narrower question of whether an address a
874    /// handle is holding still names its object.
875    ///
876    /// [`Change::InPlace`] leaves this alone: an immediate [`Dataset::append`]
877    /// rewrites a dataset's header where it stands, so an address stays good
878    /// across one. That is what lets a handle reached by object reference — the
879    /// one kind with no name to look itself up by — go on appending, while a
880    /// commit ends it.
881    address_revision: AtomicU64,
882    /// True for a file opened with [`File::open_swmr_writer`]: no OS lock is held,
883    /// the superblock's SWMR-write flag is raised, only immediate
884    /// [`Dataset::append`] is permitted (the staged surface is refused), and the
885    /// flag is cleared on [`File::close`] / `Drop`. `false` for every other file.
886    swmr_write: bool,
887}
888
889impl Drop for FileInner {
890    /// Best-effort cleanup for a writer dropped without an explicit
891    /// [`File::close`], running only when the last `Arc<FileInner>` clone drops;
892    /// a clean `close` already did this work and set `closed`, so this is
893    /// idempotent and skipped in that case.
894    ///
895    /// - A SWMR writer clears the superblock's SWMR-write flag (mirroring
896    ///   `File::close`).
897    /// - A read-write file that persists its free space rewrites its on-disk
898    ///   free-space managers into canonical shape (issue #173), so a
899    ///   dropped-without-`close` handle leaves the same file a clean `close`
900    ///   would (a no-op unless an immediate append grew the file past them). A
901    ///   true crash (`SIGKILL`, power loss) skips `drop` entirely; the appended
902    ///   data is still durable, under the default
903    ///   [`SyncPolicy::Always`](crate::SyncPolicy).
904    ///
905    /// Staged edits are *not* committed here: dropping a handle discards them,
906    /// which is what `close` exists to distinguish.
907    fn drop(&mut self) {
908        if self.closed.load(Ordering::Acquire) {
909            return;
910        }
911        let Backend::Edit(m) = &self.backend else {
912            return;
913        };
914        let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
915        // Both branches write, and this is the last moment anything can order
916        // those writes: the handle is gone once this returns, so `File::sync` is
917        // not an option the caller still has. The barrier is therefore forced
918        // rather than left to the session's `SyncPolicy` — see
919        // [`SyncPolicy::OnClose`](crate::SyncPolicy::OnClose).
920        // SWMR stages nothing and persists no free space, so it skips the
921        // re-homing; everything after that is the same teardown for both.
922        if !self.swmr_write {
923            let _ = session.finalize_persist();
924        }
925        // Only if the flush actually succeeded. The flags say this session's
926        // writes may still be in memory, and a failed `force_sync` is precisely
927        // the case where that is still true — taking them down there would
928        // publish the file as complete over a drain that did not finish.
929        if session.force_sync().is_ok() {
930            let _ = session.release_status_flags();
931        }
932    }
933}
934
935impl FileInner {
936    /// Opens the HDF5 file at `path` and reads it into memory.
937    pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
938        Self::open_with_options(path, FileAccessProperties::new())
939    }
940
941    /// Open an HDF5 file from a filesystem path with explicit access properties.
942    ///
943    /// Like [`open`](Self::open), this buffers the whole file in memory. Use
944    /// [`open_streaming_with_options`](Self::open_streaming_with_options) when
945    /// the metadata cache budget should apply to lazy metadata reads.
946    pub fn open_with_options<P: AsRef<std::path::Path>>(
947        path: P,
948        properties: FileAccessProperties,
949    ) -> Result<Self, Error> {
950        let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
951        let write_mark = properties.write_mark_policy;
952        let inner = Self::from_bytes_with_options(bytes, properties)?;
953        // The status-flag check belongs to every open that reads the live file
954        // — this one, `open_streaming` and `from_source` — and not to
955        // `from_bytes_with_options` (issue #245). The line is snapshot against
956        // live, not path against no path: a caller who already holds the bytes
957        // has taken its own snapshot, and there is no live file left to
958        // coordinate over. This is a deliberate divergence from the C library,
959        // which checks under its in-memory core driver too.
960        file_lock::check_status_flags(
961            &inner.superblock,
962            OpenIntent::Read(write_mark),
963            OpenTarget::Path(path.as_ref()),
964        )?;
965        Ok(inner)
966    }
967
968    /// Open an HDF5 file for **streaming** reads, fetching regions on demand from
969    /// the file instead of buffering it whole.
970    ///
971    /// This lets a host read a file larger than its address space — the original
972    /// motivation being 32-bit targets reading multi-gigabyte files (issue #27).
973    /// Metadata and dataset chunks are read through a `ReadSeekSource`, so peak
974    /// memory stays close to one chunk plus the metadata being parsed. Chunks
975    /// that sit next to each other on disk are fetched together, in reads of at
976    /// most 256 KiB (a larger chunk is read on its own), which is what makes a
977    /// file written a row at a time — thousands of chunks of a few dozen bytes
978    /// — read at a sensible speed. See [`crate::chunk_span`].
979    ///
980    /// Reads match the buffered [`File::open`]: every storage layout and chunk
981    /// index type, both group forms (v2 and v1 symbol-table), and compact,
982    /// dense, shared, and variable-length attributes. What differs:
983    /// [`as_bytes`](Self::as_bytes) returns an empty slice (there is no
984    /// whole-file buffer), [`persisted_free_space`](Self::persisted_free_space)
985    /// returns no regions, a streaming file cannot be the *source* of a
986    /// cross-file copy, and chunk decompression is sequential (the `parallel`
987    /// feature accelerates only buffered reads).
988    pub fn open_streaming<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
989        Self::open_streaming_with_options(path, FileAccessProperties::new())
990    }
991
992    /// Open an HDF5 file for streaming reads with explicit access properties.
993    pub fn open_streaming_with_options<P: AsRef<std::path::Path>>(
994        path: P,
995        properties: FileAccessProperties,
996    ) -> Result<Self, Error> {
997        let handle = std::fs::File::open(path.as_ref()).map_err(Error::Io)?;
998        let source = ReadSeekSource::new(handle).map_err(Error::Format)?;
999        Self::streaming(
1000            source,
1001            AccessMode::ReadOnly,
1002            properties,
1003            OpenTarget::Path(path.as_ref()),
1004        )
1005    }
1006
1007    /// Opens the file at `path` for streaming reads whose object header parses are
1008    /// made under `access_mode`. A parse under [`AccessMode::ReadWrite`] rejects a
1009    /// message of a type this crate cannot name that a decoder with write access
1010    /// must understand.
1011    pub(crate) fn open_streaming_with_access_mode(
1012        path: &std::path::Path,
1013        access_mode: AccessMode,
1014    ) -> Result<Self, Error> {
1015        let handle = std::fs::File::open(path).map_err(Error::Io)?;
1016        let source = ReadSeekSource::new(handle).map_err(Error::Format)?;
1017        Self::streaming(
1018            source,
1019            access_mode,
1020            FileAccessProperties::new(),
1021            OpenTarget::Path(path),
1022        )
1023    }
1024
1025    /// Open an HDF5 file from any [`Source`], reading metadata and chunks on
1026    /// demand as [`open_streaming`](Self::open_streaming) does.
1027    pub fn from_source<S: Source + Send + Sync + 'static>(source: S) -> Result<Self, Error> {
1028        Self::from_source_with_options(source, FileAccessProperties::new())
1029    }
1030
1031    /// Open an HDF5 file from any [`Source`] with explicit access properties.
1032    pub fn from_source_with_options<S: Source + Send + Sync + 'static>(
1033        source: S,
1034        properties: FileAccessProperties,
1035    ) -> Result<Self, Error> {
1036        // The only source that reaches the parsers from outside the crate, and
1037        // so the only one whose reads are length-checked: see `ValidatedSource`.
1038        Self::streaming(
1039            ValidatedSource::new(source),
1040            AccessMode::ReadOnly,
1041            properties,
1042            OpenTarget::Source,
1043        )
1044    }
1045
1046    /// The body every streaming open shares.
1047    ///
1048    /// Wraps the source in whatever metadata cache the properties ask for,
1049    /// parses the superblock through it, and rejects a file a writer holds.
1050    /// The three differ in where the source came from, in `access_mode`, and in
1051    /// `target`, which does nothing but name the file in that rejection. One
1052    /// body is what keeps the rest of it from drifting apart.
1053    fn streaming<S: Source + Send + Sync + 'static>(
1054        source: S,
1055        access_mode: AccessMode,
1056        properties: FileAccessProperties,
1057        target: OpenTarget<'_>,
1058    ) -> Result<Self, Error> {
1059        let source: Box<dyn Source + Send + Sync> = if properties.metadata_cache.is_enabled() {
1060            Box::new(MetadataCachingSource::new(
1061                source,
1062                properties.metadata_cache,
1063            ))
1064        } else {
1065            Box::new(source)
1066        };
1067        let (superblock, addr_offset) = Self::parse_superblock_source(source.as_ref())?;
1068        file_lock::check_status_flags(
1069            &superblock,
1070            OpenIntent::Read(properties.write_mark_policy),
1071            target,
1072        )?;
1073        Ok(Self::from_parts(
1074            Backend::Streaming(source),
1075            access_mode,
1076            superblock,
1077            addr_offset,
1078            None,
1079            properties,
1080        ))
1081    }
1082
1083    /// Open an HDF5 file for SWMR (single-writer/multiple-reader) reading.
1084    ///
1085    /// Like [`File::open`], but retains a live handle to the file so that
1086    /// [`File::refresh`] can re-read data appended by a concurrent writer
1087    /// (whether produced by this crate's append writer, the reference HDF5 C
1088    /// library, or h5py in SWMR mode). The initial view is a consistent
1089    /// snapshot; call [`File::refresh`] to advance to a newer one.
1090    ///
1091    /// Only the `std` build supports this (it requires a live filesystem
1092    /// handle); the in-memory [`File::from_bytes`] path cannot refresh.
1093    pub fn open_swmr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
1094        Self::open_swmr_with_options(path, FileAccessProperties::new())
1095    }
1096
1097    /// Open an HDF5 file for SWMR reading with explicit access properties.
1098    ///
1099    /// SWMR reads currently keep an in-memory mirror for refresh semantics, so
1100    /// only the per-dataset chunk-cache settings affect this backend.
1101    pub fn open_swmr_with_options<P: AsRef<std::path::Path>>(
1102        path: P,
1103        properties: FileAccessProperties,
1104    ) -> Result<Self, Error> {
1105        let mut handle = std::fs::File::open(path.as_ref()).map_err(Error::Io)?;
1106        let mut data = Vec::new();
1107        handle.read_to_end(&mut data).map_err(Error::Io)?;
1108        let (superblock, addr_offset) = Self::parse_superblock(&data)?;
1109        file_lock::check_status_flags(
1110            &superblock,
1111            OpenIntent::SwmrRead,
1112            OpenTarget::Path(path.as_ref()),
1113        )?;
1114        Ok(Self::from_parts(
1115            Backend::InMemory(data),
1116            AccessMode::ReadOnly,
1117            superblock,
1118            addr_offset,
1119            Some(handle),
1120            properties,
1121        ))
1122    }
1123
1124    /// Open an HDF5 file from an in-memory byte vector.
1125    pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
1126        Self::from_bytes_with_options(data, FileAccessProperties::new())
1127    }
1128
1129    /// Open an HDF5 file from an in-memory byte vector with explicit access properties.
1130    pub fn from_bytes_with_options(
1131        data: Vec<u8>,
1132        properties: FileAccessProperties,
1133    ) -> Result<Self, Error> {
1134        let (superblock, addr_offset) = Self::parse_superblock(&data)?;
1135        Ok(Self::from_parts(
1136            Backend::InMemory(data),
1137            AccessMode::ReadOnly,
1138            superblock,
1139            addr_offset,
1140            None,
1141            properties,
1142        ))
1143    }
1144
1145    /// Open an existing HDF5 file for reading **and** in-place editing, applying
1146    /// `properties` (its [`FileLocking`] policy governs the OS file lock held for
1147    /// the file's life, and its chunk cache is the file-wide default).
1148    fn open_rw<P: AsRef<std::path::Path>>(
1149        path: P,
1150        properties: FileAccessProperties,
1151    ) -> Result<Self, Error> {
1152        Self::open_rw_with_default(path, properties, MemoryStrategy::Auto)
1153    }
1154
1155    /// Open read-write under the properties' memory strategy, falling back to
1156    /// `default` when the caller expressed none. The two public read-write entry
1157    /// [`File::open_rw`] passes [`MemoryStrategy::Auto`]: prefer the bounded
1158    /// engine, but take the mirror for a file the bounded engine cannot edit
1159    /// (issue #198, step 4). A caller who states a strategy overrides it.
1160    fn open_rw_with_default<P: AsRef<std::path::Path>>(
1161        path: P,
1162        properties: FileAccessProperties,
1163        default: MemoryStrategy,
1164    ) -> Result<Self, Error> {
1165        let session = WriteEngine::open_rw_with_strategy(
1166            path.as_ref(),
1167            properties.metadata_cache,
1168            properties.locking,
1169            properties.memory_strategy.unwrap_or(default),
1170        )?;
1171        Self::from_rw_session(session, properties)
1172    }
1173
1174    /// Wrap an opened [`WriteEngine`] as a read-write [`Backend::Edit`] file.
1175    fn from_rw_session(
1176        mut session: WriteEngine,
1177        properties: FileAccessProperties,
1178    ) -> Result<Self, Error> {
1179        // The one funnel every read-write session passes through, so the fapl's
1180        // format bound and `fsync` cadence reach the engine no matter which entry
1181        // point opened it — the SWMR writer included, which
1182        // `WriteEngine::open_swmr_writer` says why.
1183        session.set_libver_bounds(properties.libver_bounds)?;
1184        session.set_sync_policy(properties.sync_policy);
1185        // Every page-buffer refusal lives in `set_page_buffer_size`, including the
1186        // `SyncPolicy::Always` one — which is why `set_sync_policy` must precede
1187        // this call rather than merely happening to.
1188        session.set_page_buffer_size(properties.page_buffer_size)?;
1189        // The engine parsed and normalized this at open; take it rather than
1190        // re-parsing, so the image need not be able to hand out a slice.
1191        let superblock = session.superblock().clone();
1192        let addr_offset = superblock.base_address;
1193        Ok(Self::from_parts(
1194            Backend::Edit(Box::new(Mutex::new(session))),
1195            AccessMode::ReadWrite,
1196            superblock,
1197            addr_offset,
1198            None,
1199            properties,
1200        ))
1201    }
1202
1203    /// Open for SWMR writing: no OS lock, superblock SWMR-write flag raised.
1204    fn open_swmr_writer<P: AsRef<std::path::Path>>(
1205        path: P,
1206        properties: FileAccessProperties,
1207    ) -> Result<Self, Error> {
1208        // The SWMR writer always mirrors. `Auto` and unset are *satisfied* by
1209        // that — they ask for the bounded engine where it applies and accept the
1210        // mirror where it does not — but `Bounded` is a guarantee, and honoring a
1211        // guarantee by ignoring it is how a caller ends up spending `O(file size)`
1212        // memory it asked not to. Refusing is also the permissive direction to be
1213        // wrong in: if this writer ever runs bounded, the refusal stops firing,
1214        // which breaks nobody.
1215        if properties.memory_strategy == Some(MemoryStrategy::Bounded) {
1216            return Err(Error::EditUnsupported(
1217                "the SWMR writer always holds the file in a whole-file mirror; leave \
1218                 MemoryStrategy unset, or pass MemoryStrategy::Auto or MemoryStrategy::Mirrored, \
1219                 to open it",
1220            ));
1221        }
1222        // A library-version bound below 1.10 is the same shape of unhonorable
1223        // guarantee. SWMR needs a version 3 superblock — neither library reads
1224        // the SWMR-write flag back on an older one — so a caller asking for the
1225        // 1.8 format here is asking for a file this writer cannot produce.
1226        if let Some((low, high)) = properties.libver_bounds
1227            && LibVer::resolve_writable(Some((low, high))).map_err(Error::Format)? < LibVer::V110
1228        {
1229            return Err(Error::EditUnsupported(
1230                "the SWMR writer requires a version 3 superblock, which is the v1.10 format; \
1231                 raise the FileAccessProperties library-version bound to open it",
1232            ));
1233        }
1234        // And a page buffer is the third. A SWMR reader follows the writer's
1235        // ordered phases as they become visible, so coalescing those writes is
1236        // not a slower or larger file but a reader that sees a state the phases
1237        // exist to keep it from seeing.
1238        if properties.page_buffer_size != 0 {
1239            return Err(Error::EditUnsupported(
1240                "the SWMR writer cannot buffer its writes: its readers observe the order they \
1241                 become visible in; leave FileAccessProperties::with_page_buffer_size unset to \
1242                 open it",
1243            ));
1244        }
1245        let session = WriteEngine::open_swmr_writer(path, properties.sync_policy)?;
1246        let mut inner = Self::from_rw_session(session, properties)?;
1247        inner.swmr_write = true;
1248        Ok(inner)
1249    }
1250
1251    /// After the caller has confirmed a [`Backend::Edit`] backend, gate the
1252    /// mutation: refuse a sealed file with [`Error::FileClosed`], and in
1253    /// SWMR-writer mode refuse a staged edit (`staged = true`) with
1254    /// [`Error::SwmrStagedUnsupported`] — only immediate appends are allowed.
1255    fn check_mutable(&self, staged: bool) -> Result<(), Error> {
1256        if self.closed.load(Ordering::Acquire) {
1257            return Err(Error::FileClosed);
1258        }
1259        if staged && self.swmr_write {
1260            return Err(Error::SwmrStagedUnsupported);
1261        }
1262        Ok(())
1263    }
1264
1265    /// Gate a staged edit *without* taking the session lock: the backend must
1266    /// offer the staged surface, and the file must still be mutable.
1267    ///
1268    /// This is the same gate the locking helpers apply before locking, split out
1269    /// so a public method taking a user closure can report a read-only or sealed
1270    /// file up front, run the closure with no lock held, and take the lock only
1271    /// to record the result (issue #200).
1272    fn check_staged_writable(&self) -> Result<(), Error> {
1273        match &self.backend {
1274            Backend::Edit(_) => self.check_mutable(true),
1275            _ => Err(Error::ReadOnly),
1276        }
1277    }
1278
1279    /// Lock this file's write session for an operation that may change the
1280    /// file, and record afterwards that it had the chance to.
1281    ///
1282    /// Every path that can change the file's bytes — [`File::commit`] and the
1283    /// copies, every staged and immediate edit a [`Dataset`] or [`Group`] handle
1284    /// makes, and the session teardown — goes through here, so a new entry point
1285    /// cannot change the file without classifying what it did. Two write the
1286    /// file without passing here, both because no handle can be alive to see it:
1287    /// [`FileInner::drop`], which runs when the last `Arc` goes, and
1288    /// [`File::refresh`], which takes `&mut self` through `Arc::get_mut` and
1289    /// advances the counters itself.
1290    ///
1291    /// The appender's claim bookkeeping ([`Dataset::claim_for_appender`] and its
1292    /// pair) locks the engine directly and rightly notes nothing: it records who
1293    /// is appending, not what the file holds — and it must keep working from a
1294    /// `Drop` on a sealed file, which this gate refuses.
1295    ///
1296    /// The counters advance whether `f` succeeded or not, and for a staged edit
1297    /// that changes no bytes at all. Both are deliberate: a refused commit can
1298    /// still have written and rolled back (issues #316 and #344), and the cost
1299    /// of a revision that did not need advancing is one re-read on the next use
1300    /// of a handle, where the cost of one that needed advancing and did not is a
1301    /// wrong answer.
1302    fn with_engine_mut<R>(
1303        &self,
1304        change: Change,
1305        f: impl FnOnce(&mut WriteEngine) -> Result<R, Error>,
1306    ) -> Result<R, Error> {
1307        let Backend::Edit(m) = &self.backend else {
1308            return Err(Error::ReadOnly);
1309        };
1310        self.check_mutable(matches!(change, Change::Relocating))?;
1311        let out = {
1312            let mut engine = m.lock().unwrap_or_else(PoisonError::into_inner);
1313            f(&mut engine)
1314        };
1315        self.note(change);
1316        out
1317    }
1318
1319    /// Record that an operation of kind `change` has run against this file.
1320    ///
1321    /// The address counter moves *first*, against [`revisions`](Self::revisions)
1322    /// reading it second. A reader that sees the new content revision has
1323    /// therefore already synchronized with this release, so it cannot then read
1324    /// an address revision from before it and conclude that its memoized address
1325    /// outlived a commit that moved it.
1326    fn note(&self, change: Change) {
1327        match change {
1328            Change::Relocating => {
1329                self.address_revision.fetch_add(1, Ordering::Release);
1330                self.content_revision.fetch_add(1, Ordering::Release);
1331            }
1332            Change::InPlace => {
1333                self.content_revision.fetch_add(1, Ordering::Release);
1334            }
1335            Change::Nothing => {}
1336        }
1337    }
1338
1339    /// How many times a write session has been given the chance to change this
1340    /// file's bytes. A [`Dataset`] handle whose header was parsed at this value
1341    /// still holds what the file holds.
1342    fn content_revision(&self) -> u64 {
1343        self.content_revision.load(Ordering::Acquire)
1344    }
1345
1346    /// How many times a write session has been given the chance to move an
1347    /// object header. An address worked out at this value still names its
1348    /// object.
1349    fn address_revision(&self) -> u64 {
1350        self.address_revision.load(Ordering::Acquire)
1351    }
1352
1353    /// Work out where a handle's object header sits now, and say what that
1354    /// answer holds as of.
1355    ///
1356    /// One rule, in the order it is written. An address nothing has moved since
1357    /// still names its object, whichever way the handle names it — which is what
1358    /// keeps an in-place append anywhere in the file from making every handle
1359    /// walk its path again. Past that, a handle opened by `path` looks its
1360    /// object up, which follows it wherever a commit put it, and one reached by
1361    /// object reference has no name to look up: `memo` held the only address it
1362    /// will ever have, and the bytes a relocated header vacates still parse as
1363    /// the object that left them, so there is nothing left to read.
1364    ///
1365    /// Both counters are read *before* the resolution, never after: a value
1366    /// taken afterwards could name a state the address predates, and a handle
1367    /// that memoized that pairing would go on answering from a header a commit
1368    /// had already moved. Taken beforehand, a concurrent change makes the
1369    /// pairing merely stale — which the next use notices, so long as it is the
1370    /// next *use*. A commit running on another thread between this read and the
1371    /// bytes it labels is not ordered against either, so a handle shared across
1372    /// threads can still serve one read from a header a concurrent commit had
1373    /// moved. What these counters order is a handle against edits already made,
1374    /// not against one in flight. The callers that classify what they resolved
1375    /// to — [`Dataset::resolved`], [`Group::header_address`] — parse that header
1376    /// in the same unordered window, so a commit landing inside it can also make
1377    /// a live handle report [`Error::NotADataset`] or
1378    /// [`Error::NotAGroup`] for an object whose kind
1379    /// never changed. That is the same staleness reporting itself instead of
1380    /// answering, which is the better half of the trade.
1381    fn locate(
1382        &self,
1383        path: Option<&ObjectPathBuf>,
1384        memo: Option<Resolution>,
1385    ) -> Result<Resolution, Error> {
1386        let revisions = self.revisions();
1387        // A handle with no memo has never resolved — it names an object this
1388        // session staged — so there is nothing to short-circuit on, and its path
1389        // is walked afresh every time until a commit gives it a header.
1390        Ok(revisions.at(match (path, memo) {
1391            (_, Some(memo)) if revisions.address == memo.address_revision => memo.address,
1392            (Some(path), _) => self.resolve_path(&path.as_path())?,
1393            (None, _) => return Err(Error::StaleHandle),
1394        }))
1395    }
1396
1397    /// [`locate`](Self::locate), reported as [`Error::NotCommitted`] when this
1398    /// session has the path *staged* rather than written, and as
1399    /// [`Error::StagingWithdrawn`] when the staging a handle was born onto is
1400    /// gone.
1401    ///
1402    /// Every handle onto a staged object comes through here, so the distinction
1403    /// between "there is no such object", "there is one, and `commit` has not
1404    /// written it yet" and "the one this handle names has been withdrawn" is
1405    /// made in one place rather than at each caller.
1406    ///
1407    /// The staged set is consulted *before* the file for a handle that has never
1408    /// resolved, because a staged creation can sit on a path the file still
1409    /// holds: a delete and a create in one commit replace the object there
1410    /// (issue #305), and until that commit runs the old bytes are still
1411    /// perfectly readable. Answering from them would hand the caller the object
1412    /// their handle was explicitly not opened onto.
1413    ///
1414    /// `birth` is the handle's own [`Standing`] input — `None` for one opened
1415    /// onto an object in the file. This question is keyed by path alone, so a
1416    /// born handle whose creation was withdrawn and replaced by one of the other
1417    /// kind at the same path reports `NotCommitted` here where
1418    /// [`staged_dataset_view`](Self::staged_dataset_view) tells it apart; both
1419    /// refuse, and neither answers from the file.
1420    fn locate_staged(
1421        &self,
1422        path: Option<&ObjectPathBuf>,
1423        memo: Option<Resolution>,
1424        birth: Option<u64>,
1425    ) -> Result<Resolution, Error> {
1426        if let (None, Some(named)) = (memo, path) {
1427            match self.staged_standing(named, birth) {
1428                Standing::Pending => return Err(Error::NotCommitted(named.to_string())),
1429                Standing::Withdrawn => return Err(Error::StagingWithdrawn(named.to_string())),
1430                Standing::Live => {}
1431            }
1432        }
1433        match self.locate(path, memo) {
1434            Err(Error::Format(FormatError::PathNotFound(missing))) => {
1435                match path.and_then(|named| self.staged_object(named)) {
1436                    Some(_) => Err(Error::NotCommitted(reported_path(path))),
1437                    None => Err(Error::Format(FormatError::PathNotFound(missing))),
1438                }
1439            }
1440            other => other,
1441        }
1442    }
1443
1444    /// Run `f` against this file's write session, or `None` when there is none
1445    /// (a read-only file stages nothing, so it has nothing to be asked about).
1446    fn query_engine<R>(&self, f: impl FnOnce(&WriteEngine) -> R) -> Option<R> {
1447        match &self.backend {
1448            Backend::Edit(m) => Some(f(&m.lock().unwrap_or_else(PoisonError::into_inner))),
1449            _ => None,
1450        }
1451    }
1452
1453    /// What this session has staged at `path`, if anything. See
1454    /// [`WriteEngine::staged_object`] for the rule.
1455    fn staged_object(&self, path: &ObjectPathBuf) -> Option<StagedObject> {
1456        self.query_engine(|e| e.staged_object(path)).flatten()
1457    }
1458
1459    /// Where a handle naming `path` stands: whether the object it addresses is
1460    /// one the file holds, one this session has staged, or one whose staging has
1461    /// been withdrawn under it.
1462    ///
1463    /// `birth` is the session's [staged generation](WriteEngine::staged_generation)
1464    /// as of the moment the handle was made onto a staged creation, and `None`
1465    /// for every handle opened onto an object in the file. It is what separates
1466    /// the two ways a staged path can stop being staged — the commit published
1467    /// it, or a [`Group::delete`] withdrew it — which otherwise look identical
1468    /// from here, and which a handle must not confuse: a withdrawn creation
1469    /// leaves the file's own object at that path, and it is the object the
1470    /// session is *deleting*.
1471    ///
1472    /// Both halves come from one lock, so a commit cannot land between them and
1473    /// pair a generation with a staged set from the other side of it.
1474    ///
1475    /// A file with no write session stages nothing, so every handle onto one is
1476    /// live. That includes the `birth`-carrying arm, which such a file cannot
1477    /// produce: a handle holds its [`FileInner`] alive and a backend never
1478    /// changes under one, so the case is unreachable rather than merely unlikely.
1479    fn staged_standing(&self, path: &ObjectPathBuf, birth: Option<u64>) -> Standing {
1480        let Some((generation, staged)) =
1481            self.query_engine(|e| (e.staged_generation(), e.staged_object(path).is_some()))
1482        else {
1483            return Standing::Live;
1484        };
1485        match birth {
1486            // A commit has taken this session's staged set since the handle was
1487            // made, so what it names is in the file (or was deleted from it
1488            // afterwards, which resolving the path reports).
1489            Some(birth) if birth != generation => Standing::Live,
1490            Some(_) if !staged => Standing::Withdrawn,
1491            _ if staged => Standing::Pending,
1492            _ => Standing::Live,
1493        }
1494    }
1495
1496    /// What a dataset staged at `path` says about itself before it is written,
1497    /// for a handle whose staged generation at birth was `birth`.
1498    ///
1499    /// `Ok(None)` means the handle should read the file: either nothing is
1500    /// staged there, or a commit has published what was. The
1501    /// [`Error::StagingWithdrawn`] arm is [`staged_standing`](Self::staged_standing)'s,
1502    /// decided under the same lock — and told apart by *kind* here, since a
1503    /// dataset creation withdrawn and replaced by a group at the same path
1504    /// leaves nothing for this handle to answer from either.
1505    fn staged_dataset_view(
1506        &self,
1507        path: &ObjectPathBuf,
1508        birth: Option<u64>,
1509    ) -> Result<Option<StagedMeta>, Error> {
1510        let Some((generation, meta)) =
1511            self.query_engine(|e| (e.staged_generation(), e.staged_dataset_meta(path)))
1512        else {
1513            return Ok(None);
1514        };
1515        match birth {
1516            Some(birth) if birth != generation => Ok(None),
1517            Some(_) if meta.is_none() => Err(Error::StagingWithdrawn(path.to_string())),
1518            _ => Ok(meta),
1519        }
1520    }
1521
1522    /// The session's staged generation now, for a handle being made onto a
1523    /// creation staged in it. `None` on a file with no write session, which
1524    /// stages nothing and so makes no such handle.
1525    fn staged_generation(&self) -> Option<u64> {
1526        self.query_engine(WriteEngine::staged_generation)
1527    }
1528
1529    /// The direct children `parent` gains from this session's staged creations.
1530    fn staged_children(&self, parent: &ObjectPathBuf) -> StagedChildren {
1531        self.query_engine(|e| e.staged_children(parent))
1532            .unwrap_or_default()
1533    }
1534
1535    /// The revisions to label a resolution that is about to be worked out with.
1536    fn revisions(&self) -> Revisions {
1537        Revisions {
1538            content: self.content_revision(),
1539            address: self.address_revision(),
1540        }
1541    }
1542
1543    /// A `Source` view over the backend, for the streaming-capable paths.
1544    pub(crate) fn source(&self) -> SourceView<'_> {
1545        match &self.backend {
1546            Backend::InMemory(v) => SourceView::Mem(v),
1547            Backend::Streaming(s) => SourceView::Stream(s.as_ref()),
1548            // A mirror or bounded file's bytes live behind a lock and cannot be
1549            // lent out as a borrowed view; the read paths that reach every
1550            // backend go through [`with_source`](Self::with_source) instead.
1551            Backend::Edit(_) => SourceView::Mem(&[]),
1552        }
1553    }
1554
1555    /// Run `f` with a random-access view of this file's bytes, taking the
1556    /// write-engine lock when the backend requires one. Unlike
1557    /// [`source`](Self::source) — which cannot lend a borrowed view out of a
1558    /// lock and returns an empty view for the mirror and bounded backends —
1559    /// this serves every backend, so it is the dispatch for read paths (heap
1560    /// reads for variable-length data, chunk enumeration) that must also work
1561    /// on a read-write file. `f` must not re-enter this file's backend (the
1562    /// engine lock is held while it runs).
1563    pub(crate) fn with_source<R>(&self, f: impl FnOnce(&dyn Source) -> R) -> R {
1564        match &self.backend {
1565            Backend::InMemory(v) => f(&BytesSource::new(v.as_slice())),
1566            Backend::Streaming(s) => f(s.as_ref()),
1567            Backend::Edit(m) => {
1568                let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1569                f(core.image())
1570            }
1571        }
1572    }
1573
1574    /// Run a read against a read-write session's file image, choosing the form
1575    /// its backing can serve: `on_slice` when the session holds the whole file
1576    /// in memory, so a slice-walking parser borrows the bytes instead of copying
1577    /// them, and `on_source` otherwise.
1578    ///
1579    /// Both closures must compute the same thing. The pair exists because a
1580    /// mirror can hand out a whole-file slice and a file-backed image cannot,
1581    /// not because the two backings answer differently (issue #198).
1582    fn with_engine<R>(
1583        engine: &Mutex<WriteEngine>,
1584        on_slice: impl FnOnce(&[u8]) -> R,
1585        on_source: impl FnOnce(&dyn Source) -> R,
1586    ) -> R {
1587        let core = engine
1588            .lock()
1589            .unwrap_or_else(std::sync::PoisonError::into_inner);
1590        match core.image_slice() {
1591            Some(data) => on_slice(data),
1592            None => on_source(core.image()),
1593        }
1594    }
1595
1596    /// Parse the superblock from `data`, returning it (with `root_group_address`
1597    /// normalized to an absolute offset) and the base-address offset.
1598    fn parse_superblock(data: &[u8]) -> Result<(Superblock, BaseAddress), Error> {
1599        let sig_offset = signature::find_signature(data)?;
1600        let mut superblock = Superblock::parse(data, sig_offset)?;
1601        let addr_offset = superblock.base_address;
1602        // Normalize root_group_address to absolute so resolve_path_any works.
1603        superblock.root_group_address =
1604            addr_offset.absolute(StoredAddress::new(superblock.root_group_address))?;
1605        Ok((superblock, addr_offset))
1606    }
1607
1608    /// Streaming counterpart of [`parse_superblock`](Self::parse_superblock): locate and parse the
1609    /// superblock by reading only small windows from the source.
1610    fn parse_superblock_source<S: Source + ?Sized>(
1611        source: &S,
1612    ) -> Result<(Superblock, BaseAddress), Error> {
1613        let sig_offset = signature::find_signature_in(source)?;
1614        let mut superblock = Superblock::parse_from_source(source, sig_offset)?;
1615        let addr_offset = superblock.base_address;
1616        superblock.root_group_address =
1617            addr_offset.absolute(StoredAddress::new(superblock.root_group_address))?;
1618        Ok((superblock, addr_offset))
1619    }
1620
1621    /// Assemble a [`File`] from parsed parts, then load the File Space Info from
1622    /// the superblock extension (best-effort, so a bad extension never fails the
1623    /// open).
1624    fn from_parts(
1625        backend: Backend,
1626        access_mode: AccessMode,
1627        superblock: Superblock,
1628        addr_offset: BaseAddress,
1629        handle: Option<std::fs::File>,
1630        access_properties: FileAccessProperties,
1631    ) -> Self {
1632        let mut file = FileInner {
1633            backend,
1634            access_mode,
1635            superblock,
1636            addr_offset,
1637            handle,
1638            file_space_info: None,
1639            sohm_table: None,
1640            access_properties,
1641            closed: AtomicBool::new(false),
1642            content_revision: AtomicU64::new(0),
1643            address_revision: AtomicU64::new(0),
1644            swmr_write: false,
1645        };
1646        file.file_space_info = file.read_file_space_info();
1647        file.sohm_table = file.read_sohm_table();
1648        file
1649    }
1650
1651    /// Parse the File Space Info message from the superblock extension, if the
1652    /// file records one and it can be read. Best-effort: any failure (no
1653    /// extension, unreadable object header, malformed message) yields `None`.
1654    fn read_file_space_info(&self) -> Option<FileSpaceInfo> {
1655        let rel = self.superblock.superblock_extension_address?;
1656        if rel == u64::MAX {
1657            return None;
1658        }
1659        let abs = self.addr_offset.absolute(StoredAddress::new(rel)).ok()?;
1660        let header = self.parse_header(abs).ok()?;
1661        let msg = header
1662            .messages
1663            .iter()
1664            .find(|m| m.msg_type == MessageType::FILE_SPACE_INFO)?;
1665        FileSpaceInfo::parse(
1666            &msg.data,
1667            self.superblock.offset_size,
1668            self.superblock.length_size,
1669        )
1670        .ok()
1671    }
1672
1673    /// Parse the Shared Message Table message from the superblock extension and
1674    /// read the master table it names, if the file records one.
1675    ///
1676    /// Best-effort in the same way and for the same reason as
1677    /// [`Self::read_file_space_info`]: a file whose shared-message table is
1678    /// unreadable still opens, and every object in it that shares no message
1679    /// still reads.
1680    fn read_sohm_table(&self) -> Option<Box<crate::sohm::SohmTable>> {
1681        let rel = self.superblock.superblock_extension_address?;
1682        if rel == u64::MAX {
1683            return None;
1684        }
1685        let abs = self.addr_offset.absolute(StoredAddress::new(rel)).ok()?;
1686        let header = self.parse_header(abs).ok()?;
1687        let msg = header
1688            .messages
1689            .iter()
1690            .find(|m| m.msg_type == MessageType::SHARED_MESSAGE_TABLE)?;
1691        let message =
1692            crate::sohm::SharedMessageTableMessage::parse(&msg.data, self.superblock.offset_size)
1693                .ok()?;
1694        // The table's address, like every address in a header message, is stored
1695        // relative to the file's base address, so the read is framed the same way
1696        // every other metadata walk here is.
1697        let base = self.addr_offset;
1698        let os = self.superblock.offset_size;
1699        match &self.backend {
1700            Backend::InMemory(v) => {
1701                crate::sohm::SohmTable::read(frame(v, base).ok()?, &message, os).ok()
1702            }
1703            Backend::Streaming(s) if base.is_zero() => {
1704                crate::sohm::SohmTable::read_from_source(s.as_ref(), &message, os).ok()
1705            }
1706            Backend::Streaming(s) => crate::sohm::SohmTable::read_from_source(
1707                &BaseOffsetSource {
1708                    inner: s.as_ref(),
1709                    base,
1710                },
1711                &message,
1712                os,
1713            )
1714            .ok(),
1715            Backend::Edit(m) => Self::with_engine(
1716                m,
1717                |d| Ok::<_, Error>(crate::sohm::SohmTable::read(frame(d, base)?, &message, os)?),
1718                |s| {
1719                    if base.is_zero() {
1720                        Ok(crate::sohm::SohmTable::read_from_source(s, &message, os)?)
1721                    } else {
1722                        Ok(crate::sohm::SohmTable::read_from_source(
1723                            &BaseOffsetSource { inner: s, base },
1724                            &message,
1725                            os,
1726                        )?)
1727                    }
1728                },
1729            )
1730            .ok(),
1731        }
1732        .map(Box::new)
1733    }
1734
1735    /// Re-read the file from disk to pick up data appended by a concurrent
1736    /// writer, then re-parse the superblock.
1737    ///
1738    /// This is the SWMR reader's refresh primitive (analogous to the C library's
1739    /// `H5Drefresh` / h5py's `Dataset.refresh()`): after it returns, newly
1740    /// fetched [`Dataset`]/[`Group`] handles observe the writer's appended
1741    /// chunks and extended dimensions, because they re-parse object headers at
1742    /// their (stable) addresses against the refreshed bytes. Existing handles
1743    /// borrow `&self`, so they must be dropped before calling this; re-fetch
1744    /// them afterward.
1745    ///
1746    /// Returns [`Error::SwmrUnsupported`] if the file was not opened with
1747    /// [`File::open_swmr`]. The superblock is checksum-validated on every
1748    /// re-read; a transient parse failure (a writer caught mid-flush) is
1749    /// retried a bounded number of times before being surfaced.
1750    ///
1751    /// Cost: each call re-reads the entire file from disk (`O(file size)`).
1752    /// That keeps the implementation simple and correct, but when following a
1753    /// large, steadily growing log it is the cost paid per refresh; budget
1754    /// refresh frequency accordingly.
1755    pub fn refresh(&mut self) -> Result<(), Error> {
1756        let handle = self.handle.as_mut().ok_or(Error::SwmrUnsupported)?;
1757
1758        // A writer only appends (the file grows) and updates a few fixed-size,
1759        // individually checksummed structures in place (superblock EOF, object
1760        // header dimensions, array header counts). Re-reading the whole file and
1761        // re-validating the superblock checksum yields a consistent view; if the
1762        // superblock is caught mid-update, retry.
1763        const MAX_ATTEMPTS: u32 = 100;
1764        let mut last_err = None;
1765        for attempt in 0..MAX_ATTEMPTS {
1766            let mut data = Vec::new();
1767            handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
1768            handle.read_to_end(&mut data).map_err(Error::Io)?;
1769            match Self::parse_superblock(&data) {
1770                Ok((superblock, addr_offset)) => {
1771                    self.backend = Backend::InMemory(data);
1772                    self.superblock = superblock;
1773                    self.addr_offset = addr_offset;
1774                    self.file_space_info = self.read_file_space_info();
1775                    self.sohm_table = self.read_sohm_table();
1776                    // Every byte just moved. `File::refresh` takes `&mut self`
1777                    // through `Arc::get_mut`, so no handle can be alive to see
1778                    // it — but the counters are the file's statement about its
1779                    // own bytes, and leaving them behind here would make that
1780                    // statement false.
1781                    *self.content_revision.get_mut() += 1;
1782                    *self.address_revision.get_mut() += 1;
1783                    return Ok(());
1784                }
1785                Err(e) => {
1786                    last_err = Some(e);
1787                    // Brief backoff before re-reading; the writer's in-place
1788                    // updates are tiny, so a short pause clears the window. Skip
1789                    // it on the final attempt, where there is no re-read to come.
1790                    if attempt + 1 < MAX_ATTEMPTS {
1791                        std::thread::sleep(std::time::Duration::from_micros(
1792                            50 * (attempt + 1) as u64,
1793                        ));
1794                    }
1795                }
1796            }
1797        }
1798        // The loop always runs at least once and only reaches here via the
1799        // `Err` arm, so `last_err` is always `Some`; surface the real error.
1800        Err(last_err.expect("refresh retried at least once before failing"))
1801    }
1802
1803    /// Resolve a path to an object-header address, dispatching on the backend.
1804    fn resolve_path(&self, path: &ObjectPath<'_>) -> Result<u64, Error> {
1805        let access = self.access_mode();
1806        Ok(match &self.backend {
1807            Backend::InMemory(v) => group_v2::resolve_path_any(v, access, &self.superblock, path)?,
1808            Backend::Streaming(s) => {
1809                group_v2::resolve_path_any_from_source(s.as_ref(), access, &self.superblock, path)?
1810            }
1811            // A staged commit can relocate the object tree's root, so this
1812            // file's cached superblock may name a stale one; resolve against the
1813            // session's own superblock, which the commit updates.
1814            Backend::Edit(m) => {
1815                let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1816                let sb = core.superblock().clone();
1817                match core.image_slice() {
1818                    Some(data) => group_v2::resolve_path_any(data, access, &sb, path)?,
1819                    None => {
1820                        group_v2::resolve_path_any_from_source(core.image(), access, &sb, path)?
1821                    }
1822                }
1823            }
1824        })
1825    }
1826
1827    /// The current root-group address (base-adjusted, absolute). For a read-write
1828    /// [`Backend::Edit`] file a prior relocating commit can have moved the
1829    /// root, so take the session's own superblock, which the commit updates;
1830    /// other backends use this file's cached one.
1831    fn mirror_root_address(&self) -> u64 {
1832        if let Backend::Edit(m) = &self.backend {
1833            let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1834            return core.superblock().root_group_address;
1835        }
1836        self.superblock.root_group_address
1837    }
1838
1839    /// Returns the raw file bytes for an in-memory file, or an empty slice for a
1840    /// streaming file (which has no whole-file buffer).
1841    pub fn as_bytes(&self) -> &[u8] {
1842        match &self.backend {
1843            Backend::InMemory(v) => v,
1844            // A streaming, mirror, or bounded file has no borrowable whole-file
1845            // buffer.
1846            Backend::Streaming(_) | Backend::Edit(_) => &[],
1847        }
1848    }
1849
1850    /// Returns the access properties this file was opened with.
1851    pub const fn access_properties(&self) -> FileAccessProperties {
1852        self.access_properties
1853    }
1854
1855    /// The backend this file's editing session resolved to, or `None` when there
1856    /// is no editing session to ask. Always [`EditBacking::Mirrored`] for the
1857    /// SWMR writer, which builds a session but does not dispatch on the strategy.
1858    fn edit_backing(&self) -> Option<EditBacking> {
1859        match &self.backend {
1860            Backend::Edit(m) => Some(m.lock().unwrap_or_else(|e| e.into_inner()).edit_backing()),
1861            _ => None,
1862        }
1863    }
1864
1865    /// What this file's metadata cache has done, or `None` where the backend
1866    /// holds none.
1867    fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
1868        self.with_source(|source| source.metadata_cache_stats())
1869    }
1870
1871    /// Zero those counters, keeping the cached entries.
1872    fn reset_metadata_cache_stats(&self) {
1873        self.with_source(|source| source.reset_metadata_cache_stats());
1874    }
1875
1876    /// Returns a reference to the parsed superblock.
1877    pub fn superblock(&self) -> &Superblock {
1878        &self.superblock
1879    }
1880
1881    /// The whole-file byte image when this file is buffered in memory
1882    /// ([`open`](Self::open) / [`from_bytes`](Self::from_bytes)); `None` for a
1883    /// streaming file ([`open_streaming`](Self::open_streaming)). Cross-file
1884    /// object copy ([`File::copy_from`](crate::File::copy_from)) uses this to read
1885    /// source objects by absolute address.
1886    pub(crate) fn in_memory_image(&self) -> Option<&[u8]> {
1887        match &self.backend {
1888            Backend::InMemory(data) => Some(data),
1889            Backend::Streaming(_) | Backend::Edit(_) => None,
1890        }
1891    }
1892
1893    /// The base address (`H5F` superblock base address), i.e. the byte offset
1894    /// added to every stored relative address. Zero for a file with no
1895    /// userblock.
1896    pub(crate) fn base_address(&self) -> BaseAddress {
1897        self.addr_offset
1898    }
1899
1900    /// Returns the mode this file reads under.
1901    pub(crate) fn access_mode(&self) -> AccessMode {
1902        self.access_mode
1903    }
1904
1905    /// The file-space management strategy this file records in its superblock
1906    /// extension (set with `H5Pset_file_space_strategy`), or `None` if the file
1907    /// records none — the default, which the C library also writes as "no
1908    /// message". See [`file_space_info`](Self::file_space_info) for the full
1909    /// record (persist flag, threshold, page size).
1910    pub fn file_space_strategy(&self) -> Option<FileSpaceStrategy> {
1911        self.file_space_info.as_ref().map(|info| info.strategy)
1912    }
1913
1914    /// The full [`FileSpaceInfo`] recorded in this file's superblock extension,
1915    /// if present and readable.
1916    pub fn file_space_info(&self) -> Option<&FileSpaceInfo> {
1917        self.file_space_info.as_ref()
1918    }
1919
1920    /// The free regions a file persists on disk in its free-space managers (when
1921    /// written with `H5Pset_file_space_strategy(..., persist = true)`), as
1922    /// `(address, length)` pairs sorted by address.
1923    ///
1924    /// Empty when the file does not persist free space, or for the streaming
1925    /// backend (which does not load the manager blocks). The addresses are file
1926    /// offsets (relative to the base address); reading data is unaffected by the
1927    /// presence or absence of these managers.
1928    pub fn persisted_free_space(&self) -> Vec<(u64, u64)> {
1929        let Some(info) = &self.file_space_info else {
1930            return Vec::new();
1931        };
1932        if !info.persist {
1933            return Vec::new();
1934        }
1935        let Backend::InMemory(data) = &self.backend else {
1936            return Vec::new();
1937        };
1938        let mut sections = free_space_manager::read_persisted_sections(
1939            data,
1940            &info.manager_addrs,
1941            self.addr_offset,
1942            self.superblock.offset_size,
1943        )
1944        .unwrap_or_default();
1945        // Distinct sections have distinct addresses in any well-formed file, so
1946        // the tie-break never arises; only a malformed manager can advertise one
1947        // address twice, and which of the pair is reported first is already
1948        // unspecified. No `debug_assert` here: this parses untrusted bytes, which
1949        // must not panic a debug build.
1950        sections.sort_unstable_by_key(|s| s.addr);
1951        sections
1952            .into_iter()
1953            .map(|s| (s.addr.get(), s.size))
1954            .collect()
1955    }
1956
1957    /// The size of the underlying file in bytes (the HDF5 `H5Fget_filesize`).
1958    ///
1959    /// This is the total byte length of the backing store — for a streaming
1960    /// file the length reported by its source, for an in-memory file the length
1961    /// of its buffer. It includes any userblock prefix and trailing bytes, so it
1962    /// may exceed the superblock's logical end-of-file address; compare against
1963    /// `Superblock::eof_address` (reachable via
1964    /// [`File::superblock`]) to detect appended or unaccounted tail bytes.
1965    pub fn file_size(&self) -> u64 {
1966        match &self.backend {
1967            Backend::Edit(m) => {
1968                let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1969                core.image().len()
1970            }
1971            _ => self.source().len(),
1972        }
1973    }
1974
1975    /// The minimum library version required to read this file, derived from its
1976    /// superblock version (the *low bound* of HDF5's `H5Fget_libver_bounds`).
1977    ///
1978    /// A version 3 superblock, for example, reports [`LibVer::V110`] because it
1979    /// was introduced in HDF5 1.10.
1980    pub fn libver_bound(&self) -> LibVer {
1981        LibVer::from_superblock_version(self.superblock.version)
1982    }
1983
1984    fn parse_header(&self, address: u64) -> Result<ObjectHeader, FormatError> {
1985        let os = self.superblock.offset_size;
1986        let ls = self.superblock.length_size;
1987        let access = self.access_mode();
1988        match &self.backend {
1989            Backend::InMemory(v) => ObjectHeader::parse_with_base(
1990                v,
1991                access,
1992                address.to_usize()?,
1993                os,
1994                ls,
1995                self.addr_offset,
1996            ),
1997            Backend::Streaming(s) => ObjectHeader::parse_from_source(
1998                s.as_ref(),
1999                access,
2000                address,
2001                os,
2002                ls,
2003                self.addr_offset,
2004            ),
2005            Backend::Edit(m) => Self::with_engine(
2006                m,
2007                |d| {
2008                    ObjectHeader::parse_with_base(
2009                        d,
2010                        access,
2011                        address.to_usize()?,
2012                        os,
2013                        ls,
2014                        self.addr_offset,
2015                    )
2016                },
2017                |s| ObjectHeader::parse_from_source(s, access, address, os, ls, self.addr_offset),
2018            ),
2019        }
2020    }
2021
2022    /// Resolve a base-relative object-header address (the value stored in an
2023    /// HDF5 `H5R_OBJECT` reference element) to the [`Object`] it points at.
2024    ///
2025    /// The stored address is relative to the superblock base address, so any
2026    /// MAT-file userblock is accounted for here. A null (`0`) or undefined
2027    /// (`HADDR_UNDEF`) address, or one whose object header is neither a dataset
2028    /// nor a group, yields [`FormatError::InvalidObjectReference`].
2029    fn object_at_relative(
2030        file: &Arc<FileInner>,
2031        revisions: Revisions,
2032        rel_addr: u64,
2033    ) -> Result<Object, Error> {
2034        // HADDR_UNDEF and the null address never name a real object. (Relative
2035        // address 0 is where the superblock sits, not an object header.)
2036        if rel_addr == u64::MAX || rel_addr == 0 {
2037            return Err(FormatError::InvalidObjectReference(rel_addr).into());
2038        }
2039        let abs = file.addr_offset.absolute(StoredAddress::new(rel_addr))?;
2040        let at = revisions.at(abs);
2041        let hdr = file.parse_header(abs)?;
2042        if has_message(&hdr, MessageType::DATA_LAYOUT) {
2043            let chunk_cache = DatasetAccessProperties::new()
2044                .resolved_chunk_cache(file.access_properties.chunk_cache);
2045            Ok(Object::Dataset(Box::new(Dataset::new(
2046                file.clone(),
2047                at,
2048                hdr,
2049                chunk_cache,
2050                None,
2051            ))))
2052        } else if is_group(&hdr) {
2053            Ok(Object::Group(Group::new(file.clone(), at, None)))
2054        } else {
2055            Err(FormatError::InvalidObjectReference(rel_addr).into())
2056        }
2057    }
2058
2059    fn offset_size(&self) -> u8 {
2060        self.superblock.offset_size
2061    }
2062
2063    fn length_size(&self) -> u8 {
2064        self.superblock.length_size
2065    }
2066
2067    /// Returns the entries of the group `hdr` describes, dispatching on the backend.
2068    ///
2069    /// Each entry holds the address the file stores for its child, which
2070    /// [`entry_address`](Self::entry_address) turns into a position to read at.
2071    ///
2072    /// # Errors
2073    ///
2074    /// Returns [`Error::Format`] if the group's own structures do not parse, or if `hdr` describes
2075    /// no group at all.
2076    fn group_children(&self, hdr: &ObjectHeader) -> Result<Vec<GroupEntry>, Error> {
2077        let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
2078        match &self.backend {
2079            Backend::InMemory(v) => group_v2::resolve_group_entries(v, hdr, os, ls, base),
2080            Backend::Streaming(s) => {
2081                group_v2::resolve_group_entries_from_source(s.as_ref(), hdr, os, ls, base)
2082            }
2083            Backend::Edit(m) => Self::with_engine(
2084                m,
2085                |d| group_v2::resolve_group_entries(d, hdr, os, ls, base),
2086                |s| group_v2::resolve_group_entries_from_source(s, hdr, os, ls, base),
2087            ),
2088        }
2089        .map_err(Error::Format)
2090    }
2091
2092    /// Returns the absolute file address of the object header `entry` names.
2093    ///
2094    /// # Errors
2095    ///
2096    /// Returns [`FormatError::OffsetOverflow`] if the base address cannot be added to it, which a
2097    /// crafted entry naming `HADDR_UNDEF` in a file with a userblock arranges.
2098    fn entry_address(&self, entry: &GroupEntry) -> Result<u64, FormatError> {
2099        self.addr_offset.absolute(entry.object_header_address)
2100    }
2101
2102    /// The child named `name`, at the absolute file address
2103    /// [`ChildLookup::Found`] carries.
2104    ///
2105    /// The by-name counterpart of [`group_children`](Self::group_children), and
2106    /// the one to reach for when a single child is wanted: it stops at the match
2107    /// rather than building an entry, and an owned name, for every other child
2108    /// of the group (issue #228).
2109    fn group_child(&self, group_address: u64, name: LinkName<'_>) -> Result<ChildLookup, Error> {
2110        let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
2111        let access = self.access_mode();
2112        let addr = group_address;
2113        match &self.backend {
2114            Backend::InMemory(v) => {
2115                group_v2::find_child_address(v, access, addr, os, ls, base, name)
2116            }
2117            Backend::Streaming(s) => group_v2::find_child_address_from_source(
2118                s.as_ref(),
2119                access,
2120                addr,
2121                os,
2122                ls,
2123                base,
2124                name,
2125            ),
2126            Backend::Edit(m) => Self::with_engine(
2127                m,
2128                |d| group_v2::find_child_address(d, access, addr, os, ls, base, name),
2129                |s| group_v2::find_child_address_from_source(s, access, addr, os, ls, base, name),
2130            ),
2131        }
2132        .map_err(Error::Format)
2133    }
2134
2135    /// Read all attributes attached to an object header, dispatching on the
2136    /// backend.
2137    fn attrs_of(&self, hdr: &ObjectHeader) -> Result<HashMap<String, AttrValue>, Error> {
2138        let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
2139        let attr_msgs = self.attr_messages_of(hdr)?;
2140        match &self.backend {
2141            Backend::Edit(m) => Ok(Self::with_engine(
2142                m,
2143                |d| attrs_to_map(&attr_msgs, &BytesSource::new(d), os, ls, base),
2144                |s| attrs_to_map(&attr_msgs, s, os, ls, base),
2145            )),
2146            _ => Ok(attrs_to_map(&attr_msgs, &self.source(), os, ls, base)),
2147        }
2148    }
2149
2150    /// The content of a header message, following the reference when the record
2151    /// marks the message *shared*.
2152    ///
2153    /// A shared record's body is not the message: it is an address, and a
2154    /// committed (`H5Tcommit`) datatype is stored exactly that way. Decoding the
2155    /// body directly turns a named `H5T_STD_I32LE` into a zero-width time type
2156    /// with no error anywhere, so every read of a message that HDF5 permits to be
2157    /// shared — datatype, dataspace, fill value, filter pipeline — goes through
2158    /// here. The borrowed case allocates nothing, which is every message this
2159    /// crate writes and nearly every one it reads.
2160    fn message_body<'m>(
2161        &self,
2162        msg: &'m crate::object_header::HeaderMessage,
2163    ) -> Result<Cow<'m, [u8]>, Error> {
2164        if !msg.flags.is_shared() {
2165            return Ok(Cow::Borrowed(&msg.data));
2166        }
2167        let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
2168        let access = self.access_mode();
2169        let sohm = self.sohm_table.as_deref();
2170        // A shared reference stores its address relative to the base address, so
2171        // frame the file at `base` exactly as [`Self::attr_messages_of`] does.
2172        let resolved = match &self.backend {
2173            Backend::InMemory(v) => BufferedResolver::new(frame(v, base)?, access, os, ls, sohm)
2174                .resolve(&msg.data, msg.msg_type),
2175            Backend::Streaming(s) if base.is_zero() => {
2176                SourceResolver::new(s.as_ref(), access, os, ls, sohm)
2177                    .resolve(&msg.data, msg.msg_type)
2178            }
2179            Backend::Streaming(s) => SourceResolver::new(
2180                &BaseOffsetSource {
2181                    inner: s.as_ref(),
2182                    base,
2183                },
2184                access,
2185                os,
2186                ls,
2187                sohm,
2188            )
2189            .resolve(&msg.data, msg.msg_type),
2190            Backend::Edit(m) => Self::with_engine(
2191                m,
2192                |d| {
2193                    BufferedResolver::new(frame(d, base)?, access, os, ls, sohm)
2194                        .resolve(&msg.data, msg.msg_type)
2195                },
2196                |s| {
2197                    if base.is_zero() {
2198                        SourceResolver::new(s, access, os, ls, sohm)
2199                            .resolve(&msg.data, msg.msg_type)
2200                    } else {
2201                        SourceResolver::new(
2202                            &BaseOffsetSource { inner: s, base },
2203                            access,
2204                            os,
2205                            ls,
2206                            sohm,
2207                        )
2208                        .resolve(&msg.data, msg.msg_type)
2209                    }
2210                },
2211            ),
2212        }?;
2213        Ok(Cow::Owned(resolved))
2214    }
2215
2216    /// The object-header address a *shared* header message names, or `None` when
2217    /// the record carries its own content or names the shared-message heap.
2218    ///
2219    /// [`Self::message_body`] answers what the message says; this answers which
2220    /// object says it. A rewrite needs both: the content to reproduce the type,
2221    /// and the address to tell which users share one committed object rather than
2222    /// each naming a type of their own. A heap-stored message has no such object
2223    /// — it is one anonymous copy rather than a named one — so it answers `None`
2224    /// and a rewrite spells the message out, which is what the file already
2225    /// means.
2226    pub(crate) fn shared_target_address(
2227        &self,
2228        msg: &crate::object_header::HeaderMessage,
2229    ) -> Result<Option<u64>, Error> {
2230        if !msg.flags.is_shared() {
2231            return Ok(None);
2232        }
2233        let reference =
2234            shared_message::parse_shared_ref(&msg.data, self.offset_size(), self.length_size())?;
2235        match reference.location {
2236            shared_message::SharedLocation::ObjectHeader(addr) => Ok(Some(addr.get())),
2237            shared_message::SharedLocation::SohmHeap(_) => Ok(None),
2238        }
2239    }
2240
2241    /// Extract every attribute message attached to an object header (compact,
2242    /// shared, and dense storage), dispatching on the backend.
2243    pub(crate) fn attr_messages_of(
2244        &self,
2245        hdr: &ObjectHeader,
2246    ) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
2247        let (os, ls) = (self.offset_size(), self.length_size());
2248        // Compact attributes come out of `hdr`, but the two addresses this walk
2249        // follows are read from message bodies and so are stored relative to the
2250        // base address: the Attribute Info message's fractal-heap address, and a
2251        // shared attribute's message address. Frame the file at `base` exactly as
2252        // [`Self::read_dataset_raw`] does, so both index it directly. For a plain
2253        // file (`base == 0`) this is the identity; without it, a userblock file's
2254        // dense attributes are looked for one userblock too early.
2255        let base = self.addr_offset;
2256        let access = self.access_mode();
2257        let sohm = self.sohm_table.as_deref();
2258        match &self.backend {
2259            Backend::InMemory(v) => Ok(extract_attributes_full(
2260                frame(v, base)?,
2261                access,
2262                hdr,
2263                os,
2264                ls,
2265                sohm,
2266            )?),
2267            Backend::Streaming(s) if base.is_zero() => Ok(extract_attributes_full_from_source(
2268                s.as_ref(),
2269                access,
2270                hdr,
2271                os,
2272                ls,
2273                sohm,
2274            )?),
2275            Backend::Streaming(s) => {
2276                let framed = BaseOffsetSource {
2277                    inner: s.as_ref(),
2278                    base,
2279                };
2280                Ok(extract_attributes_full_from_source(
2281                    &framed, access, hdr, os, ls, sohm,
2282                )?)
2283            }
2284            Backend::Edit(m) => Self::with_engine(
2285                m,
2286                |d| {
2287                    Ok(extract_attributes_full(
2288                        frame(d, base)?,
2289                        access,
2290                        hdr,
2291                        os,
2292                        ls,
2293                        sohm,
2294                    )?)
2295                },
2296                |s| {
2297                    if base.is_zero() {
2298                        Ok(extract_attributes_full_from_source(
2299                            s, access, hdr, os, ls, sohm,
2300                        )?)
2301                    } else {
2302                        let framed = BaseOffsetSource { inner: s, base };
2303                        Ok(extract_attributes_full_from_source(
2304                            &framed, access, hdr, os, ls, sohm,
2305                        )?)
2306                    }
2307                },
2308            ),
2309        }
2310    }
2311
2312    /// Read a dataset's raw bytes for the given layout, dispatching on the backend.
2313    fn read_dataset_raw(
2314        &self,
2315        spec: RawReadSpec<'_>,
2316        cache: &ChunkCache,
2317    ) -> Result<Vec<u8>, FormatError> {
2318        let (os, ls) = (self.offset_size(), self.length_size());
2319        // Every on-disk address in `dl` — the contiguous data address, the chunk
2320        // index root, and (followed deeper in the chunked reader) every B-tree /
2321        // fixed-array / extensible-array node and chunk-data address — is stored
2322        // relative to the base address. Present the payload reader a base-relative
2323        // view of the file so all of them index it directly: slice the in-memory
2324        // buffer at `base`, or wrap the streaming source to add `base` to each
2325        // read. For a plain file (`base == 0`) this is the identity.
2326        let base = self.addr_offset;
2327        match &self.backend {
2328            Backend::InMemory(v) => {
2329                data_read::read_raw_data_cached(frame(v, base)?, spec, os, ls, cache)
2330            }
2331            Backend::Streaming(s) if base.is_zero() => {
2332                data_read::read_raw_data_cached_from_source(s.as_ref(), spec, os, ls, cache)
2333            }
2334            Backend::Streaming(s) => {
2335                let framed = BaseOffsetSource {
2336                    inner: s.as_ref(),
2337                    base,
2338                };
2339                data_read::read_raw_data_cached_from_source(&framed, spec, os, ls, cache)
2340            }
2341            Backend::Edit(m) => Self::with_engine(
2342                m,
2343                |data| {
2344                    let framed = frame(data, base)?;
2345                    data_read::read_raw_data_cached(framed, spec, os, ls, cache)
2346                },
2347                |s| {
2348                    let framed = BaseOffsetSource { inner: s, base };
2349                    data_read::read_raw_data_cached_from_source(&framed, spec, os, ls, cache)
2350                },
2351            ),
2352        }
2353    }
2354
2355    /// Windowed counterpart of [`read_dataset_raw`](Self::read_dataset_raw): read
2356    /// the raw element bytes of the row window `[start_row, start_row + num_rows)`,
2357    /// touching only the storage it overlaps. Reads through the same base-framed
2358    /// `Source`, so on-disk addresses resolve the same way. The caller clamps
2359    /// the window to the dataset.
2360    fn read_dataset_raw_rows(
2361        &self,
2362        spec: RawReadSpec<'_>,
2363        cache: &ChunkCache,
2364        pass: CachePass,
2365        start_row: u64,
2366        num_rows: u64,
2367    ) -> Result<Vec<u8>, FormatError> {
2368        let (os, ls) = (self.offset_size(), self.length_size());
2369        let (dl, ds, dt) = (spec.layout, spec.dataspace, spec.datatype);
2370        let elem_size = dt.element_size_usize()?;
2371        // Elements per row (product of inner dims; 1 when 0-D or 1-D). Checked so
2372        // a crafted dataspace whose inner dims overflow `usize` errors instead of
2373        // panicking (debug) or wrapping (release).
2374        let row_elems: usize = ds.dimensions.iter().skip(1).try_fold(1usize, |acc, &d| {
2375            acc.checked_mul(d.to_usize()?)
2376                .ok_or(FormatError::OffsetOverflow {
2377                    offset: acc as u64,
2378                    length: d,
2379                })
2380        })?;
2381        let row_bytes =
2382            row_elems
2383                .checked_mul(elem_size.get())
2384                .ok_or(FormatError::OffsetOverflow {
2385                    offset: row_elems as u64,
2386                    length: elem_size.get() as u64,
2387                })?;
2388
2389        // Compact data is inline in the layout message — no I/O, no framing.
2390        if let DataLayout::Compact { data } = dl {
2391            return compact_rows(data, start_row, num_rows, row_bytes);
2392        }
2393
2394        let base = self.addr_offset;
2395        match &self.backend {
2396            Backend::InMemory(v) => {
2397                let framed = frame(v, base)?;
2398                read_rows_framed(
2399                    &BytesSource::new(framed),
2400                    spec,
2401                    os,
2402                    ls,
2403                    cache,
2404                    pass,
2405                    start_row,
2406                    num_rows,
2407                    row_bytes,
2408                )
2409            }
2410            Backend::Streaming(s) if base.is_zero() => read_rows_framed(
2411                s.as_ref(),
2412                spec,
2413                os,
2414                ls,
2415                cache,
2416                pass,
2417                start_row,
2418                num_rows,
2419                row_bytes,
2420            ),
2421            Backend::Streaming(s) => {
2422                let framed = BaseOffsetSource {
2423                    inner: s.as_ref(),
2424                    base,
2425                };
2426                read_rows_framed(
2427                    &framed, spec, os, ls, cache, pass, start_row, num_rows, row_bytes,
2428                )
2429            }
2430            Backend::Edit(m) => Self::with_engine(
2431                m,
2432                |data| {
2433                    let framed = frame(data, base)?;
2434                    read_rows_framed(
2435                        &BytesSource::new(framed),
2436                        spec,
2437                        os,
2438                        ls,
2439                        cache,
2440                        pass,
2441                        start_row,
2442                        num_rows,
2443                        row_bytes,
2444                    )
2445                },
2446                |s| {
2447                    let framed = BaseOffsetSource { inner: s, base };
2448                    read_rows_framed(
2449                        &framed, spec, os, ls, cache, pass, start_row, num_rows, row_bytes,
2450                    )
2451                },
2452            ),
2453        }
2454    }
2455}
2456
2457/// Reads a row window through an already base-framed [`Source`].
2458///
2459/// A compact layout reads from the bytes the layout message holds, a contiguous
2460/// one from a single bounded sub-read or, where the file allocated no storage,
2461/// from the fill value, and a chunked one from the windowed chunk reader. Only
2462/// the rank-0 chunked corner a crafted file reaches falls back to a whole read
2463/// plus a slice.
2464///
2465/// # Errors
2466///
2467/// Returns [`FormatError::UnsupportedVirtualLayout`] for a virtual layout, and
2468/// the error the layout's own reader reports for a window the dataset's storage
2469/// does not hold.
2470fn read_rows_framed<S: Source + ?Sized>(
2471    source: &S,
2472    spec: RawReadSpec<'_>,
2473    os: u8,
2474    ls: u8,
2475    cache: &ChunkCache,
2476    pass: CachePass,
2477    start_row: u64,
2478    num_rows: u64,
2479    row_bytes: usize,
2480) -> Result<Vec<u8>, FormatError> {
2481    let (dl, fill) = (spec.layout, spec.fill);
2482    // A zero-row window reads nothing, uniformly across the *supported* layouts.
2483    // A `Virtual` layout is unsupported and must still error like `read_raw`
2484    // does, so it is excluded here and falls through to the match.
2485    if num_rows == 0 && !matches!(dl, DataLayout::Virtual) {
2486        return Ok(Vec::new());
2487    }
2488    match dl {
2489        DataLayout::Compact { data } => compact_rows(data, start_row, num_rows, row_bytes),
2490        DataLayout::Contiguous { address, size } => {
2491            // Unallocated storage: the window reads as the fill value, the same
2492            // answer the whole-dataset readers give for it.
2493            let Some(addr) = *address else {
2494                let len = num_rows.to_usize()?.saturating_mul(row_bytes);
2495                return fill.buffer(len);
2496            };
2497            let start =
2498                start_row
2499                    .checked_mul(row_bytes as u64)
2500                    .ok_or(FormatError::OffsetOverflow {
2501                        offset: start_row,
2502                        length: row_bytes as u64,
2503                    })?;
2504            let len =
2505                num_rows
2506                    .to_usize()?
2507                    .checked_mul(row_bytes)
2508                    .ok_or(FormatError::OffsetOverflow {
2509                        offset: num_rows,
2510                        length: row_bytes as u64,
2511                    })?;
2512            // Never read past the dataset's own contiguous storage.
2513            if start.saturating_add(len as u64) > *size {
2514                return Err(FormatError::DataSizeMismatch {
2515                    expected: start.to_usize()?.saturating_add(len),
2516                    actual: (*size).to_usize()?,
2517                });
2518            }
2519            let off = addr
2520                .get()
2521                .checked_add(start)
2522                .ok_or(FormatError::OffsetOverflow {
2523                    offset: addr.get(),
2524                    length: start,
2525                })?;
2526            source.read_exact_at(off, len)
2527        }
2528        DataLayout::Chunked { .. } => {
2529            match crate::chunked_read::read_chunked_rows_from_source(
2530                source, spec, os, ls, cache, pass, start_row, num_rows,
2531            )? {
2532                Some(bytes) => Ok(bytes),
2533                // Rank-0 chunked (a crafted-file corner): fall back to a whole
2534                // read, then slice.
2535                None => {
2536                    let full =
2537                        data_read::read_raw_data_cached_from_source(source, spec, os, ls, cache)?;
2538                    let start = start_row.to_usize()? * row_bytes;
2539                    let len = num_rows.to_usize()? * row_bytes;
2540                    full.get(start..start + len).map(<[u8]>::to_vec).ok_or(
2541                        FormatError::DataSizeMismatch {
2542                            expected: start + len,
2543                            actual: full.len(),
2544                        },
2545                    )
2546                }
2547            }
2548        }
2549        DataLayout::Virtual => Err(FormatError::UnsupportedVirtualLayout),
2550    }
2551}
2552
2553/// Reads the rows `[start_row, start_row + num_rows)` of a compact dataset from
2554/// the element bytes its layout message holds.
2555///
2556/// Compact data is inline in the object header, so the window is a slice of
2557/// `data` and the file is never read.
2558///
2559/// # Errors
2560///
2561/// Returns [`FormatError::ValueTooLargeForPlatform`] if a row index exceeds
2562/// `usize`, [`FormatError::OffsetOverflow`] if the window's byte range overflows
2563/// it, and [`FormatError::DataSizeMismatch`] if the window ends past the bytes
2564/// the message holds.
2565fn compact_rows(
2566    data: &[u8],
2567    start_row: u64,
2568    num_rows: u64,
2569    row_bytes: usize,
2570) -> Result<Vec<u8>, FormatError> {
2571    let start = start_row.to_usize()?.checked_mul(row_bytes);
2572    let len = num_rows.to_usize()?.checked_mul(row_bytes);
2573    let (Some(start), Some(len)) = (start, len) else {
2574        return Err(FormatError::OffsetOverflow {
2575            offset: start_row,
2576            length: row_bytes as u64,
2577        });
2578    };
2579    let end = start.checked_add(len).ok_or(FormatError::OffsetOverflow {
2580        offset: start as u64,
2581        length: len as u64,
2582    })?;
2583    data.get(start..end)
2584        .map(<[u8]>::to_vec)
2585        .ok_or(FormatError::DataSizeMismatch {
2586            expected: end,
2587            actual: data.len(),
2588        })
2589}
2590
2591impl std::fmt::Debug for FileInner {
2592    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2593        f.debug_struct("File")
2594            .field("size", &self.file_size())
2595            .field("superblock_version", &self.superblock.version)
2596            .finish()
2597    }
2598}
2599
2600/// An open HDF5 file.
2601///
2602/// A `File` is an owned, cheaply cloneable handle to an open file: cloning it (or
2603/// deriving a [`Dataset`]/[`Group`] from it) shares one underlying open file
2604/// rather than re-reading it. Object handles returned by [`dataset`](Self::dataset),
2605/// [`group`](Self::group), and [`root`](Self::root) are **owned** — they keep the
2606/// file open for as long as they live and carry no borrow of the `File`, so they
2607/// can be stored in a struct, cached, cloned, and moved across threads. They stay
2608/// usable across a [`commit`](Self::commit), which is what makes caching one
2609/// worthwhile; see [`commit`](Self::commit) for the two cases that report
2610/// instead.
2611#[derive(Clone)]
2612pub struct File {
2613    inner: Arc<FileInner>,
2614}
2615
2616impl std::fmt::Debug for File {
2617    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2618        std::fmt::Debug::fmt(&*self.inner, f)
2619    }
2620}
2621
2622impl File {
2623    /// Opens the HDF5 file at `path` and reads it into memory.
2624    ///
2625    /// [`File::open_streaming`] reads a file larger than memory without buffering it, and
2626    /// [`File::open_swmr`] follows a file that a concurrent writer is appending to.
2627    ///
2628    /// # Errors
2629    ///
2630    /// Returns [`Error::FileMarkedInUse`] if the status flags mark
2631    /// the file as open for writing. `H5Fopen` makes the same check. The flag marks a live writer
2632    /// or one that exited without closing the file.
2633    /// [`clear_swmr_flag`](Self::clear_swmr_flag) clears a stale flag,
2634    /// [`open_swmr`](Self::open_swmr) follows a live SWMR writer, and
2635    /// [`from_bytes`](Self::from_bytes) reads the bytes its caller already holds without the check,
2636    /// which is the way to read a flagged file on a read-only mount.
2637    pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2638        Ok(File {
2639            inner: Arc::new(FileInner::open(path)?),
2640        })
2641    }
2642
2643    /// Open an HDF5 file from a filesystem path with explicit access properties.
2644    pub fn open_with_options<P: AsRef<std::path::Path>>(
2645        path: P,
2646        properties: FileAccessProperties,
2647    ) -> Result<Self, Error> {
2648        Ok(File {
2649            inner: Arc::new(FileInner::open_with_options(path, properties)?),
2650        })
2651    }
2652
2653    /// Open an HDF5 file for **streaming** reads, fetching regions on demand from
2654    /// the file instead of buffering it whole.
2655    ///
2656    /// This lets a host read a file larger than its address space. Metadata and
2657    /// dataset chunks are read through a `ReadSeekSource`, so peak memory stays
2658    /// close to one chunk plus the metadata being parsed; chunks adjacent on
2659    /// disk are fetched together, in reads of at most 256 KiB, and a chunk
2660    /// larger than that is read on its own. Attribute reading and v1
2661    /// symbol-table groups on the resolved path are not yet supported on this
2662    /// backend.
2663    ///
2664    /// Like [`open`](Self::open), this refuses a file whose superblock marks it
2665    /// as held by a writer.
2666    pub fn open_streaming<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2667        Ok(File {
2668            inner: Arc::new(FileInner::open_streaming(path)?),
2669        })
2670    }
2671
2672    /// Open an HDF5 file for streaming reads with explicit access properties.
2673    pub fn open_streaming_with_options<P: AsRef<std::path::Path>>(
2674        path: P,
2675        properties: FileAccessProperties,
2676    ) -> Result<Self, Error> {
2677        Ok(File {
2678            inner: Arc::new(FileInner::open_streaming_with_options(path, properties)?),
2679        })
2680    }
2681
2682    /// Opens the file at `path` for the streaming reads of a repack, whose object
2683    /// header parses are made under the `access_mode` the caller's
2684    /// [`RepackOptions`](crate::RepackOptions) gives.
2685    pub(crate) fn open_streaming_with_access_mode(
2686        path: &std::path::Path,
2687        access_mode: AccessMode,
2688    ) -> Result<Self, Error> {
2689        Ok(File {
2690            inner: Arc::new(FileInner::open_streaming_with_access_mode(
2691                path,
2692                access_mode,
2693            )?),
2694        })
2695    }
2696
2697    /// Open an HDF5 file from any [`Source`], reading metadata and chunks on
2698    /// demand exactly as [`open_streaming`](Self::open_streaming) does.
2699    ///
2700    /// This is the streaming open for a caller whose bytes are not a path: an
2701    /// object store addressed by HTTP range request, a sandboxed guest that
2702    /// receives byte ranges from its host, a decrypting layer. A [`Source`]
2703    /// supplies a length and reads at an absolute offset, which is all the
2704    /// reader asks of a file, so peak memory stays at the metadata being parsed
2705    /// plus the chunks a read touches — not the file. Wrap a `Read + Seek` in
2706    /// [`ReadSeekSource`] rather than writing that impl again.
2707    ///
2708    /// A file marked as held by a writer is refused here as it is by
2709    /// [`open_streaming`](Self::open_streaming); with no path to report, the
2710    /// error names the source instead. Recovering such a file in place needs a
2711    /// path, through [`File::clear_swmr_flag`], so a caller that has none is
2712    /// left with [`File::from_bytes`](Self::from_bytes) and the whole file in
2713    /// memory.
2714    ///
2715    /// The metadata cache is **off** unless
2716    /// [`from_source_with_options`](Self::from_source_with_options) turns it
2717    /// on, which matters more here than it does for a local file: without one,
2718    /// every read a parser makes is a round trip. See
2719    /// [`MetadataCacheConfig`].
2720    ///
2721    /// ```no_run
2722    /// use hdf5_pure::{File, FormatError, Source};
2723    ///
2724    /// // However the bytes actually arrive: a range request, a host call, a
2725    /// // decrypting layer over a file.
2726    /// # fn fetch(offset: u64, len: usize) -> Result<Vec<u8>, String> { unimplemented!() }
2727    ///
2728    /// struct Remote {
2729    ///     len: u64,
2730    /// }
2731    ///
2732    /// impl Source for Remote {
2733    ///     fn len(&self) -> u64 {
2734    ///         self.len
2735    ///     }
2736    ///
2737    ///     fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
2738    ///         // Fill the whole request or fail: a short read is an error.
2739    ///         let bytes = fetch(offset, buf.len()).map_err(FormatError::Source)?;
2740    ///         buf.copy_from_slice(&bytes);
2741    ///         Ok(())
2742    ///     }
2743    /// }
2744    ///
2745    /// let file = File::from_source(Remote { len: 1 << 30 })?;
2746    /// let rows = file.dataset("frames")?.read_f64_rows(0, 64)?;
2747    /// # Ok::<(), hdf5_pure::Error>(())
2748    /// ```
2749    ///
2750    /// A `Read + Seek` needs none of that: wrap it in [`ReadSeekSource`].
2751    /// Reading a *file* that way is [`open_streaming`](Self::open_streaming),
2752    /// which does the wrapping for you.
2753    pub fn from_source<S: Source + Send + Sync + 'static>(source: S) -> Result<Self, Error> {
2754        Ok(File {
2755            inner: Arc::new(FileInner::from_source(source)?),
2756        })
2757    }
2758
2759    /// Open an HDF5 file from any [`Source`] with explicit access properties.
2760    ///
2761    /// The metadata cache is what a remote source wants tuned: every parser
2762    /// read becomes a round trip without one. See [`MetadataCacheConfig`].
2763    pub fn from_source_with_options<S: Source + Send + Sync + 'static>(
2764        source: S,
2765        properties: FileAccessProperties,
2766    ) -> Result<Self, Error> {
2767        Ok(File {
2768            inner: Arc::new(FileInner::from_source_with_options(source, properties)?),
2769        })
2770    }
2771
2772    /// Open an HDF5 file for SWMR (single-writer/multiple-reader) reading.
2773    ///
2774    /// Like [`File::open`], but retains a live handle to the file so that
2775    /// [`File::refresh`] can re-read data appended by a concurrent writer.
2776    ///
2777    /// This is the open that *follows* a file marked as held by a SWMR writer,
2778    /// where [`open`](Self::open) refuses one. Only a half-set mark is refused
2779    /// here, with [`Error::FileMarkedInUse`]:
2780    /// either bit without the other. Write access alone is what a plain
2781    /// (non-SWMR) writer leaves, and there is no protocol for following a writer
2782    /// that is not publishing consistent prefixes; the SWMR bit alone is a state
2783    /// no writer produces. Both bits is the live SWMR writer this exists to
2784    /// follow, and neither is a quiescent file.
2785    #[doc(alias = "H5F_ACC_SWMR_READ")]
2786    pub fn open_swmr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2787        Ok(File {
2788            inner: Arc::new(FileInner::open_swmr(path)?),
2789        })
2790    }
2791
2792    /// Open an HDF5 file for SWMR reading with explicit access properties.
2793    pub fn open_swmr_with_options<P: AsRef<std::path::Path>>(
2794        path: P,
2795        properties: FileAccessProperties,
2796    ) -> Result<Self, Error> {
2797        Ok(File {
2798            inner: Arc::new(FileInner::open_swmr_with_options(path, properties)?),
2799        })
2800    }
2801
2802    /// Open an HDF5 file from an in-memory byte vector.
2803    pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
2804        Ok(File {
2805            inner: Arc::new(FileInner::from_bytes(data)?),
2806        })
2807    }
2808
2809    /// Open an HDF5 file from an in-memory byte vector with explicit access properties.
2810    pub fn from_bytes_with_options(
2811        data: Vec<u8>,
2812        properties: FileAccessProperties,
2813    ) -> Result<Self, Error> {
2814        Ok(File {
2815            inner: Arc::new(FileInner::from_bytes_with_options(data, properties)?),
2816        })
2817    }
2818
2819    /// Open an existing HDF5 file for reading **and** in-place editing.
2820    ///
2821    /// Unlike [`open`](Self::open) (read-only, buffered), this takes an exclusive
2822    /// OS file lock held for the file's life and lets owned handles modify the
2823    /// file — immediate [`Dataset::append`]s, plus [`Dataset::write`]/`set_attr`,
2824    /// [`Group::create_dataset`]/`create_group`/`delete`/`set_attr`, and
2825    /// [`copy`](Self::copy)/[`copy_from`](Self::copy_from) staged until
2826    /// [`commit`](Self::commit). The file must use 8-byte offsets and lengths and
2827    /// keep its superblock at its base address (a canonical userblock, as in a
2828    /// MATLAB `.mat` file, is supported); anything else is refused with
2829    /// [`Error::EditUnsupported`].
2830    ///
2831    /// The fast immediate [`Dataset::append`] additionally requires a
2832    /// latest-format (version-2/3) file with no userblock and an
2833    /// Extensible-Array-indexed dataset; [`Dataset::append_staged`] covers the
2834    /// general case.
2835    ///
2836    /// Two things can turn this open away because another writer holds the file:
2837    /// the exclusive OS lock, reported as
2838    /// [`Error::FileLocked`], and the superblock's
2839    /// status-flags byte, reported as
2840    /// [`Error::FileMarkedInUse`]. The second
2841    /// covers what the first cannot — a SWMR writer takes no lock, and a writer
2842    /// that exited without closing the file leaves the flag behind; recover a
2843    /// stale one with [`clear_swmr_flag`](Self::clear_swmr_flag).
2844    ///
2845    /// # Memory
2846    ///
2847    /// This picks its backing from the file rather than making the caller pick a
2848    /// function (issue #198): a latest-format file with no userblock is edited
2849    /// **bounded**, holding only the metadata being parsed plus the configured
2850    /// caches plus what an edit is building, so resident memory does not scale
2851    /// with the file; anything else falls back to a whole-file in-memory mirror,
2852    /// which is what makes a pre-v2 or userblock file editable at all. The two
2853    /// backings are the same engine over different storage and offer the same
2854    /// edit surface, differing in one trade: the bounded one applies a large
2855    /// immediate append in whole-chunk batches, each crash-atomic on its own, so
2856    /// a crash mid-call leaves a valid shorter dataset rather than none of the
2857    /// append. Ask a file which it got with
2858    /// [`edit_backing`](Self::edit_backing), and demand one with
2859    /// [`FileAccessProperties::with_memory_strategy`] —
2860    /// [`MemoryStrategy::Mirrored`] restores the unconditional mirror this
2861    /// entry point used before it learned to dispatch.
2862    #[doc(alias = "H5Fopen")]
2863    pub fn open_rw<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2864        Self::open_rw_with_options(path, FileAccessProperties::new())
2865    }
2866
2867    /// Open an existing file for reading and in-place editing with explicit
2868    /// access properties — see [`open_rw`](Self::open_rw).
2869    ///
2870    /// The properties carry the locking policy (the `H5Pset_file_locking` analogue,
2871    /// [`FileAccessProperties::with_locking`]), the memory strategy
2872    /// ([`FileAccessProperties::with_memory_strategy`], which overrides the
2873    /// dispatch described on [`open_rw`](Self::open_rw)), the `fsync` cadence
2874    /// ([`FileAccessProperties::with_sync_policy`]), the metadata cache used by
2875    /// the bounded backing, and the file-wide chunk-cache default applied to
2876    /// datasets opened from this file. Because one [`FileAccessProperties`] value
2877    /// serves every open, the same configuration can be shared with a read path.
2878    pub fn open_rw_with_options<P: AsRef<std::path::Path>>(
2879        path: P,
2880        properties: FileAccessProperties,
2881    ) -> Result<Self, Error> {
2882        Ok(File {
2883            inner: Arc::new(FileInner::open_rw(path, properties)?),
2884        })
2885    }
2886
2887    /// Open exactly as [`open_rw`](Self::open_rw) does, but behind an image that
2888    /// withholds its whole-file slice, so every read takes the `Source` path
2889    /// rather than the slice fast path.
2890    ///
2891    /// Each read this file serves has two forms (see `with_engine`), and only
2892    /// the slice form runs in production until a mirrorless backing lands
2893    /// (issue #198). Opening the same file both ways and comparing is what
2894    /// holds the other form to the same answers in the meantime.
2895    #[cfg(test)]
2896    pub(crate) fn open_rw_source_only(path: &std::path::Path) -> Result<Self, Error> {
2897        Ok(File {
2898            inner: Arc::new(FileInner::from_rw_session(
2899                WriteEngine::open_source_only(path)?,
2900                FileAccessProperties::new(),
2901            )?),
2902        })
2903    }
2904
2905    /// Open an existing file for **SWMR** (single-writer/multiple-reader)
2906    /// appending: take **no** OS lock (so concurrent readers, and Windows'
2907    /// mandatory locks, are never blocked) and raise the superblock's SWMR-write
2908    /// flag so a reader may attach with [`File::open_swmr`], the C library's
2909    /// `H5F_ACC_SWMR_READ`, or h5py `swmr=True`.
2910    ///
2911    /// Only immediate [`Dataset::append`] is permitted, and only over the SWMR
2912    /// subset — an **unfiltered**, chunk-aligned append, so a concurrent reader
2913    /// only ever observes a consistent prefix; a filtered or non-chunk-aligned
2914    /// append returns [`Error::SwmrAppendUnsupported`].
2915    /// The staged edit surface (`write`/`set_attr`/`create_*`/`delete`/`copy`/
2916    /// `commit`) returns
2917    /// [`Error::SwmrStagedUnsupported`].
2918    /// [`close`](Self::close) clears the SWMR-write flag; a writer that exits
2919    /// without a clean close leaves it set — recover with
2920    /// [`clear_swmr_flag`](Self::clear_swmr_flag). While the flag stands, this
2921    /// open is refused with
2922    /// [`Error::FileMarkedInUse`], which is what keeps a second writer off a
2923    /// file SWMR gives only one (no OS lock is held to do it).
2924    ///
2925    /// Requires a latest-format (version-3 superblock) file with no userblock
2926    /// and no persisted free-space; other files are refused with
2927    /// [`Error::SwmrAppendUnsupported`]. The version-3 requirement is the C
2928    /// library's: neither library reads the SWMR-write flag back on an older
2929    /// superblock, so raising one there would announce the writer to nobody.
2930    #[doc(alias = "H5F_ACC_SWMR_WRITE")]
2931    pub fn open_swmr_writer<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2932        Self::open_swmr_writer_with_options(path, FileAccessProperties::new())
2933    }
2934
2935    /// Open for SWMR appending with explicit access properties — see
2936    /// [`open_swmr_writer`](Self::open_swmr_writer).
2937    ///
2938    /// The properties' chunk cache is the file-wide default for datasets opened
2939    /// from this file. Its locking policy is ignored, which costs the caller
2940    /// nothing: SWMR takes no OS lock by design, which is stronger than any
2941    /// locking a caller could ask for. Its memory strategy is *not* ignored the
2942    /// same way — this writer always mirrors, so an explicit
2943    /// [`MemoryStrategy::Bounded`] is a guarantee it cannot meet and is refused
2944    /// with [`Error::EditUnsupported`]; [`MemoryStrategy::Auto`] and
2945    /// [`MemoryStrategy::Mirrored`] are both satisfied by the mirror.
2946    ///
2947    /// Its [`SyncPolicy`] applies here as to any other
2948    /// read-write session, the SWMR-write flag included; a reader on this
2949    /// machine is unaffected either way, since the barriers carry the write
2950    /// order across power loss rather than across processes.
2951    pub fn open_swmr_writer_with_options<P: AsRef<std::path::Path>>(
2952        path: P,
2953        properties: FileAccessProperties,
2954    ) -> Result<Self, Error> {
2955        Ok(File {
2956            inner: Arc::new(FileInner::open_swmr_writer(path, properties)?),
2957        })
2958    }
2959
2960    /// Clear a stale status flag left in `path` by a writer that exited without a
2961    /// clean [`close`](Self::close) — the `h5clear -s` equivalent, for recovering
2962    /// a file that both this crate and the reference C library otherwise refuse
2963    /// to open ([`Error::FileMarkedInUse`]). A no-op if the flag is already
2964    /// clear.
2965    ///
2966    /// It takes the exclusive OS lock first, so it cannot clear the flag out
2967    /// from under a *live* [`open_rw`](Self::open_rw) writer. A live SWMR writer
2968    /// holds no lock, so make sure it is really gone: clearing the flag under
2969    /// one leaves its readers with no record that it is publishing.
2970    ///
2971    /// It also clears the crash mark a page-buffered session raises
2972    /// ([`FileAccessProperties::with_page_buffer_size`]), and there the warning is
2973    /// sharper. That mark stands for pages that were still in memory, so a file
2974    /// still carrying it was left by a writer that did not finish: clearing it
2975    /// hands back a file whose datasets may read clean and return fill values or
2976    /// a deleted object's bytes, with every checksum verifying. Clear it to
2977    /// salvage what is there, not to resume trusting it. `h5clear` makes the same
2978    /// trade for the same reason.
2979    pub fn clear_swmr_flag<P: AsRef<std::path::Path>>(path: P) -> Result<(), Error> {
2980        crate::file_lock::clear_swmr_flag_at(path.as_ref())
2981    }
2982
2983    /// Create a new, empty HDF5 file at `path` and open it for reading and
2984    /// writing, so its contents can be built entirely through owned handles
2985    /// ([`Group::create_dataset`]/[`create_group`](Group::create_group), then
2986    /// [`commit`](Self::commit)).
2987    ///
2988    /// Overwrites any existing file at `path`. For an all-at-once write, use
2989    /// [`FileBuilder`](crate::FileBuilder) instead.
2990    #[doc(alias = "H5Fcreate")]
2991    pub fn create<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2992        Self::create_with_options(
2993            path,
2994            FileCreateProperties::new(),
2995            FileAccessProperties::new(),
2996        )
2997    }
2998
2999    /// Create a new, empty HDF5 file with explicit creation and access properties,
3000    /// then open it for reading and writing — see [`create`](Self::create).
3001    ///
3002    /// Mirrors `H5Fcreate(name, flags, fcpl_id, fapl_id)`: `create` carries the
3003    /// creation properties recorded in the new file (userblock, file-space
3004    /// strategy, library-version bounds), and `access` the properties governing
3005    /// the handle returned (locking policy, `fsync` cadence, chunk cache). Both are values, so a
3006    /// layout defined once can be reused across every file an application writes.
3007    ///
3008    /// A creation property is validated as the file is written, so an invalid
3009    /// userblock or page size surfaces here rather than when the properties were
3010    /// built. A file created with [`FileSpaceStrategy::Page`] can be grown
3011    /// through either editor, by an immediate [`Dataset::append`] or a staged
3012    /// commit, provided it also persists its free space (issue #198).
3013    pub fn create_with_options<P: AsRef<std::path::Path>>(
3014        path: P,
3015        create: FileCreateProperties,
3016        access: FileAccessProperties,
3017    ) -> Result<Self, Error> {
3018        // Refuse a pair the reopen below would refuse, before anything is
3019        // written: this call promises a file *and* an open handle, and half of
3020        // that is worse than neither.
3021        if let Some(reason) = crate::edit::create_would_refuse_reopen(&create, &access) {
3022            return Err(Error::EditUnsupported(reason));
3023        }
3024        let mut builder = crate::writer::FileBuilder::new();
3025        builder.with_create_properties(create);
3026        let bytes = builder.finish()?;
3027        std::fs::write(path.as_ref(), bytes).map_err(Error::Io)?;
3028        Self::open_rw_with_options(path, access)
3029    }
3030
3031    /// Apply all staged structural edits made through this file's handles —
3032    /// [`Dataset::write`]/`set_attr`/`remove_attr` and
3033    /// [`Group::create_group`]/`delete` — as one transaction. Immediate
3034    /// [`Dataset::append`]s need no commit.
3035    ///
3036    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
3037    /// [`Error::ReadOnly`].
3038    ///
3039    /// Outstanding [`Dataset`] and [`Group`] handles stay usable: a commit
3040    /// relocates object headers, and each handle looks its object up again by
3041    /// path on its first use afterwards, so a long-lived handle answers for the
3042    /// file the commit left rather than for the copy it moved away from. Two
3043    /// exceptions, both of which report rather than answer wrongly. A *read*
3044    /// through a handle onto an object the commit deleted, or replaced with one
3045    /// of a different kind ([`Error::NotADataset`] or [`Error::NotAGroup`]),
3046    /// fails the way opening it
3047    /// would; its write methods still address the file by path, so they stage
3048    /// and the commit refuses them. And a handle reached by object reference
3049    /// ([`Dataset::dereference`]) has no path to look up, so it returns
3050    /// [`Error::StaleHandle`], and not only after a commit but after anything
3051    /// staged, synced or torn down, since only an immediate [`Dataset::append`]
3052    /// is known to leave every header where it stands. Dereference again from a
3053    /// fresh read.
3054    ///
3055    /// A handle onto an object this commit *publishes* — one
3056    /// [`Group::create_group`], [`Group::create_group_with`] or
3057    /// [`Group::create_dataset`] handed back, or a lookup of a staged name found
3058    /// — starts reading its object here. Until then it answers
3059    /// [`Error::NotCommitted`] for anything needing
3060    /// bytes, and a refused commit leaves it doing so.
3061    ///
3062    /// The commit is durable when it returns, under the default
3063    /// [`SyncPolicy::Always`]; under
3064    /// [`SyncPolicy::OnClose`] it has reached the operating system and waits for
3065    /// a [`sync`](Self::sync).
3066    ///
3067    /// **A commit refused before it publishes leaves every dataset reading what
3068    /// it read before.** Almost everything such a commit writes lands where
3069    /// nothing reaches it until the commit's linearization point; the one edit
3070    /// that does not is a same-length [`Dataset::write`], which overwrites the
3071    /// dataset's existing block, and the refusal writes those bytes back on its
3072    /// way out. A refusal raised before the first write keeps the staged batch
3073    /// too, so it can be corrected and committed again (issue #316).
3074    ///
3075    /// # Errors
3076    ///
3077    /// Two failures do not carry that promise, and both call for **re-reading**
3078    /// the datasets the batch named rather than for a retry:
3079    ///
3080    /// - [`Error::CommitPartiallyApplied`], where the restore itself failed, so
3081    ///   a dataset may hold either value.
3082    /// - An error from a step *after* the commit published — repointing the
3083    ///   object references that named a moved object is the one that can raise
3084    ///   it. The batch is in the file and stays there; what failed is work the
3085    ///   commit owed afterwards. The file is valid either way.
3086    pub fn commit(&self) -> Result<(), Error> {
3087        self.with_mirror_session(Change::Relocating, |session| session.commit())
3088    }
3089
3090    /// Copy the object at `src` to `dst` within this file (the in-file
3091    /// `H5Ocopy`), staged until [`commit`](Self::commit).
3092    ///
3093    /// A dataset whose storage was never allocated is copied as the storage it
3094    /// has — none — rather than as the fill value reading it answers with, so a
3095    /// schema-only dataset stays one. A dataset whose elements live in external
3096    /// files (`H5Pset_external`) carries that same empty storage while holding
3097    /// data this crate does not read, and is refused with
3098    /// [`Error::EditUnsupported`]. Copying it would leave its elements behind.
3099    ///
3100    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
3101    /// [`Error::ReadOnly`].
3102    pub fn copy(&self, src: &str, dst: &str) -> Result<(), Error> {
3103        self.with_mirror_session(Change::Relocating, |session| {
3104            session.copy(&ObjectPathBuf::parse(src), &ObjectPathBuf::parse(dst))
3105        })
3106    }
3107
3108    /// Copy the object at `src` in `source` — a separate, buffered read-only
3109    /// file — into this file at `dst`: the cross-file `H5Ocopy`, staged until
3110    /// [`commit`](Self::commit).
3111    ///
3112    /// `source` must be a buffered file ([`File::open`] or [`File::from_bytes`],
3113    /// not [`File::open_streaming`]) that uses 8-byte offsets and has no
3114    /// userblock; anything else is refused with
3115    /// [`Error::EditUnsupported`]. The source
3116    /// subtree is read and validated eagerly, so `source` need not outlive this
3117    /// call — and so a source this cannot reproduce, external storage included,
3118    /// is refused by this call. Refusals that concern the *destination* — `dst`
3119    /// already exists, or its parent group does not — still come from `commit`.
3120    /// Requires a read-write destination ([`File::open_rw`]); a read-only
3121    /// one returns [`Error::ReadOnly`].
3122    pub fn copy_from(&self, source: &File, src: &str, dst: &str) -> Result<(), Error> {
3123        self.with_mirror_session(Change::Relocating, |session| {
3124            session.copy_from(
3125                source,
3126                &ObjectPathBuf::parse(src),
3127                &ObjectPathBuf::parse(dst),
3128            )
3129        })
3130    }
3131
3132    /// Report whether this file has structural edits staged but not yet applied
3133    /// by [`commit`](Self::commit) — [`Dataset::write`]/`set_attr`/`remove_attr`,
3134    /// [`Dataset::append_staged`], [`Group::create_group`]/`create_dataset`/
3135    /// `delete`/`set_attr`/`remove_attr`, and [`copy`](Self::copy)/
3136    /// [`copy_from`](Self::copy_from). Immediate [`Dataset::append`]s are never
3137    /// staged and do not count. Always `false` for a read-only file.
3138    ///
3139    /// A `commit` that refuses puts the staged set back untouched, so this still
3140    /// answers `true` afterwards and the same batch can be committed again — to
3141    /// the same refusal, until the session is dropped.
3142    pub fn has_staged_edits(&self) -> bool {
3143        match &self.inner.backend {
3144            Backend::Edit(m) => {
3145                let session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
3146                session.has_staged_edits()
3147            }
3148            _ => false,
3149        }
3150    }
3151
3152    /// Report this read-write file's live space usage as a [`SpaceAccounting`] —
3153    /// the current logical size, total reusable free bytes, and reusable free
3154    /// regions. It reflects committed state plus immediate in-place appends, not
3155    /// edits still staged for [`commit`](Self::commit).
3156    ///
3157    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
3158    /// [`Error::ReadOnly`].
3159    pub fn space_accounting(&self) -> Result<SpaceAccounting, Error> {
3160        match &self.inner.backend {
3161            Backend::Edit(m) => {
3162                let session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
3163                Ok(session.space_accounting())
3164            }
3165            _ => Err(Error::ReadOnly),
3166        }
3167    }
3168
3169    /// Force everything written to this file so far to durable storage — the
3170    /// `fsync` the application issues at its own cadence under
3171    /// [`SyncPolicy::OnClose`], and a redundant one under the default
3172    /// [`SyncPolicy::Always`]. A SWMR-writer file syncs the same way.
3173    ///
3174    /// This is a durability barrier, not a flush: it writes nothing itself.
3175    /// Staged edits are not applied ([`commit`](Self::commit) does that, and a
3176    /// `sync` before one makes only the *previous* state durable), and elements
3177    /// held by a live [`BufferedAppender`] have not
3178    /// reached the file at all — flush it first.
3179    ///
3180    /// There is no need to call it before [`close`](Self::close): `close` — and
3181    /// dropping the last handle — issues its own barrier under every policy,
3182    /// because both write and both destroy the handle that would have ordered
3183    /// those writes. This is the mid-session checkpoint, not the closing one.
3184    ///
3185    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
3186    /// [`Error::ReadOnly`], and a sealed one [`Error::FileClosed`]. A closed
3187    /// file has already been synced.
3188    #[doc(alias = "fsync")]
3189    pub fn sync(&self) -> Result<(), Error> {
3190        // A barrier over writes the operations that made them already accounted
3191        // for, so it invalidates no handle. Classing it as a change would end
3192        // every by-reference handle in the session because the caller asked for
3193        // an `fsync`.
3194        self.with_mirror_session(Change::Nothing, |session| session.force_sync())
3195    }
3196
3197    /// Commit any staged edits and seal this file. The exclusive OS lock is
3198    /// released once the last handle derived from this file is also dropped.
3199    ///
3200    /// After `close`, a write through any surviving [`Dataset`]/[`Group`] handle
3201    /// or [`File`] clone returns [`Error::FileClosed`], and
3202    /// reads still work. `close` commits, so the one handle a commit ends ends
3203    /// here too: one reached by [`Dataset::dereference`] reports
3204    /// [`Error::StaleHandle`] afterwards, where a handle opened by path
3205    /// re-resolves and keeps reading.
3206    pub fn close(self) -> Result<(), Error> {
3207        if matches!(self.inner.backend, Backend::Edit(_)) {
3208            // SWMR mode stages nothing — the staged surface is refused — so there
3209            // is nothing to commit, and it persists no free space, so there is
3210            // nothing to re-home.
3211            let swmr = self.inner.swmr_write;
3212            if !swmr {
3213                self.commit()?;
3214            }
3215            // Free-space managers and status flags, both rewritten where they
3216            // stand. No object header moves, so a handle that could read through
3217            // this file before `close` still can — which is what `close`'s own
3218            // documentation promises.
3219            self.with_mirror_session(Change::InPlace, |session| {
3220                // Immediate appends grow the file past any persisted free-space
3221                // managers without running a commit tail, so re-home them here. A
3222                // no-op unless this session left them stale.
3223                if !swmr {
3224                    session.finalize_persist()?;
3225                }
3226                // Forced under every policy, and covering everything above: this
3227                // call consumes the handle, so it is the last point at which any
3228                // of these writes can be ordered at all.
3229                session.force_sync()?;
3230                // Last, and only after that sync. A session's status flags stand
3231                // for writes that may still have been in memory — a SWMR writer's
3232                // pair, a page buffer's crash mark — and this is the point at
3233                // which none are.
3234                session.release_status_flags()
3235            })?;
3236            self.inner.closed.store(true, Ordering::Release);
3237        }
3238        Ok(())
3239    }
3240
3241    /// Run `f` with the locked write session of a read-write file. `staged`
3242    /// distinguishes an edit applied by [`commit`](Self::commit) from an immediate
3243    /// one. Returns [`Error::ReadOnly`] for a read-only file,
3244    /// [`Error::FileClosed`] once the file is sealed by [`close`](Self::close),
3245    /// and [`Error::SwmrStagedUnsupported`] for a staged edit on a SWMR-writer
3246    /// file.
3247    fn with_mirror_session<R>(
3248        &self,
3249        change: Change,
3250        f: impl FnOnce(&mut WriteEngine) -> Result<R, Error>,
3251    ) -> Result<R, Error> {
3252        self.inner.with_engine_mut(change, f)
3253    }
3254
3255    /// Returns an owned handle to the root group.
3256    pub fn root(&self) -> Group {
3257        let revisions = self.inner.revisions();
3258        Group::new(
3259            self.inner.clone(),
3260            // A relocating commit on a read-write file can move the root, so
3261            // resolve it from the live mirror rather than the cached superblock.
3262            revisions.at(self.inner.mirror_root_address()),
3263            Some(ObjectPathBuf::root()),
3264        )
3265    }
3266
3267    /// Resolve a path and return an owned [`Dataset`] handle.
3268    ///
3269    /// The dataset uses the file-wide chunk-cache default (configured with
3270    /// [`FileAccessProperties::with_chunk_cache`]). To override the cache for this
3271    /// one dataset, use [`dataset_with_options`](Self::dataset_with_options).
3272    ///
3273    /// Returns [`Error::NotADataset`] if the path names something that is not a
3274    /// dataset, and [`Error::NotAGroup`] if a component *along* the path is not
3275    /// a group: resolving `a/b/c` opens `a` and then `a/b` to look inside them,
3276    /// so a dataset at `a/b` reports `NotAGroup("a/b")` (issue #365).
3277    pub fn dataset(&self, path: &str) -> Result<Dataset, Error> {
3278        self.dataset_with_options(path, DatasetAccessProperties::new())
3279    }
3280
3281    /// Resolve a path and return an owned [`Dataset`] handle, applying per-dataset
3282    /// [`DatasetAccessProperties`] that override file-wide access defaults.
3283    ///
3284    /// This is the dataset-open-with-access-property-list path (HDF5's `dapl`):
3285    /// the properties' chunk cache corresponds to `H5Pset_chunk_cache` and takes
3286    /// precedence, for this dataset only, over the `H5Pset_cache`-style
3287    /// file-wide default.
3288    pub fn dataset_with_options(
3289        &self,
3290        path: &str,
3291        properties: DatasetAccessProperties,
3292    ) -> Result<Dataset, Error> {
3293        let chunk_cache = properties.resolved_chunk_cache(self.inner.access_properties.chunk_cache);
3294        let path = ObjectPathBuf::parse(path);
3295        match self.inner.staged_object(&path).map(|o| o.kind) {
3296            Some(StagedKind::Dataset) => {
3297                return Ok(Dataset::pending(self.inner.clone(), chunk_cache, path));
3298            }
3299            Some(StagedKind::Group) => return Err(Error::NotADataset(path.to_string())),
3300            None => {}
3301        }
3302        let revisions = self.inner.revisions();
3303        let addr = self.inner.resolve_path(&path.as_path())?;
3304        let hdr = self.inner.parse_header(addr)?;
3305        if !has_message(&hdr, MessageType::DATA_LAYOUT) {
3306            return Err(Error::NotADataset(path.to_string()));
3307        }
3308        Ok(Dataset::new(
3309            self.inner.clone(),
3310            revisions.at(addr),
3311            hdr,
3312            chunk_cache,
3313            Some(path),
3314        ))
3315    }
3316
3317    /// Resolve a path and return an owned [`Group`] handle.
3318    ///
3319    /// Returns [`Error::NotAGroup`] if the path names an object that is not a
3320    /// group, the way [`dataset`](Self::dataset) returns
3321    /// [`Error::NotADataset`] for the mirror case, and
3322    /// [`FormatError::PathNotFound`] if it names nothing.
3323    ///
3324    /// The same error reports a component *along* the path that is not a group,
3325    /// naming that component's own path rather than the one asked for: `a/b/c`
3326    /// stopped by a dataset at `a/b` reports `NotAGroup("a/b")` (issue #365).
3327    pub fn group(&self, path: &str) -> Result<Group, Error> {
3328        let path = ObjectPathBuf::parse(path);
3329        match self.inner.staged_object(&path).map(|o| o.kind) {
3330            Some(StagedKind::Group) => {
3331                return Ok(Group::pending(self.inner.clone(), path));
3332            }
3333            Some(StagedKind::Dataset) => return Err(Error::NotAGroup(path.to_string())),
3334            None => {}
3335        }
3336        let revisions = self.inner.revisions();
3337        let addr = self.inner.resolve_path(&path.as_path())?;
3338        if !is_group(&self.inner.parse_header(addr)?) {
3339            // Normalized, so that the same object refused here and refused by a
3340            // live handle below names itself the same way: a handle knows only
3341            // the normalized path it memoized.
3342            return Err(Error::NotAGroup(path.to_string()));
3343        }
3344        Ok(Group::new(
3345            self.inner.clone(),
3346            revisions.at(addr),
3347            Some(path),
3348        ))
3349    }
3350
3351    /// Re-read the file from disk to pick up data appended by a concurrent
3352    /// writer, then re-parse the superblock.
3353    ///
3354    /// This is the SWMR reader's refresh primitive. Returns
3355    /// [`Error::SwmrUnsupported`] if the file was not opened with
3356    /// [`File::open_swmr`], and [`Error::HandlesOutstanding`] if any owned
3357    /// [`Dataset`]/[`Group`] handle (or a clone of this `File`) is still alive —
3358    /// drop them before refreshing, then re-fetch them afterward, since they
3359    /// observe the new bytes only when re-derived from the refreshed file.
3360    pub fn refresh(&mut self) -> Result<(), Error> {
3361        let inner = Arc::get_mut(&mut self.inner).ok_or(Error::HandlesOutstanding)?;
3362        inner.refresh()
3363    }
3364
3365    // --- delegating value getters (forward to the shared inner state) ---
3366
3367    /// Returns the raw file bytes for an in-memory file, or an empty slice for a
3368    /// streaming file (which has no whole-file buffer).
3369    pub fn as_bytes(&self) -> &[u8] {
3370        self.inner.as_bytes()
3371    }
3372
3373    /// Returns the access properties this file was opened with.
3374    ///
3375    /// An open without options, such as [`File::open`], has [`FileAccessProperties::new`].
3376    pub fn access_properties(&self) -> FileAccessProperties {
3377        self.inner.access_properties()
3378    }
3379
3380    /// Which backend this file's read-write session resolved to:
3381    /// [`EditBacking::Bounded`] when it reads through a handle, or
3382    /// [`EditBacking::Mirrored`] when it holds a whole-file image.
3383    ///
3384    /// This is how a caller who opened with [`MemoryStrategy::Auto`] finds out
3385    /// whether the fallback was taken, and so whether memory scales with the
3386    /// file. A file with no editing session — a read-only open, a streaming open
3387    /// — reports `None`.
3388    ///
3389    /// The answer is an [`EditBacking`] rather than the [`MemoryStrategy`] that
3390    /// was asked for, because `Auto` is a preference between the two backends and
3391    /// not an outcome either can report; `.into()` converts back when a later
3392    /// reopen should be pinned to what this one got.
3393    pub fn edit_backing(&self) -> Option<EditBacking> {
3394        self.inner.edit_backing()
3395    }
3396
3397    /// What this file's metadata cache has done, and what it is holding.
3398    ///
3399    /// [`FileAccessProperties::with_metadata_cache`] sets a byte budget before
3400    /// any read has happened; this is how a caller finds out whether it was the
3401    /// right one. See [`MetadataCacheStats`] for which figure answers which
3402    /// question. Together the two are the `hdf5-pure` counterpart of HDF5's
3403    /// `H5Fget_mdc_hit_rate` and `H5Fget_mdc_size`.
3404    ///
3405    /// `None` where there is no metadata cache to report on: a buffered
3406    /// [`open`](Self::open) or [`from_bytes`](Self::from_bytes), which already
3407    /// holds the whole file; a mirrored read-write session, for the same reason;
3408    /// or a streaming or bounded open left at the default disabled budget.
3409    ///
3410    /// ```no_run
3411    /// # fn main() -> Result<(), hdf5_pure::Error> {
3412    /// use hdf5_pure::{File, FileAccessProperties, MetadataCacheConfig};
3413    ///
3414    /// let properties =
3415    ///     FileAccessProperties::new().with_metadata_cache(MetadataCacheConfig::new(8 << 20));
3416    /// let file = File::open_streaming_with_options("data.h5", properties)?;
3417    /// for name in file.root().datasets()? {
3418    ///     let _ = file.dataset(&name)?.read_raw()?;
3419    /// }
3420    ///
3421    /// let stats = file.metadata_cache_stats().expect("the budget enabled a cache");
3422    /// println!("{:?} over {} reads, {} evicted", stats.hit_rate(), stats.reads(), stats.evictions());
3423    /// # Ok(())
3424    /// # }
3425    /// ```
3426    pub fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
3427        self.inner.metadata_cache_stats()
3428    }
3429
3430    /// Zero this file's metadata-cache counters, keeping every cached entry.
3431    ///
3432    /// HDF5's `H5Freset_mdc_hit_rate_stats`, for measuring one phase of a
3433    /// program rather than a whole run: the reads that populate a cache miss by
3434    /// definition, so a hit rate taken over the run charges the steady state for
3435    /// the warm-up. Reset after warming to measure the part that repeats.
3436    ///
3437    /// It evicts nothing: occupancy, which
3438    /// [`metadata_cache_stats`](Self::metadata_cache_stats) also reports, is a
3439    /// measurement of the cache rather than a tally of its history. A file with
3440    /// no metadata cache ignores the call.
3441    pub fn reset_metadata_cache_stats(&self) {
3442        self.inner.reset_metadata_cache_stats();
3443    }
3444
3445    /// Returns a reference to the parsed superblock.
3446    pub fn superblock(&self) -> &Superblock {
3447        self.inner.superblock()
3448    }
3449
3450    /// The file-space management strategy this file records in its superblock
3451    /// extension, or `None` if it records none.
3452    pub fn file_space_strategy(&self) -> Option<FileSpaceStrategy> {
3453        self.inner.file_space_strategy()
3454    }
3455
3456    /// The full [`FileSpaceInfo`] recorded in this file's superblock extension,
3457    /// if present and readable.
3458    pub fn file_space_info(&self) -> Option<&FileSpaceInfo> {
3459        self.inner.file_space_info()
3460    }
3461
3462    /// The free regions a file persists on disk in its free-space managers, as
3463    /// `(address, length)` pairs sorted by address.
3464    pub fn persisted_free_space(&self) -> Vec<(u64, u64)> {
3465        self.inner.persisted_free_space()
3466    }
3467
3468    /// The size of the underlying file in bytes (the HDF5 `H5Fget_filesize`).
3469    pub fn file_size(&self) -> u64 {
3470        self.inner.file_size()
3471    }
3472
3473    /// The minimum library version required to read this file, derived from its
3474    /// superblock version (the *low bound* of HDF5's `H5Fget_libver_bounds`).
3475    pub fn libver_bound(&self) -> LibVer {
3476        self.inner.libver_bound()
3477    }
3478
3479    /// A `Source` view over the backend, for the streaming-capable paths.
3480    pub(crate) fn source(&self) -> SourceView<'_> {
3481        self.inner.source()
3482    }
3483
3484    /// The whole-file byte image when this file is buffered in memory; `None`
3485    /// for a streaming file. Used by cross-file object copy.
3486    pub(crate) fn in_memory_image(&self) -> Option<&[u8]> {
3487        self.inner.in_memory_image()
3488    }
3489
3490    /// The access mode this file was opened under, which a cross-file copy passes
3491    /// to every parse it makes of this file's objects.
3492    pub(crate) fn access_mode(&self) -> AccessMode {
3493        self.inner.access_mode()
3494    }
3495
3496    /// The base address (superblock base address) added to every stored relative
3497    /// address. Zero for a file with no userblock.
3498    pub(crate) fn base_address(&self) -> BaseAddress {
3499        self.inner.base_address()
3500    }
3501}
3502
3503// ---------------------------------------------------------------------------
3504// Object reference target
3505// ---------------------------------------------------------------------------
3506
3507/// The resolved target of an HDF5 object reference (`H5R_OBJECT`): either a
3508/// group or a dataset.
3509///
3510/// Produced by [`Dataset::dereference`]. MATLAB `.mat` files use object
3511/// references pervasively — a cell array stores one reference per element, and
3512/// the `#subsystem#` machinery references its payloads — so resolving a
3513/// reference to the group or dataset it names is the foundation for reading
3514/// those structures.
3515///
3516/// The [`Dataset`](Object::Dataset) handle is boxed: it carries a parsed object
3517/// header and is much larger than a [`Group`](Object::Group) handle, so boxing
3518/// keeps `Object` (and a `Vec<Object>`) compact without a size disparity. The
3519/// `Box` derefs transparently, so `&obj_dataset` is usable wherever a
3520/// `&Dataset` is expected.
3521///
3522/// Non-exhaustive: a reference can name an object kind this crate does not yet
3523/// resolve — a committed (named) datatype is refused with
3524/// [`FormatError::InvalidObjectReference`]
3525/// today — so match with a `_` arm.
3526#[non_exhaustive]
3527pub enum Object {
3528    /// The reference points at a group's object header.
3529    Group(Group),
3530    /// The reference points at a dataset's object header.
3531    Dataset(Box<Dataset>),
3532}
3533
3534impl std::fmt::Debug for Object {
3535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3536        match self {
3537            Object::Group(_) => f.write_str("Object::Group"),
3538            Object::Dataset(_) => f.write_str("Object::Dataset"),
3539        }
3540    }
3541}
3542
3543// ---------------------------------------------------------------------------
3544// Group handle
3545// ---------------------------------------------------------------------------
3546
3547/// A group that exists only as a staged edit, handed to
3548/// [`Group::create_group_with`]'s closure so a whole subtree — attributes,
3549/// nested groups, datasets — can be described in one call.
3550///
3551/// It is a convenience, not the only way in: [`Group::create_group`] and
3552/// [`Group::create_group_with`] both return a live [`Group`] handle onto the
3553/// staged group, and everything this offers can be staged through that handle
3554/// instead. Reach for the closure when the shape of the subtree is known at the
3555/// call, and for the handle when it is built up by code that takes a `&Group`.
3556///
3557/// Every method stages; nothing is written until [`File::commit`].
3558///
3559/// The closure holding this records into a buffer rather than into the file's
3560/// writable session, so nothing is locked while it runs. The recorded operations
3561/// are applied together when it returns, which is why an object staged here is
3562/// addressable only once the closure has returned.
3563pub struct StagedGroup<'a> {
3564    ops: &'a mut Vec<StagedOp>,
3565    path: ObjectPathBuf,
3566}
3567
3568impl StagedGroup<'_> {
3569    /// Stage an attribute on this group, applied with its creation on
3570    /// [`File::commit`].
3571    pub fn set_attr(&mut self, name: &str, value: AttrValue) -> &mut Self {
3572        self.ops.push(StagedOp::SetGroupAttr {
3573            path: self.path.clone(),
3574            name: name.to_string(),
3575            value,
3576        });
3577        self
3578    }
3579
3580    /// Stage an empty subgroup of this group.
3581    ///
3582    /// `path` may have several components, which stages a group under this one, or begin with
3583    /// `/`, which stages it from the root group, both on the terms
3584    /// [`Group::create_group`] sets out.
3585    ///
3586    /// To configure it in the same commit, use
3587    /// [`create_group_with`](Self::create_group_with). To get a [`Group`] handle
3588    /// onto it, look it up by name once this closure has returned, or stage it
3589    /// through [`Group::create_group`] instead, which hands one back.
3590    pub fn create_group(&mut self, path: &str) -> &mut Self {
3591        self.create_group_with(path, |_| {})
3592    }
3593
3594    /// Stage a subgroup of this group, configured through `build`.
3595    pub fn create_group_with(
3596        &mut self,
3597        path: &str,
3598        build: impl FnOnce(&mut StagedGroup<'_>),
3599    ) -> &mut Self {
3600        let child = self.path.join_path(&ObjectPath::parse(path));
3601        self.ops.push(StagedOp::CreateGroup(child.clone()));
3602        let mut staged = StagedGroup {
3603            ops: &mut *self.ops,
3604            path: child,
3605        };
3606        build(&mut staged);
3607        self
3608    }
3609
3610    /// Stage a dataset in this group, configured through `build`.
3611    ///
3612    /// `path` may have several components, as it may for [`create_group`](Self::create_group).
3613    pub fn create_dataset(
3614        &mut self,
3615        path: &str,
3616        build: impl FnOnce(&mut DatasetBuilder),
3617    ) -> &mut Self {
3618        let mut builder = DatasetBuilder::new(path);
3619        build(&mut builder);
3620        self.ops.push(StagedOp::CreateDataset {
3621            path: self.path.join_path(&ObjectPath::parse(path)),
3622            builder: Box::new(builder),
3623        });
3624        self
3625    }
3626}
3627
3628/// One edit recorded by a [`StagedGroup`] closure, replayed onto the writable
3629/// session after the closure returns.
3630///
3631/// The indirection is what keeps user code off the session lock: the closure
3632/// touches only this buffer, so calling back into the same [`File`] from inside
3633/// it is at worst wrongly ordered rather than a deadlock (issue #200).
3634enum StagedOp {
3635    CreateGroup(ObjectPathBuf),
3636    SetGroupAttr {
3637        path: ObjectPathBuf,
3638        name: String,
3639        value: AttrValue,
3640    },
3641    CreateDataset {
3642        path: ObjectPathBuf,
3643        /// Boxed because a `DatasetBuilder` dwarfs the other variants, and a
3644        /// closure staging many groups would otherwise pay its size per entry.
3645        builder: Box<DatasetBuilder>,
3646    },
3647}
3648
3649impl StagedOp {
3650    /// Record this edit on the session. Applied in the order the closure made
3651    /// the calls, so a group is always staged before its own attributes and
3652    /// children.
3653    fn apply(self, session: &mut WriteEngine) -> Result<(), Error> {
3654        match self {
3655            StagedOp::CreateGroup(path) => session.create_group(&path),
3656            StagedOp::SetGroupAttr { path, name, value } => {
3657                session.set_group_attr(&path, &name, value)
3658            }
3659            StagedOp::CreateDataset { path, builder } => {
3660                session.stage_created_dataset(&path, *builder)
3661            }
3662        }
3663    }
3664}
3665
3666/// An owned handle to an HDF5 group.
3667///
3668/// The handle names the group by its root-relative path and remembers where that
3669/// path resolved to. A [`File::commit`] rewrites and relocates object headers,
3670/// so the memo is worked out again on the first use after any edit and the
3671/// handle goes on answering for the same group — see [`File::commit`] for the
3672/// two cases that report instead. Cloning gives a second handle to the same
3673/// group.
3674pub struct Group {
3675    file: Arc<FileInner>,
3676    /// Where this group's object header sits, as of the file revision it was
3677    /// resolved at. Re-resolved on first use after an edit could have moved it;
3678    /// see [`Group::header_address`]. A group carries no parsed header of its
3679    /// own — it re-reads one per call — so the address is the whole memo, and the
3680    /// content revision the [`Resolution`] carries beside it names no header this
3681    /// handle read and is never read back.
3682    ///
3683    /// `None` for a group this session has *staged* and not yet committed: there
3684    /// is no header to name one. Such a handle resolves its path on every use,
3685    /// and installs a memo the first time a commit gives it something to point
3686    /// at.
3687    state: RwLock<Option<Resolution>>,
3688    /// Root-relative path of this group (the root group's own path has no
3689    /// components), used to address the group and its children for write
3690    /// operations on a read-write file, and to find it again after an edit moved
3691    /// it. `None` for a group reached by object reference
3692    /// ([`Dataset::dereference`]), which has no resolvable path.
3693    path: Option<ObjectPathBuf>,
3694    /// The session's staged generation when this handle was made onto a staged
3695    /// creation, and `None` for a handle opened onto a group in the file.
3696    ///
3697    /// It is what keeps such a handle from being retargeted at a different
3698    /// object: see [`Standing`] and [`FileInner::staged_standing`].
3699    staged_birth: Option<u64>,
3700}
3701
3702impl Clone for Group {
3703    /// Clones share nothing but the open file: the clone is a second handle to
3704    /// the same group, resolved as of the same revision.
3705    fn clone(&self) -> Self {
3706        Self {
3707            file: Arc::clone(&self.file),
3708            state: RwLock::new(*self.state.read().unwrap_or_else(PoisonError::into_inner)),
3709            path: self.path.clone(),
3710            staged_birth: self.staged_birth,
3711        }
3712    }
3713}
3714
3715impl std::fmt::Debug for Group {
3716    /// Reports the handle as it stands, without re-resolving: a `Debug` that
3717    /// read the file could fail, and one that failed would have nothing to
3718    /// print. `staged` is true for a group this session has created and not yet
3719    /// committed, which has no address to report.
3720    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3721        let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
3722        f.debug_struct("Group")
3723            .field("path", &self.path)
3724            .field("staged", &state.is_none())
3725            .finish()
3726    }
3727}
3728
3729impl Group {
3730    /// Build a handle for a group whose header was found at `at`.
3731    fn new(file: Arc<FileInner>, at: Resolution, path: Option<ObjectPathBuf>) -> Self {
3732        Self {
3733            file,
3734            state: RwLock::new(Some(at)),
3735            path,
3736            staged_birth: None,
3737        }
3738    }
3739
3740    /// Build a handle for a group this session has staged and not yet
3741    /// committed, which has no header to memoize.
3742    ///
3743    /// It addresses the group by name from the moment it is staged: further
3744    /// creations, deletions and attribute edits under it are staged through it
3745    /// like any other handle. Anything that has to *read* the group reports
3746    /// [`Error::NotCommitted`] until [`File::commit`], after which the first use
3747    /// resolves the path and the handle behaves as if it had been opened by
3748    /// name.
3749    ///
3750    /// The session's staged generation is taken here so that the handle can tell
3751    /// that commit from a *withdrawal* of the same staging, which leaves the
3752    /// path meaning whatever the file holds — see [`Standing`].
3753    fn pending(file: Arc<FileInner>, path: ObjectPathBuf) -> Self {
3754        Self {
3755            staged_birth: file.staged_generation(),
3756            file,
3757            state: RwLock::new(None),
3758            path: Some(path),
3759        }
3760    }
3761
3762    /// This group's object-header address (base-adjusted, file-absolute), worked
3763    /// out again from its path if an edit could have moved it since the memo was
3764    /// taken. Also what resolves an object reference that points at this group.
3765    ///
3766    /// Returns [`Error::StaleHandle`] for a handle
3767    /// that has no path to re-resolve — one an object reference produced — once
3768    /// a commit has run under it, and the resolution's own error (a
3769    /// `PathNotFound`, say, for a group a commit deleted) when the path no
3770    /// longer names anything. A group this session has staged and not committed
3771    /// has no header at all, and that is [`Error::NotCommitted`]. A commit that
3772    /// replaces this group with a dataset of the same name (issue #305) leaves
3773    /// the path naming something that is not a group, and that is
3774    /// [`Error::NotAGroup`].
3775    pub(crate) fn header_address(&self) -> Result<u64, Error> {
3776        let memo = *self.state.read().unwrap_or_else(PoisonError::into_inner);
3777        if let Some(memo) = memo {
3778            if memo.address_revision == self.file.address_revision() {
3779                return Ok(memo.address);
3780            }
3781        }
3782        let at = self
3783            .file
3784            .locate_staged(self.path.as_ref(), memo, self.staged_birth)?;
3785        // Checked before it is memoized. The short-circuit above does not
3786        // re-check, so an address installed and then refused would be the
3787        // answer every later call returns without looking at it again.
3788        if !is_group(&self.file.parse_header(at.address)?) {
3789            // A path-less handle never reaches here: it failed the short-circuit
3790            // above, and `locate` answers `StaleHandle` for one whose address
3791            // memo it cannot reuse.
3792            return Err(Error::NotAGroup(reported_path(self.path.as_ref())));
3793        }
3794        let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
3795        // Two threads can re-resolve at once. The older answer must not land on
3796        // top of the newer one, or the newer handle would go on serving an
3797        // address the file has already moved past. A handle that had no memo
3798        // takes this one: any address beats naming nothing.
3799        if state.is_none_or(|memo| at.address_revision >= memo.address_revision) {
3800            *state = Some(at);
3801        }
3802        Ok(at.address)
3803    }
3804
3805    /// List the names of datasets in this group.
3806    ///
3807    /// To read from the datasets themselves, prefer
3808    /// [`iter_datasets`](Self::iter_datasets): it hands back opened handles for
3809    /// the cost of this call, where opening each name separately re-walks the
3810    /// group once per member.
3811    ///
3812    /// A dataset this session has staged and not yet committed is listed too,
3813    /// once — a name that is both on disk and staged is the replacement of issue
3814    /// #305, and the staged object is what that name already resolves to.
3815    pub fn datasets(&self) -> Result<Vec<String>, Error> {
3816        let (entries, staged) = self.children_and_staged()?;
3817        let members = staged_members(&staged, StagedKind::Dataset, &entries);
3818        let mut names = Vec::new();
3819        for entry in &entries {
3820            if superseded(&staged, &entry.name) {
3821                continue;
3822            }
3823            let hdr = self.file.parse_header(self.file.entry_address(entry)?)?;
3824            if has_message(&hdr, MessageType::DATA_LAYOUT) {
3825                names.push(entry.name.clone());
3826            }
3827        }
3828        names.extend(members);
3829        Ok(names)
3830    }
3831
3832    /// Open every dataset in this group, each paired with its name.
3833    ///
3834    /// This is the walk to reach for when the members themselves are what you
3835    /// want — their attributes, shapes or data — rather than a list of names.
3836    /// [`datasets`](Self::datasets) already parses every child's object header to
3837    /// tell a dataset from a group, and then keeps only the name, so following it
3838    /// with a [`dataset`](Self::dataset) call per entry re-walks the group's link
3839    /// structure and re-parses that same header. This keeps what it read, and
3840    /// costs one enumeration of the group rather than one per member.
3841    ///
3842    /// **This walk is for taking every member, or nearly every one.** Telling a
3843    /// dataset from a group means parsing its header, so the whole group is
3844    /// enumerated and every child's header parsed before the iterator is
3845    /// returned — breaking out early saves nothing, and reaching one known member
3846    /// this way costs far more than [`dataset`](Self::dataset) does. Only the
3847    /// handle construction is deferred to each step, and that is not where the
3848    /// cost is.
3849    ///
3850    /// The headers of the members are held for the length of the walk, since each
3851    /// one is what its handle is built from. That is bounded by the group being
3852    /// walked rather than by the file, but it is proportional to the group: a
3853    /// header carries a compact dataset's data and its compact attributes inline,
3854    /// so a large group of such datasets is a large allocation.
3855    ///
3856    /// Each dataset gets the file-wide chunk-cache default; to override the cache
3857    /// for one, open it by name with
3858    /// [`dataset_with_options`](Self::dataset_with_options).
3859    ///
3860    /// Members arrive in the order the group's link structure yields them — the
3861    /// same order [`datasets`](Self::datasets) reports, which is not necessarily
3862    /// sorted, with this session's staged datasets after them. Each handle is a
3863    /// snapshot taken when the iterator was built, so a [`File::commit`] that
3864    /// runs mid-walk is not reflected in the members still to come; re-open the
3865    /// group to see past it.
3866    ///
3867    /// ```no_run
3868    /// # use hdf5_pure::File;
3869    /// # fn main() -> Result<(), hdf5_pure::Error> {
3870    /// let file = File::open("runs.h5")?;
3871    /// for (name, dataset) in file.root().iter_datasets()? {
3872    ///     println!("{name}: {:?}", dataset.shape());
3873    /// }
3874    /// # Ok(())
3875    /// # }
3876    /// ```
3877    pub fn iter_datasets(
3878        &self,
3879    ) -> Result<impl ExactSizeIterator<Item = (String, Dataset)> + use<>, Error> {
3880        let revisions = self.file.revisions();
3881        let (entries, staged) = self.children_and_staged()?;
3882        // Taken before the entries are consumed below, since which staged
3883        // members survive depends on the names the file already holds.
3884        let staged_members = staged_members(&staged, StagedKind::Dataset, &entries);
3885        // A member is either an on-disk header this walk read, or a staged
3886        // creation with no header to read yet.
3887        let mut members: Vec<(String, Option<(u64, ObjectHeader)>)> = Vec::new();
3888        for entry in entries {
3889            if superseded(&staged, &entry.name) {
3890                continue;
3891            }
3892            let address = self.file.entry_address(&entry)?;
3893            let hdr = self.file.parse_header(address)?;
3894            if has_message(&hdr, MessageType::DATA_LAYOUT) {
3895                members.push((entry.name, Some((address, hdr))));
3896            }
3897        }
3898        members.extend(staged_members.into_iter().map(|name| (name, None)));
3899        let file = Arc::clone(&self.file);
3900        let parent = self.path.clone();
3901        let chunk_cache = DatasetAccessProperties::new()
3902            .resolved_chunk_cache(self.file.access_properties.chunk_cache);
3903        Ok(members.into_iter().map(move |(name, on_disk)| {
3904            let path = LinkName::new(&name).and_then(|leaf| child_path_of(parent.as_ref(), leaf));
3905            let dataset = match on_disk {
3906                Some((address, header)) => Dataset::new(
3907                    Arc::clone(&file),
3908                    revisions.at(address),
3909                    header,
3910                    chunk_cache,
3911                    path,
3912                ),
3913                // Only a group with a path of its own reports staged children,
3914                // and a staged name is one path component, so a staged member
3915                // always has a path of its own.
3916                None => Dataset::pending(
3917                    Arc::clone(&file),
3918                    chunk_cache,
3919                    path.expect("a staged member is a link name under a parent with a path"),
3920                ),
3921            };
3922            (name, dataset)
3923        }))
3924    }
3925
3926    /// The names of children that are committed (`H5Tcommit`) datatype objects:
3927    /// an object header carrying a datatype and neither data nor links.
3928    ///
3929    /// Such an object is the third kind HDF5 links into a group, and it appears
3930    /// in neither [`datasets`](Self::datasets) nor [`groups`](Self::groups) — so a
3931    /// walk that asks only for those two passes over one without noticing. Read
3932    /// the type itself with [`named_datatype`](Self::named_datatype).
3933    pub fn named_datatypes(&self) -> Result<Vec<String>, Error> {
3934        let entries = self.children()?;
3935        let mut names = Vec::new();
3936        for entry in &entries {
3937            let hdr = self.file.parse_header(self.file.entry_address(entry)?)?;
3938            if is_named_datatype(&hdr) {
3939                names.push(entry.name.clone());
3940            }
3941        }
3942        Ok(names)
3943    }
3944
3945    /// Returns the datatype the committed (`H5Tcommit`) object `path` names holds.
3946    ///
3947    /// `path` resolves as it does for [`dataset`](Self::dataset), and its last component is a name
3948    /// [`named_datatypes`](Self::named_datatypes) reports for the group that holds it.
3949    ///
3950    /// # Errors
3951    ///
3952    /// Returns [`FormatError::PathNotFound`] if a component of `path` reaches nothing,
3953    /// [`Error::NotAGroup`] if an object along the way is not a group, and
3954    /// [`Error::NotANamedDatatype`] if `path` identifies an object of another kind, as `H5Topen`
3955    /// rejects one.
3956    pub fn named_datatype(&self, path: &str) -> Result<Datatype, Error> {
3957        Ok(self.named_datatype_at(path)?.0)
3958    }
3959
3960    /// Returns how many things reference the committed (`H5Tcommit`) datatype `path` names:
3961    /// its hard links, plus every dataset and attribute that names it.
3962    ///
3963    /// This is HDF5's own object reference count (`H5Oget_info`'s `rc`), and what
3964    /// says whether unlinking the name would destroy the type or merely stop it
3965    /// being reachable through the link. A version 1 object header keeps the count
3966    /// in its prefix, a version 2 header in an Object Reference Count message,
3967    /// and a version 2 header without that message has exactly one reference,
3968    /// which is what the format means by omitting it.
3969    ///
3970    /// # Errors
3971    ///
3972    /// Returns [`FormatError::PathNotFound`], [`Error::NotAGroup`] and
3973    /// [`Error::NotANamedDatatype`] on the terms [`named_datatype`](Self::named_datatype) sets out,
3974    /// and
3975    /// [`FormatError::UnexpectedEof`] for an Object Reference Count message too short to hold its
3976    /// count.
3977    pub fn named_datatype_references(&self, path: &str) -> Result<u32, Error> {
3978        let (_, hdr) = self.named_datatype_header(path)?;
3979        if let Some(count) = hdr.reference_count {
3980            return Ok(count);
3981        }
3982        let Ok(msg) = find_message(&hdr, MessageType::OBJECT_REFERENCE_COUNT) else {
3983            return Ok(1);
3984        };
3985        // version(1) + count(4).
3986        let body = self.file.message_body(msg)?;
3987        if body.len() < 5 {
3988            return Err(Error::Format(FormatError::UnexpectedEof {
3989                expected: 5,
3990                available: body.len(),
3991            }));
3992        }
3993        Ok(u32::from_le_bytes([body[1], body[2], body[3], body[4]]))
3994    }
3995
3996    /// Returns the object header of the committed datatype `path` identifies, and its
3997    /// address.
3998    ///
3999    /// The one place the by-name datatype lookups classify what they reached, so
4000    /// that an object this rejects cannot be one
4001    /// [`named_datatypes`](Self::named_datatypes) would list. Reached the way
4002    /// [`group`](Self::group) and [`dataset`](Self::dataset) reach theirs, which
4003    /// looks each component up by name.
4004    fn named_datatype_header(&self, path: &str) -> Result<(u64, ObjectHeader), Error> {
4005        let relative = ObjectPath::parse(path);
4006        let address = self.resolve_path(&relative)?;
4007        let hdr = self.file.parse_header(address)?;
4008        if !is_named_datatype(&hdr) {
4009            return Err(Error::NotANamedDatatype(self.root_relative(&relative)));
4010        }
4011        Ok((address, hdr))
4012    }
4013
4014    /// Returns the datatype the committed object `path` identifies holds, and the address of the
4015    /// object header holding it.
4016    ///
4017    /// The address is the identity every user of the type shares: two datasets
4018    /// naming the same address name one type, and reproducing that requires
4019    /// matching them up by address rather than by what the type decodes to.
4020    pub(crate) fn named_datatype_at(&self, path: &str) -> Result<(Datatype, u64), Error> {
4021        let (address, hdr) = self.named_datatype_header(path)?;
4022        let msg = find_message(&hdr, MessageType::DATATYPE)?;
4023        let (dt, _) = Datatype::parse(&self.file.message_body(msg)?)?;
4024        Ok((dt, address))
4025    }
4026
4027    /// List the names of subgroups in this group.
4028    ///
4029    /// To descend into the subgroups themselves, prefer
4030    /// [`iter_groups`](Self::iter_groups), which hands back opened handles for
4031    /// the cost of this call.
4032    ///
4033    /// A group this session has staged and not yet committed is listed too, on
4034    /// the terms [`datasets`](Self::datasets) sets out.
4035    pub fn groups(&self) -> Result<Vec<String>, Error> {
4036        let (entries, staged) = self.children_and_staged()?;
4037        let members = staged_members(&staged, StagedKind::Group, &entries);
4038        let mut names = Vec::new();
4039        for entry in &entries {
4040            if superseded(&staged, &entry.name) {
4041                continue;
4042            }
4043            let hdr = self.file.parse_header(self.file.entry_address(entry)?)?;
4044            if is_group(&hdr) {
4045                names.push(entry.name.clone());
4046            }
4047        }
4048        names.extend(members);
4049        Ok(names)
4050    }
4051
4052    /// Open every subgroup of this group, each paired with its name.
4053    ///
4054    /// The counterpart to [`iter_datasets`](Self::iter_datasets), and the way to
4055    /// recurse without paying a [`group`](Self::group) lookup per child: that
4056    /// lookup re-walks this group's link structure, which a walk of the whole
4057    /// tree would otherwise repeat once per subgroup.
4058    ///
4059    /// As with [`iter_datasets`](Self::iter_datasets), the whole group is
4060    /// enumerated and classified before the iterator is returned, so this is the
4061    /// walk for taking every subgroup rather than for reaching one — breaking out
4062    /// early saves nothing. A [`Group`] handle carries no parsed header, so
4063    /// unlike `iter_datasets` this holds none of them.
4064    ///
4065    /// Members arrive in the order the group's link structure yields them — the
4066    /// same order [`groups`](Self::groups) reports, which is not necessarily
4067    /// sorted, with this session's staged groups after them.
4068    ///
4069    /// ```no_run
4070    /// # use hdf5_pure::{Error, Group};
4071    /// fn total_datasets(group: &Group) -> Result<usize, Error> {
4072    ///     let mut n = group.datasets()?.len();
4073    ///     for (_, child) in group.iter_groups()? {
4074    ///         n += total_datasets(&child)?;
4075    ///     }
4076    ///     Ok(n)
4077    /// }
4078    /// ```
4079    pub fn iter_groups(
4080        &self,
4081    ) -> Result<impl ExactSizeIterator<Item = (String, Group)> + use<>, Error> {
4082        let revisions = self.file.revisions();
4083        let (entries, staged) = self.children_and_staged()?;
4084        // Taken before the entries are consumed, as in `iter_datasets`.
4085        let staged_members = staged_members(&staged, StagedKind::Group, &entries);
4086        // `None` for a staged member, which has no address to resolve until the
4087        // commit places its header.
4088        let mut members: Vec<(String, Option<u64>)> = Vec::new();
4089        for entry in entries {
4090            if superseded(&staged, &entry.name) {
4091                continue;
4092            }
4093            // A `Group` handle carries no parsed header, so the header that
4094            // classified this child is dropped here rather than held for the
4095            // length of the walk.
4096            let address = self.file.entry_address(&entry)?;
4097            if is_group(&self.file.parse_header(address)?) {
4098                members.push((entry.name, Some(address)));
4099            }
4100        }
4101        members.extend(staged_members.into_iter().map(|name| (name, None)));
4102        let file = Arc::clone(&self.file);
4103        let parent = self.path.clone();
4104        Ok(members.into_iter().map(move |(name, address)| {
4105            let path = LinkName::new(&name).and_then(|leaf| child_path_of(parent.as_ref(), leaf));
4106            let group = match address {
4107                Some(address) => Group::new(Arc::clone(&file), revisions.at(address), path),
4108                // As in `iter_datasets`: a staged member is one path component
4109                // under a group that has a path of its own.
4110                None => Group::pending(
4111                    Arc::clone(&file),
4112                    path.expect("a staged member is a link name under a parent with a path"),
4113                ),
4114            };
4115            (name, group)
4116        }))
4117    }
4118
4119    /// Read all attributes of this group.
4120    ///
4121    /// Each value takes the [`AttrValue`] variant that describes its on-disk
4122    /// encoding, so the variant reflects the charset, width and dataspace its
4123    /// writer chose rather than the shape of the data alone: a one-element array
4124    /// stays an array, an ASCII string does not arrive as a UTF-8
4125    /// [`String`](AttrValue::String), and a 16-bit integer arrives as
4126    /// [`I16`](AttrValue::I16) rather than widened, a 32-bit float as
4127    /// [`F32`](AttrValue::F32). Prefer the accessors —
4128    /// [`AttrValue::as_str`], [`as_strings`](AttrValue::as_strings),
4129    /// [`as_i64`](AttrValue::as_i64) and the rest — over matching on the variant,
4130    /// unless the encoding is the thing you care about. **The variant may become
4131    /// more specific in a future release** as `AttrValue` grows further ones
4132    /// (variable-length strings, say), and a `_` arm is required regardless
4133    /// because the enum is `#[non_exhaustive]`.
4134    ///
4135    /// An attribute whose datatype has no `AttrValue` representation is omitted
4136    /// from the map rather than reported as an error. Read
4137    /// [`attr_datatypes`](Self::attr_datatypes) to see it.
4138    pub fn attrs(&self) -> Result<HashMap<String, AttrValue>, Error> {
4139        let hdr = self.file.parse_header(self.header_address()?)?;
4140        self.file.attrs_of(&hdr)
4141    }
4142
4143    /// The exact on-disk [`Datatype`] of every attribute on this group, keyed by
4144    /// name — including compound field offsets, integer widths and enumeration
4145    /// members.
4146    ///
4147    /// This is the type channel to [`attrs`](Self::attrs)'s value channel, the
4148    /// pair a dataset already has in [`Dataset::datatype`] and its `read_*`
4149    /// methods. An [`AttrValue`] is a deliberately lossy view of the value, so an
4150    /// attribute's byte order, sub-width precision, string padding and
4151    /// enumeration members are recoverable only from here — its width is not,
4152    /// since [`attrs`](Self::attrs) keeps that. Its *rank* is not either: that
4153    /// lives in the
4154    /// dataspace, which nothing public exposes, so a rank-2 attribute still
4155    /// reads as a flat `AttrValue` array with no way to recover its shape.
4156    ///
4157    /// **Every attribute message is reported, including the ones `attrs` omits**
4158    /// because no `AttrValue` can carry them, so a name missing from that map can
4159    /// be told from one the object does not have.
4160    ///
4161    /// A **committed** datatype — one created with `H5Tcommit`, what netCDF-4
4162    /// writes for a user-defined type and what h5py writes for
4163    /// `f["t"] = np.dtype(...)` — is stored as a reference to the type's own
4164    /// object header rather than inline, and is resolved to the type it names.
4165    /// What it does *not* carry is the name: two attributes sharing `/mytype`
4166    /// report the same [`Datatype`] as one that spells it out inline.
4167    ///
4168    /// A boolean attribute is the case that needs both channels. The C library
4169    /// gives `H5T_NATIVE_HBOOL` — what h5py writes for every `np.bool_` — a
4170    /// [`Datatype::Enumeration`] of `FALSE` and `TRUE` over an 8-bit base, and
4171    /// `attrs` decodes it through that base, so the value arrives as `0` or `1`
4172    /// and only the datatype records that it was a bool.
4173    pub fn attr_datatypes(&self) -> Result<HashMap<String, Datatype>, Error> {
4174        Ok(self
4175            .attr_messages()?
4176            .into_iter()
4177            .map(|a| (a.name, a.datatype))
4178            .collect())
4179    }
4180
4181    /// Every attribute message on this group as it is encoded on disk, in the
4182    /// order the header holds them.
4183    ///
4184    /// [`attrs`](Self::attrs) decodes each into an [`AttrValue`], which loses the
4185    /// encoding; this keeps it. Repack copies from here so an attribute survives
4186    /// a rewrite unchanged, and falls back to the decoded map only where the
4187    /// bytes are not position-independent.
4188    pub(crate) fn attr_messages(&self) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
4189        let hdr = self.file.parse_header(self.header_address()?)?;
4190        self.file.attr_messages_of(&hdr)
4191    }
4192
4193    /// Returns the dataset `path` names, relative to this group.
4194    ///
4195    /// `path` is one link name or several separated by `/`, and a `path` that begins with `/`
4196    /// resolves from the root group of the file instead. The C library starts an absolute name at
4197    /// the root the same way (`H5G__traverse_real` in `H5Gtraverse.c`, HDF5 1.14.6). A repeated or
4198    /// a trailing separator separates nothing, and a `.` component identifies the group reached so
4199    /// far, so `a/b`, `a//b/` and `./a/b` identify one dataset.
4200    ///
4201    /// The dataset uses the file-wide chunk-cache default. To override the cache
4202    /// for this one dataset, use
4203    /// [`dataset_with_options`](Self::dataset_with_options).
4204    ///
4205    /// # Errors
4206    ///
4207    /// Returns [`FormatError::PathNotFound`] if a component of `path` reaches nothing,
4208    /// [`Error::NotADataset`] if `path` identifies an object that is not a dataset, and
4209    /// [`Error::NotAGroup`] if an object along the way is not a group. Each reports the object by
4210    /// its path from the root group, so one object has one name whichever handle a caller holds,
4211    /// except through a handle an object reference produced, which has no path of its own
4212    /// (issue #365).
4213    pub fn dataset(&self, path: &str) -> Result<Dataset, Error> {
4214        self.dataset_with_options(path, DatasetAccessProperties::new())
4215    }
4216
4217    /// Returns the dataset `path` names, applying per-dataset
4218    /// [`DatasetAccessProperties`] that override the file-wide access defaults (HDF5's
4219    /// `dapl`, `H5Pset_chunk_cache`).
4220    ///
4221    /// `path` resolves as it does for [`dataset`](Self::dataset).
4222    ///
4223    /// # Errors
4224    ///
4225    /// Returns [`FormatError::PathNotFound`], [`Error::NotADataset`] and [`Error::NotAGroup`] on
4226    /// the terms [`dataset`](Self::dataset) sets out.
4227    pub fn dataset_with_options(
4228        &self,
4229        path: &str,
4230        properties: DatasetAccessProperties,
4231    ) -> Result<Dataset, Error> {
4232        let chunk_cache = properties.resolved_chunk_cache(self.file.access_properties.chunk_cache);
4233        let relative = ObjectPath::parse(path);
4234        let child = self.child_path(&relative);
4235        if let Some(child) = &child {
4236            match self.file.staged_object(child).map(|o| o.kind) {
4237                Some(StagedKind::Dataset) => {
4238                    return Ok(Dataset::pending(
4239                        self.file.clone(),
4240                        chunk_cache,
4241                        child.clone(),
4242                    ));
4243                }
4244                Some(StagedKind::Group) => return Err(Error::NotADataset(child.to_string())),
4245                None => {}
4246            }
4247        }
4248        let revisions = self.file.revisions();
4249        let address = self.resolve_path(&relative)?;
4250        let hdr = self.file.parse_header(address)?;
4251        if !has_message(&hdr, MessageType::DATA_LAYOUT) {
4252            return Err(Error::NotADataset(self.root_relative(&relative)));
4253        }
4254        Ok(Dataset::new(
4255            self.file.clone(),
4256            revisions.at(address),
4257            hdr,
4258            chunk_cache,
4259            child,
4260        ))
4261    }
4262
4263    /// Returns the subgroup `path` names, relative to this group.
4264    ///
4265    /// `path` resolves as it does for [`dataset`](Self::dataset). A relative `path` that spells no
4266    /// link, `""` or `"."`, identifies this group, and an absolute one, `"/"`, identifies the root
4267    /// group.
4268    ///
4269    /// # Errors
4270    ///
4271    /// Returns [`Error::NotAGroup`] if `path` identifies an object that is not a group, the way
4272    /// [`dataset`](Self::dataset) returns [`Error::NotADataset`] for the mirror case, and
4273    /// [`FormatError::PathNotFound`] if a component of `path` reaches nothing.
4274    pub fn group(&self, path: &str) -> Result<Group, Error> {
4275        let relative = ObjectPath::parse(path);
4276        let child = self.child_path(&relative);
4277        if let Some(child) = &child {
4278            match self.file.staged_object(child).map(|o| o.kind) {
4279                Some(StagedKind::Group) => {
4280                    return Ok(Group::pending(self.file.clone(), child.clone()));
4281                }
4282                Some(StagedKind::Dataset) => return Err(Error::NotAGroup(child.to_string())),
4283                None => {}
4284            }
4285        }
4286        let revisions = self.file.revisions();
4287        let address = self.resolve_path(&relative)?;
4288        if !is_group(&self.file.parse_header(address)?) {
4289            return Err(Error::NotAGroup(self.root_relative(&relative)));
4290        }
4291        Ok(Group::new(self.file.clone(), revisions.at(address), child))
4292    }
4293
4294    /// Returns the address of the object header `path` identifies, walking from this group, or
4295    /// from the root group where `path` is absolute.
4296    ///
4297    /// [`FileInner::group_child`] looks each component up by name, which reads the one Link
4298    /// message a compact group holds for it and enumerates a version 1 or a dense group
4299    /// (issue #228). A `path` that spells no link identifies the object the walk starts from.
4300    ///
4301    /// The root is the one handle whose object nothing classifies: every other `Group` comes from
4302    /// a lookup that classified it (`File::group`, `Group::group`, `iter_groups`,
4303    /// `object_at_relative`) or re-resolves through `header_address`, which classifies again.
4304    /// `File::root` takes the superblock's word for it, so a root whose header describes some
4305    /// other object is rejected here, under the empty path a root handle stores.
4306    ///
4307    /// # Errors
4308    ///
4309    /// Returns [`FormatError::PathNotFound`] if a component reaches nothing and
4310    /// [`Error::NotAGroup`] if an object along the way is not a group. Both report the object
4311    /// through [`root_relative`](Self::root_relative).
4312    fn resolve_path(&self, path: &ObjectPath<'_>) -> Result<u64, Error> {
4313        // Resolved before the walk whichever way the walk starts, so a handle
4314        // that cannot resolve itself reports that.
4315        let own = self.header_address()?;
4316        // An absolute path starts at the root group, as `H5G__traverse_real`
4317        // starts one there (`H5Gtraverse.c`, HDF5 1.14.6).
4318        let mut address = if path.is_absolute() {
4319            self.file.mirror_root_address()
4320        } else {
4321            own
4322        };
4323        for (walked, &name) in path.components().iter().enumerate() {
4324            address = match self.file.group_child(address, name)? {
4325                ChildLookup::Found(found) => found,
4326                ChildLookup::Absent => {
4327                    return Err(Error::Format(FormatError::PathNotFound(
4328                        self.root_relative(&path.prefix(walked + 1)),
4329                    )));
4330                }
4331                ChildLookup::NotAGroup => {
4332                    return Err(Error::NotAGroup(self.root_relative(&path.prefix(walked))));
4333                }
4334            };
4335        }
4336        Ok(address)
4337    }
4338
4339    /// Returns the root-relative path of the object `path` identifies from this group, or from
4340    /// the root group where `path` is absolute, and `None` if this group itself has no resolvable
4341    /// path (reached by object reference).
4342    fn child_path(&self, path: &ObjectPath<'_>) -> Option<ObjectPathBuf> {
4343        Some(self.path.as_ref()?.join_path(path))
4344    }
4345
4346    /// Returns the path an error reports the object `path` reaches by: its path from the root
4347    /// group.
4348    ///
4349    /// One object is reported one way whether the caller holds a [`File`] or a [`Group`] handle,
4350    /// and from the root, so that the caller can go and open what the error reported. For a group
4351    /// reached by object reference, whose own path is `None`, the result is `path` alone, in the
4352    /// form [`ObjectPath`] displays.
4353    fn root_relative(&self, path: &ObjectPath<'_>) -> String {
4354        self.child_path(path)
4355            .map_or_else(|| path.to_string(), |child| child.to_string())
4356    }
4357
4358    /// Creates an empty subgroup at `path` within this group, staged until
4359    /// [`File::commit`], and returns a handle to it.
4360    ///
4361    /// The handle addresses the new group straight away: further groups,
4362    /// datasets, deletions and attributes can be staged through it, and
4363    /// [`group`](Self::group) finds it by name from the same session. `path` may have several
4364    /// components, and every group above the new one has to be in the file already or
4365    /// staged in this session: `create_group("a/b")` with no `a` anywhere stages it, and the
4366    /// commit rejects the whole batch (issue #533). Reading it
4367    /// — its attributes, or a member's data — reports
4368    /// [`Error::NotCommitted`] until the commit,
4369    /// after which the same handle answers for the group in the file. Deleting
4370    /// it before the commit withdraws the staging, and the handle then reports
4371    /// [`Error::StagingWithdrawn`] without
4372    /// answering for whatever else the path may name.
4373    ///
4374    /// **The handle keeps the file's exclusive OS lock alive**, as every
4375    /// [`Group`] and [`Dataset`] handle does, so `let g = root.create_group(..)?`
4376    /// holds what `root.create_group(..)?;` used to drop and a reopen of the
4377    /// file fails until it goes. [`File::close`] states the rule.
4378    ///
4379    /// [`create_group_with`](Self::create_group_with) builds a whole subtree in
4380    /// one call instead.
4381    ///
4382    /// Requires a read-write file ([`File::open_rw`]), else
4383    /// [`Error::ReadOnly`]. A `path` that begins with `/` stages
4384    /// the group from the root, as [`dataset`](Self::dataset) resolves an absolute path, and one
4385    /// that spells no link, `""`, `"."` or `"/"`, is rejected with
4386    /// [`Error::EditUnsupported`], since a write addresses a link. A name the file already
4387    /// links to is refused here with
4388    /// [`Error::EditUnsupported`] unless this
4389    /// session also deletes it — a [replacement](Self::delete) — since there
4390    /// would otherwise be no new object for the handle to address. A name this
4391    /// session already staged a *dataset* at is refused for the same reason —
4392    /// one name cannot mean two objects, and the handle would answer for
4393    /// whichever was staged first — while staging the same group twice is
4394    /// allowed and hands back another handle onto that one group, which is how
4395    /// attributes and children are added to a group already staged.
4396    /// [`delete`](Self::delete) withdraws a staged creation, which frees the
4397    /// name for another.
4398    ///
4399    /// ```no_run
4400    /// # use hdf5_pure::File;
4401    /// # fn main() -> Result<(), hdf5_pure::Error> {
4402    /// let file = File::open_rw("runs.h5")?;
4403    /// let run = file.root().create_group("run2")?;
4404    /// run.create_dataset("signal", |b| {
4405    ///     b.with_f64_data(&[1.0, 2.0, 3.0]);
4406    /// })?;
4407    /// file.commit()?;
4408    /// # Ok(())
4409    /// # }
4410    /// ```
4411    pub fn create_group(&self, path: &str) -> Result<Group, Error> {
4412        self.create_group_with(path, |_| {})
4413    }
4414
4415    /// Creates a subgroup at `path` within this group, configuring it through
4416    /// `build` (attributes, nested groups and datasets), staged until
4417    /// [`File::commit`], and returns a handle to it.
4418    ///
4419    /// The closure describes a whole subtree in one call, which is what makes it
4420    /// worth having over [`create_group`](Self::create_group) plus calls on the
4421    /// handle that returns; either way the new group is addressable by name
4422    /// before the commit, and the handle this returns is the same one
4423    /// [`group`](Self::group) would give back.
4424    ///
4425    /// The closure records into a buffer rather than into the file itself, and
4426    /// what it stages is applied together when it returns — so reading the same
4427    /// [`File`] from inside it sees the file as it was before this call, and
4428    /// everything it staged is addressable by name from the moment it returns.
4429    ///
4430    /// The handle this returns keeps the file's exclusive OS lock alive, as
4431    /// every [`Group`] and [`Dataset`] handle does; see [`File::close`].
4432    ///
4433    /// Requires a read-write file ([`File::open_rw`]), else
4434    /// [`Error::ReadOnly`]. Name collisions are rejected on
4435    /// [`create_group`](Self::create_group)'s terms, for the group this creates
4436    /// and for everything the closure stages under it.
4437    ///
4438    /// ```no_run
4439    /// # use hdf5_pure::{AttrValue, File};
4440    /// # fn main() -> Result<(), hdf5_pure::Error> {
4441    /// let file = File::open_rw("runs.h5")?;
4442    /// file.root().create_group_with("run2", |g| {
4443    ///     g.set_attr("count", AttrValue::I64(7));
4444    ///     g.set_attr("label", AttrValue::String("second".into()));
4445    /// })?;
4446    /// file.commit()?;
4447    /// # Ok(())
4448    /// # }
4449    /// ```
4450    pub fn create_group_with(
4451        &self,
4452        path: &str,
4453        build: impl FnOnce(&mut StagedGroup<'_>),
4454    ) -> Result<Group, Error> {
4455        let child = self.child_edit_path(path)?;
4456        let mut ops = vec![StagedOp::CreateGroup(child.clone())];
4457        build(&mut StagedGroup {
4458            ops: &mut ops,
4459            path: child.clone(),
4460        });
4461        self.apply_staged(ops)?;
4462        Ok(Group::pending(self.file.clone(), child))
4463    }
4464
4465    /// Creates a dataset at `path` within this group, configuring it through `build`
4466    /// (shape, data, chunks, filters, …), staged until [`File::commit`], and
4467    /// returns a handle to it.
4468    ///
4469    /// The handle addresses the new dataset straight away, which is what lets a
4470    /// writer cache one per column while it is still building the schema. It
4471    /// answers [`shape`](Dataset::shape), [`maxshape`](Dataset::maxshape),
4472    /// [`dtype`](Dataset::dtype), [`datatype`](Dataset::datatype),
4473    /// [`is_chunked`](Dataset::is_chunked) and [`filters`](Dataset::filters)
4474    /// from what was staged, and [`append_staged`](Dataset::append_staged) folds
4475    /// more elements into the pending creation. Anything that reads the
4476    /// dataset's bytes — a `read_*`, its attributes, an immediate
4477    /// [`append`](Dataset::append) — reports
4478    /// [`Error::NotCommitted`] until the commit, after which the same handle
4479    /// reads the dataset in the file. Deleting it before the commit withdraws the
4480    /// staging, and the handle then reports [`Error::StagingWithdrawn`] without
4481    /// answering for whatever else the path may name.
4482    ///
4483    /// **The handle keeps the file's exclusive OS lock alive**, as every
4484    /// [`Group`] and [`Dataset`] handle does, so `let ds = root.create_dataset(..)?`
4485    /// holds what `root.create_dataset(..)?;` used to drop and a reopen of the
4486    /// file fails until it goes. [`File::close`] states the rule.
4487    ///
4488    /// As with [`create_group_with`](Self::create_group_with), the closure
4489    /// configures a builder rather than the file, so it may read the same
4490    /// [`File`] — it will see the file as it was before this call.
4491    ///
4492    /// Requires a read-write file ([`File::open_rw`]), else
4493    /// [`Error::ReadOnly`]. `path` may have several components or begin with `/`, and one
4494    /// that spells no link is rejected, all on the terms
4495    /// [`create_group`](Self::create_group) sets out.
4496    /// A name the file already
4497    /// links to is refused here with
4498    /// [`Error::EditUnsupported`] unless this
4499    /// session also deletes it — a [replacement](Self::delete) — since there
4500    /// would otherwise be no new dataset for the handle to address. A name this
4501    /// session already staged a creation at is refused for the same reason:
4502    /// two creations at one path are one name for two objects, and the handle
4503    /// would answer for whichever was staged first. [`delete`](Self::delete)
4504    /// withdraws a staged creation, which frees the name for another.
4505    ///
4506    /// ```no_run
4507    /// # use hdf5_pure::{File, MaxExtent};
4508    /// # fn main() -> Result<(), hdf5_pure::Error> {
4509    /// let file = File::open_rw("runs.h5")?;
4510    /// let mut col = file.root().create_dataset("col", |b| {
4511    ///     b.with_f64_data(&[])
4512    ///         .with_shape(&[0])
4513    ///         .with_maxshape(&[MaxExtent::Unlimited])
4514    ///         .with_chunks(&[512]);
4515    /// })?;
4516    /// col.append_staged(|a| {
4517    ///     a.append_f64(&[1.0, 2.0, 3.0]);
4518    /// })?;
4519    /// file.commit()?;
4520    /// assert_eq!(col.read_f64()?, vec![1.0, 2.0, 3.0]);
4521    /// # Ok(())
4522    /// # }
4523    /// ```
4524    pub fn create_dataset(
4525        &self,
4526        path: &str,
4527        build: impl FnOnce(&mut DatasetBuilder),
4528    ) -> Result<Dataset, Error> {
4529        let child = self.child_edit_path(path)?;
4530        let mut builder = DatasetBuilder::new(path);
4531        build(&mut builder);
4532        self.apply_staged(vec![StagedOp::CreateDataset {
4533            path: child.clone(),
4534            builder: Box::new(builder),
4535        }])?;
4536        Ok(Dataset::pending(
4537            self.file.clone(),
4538            DatasetAccessProperties::new()
4539                .resolved_chunk_cache(self.file.access_properties.chunk_cache),
4540            child,
4541        ))
4542    }
4543
4544    /// Deletes the object at `path` from this group, staged until
4545    /// [`File::commit`]. See [`create_group`](Self::create_group) for the
4546    /// file-mode rules.
4547    ///
4548    /// Creating a new object at the same path in the same commit *replaces* it:
4549    /// the removal is applied before the addition and one superblock write
4550    /// publishes both, so a rotation costs one commit and the path is never
4551    /// momentarily absent.
4552    ///
4553    /// Deleting an object this session **staged** and has not committed
4554    /// withdraws that staging instead — its attributes, appends and staged
4555    /// children go with it — since there is no link in the file to unlink. Where
4556    /// the deletion was part of a replacement, the plain deletion of the file's
4557    /// own object is what remains. A handle onto the withdrawn creation reports
4558    /// [`Error::StagingWithdrawn`] from then on:
4559    /// it names nothing, and the object the file holds at that path is the one
4560    /// this session is removing.
4561    ///
4562    /// Deleting a group carries its whole subtree away, but only a commit that
4563    /// builds that group *again* can put anything back under it: staging a
4564    /// creation below a deleted path that nothing recreates is a batch `commit`
4565    /// refuses, and until it does the file's own children still own their names.
4566    /// The root itself cannot be deleted.
4567    ///
4568    /// A `path` that spells no link, `""`, `"."` or `"/"`, is rejected with
4569    /// [`Error::EditUnsupported`], since a deletion takes a link. The C library rejects `"."` from
4570    /// `H5L__delete_cb` (`H5Lint.c`) and an empty name in its argument check
4571    /// (`H5VL_setup_name_args` in `H5VLint.c`, HDF5 1.14.6). A `path` that begins with `/` deletes
4572    /// from the root group, as [`create_group`](Self::create_group) stages from there.
4573    ///
4574    /// ```no_run
4575    /// # use hdf5_pure::File;
4576    /// # fn main() -> Result<(), hdf5_pure::Error> {
4577    /// let file = File::open_rw("ring.h5")?;
4578    /// file.root().delete("t0")?;
4579    /// file.root().create_dataset("t0", |b| { b.with_i32_data(&[1, 2, 3]); })?;
4580    /// file.commit()?;
4581    /// # Ok(())
4582    /// # }
4583    /// ```
4584    pub fn delete(&self, path: &str) -> Result<(), Error> {
4585        self.with_child_session(path, |session, child| session.delete(child))
4586    }
4587
4588    /// Add or update an attribute on this group, staged until [`File::commit`].
4589    /// Use [`remove_attr`](Self::remove_attr) to remove one. The
4590    /// [`root`](File::root) group's attributes are edited the same way.
4591    ///
4592    /// Requires a read-write file ([`File::open_rw`]), else
4593    /// [`Error::ReadOnly`]. An attribute set too large
4594    /// for the object header — more than eight attributes, or one whose message
4595    /// the header's 2-byte size field cannot describe — is written to a fractal
4596    /// heap on `commit`, as it is when the whole file is written, and a group
4597    /// already storing its attributes in one is rebuilt.
4598    pub fn set_attr(&self, name: &str, value: AttrValue) -> Result<(), Error> {
4599        self.with_own_session(|session, path| session.set_group_attr(path, name, value))
4600    }
4601
4602    /// Remove an attribute from this group, staged until [`File::commit`].
4603    /// See [`set_attr`](Self::set_attr) for the file-mode rules.
4604    pub fn remove_attr(&self, name: &str) -> Result<(), Error> {
4605        self.with_own_session(|session, path| session.remove_group_attr(path, name))
4606    }
4607
4608    /// Runs `f` with the writable session and the root-relative path of the child `path`
4609    /// identifies.
4610    ///
4611    /// # Errors
4612    ///
4613    /// Returns what [`child_edit_path`](Self::child_edit_path) returns for `path`, and whatever
4614    /// `f` returns.
4615    fn with_child_session<R>(
4616        &self,
4617        path: &str,
4618        f: impl FnOnce(&mut WriteEngine, &ObjectPathBuf) -> Result<R, Error>,
4619    ) -> Result<R, Error> {
4620        let child = self.child_edit_path(path)?;
4621        self.file
4622            .with_engine_mut(Change::Relocating, |session| f(session, &child))
4623    }
4624
4625    /// Refuse an edit staged *through* a handle whose own staged creation has
4626    /// been withdrawn.
4627    ///
4628    /// Every read through such a handle already reports it: they resolve the
4629    /// path, and [`FileInner::locate_staged`] is where that is decided. The
4630    /// staging calls resolve nothing — they address the file by path — so this
4631    /// is where they ask the same question, and without it an edit staged
4632    /// through a withdrawn group would land under whatever the file holds at its
4633    /// path, which is the object the session is deleting.
4634    ///
4635    /// Costs nothing for a handle opened onto an object in the file: those carry
4636    /// no birth generation, so the session is never locked to answer.
4637    fn refuse_if_withdrawn(&self) -> Result<(), Error> {
4638        let Some(path) = self.path.as_ref().filter(|_| self.staged_birth.is_some()) else {
4639            return Ok(());
4640        };
4641        match self.file.staged_standing(path, self.staged_birth) {
4642            Standing::Withdrawn => Err(Error::StagingWithdrawn(path.to_string())),
4643            Standing::Live | Standing::Pending => Ok(()),
4644        }
4645    }
4646
4647    /// Returns the root-relative path of the child `path` identifies, checked as the target of a
4648    /// staged edit, *without* taking the session lock.
4649    ///
4650    /// Paired with [`apply_staged`](Self::apply_staged): the checks run first so
4651    /// a read-only or sealed file is reported before any user closure runs, the
4652    /// closure then runs unlocked, and the lock is taken only to record what it
4653    /// built (issue #200).
4654    ///
4655    /// # Errors
4656    ///
4657    /// Returns [`Error::EditUnsupported`] if `path` spells no link, since a write addresses a
4658    /// link. Returns [`Error::ReadOnly`] if the
4659    /// file is read-only or
4660    /// this group has no path of its own, and the failures
4661    /// [`check_staged_writable`](FileInner::check_staged_writable) and
4662    /// [`refuse_if_withdrawn`](Self::refuse_if_withdrawn) report.
4663    fn child_edit_path(&self, path: &str) -> Result<ObjectPathBuf, Error> {
4664        self.file.check_staged_writable()?;
4665        self.refuse_if_withdrawn()?;
4666        let relative = ObjectPath::parse(path);
4667        if relative.components().is_empty() {
4668            // A write addresses a link, and a path that parses to no
4669            // components spells none. The C library rejects the same call from
4670            // `H5L__delete_cb`, which reports a `"."` as a link it cannot
4671            // delete (`H5Lint.c`, HDF5 1.14.6).
4672            return Err(Error::EditUnsupported(NO_LINK_TO_WRITE));
4673        }
4674        self.child_path(&relative).ok_or(Error::ReadOnly)
4675    }
4676
4677    /// Record already-built edits on the writable session, holding the lock only
4678    /// for the duration of the replay.
4679    ///
4680    /// The file is re-checked here because the closure that produced `ops` ran
4681    /// unlocked and could have closed the file in the meantime; staging into a
4682    /// sealed file would otherwise be silently accepted and then dropped.
4683    fn apply_staged(&self, ops: Vec<StagedOp>) -> Result<(), Error> {
4684        self.file.with_engine_mut(Change::Relocating, |session| {
4685            // All or nothing: one call can carry a whole subtree, and each op is
4686            // validated as it is staged, so a refusal partway must not leave the
4687            // ops before it recorded.
4688            session.stage_atomically(|s| {
4689                for op in ops {
4690                    op.apply(s)?;
4691                }
4692                Ok(())
4693            })
4694        })
4695    }
4696
4697    /// Run `f` with the writable session and this group's *own* root-relative
4698    /// path (for attribute edits, which act on the group itself rather than a
4699    /// child). Returns [`Error::ReadOnly`] if the file is read-only or this
4700    /// group has no resolvable path, and [`Error::FileClosed`] once the file is
4701    /// sealed.
4702    fn with_own_session<R>(
4703        &self,
4704        f: impl FnOnce(&mut WriteEngine, &ObjectPathBuf) -> Result<R, Error>,
4705    ) -> Result<R, Error> {
4706        self.refuse_if_withdrawn()?;
4707        let path = self.path.clone().ok_or(Error::ReadOnly)?;
4708        self.file
4709            .with_engine_mut(Change::Relocating, |session| f(session, &path))
4710    }
4711
4712    fn children(&self) -> Result<Vec<GroupEntry>, Error> {
4713        let hdr = self.file.parse_header(self.header_address()?)?;
4714        self.file.group_children(&hdr)
4715    }
4716
4717    /// This group's on-disk children, paired with the children the session has
4718    /// staged under it.
4719    ///
4720    /// A staged name supersedes an on-disk link of the same name: the two can
4721    /// coexist only as a replacement (issue #305), where the commit removes the
4722    /// link before adding the new object, and every by-name lookup already
4723    /// answers with the staged one. A group that is *itself* staged has no links
4724    /// on disk to enumerate, and its members are exactly what is staged under
4725    /// it.
4726    fn children_and_staged(&self) -> Result<(Vec<GroupEntry>, StagedChildren), Error> {
4727        let staged = match self.path.as_ref() {
4728            Some(path) => self.file.staged_children(path),
4729            // A group reached by object reference cannot name itself, so nothing
4730            // can have been staged under it by name either.
4731            None => Vec::new(),
4732        };
4733        match self.children() {
4734            Ok(entries) => Ok((entries, staged)),
4735            Err(Error::NotCommitted(_)) => Ok((Vec::new(), staged)),
4736            Err(e) => Err(e),
4737        }
4738    }
4739}
4740
4741/// The children of one group this session has staged.
4742type StagedChildren = Vec<StagedChild>;
4743
4744/// Whether a staged creation takes `name` over from the link the file holds
4745/// there — which it does only when the same commit removes that link.
4746///
4747/// A creation that merely collides with a surviving link is refused where it is
4748/// staged, so the file's own object is what the name lists as.
4749fn superseded(staged: &[StagedChild], name: &str) -> bool {
4750    staged.iter().any(|c| c.name == name && c.replaces_link)
4751}
4752
4753/// The staged children of `kind` that own their names, given the on-disk links
4754/// they sit beside, in the order they were staged.
4755fn staged_members(staged: &[StagedChild], kind: StagedKind, entries: &[GroupEntry]) -> Vec<String> {
4756    staged
4757        .iter()
4758        .filter(|c| c.kind == kind)
4759        // Either the file has no link of this name, or the commit removes it.
4760        .filter(|c| c.replaces_link || !entries.iter().any(|e| e.name == c.name))
4761        .map(|c| c.name.clone())
4762        .collect()
4763}
4764
4765// ---------------------------------------------------------------------------
4766// Dataset handle
4767// ---------------------------------------------------------------------------
4768
4769/// A [`Dataset`] handle's memo: where its object header sits, what that header
4770/// says, and what both hold as of.
4771///
4772/// The handle names the dataset; this is a memo of what that name resolved to.
4773/// See [`Dataset::resolved`].
4774struct DatasetState {
4775    /// Where the header sits — [`Resolution::address`] is base-adjusted and
4776    /// file-absolute — and the revisions the address and the parse hold as of.
4777    at: Resolution,
4778    header: ObjectHeader,
4779}
4780
4781/// An owned handle to an HDF5 dataset.
4782///
4783/// The handle names the dataset by its root-relative path and remembers where
4784/// that path resolved to and what the header there said. A [`File::commit`]
4785/// rewrites and relocates object headers, and an immediate [`append`](Self::append)
4786/// rewrites one where it stands, so the memo is worked out again on the first
4787/// use after either and the handle goes on answering for the same dataset — see
4788/// [`File::commit`] for the two cases that report instead. That covers an edit
4789/// made through *another* handle to the same dataset as well as through this
4790/// one. Cloning gives a second handle to the same dataset, sharing its chunk
4791/// cache.
4792///
4793/// Three accessors cannot report a handle that no longer resolves, because they
4794/// return no `Result`: [`filters`](Self::filters) and
4795/// [`filter_pipeline`](Self::filter_pipeline) answer empty, the same answer an
4796/// unfiltered dataset gives, and [`is_chunked`](Self::is_chunked) answers
4797/// `false`. Every other reader of the header says what went wrong.
4798///
4799/// # Reading
4800///
4801/// A read returns a flat `Vec<T>` in row-major order. Each typed `read_*` method coerces the
4802/// stored bytes into its own element type, so a read whose element type differs from the stored
4803/// datatype can lose precision.
4804///
4805/// | Method | Returns |
4806/// |---|---|
4807/// | [`read_f64`](Self::read_f64) / [`read_f32`](Self::read_f32) | `Vec<f64>` / `Vec<f32>` |
4808/// | [`read_i8`](Self::read_i8) / [`read_i16`](Self::read_i16) / [`read_i32`](Self::read_i32) / [`read_i64`](Self::read_i64) | signed-integer vectors |
4809/// | [`read_u8`](Self::read_u8) / [`read_u16`](Self::read_u16) / [`read_u32`](Self::read_u32) / [`read_u64`](Self::read_u64) | unsigned-integer vectors |
4810/// | [`read::<T>()`](Self::read) (generic over [`H5Element`]) | `Vec<T>` |
4811/// | [`read_compound::<T>()`](Self::read_compound) (over [`CompoundType`]) | `Vec<T>` |
4812/// | [`read_string`](Self::read_string) | `Vec<String>` (fixed- and variable-length string datasets) |
4813/// | [`read_raw`](Self::read_raw) | `Vec<u8>` (complete unfiltered record bytes) |
4814/// | [`read_array`](Self::read_array) (`ndarray` feature) | `Array<T, D>` (static rank `D`) |
4815/// | [`read_array_dyn`](Self::read_array_dyn) (`ndarray` feature) | `ArrayD<T>` (runtime rank) |
4816///
4817/// ```rust
4818/// # let dir = tempfile::tempdir()?;
4819/// # let path = dir.path().join("output.h5");
4820/// # let mut builder = hdf5_pure::FileBuilder::new();
4821/// # builder.create_dataset("temperature").with_f64_data(&[22.5, 23.1, 21.8]);
4822/// # builder.write(&path)?;
4823/// use hdf5_pure::File;
4824///
4825/// let file = File::open(&path)?;
4826/// let ds = file.dataset("temperature")?;
4827/// let values = ds.read_f64()?;
4828/// # assert_eq!(values, vec![22.5, 23.1, 21.8]);
4829/// # Ok::<(), hdf5_pure::Error>(())
4830/// ```
4831///
4832/// [`read::<T>()`](Self::read) requests delivery as `T`, which asserts nothing about the stored
4833/// datatype, so a lossless read picks the `T` the file stores.
4834/// [`read_compound::<T>()`](Self::read_compound) decodes each element using its exact on-disk
4835/// datatype, reading field offsets from the file. [`read_raw`](Self::read_raw) returns the
4836/// unfiltered record bytes verbatim, which pairs with [`datatype`](Self::datatype) for inspecting
4837/// or re-emitting an arbitrary type. [`read_array`](Self::read_array) and
4838/// [`read_array_dyn`](Self::read_array_dyn) require the `ndarray` feature, which the [ndarray
4839/// guide](crate::_guide::ndarray) covers.
4840///
4841/// Two further reads take variable-length strings beyond [`read_string`](Self::read_string):
4842/// [`read_vlen_strings(options)`](Self::read_vlen_strings) and the streaming
4843/// [`visit_vlen_strings(options, f)`](Self::visit_vlen_strings), bounded by
4844/// [`VlenStringReadOptions`], with [`vlen_string_payload_size()`](Self::vlen_string_payload_size)
4845/// reporting the payload size up front. See the
4846/// [variable-length strings guide](crate::_guide::vlen_strings).
4847///
4848/// # Inspecting a dataset's type
4849///
4850/// Two accessors describe an existing dataset's type:
4851///
4852/// - [`dtype`](Self::dtype) returns a simplified [`DType`] classification.
4853/// - [`datatype`](Self::datatype) returns the full low-level [`Datatype`], including exact compound
4854///   field offsets.
4855pub struct Dataset {
4856    file: Arc<FileInner>,
4857    /// Where this dataset's object header sits and what it says, as of the file
4858    /// revision they were read at. Re-taken on first use after the file changes;
4859    /// see [`Dataset::resolved`].
4860    ///
4861    /// `None` for a dataset this session has *staged* and not yet committed:
4862    /// there is no header to read. Such a handle answers the metadata questions
4863    /// from what was staged and resolves its path on every other use, until a
4864    /// commit gives it a header to memoize.
4865    state: RwLock<Option<Arc<DatasetState>>>,
4866    // Held per-dataset: the chunk index is keyed only by chunk coordinate, so
4867    // a file-level cache would alias chunk addresses across datasets. Shared
4868    // between clones, which are the same dataset: a chunk read through one is
4869    // warm for the other, and an edit through either drops it for both.
4870    chunk_cache: Arc<ChunkCache>,
4871    // The effective chunk-cache config for this dataset: the file-wide default
4872    // or a per-dataset DAPL override. Reported by `chunk_cache_config`.
4873    chunk_cache_config: ChunkCacheConfig,
4874    /// Root-relative path of this dataset, used to address it for write
4875    /// operations on a read-write file, and to find it again after an edit moved
4876    /// it. `None` for a dataset reached by object reference
4877    /// ([`Dataset::dereference`]), which has no resolvable path.
4878    path: Option<ObjectPathBuf>,
4879    /// The session's staged generation when this handle was made onto a staged
4880    /// creation, and `None` for a handle opened onto a dataset in the file.
4881    ///
4882    /// It is what keeps such a handle from being retargeted at a different
4883    /// object: see [`Standing`] and [`FileInner::staged_standing`].
4884    staged_birth: Option<u64>,
4885}
4886
4887impl Clone for Dataset {
4888    /// The clone is a second handle to the same dataset, sharing its chunk cache
4889    /// and resolved as of the same revision.
4890    fn clone(&self) -> Self {
4891        Self {
4892            file: Arc::clone(&self.file),
4893            state: RwLock::new(
4894                self.state
4895                    .read()
4896                    .unwrap_or_else(PoisonError::into_inner)
4897                    .clone(),
4898            ),
4899            chunk_cache: Arc::clone(&self.chunk_cache),
4900            chunk_cache_config: self.chunk_cache_config,
4901            path: self.path.clone(),
4902            staged_birth: self.staged_birth,
4903        }
4904    }
4905}
4906
4907impl std::fmt::Debug for Dataset {
4908    /// Reports the memo as it stands, without re-resolving: a `Debug` that read
4909    /// the file could fail, and one that failed would have nothing to print.
4910    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4911        let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
4912        match state.as_ref() {
4913            Some(state) => f
4914                .debug_struct("Dataset")
4915                .field("messages", &state.header.messages.len())
4916                .finish(),
4917            None => f.debug_struct("Dataset").field("staged", &true).finish(),
4918        }
4919    }
4920}
4921
4922/// How many stored bytes a typed whole-dataset read holds beside its output,
4923/// before rounding the window up to whole chunk bands.
4924///
4925/// The decoded values are the caller's and there is no bound to put on them;
4926/// what this bounds is the *stored* copy standing next to them, which used to be
4927/// the whole dataset over again (issue #289). A mebibyte is small against any
4928/// dataset large enough for that to matter, and large enough that a sweep costs
4929/// reads in the tens rather than the thousands. A dataset that fits inside one
4930/// window is read whole, exactly as before.
4931const TYPED_READ_WINDOW_BYTES: u64 = 1 << 20;
4932
4933/// How many leading-dimension rows a typed whole-dataset read decodes at a time.
4934///
4935/// [`TYPED_READ_WINDOW_BYTES`] of stored bytes, rounded *down* to whole chunk
4936/// bands so that no chunk is ever decoded for two windows, and never fewer than
4937/// one row — or one band, when a single band is already over budget, since a
4938/// window narrower than that would decode the same chunks again.
4939///
4940/// A dataset whose rows have no bytes (a zero inner dimension) has no elements
4941/// to read at all: it answers `NonZeroU64::MAX`, which the caller reads as "one
4942/// window covers it".
4943///
4944/// The answer is a `NonZeroU64` because the sweep advances by it: a window of no
4945/// rows would leave that loop running forever rather than returning something
4946/// wrong, and a test cannot report the difference.
4947fn typed_window_rows(
4948    dl: &DataLayout,
4949    ds: &Dataspace,
4950    elem_size: NonZeroUsize,
4951) -> Result<NonZeroU64, FormatError> {
4952    let mut row_bytes = elem_size.get() as u64;
4953    for &d in ds.dimensions.iter().skip(1) {
4954        row_bytes = row_bytes
4955            .checked_mul(d)
4956            .ok_or(FormatError::OffsetOverflow {
4957                offset: row_bytes,
4958                length: d,
4959            })?;
4960    }
4961    if row_bytes == 0 {
4962        return Ok(NonZeroU64::MAX);
4963    }
4964
4965    let mut rows = (TYPED_READ_WINDOW_BYTES / row_bytes).max(1);
4966    if let DataLayout::Chunked {
4967        chunk_dimensions, ..
4968    } = dl
4969    {
4970        // A chunked layout message carries rank + 1 dimensions, the last being
4971        // the element size, so the first is the leading dimension's chunk extent
4972        // for every layout version this crate parses.
4973        if let Some(band) = chunk_dimensions.first().copied().filter(|&d| d > 0) {
4974            rows = if rows < band {
4975                band
4976            } else {
4977                rows - rows % band
4978            };
4979        }
4980    }
4981    // At least one row always, and at least one whole band when a band applied:
4982    // that arm runs only when `rows >= band`, so the remainder it subtracts
4983    // leaves a band standing.
4984    Ok(NonZeroU64::new(rows).unwrap_or(NonZeroU64::MIN))
4985}
4986
4987impl Dataset {
4988    /// Build a handle for a dataset whose header was found, and read, at `at`.
4989    fn new(
4990        file: Arc<FileInner>,
4991        at: Resolution,
4992        header: ObjectHeader,
4993        chunk_cache_config: ChunkCacheConfig,
4994        path: Option<ObjectPathBuf>,
4995    ) -> Self {
4996        Self {
4997            file,
4998            state: RwLock::new(Some(Arc::new(DatasetState { at, header }))),
4999            chunk_cache: Arc::new(ChunkCache::with_config(chunk_cache_config)),
5000            chunk_cache_config,
5001            path,
5002            staged_birth: None,
5003        }
5004    }
5005
5006    /// Build a handle for a dataset this session has staged and not yet
5007    /// committed, which has no object header to read.
5008    ///
5009    /// It answers the questions the staged builder already settles — shape,
5010    /// maximum shape, datatype, whether the storage is chunked and which filters
5011    /// it carries — and stages further edits on the pending creation. Everything
5012    /// that needs bytes reports [`Error::NotCommitted`] until [`File::commit`],
5013    /// after which the first use resolves the path and the handle behaves as if
5014    /// it had been opened by name.
5015    fn pending(
5016        file: Arc<FileInner>,
5017        chunk_cache_config: ChunkCacheConfig,
5018        path: ObjectPathBuf,
5019    ) -> Self {
5020        Self {
5021            staged_birth: file.staged_generation(),
5022            file,
5023            state: RwLock::new(None),
5024            chunk_cache: Arc::new(ChunkCache::with_config(chunk_cache_config)),
5025            chunk_cache_config,
5026            path: Some(path),
5027        }
5028    }
5029
5030    /// What this dataset's staged creation says about itself, `Ok(None)` once it
5031    /// is committed (or when it never was staged), and
5032    /// [`Error::StagingWithdrawn`] when the creation this handle was made onto
5033    /// has been withdrawn — the one answer that is neither the staged record nor
5034    /// the file, because the object at that path is one the session is deleting.
5035    ///
5036    /// Only a handle with no memo can be pending: one that has resolved a header
5037    /// names an object the file already holds, so this costs a lock read rather
5038    /// than a session lock on the common path.
5039    fn staged_meta(&self) -> Result<Option<StagedMeta>, Error> {
5040        if self
5041            .state
5042            .read()
5043            .unwrap_or_else(PoisonError::into_inner)
5044            .is_some()
5045        {
5046            return Ok(None);
5047        }
5048        let Some(path) = self.path.as_ref() else {
5049            return Ok(None);
5050        };
5051        self.file.staged_dataset_view(path, self.staged_birth)
5052    }
5053
5054    /// Refuse an operation that needs this dataset's bytes while it is still
5055    /// staged.
5056    fn refuse_if_pending(&self) -> Result<(), Error> {
5057        match self.staged_meta()? {
5058            // `staged_meta` answers only for a handle with a path.
5059            Some(_) => Err(Error::NotCommitted(reported_path(self.path.as_ref()))),
5060            None => Ok(()),
5061        }
5062    }
5063
5064    /// This dataset's address and parsed header, worked out again if the file
5065    /// has changed since the memo was taken.
5066    ///
5067    /// Returns [`Error::StaleHandle`] for a handle
5068    /// that has no path to re-resolve — one an object reference produced — once
5069    /// a commit has run under it, and the resolution's own error (a
5070    /// `PathNotFound`, say, for a dataset a commit deleted) when the path no
5071    /// longer names anything. A dataset this session has staged and not
5072    /// committed has no header at all, and that is
5073    /// [`Error::NotCommitted`]. A path that now names something other than a
5074    /// dataset is [`Error::NotADataset`], the error opening it afresh reports.
5075    fn resolved(&self) -> Result<Arc<DatasetState>, Error> {
5076        let live = self.file.content_revision();
5077        let memo = {
5078            let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
5079            match state.as_ref() {
5080                Some(state) if state.at.content_revision == live => {
5081                    return Ok(Arc::clone(state));
5082                }
5083                Some(state) => Some(state.at),
5084                None => None,
5085            }
5086        };
5087        let at = self
5088            .file
5089            .locate_staged(self.path.as_ref(), memo, self.staged_birth)?;
5090        let header = self.file.parse_header(at.address)?;
5091        // Checked *before* it is memoized. A header installed and then refused is
5092        // the answer every later call short-circuits on, so this handle would
5093        // report `NotADataset` once and then serve the other object's header —
5094        // which a commit replacing a dataset with a group at the same path
5095        // (issue #305) makes reachable.
5096        if !has_message(&header, MessageType::DATA_LAYOUT) {
5097            // Only a path can reach this: an address memo that survived is the
5098            // address of the dataset this handle already read there.
5099            return Err(Error::NotADataset(reported_path(self.path.as_ref())));
5100        }
5101        Ok(self.install(at, header))
5102    }
5103
5104    /// Memoize `header`, read at `at`, as this handle's resolution.
5105    ///
5106    /// Drops the chunk cache: an edit that rewrote this header can have moved
5107    /// the chunk index and the chunks it names, so what the cache holds belongs
5108    /// to the copy the edit replaced.
5109    fn install(&self, at: Resolution, header: ObjectHeader) -> Arc<DatasetState> {
5110        let fresh = Arc::new(DatasetState { at, header });
5111        self.chunk_cache.clear();
5112        let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
5113        // Two threads can re-resolve at once. The older answer must not land on
5114        // top of the newer one, or the newer handle would go on serving a header
5115        // the file has already moved past. A handle that had no memo takes this
5116        // one: any header beats naming nothing.
5117        if state
5118            .as_ref()
5119            .is_none_or(|memo| at.content_revision >= memo.at.content_revision)
5120        {
5121            *state = Some(Arc::clone(&fresh));
5122        }
5123        fresh
5124    }
5125
5126    /// Address of this dataset's object header (base-adjusted, file-absolute).
5127    /// Used to resolve object references that point at this dataset.
5128    pub(crate) fn header_address(&self) -> Result<u64, Error> {
5129        Ok(self.resolved()?.at.address)
5130    }
5131
5132    /// Append `data` to this dataset in place, growing it along its first
5133    /// (unlimited) dimension. Every handle onto the dataset reads the new length
5134    /// afterwards, this one included.
5135    ///
5136    /// The file must have been opened for writing with [`File::open_rw`];
5137    /// a read-only file returns
5138    /// [`Error::ReadOnly`]. The target must be a chunked,
5139    /// rank-1, unlimited, Extensible-Array-indexed dataset: anything else returns
5140    /// [`Error::AppendInPlaceUnsupported`]. A missing path returns
5141    /// [`Error::Format`] with [`FormatError::PathNotFound`], and a header message
5142    /// that does not read or parse returns [`Error::Format`] with the failure the
5143    /// read or the parse produced.
5144    /// Both the dataset's current length and the appended length are
5145    /// unconstrained, on a filtered dataset as much as an unfiltered one: a
5146    /// partial trailing chunk is rewritten into a fresh allocation — decoded,
5147    /// extended and re-encoded when there is a filter pipeline — and its index
5148    /// element is repointed once those bytes are on the disk. The bytes the old
5149    /// chunk occupied are left for [`repack`](crate::repack()).
5150    ///
5151    /// Two things still require a chunk-aligned starting length. A **lossy**
5152    /// pipeline (ZFP, or float D-scale scale-offset) is refused, because
5153    /// re-encoding the trailing chunk would change values that are already
5154    /// committed rather than reproduce them. And a trailing chunk the chunk index
5155    /// does not name — one a writer allocated lazily, which the reference C
5156    /// library does for a chunk it has not written — cannot be read to be grown,
5157    /// so it is refused too; append whole chunks, or use
5158    /// [`append_staged`](Self::append_staged).
5159    ///
5160    /// The append is immediate and crash-atomic (no `commit` needed) — under the
5161    /// default [`SyncPolicy::Always`]. Under [`SyncPolicy::OnClose`] the same
5162    /// writes are made in the same order without the `fsync` barriers between
5163    /// them, so the append is still immediate and still crash-atomic against
5164    /// *this process* failing, but ordering it against power loss is the
5165    /// caller's, through [`File::sync`].
5166    ///
5167    /// A **SWMR** writer ([`File::open_swmr_writer`]) keeps the narrower rule it
5168    /// always had — unfiltered, and chunk-aligned at both ends — because its
5169    /// readers are concurrent by contract and a rewritten trailing chunk is one
5170    /// they could be crossing. It allocates at end-of-file for that same reason:
5171    /// a region this session freed is one a reader may still be inside.
5172    ///
5173    /// Where the new chunks land otherwise depends on how the file records its
5174    /// free space. A default-strategy file spends a hole an earlier commit in
5175    /// this session left; a file that **persists** its free-space managers
5176    /// (including every paged file) spends only space the session has first taken
5177    /// *out* of them, in a rewrite of its own, so no byte an append writes is one
5178    /// a durable manager advertises — a crash can strand the unspent remainder of
5179    /// such a batch, but nothing can be handed out twice. A batch gathers every
5180    /// hole the appended chunk fits in, up to a megabyte of them, so the file
5181    /// grows only when no hole can hold the chunk.
5182    ///
5183    /// A handle reached by object reference ([`dereference`](Self::dereference))
5184    /// has no resolvable path, so it names its dataset by the object-header
5185    /// address it was reached through and can append like any other — until the
5186    /// session stages or commits an edit. A commit can move that header, and the
5187    /// bytes it vacates still parse as the dataset they were, so an append
5188    /// against the old address would land in a header nothing points at. Rather
5189    /// than do that silently, such an append is refused once edits are staged or
5190    /// a commit has run; re-open the dataset by path to keep appending. A
5191    /// path-named handle is unaffected, because the path is resolved afresh every
5192    /// time.
5193    pub fn append<T: H5Element>(&mut self, data: &[T]) -> Result<(), Error> {
5194        let g = self.append_geometry()?;
5195        self.append_batches(g, data.len() as u64, |b, r| {
5196            b.append(&data[r]);
5197        })
5198    }
5199
5200    /// Append raw little-endian element bytes to this dataset in place. Prefer
5201    /// [`append`](Self::append) when the element type is known; see it for the
5202    /// file-mode and eligibility rules.
5203    pub fn append_raw(&mut self, bytes: &[u8]) -> Result<(), Error> {
5204        let g = self.append_geometry()?;
5205        let es = g.element_size;
5206        // Whole-element length is checked before any batch applies, so the
5207        // refusal is atomic (the per-batch validation would only reject the
5208        // final, short batch after earlier ones had durably committed).
5209        if bytes.len() % es != 0 {
5210            return Err(Error::AppendInPlaceUnsupported(
5211                "appended byte length is not a whole number of elements",
5212            ));
5213        }
5214        let total = (bytes.len() / es) as u64;
5215        self.append_batches(g, total, |b, r| {
5216            b.append_raw(&bytes[r.start * es.get()..r.end * es.get()]);
5217        })
5218    }
5219
5220    /// How an append names this dataset to the session: by path when the handle
5221    /// has one, so the session can check the target against its own staged
5222    /// edits, and otherwise by the object-header address the handle was reached
5223    /// through — which is what lets a handle obtained by object reference append
5224    /// at all.
5225    fn append_target(&self) -> Result<AppendTarget<'_>, Error> {
5226        Ok(match &self.path {
5227            Some(path) => AppendTarget::Path(path),
5228            None => AppendTarget::Header(self.resolved()?.at.address),
5229        })
5230    }
5231
5232    /// A [`BufferedAppender`] over this dataset: appended elements are held in
5233    /// memory and written a whole chunk at a time, so a caller appending less
5234    /// than a chunk per call writes to the file once per chunk instead of once
5235    /// per call, and a filtered dataset is never left mid-chunk for the next
5236    /// write to re-encode.
5237    ///
5238    /// Every eligibility rule [`append`](Self::append) applies is applied here,
5239    /// so an ineligible dataset is reported now rather than on the first write.
5240    /// Buffered elements are not in the file until the appender flushes; see
5241    /// [`BufferedAppender`] for the full bargain.
5242    pub fn buffered_appender(&mut self) -> Result<BufferedAppender<'_>, Error> {
5243        BufferedAppender::new(self)
5244    }
5245
5246    /// Register a live `BufferedAppender` on this dataset with the session, so a
5247    /// staged edit that would stop it from flushing is refused at the call that
5248    /// creates the conflict rather than in the appender's `Drop`.
5249    pub(crate) fn claim_for_appender(&self) -> Result<u64, Error> {
5250        let Backend::Edit(m) = &self.file.backend else {
5251            return Err(Error::ReadOnly);
5252        };
5253        m.lock()
5254            .unwrap_or_else(std::sync::PoisonError::into_inner)
5255            .claim_for_appender(self.path.as_ref())
5256    }
5257
5258    /// Release the claim taken by [`claim_for_appender`](Self::claim_for_appender).
5259    pub(crate) fn release_appender_claim(&self, token: u64) {
5260        if let Backend::Edit(m) = &self.file.backend {
5261            m.lock()
5262                .unwrap_or_else(std::sync::PoisonError::into_inner)
5263                .release_appender_claim(token);
5264        }
5265    }
5266
5267    /// Whether this dataset's session is the SWMR writer, whose append rules are
5268    /// a strict subset of the ordinary ones. `false` for a read-only file, which
5269    /// has no session to ask.
5270    pub(crate) fn session_is_swmr(&self) -> bool {
5271        match &self.file.backend {
5272            Backend::Edit(m) => m
5273                .lock()
5274                .unwrap_or_else(std::sync::PoisonError::into_inner)
5275                .is_swmr(),
5276            _ => false,
5277        }
5278    }
5279
5280    /// Immediate in-place append of an already-gathered builder, used by
5281    /// [`BufferedAppender`], whose bytes are materialized in its buffer before
5282    /// it decides how many of them to write. `append_batches` exists for the
5283    /// opposite case — a caller whose bytes are cheaper to build per batch — so
5284    /// this hands the engine one builder and lets it batch the plan.
5285    pub(crate) fn append_prebuilt(&mut self, b: &AppendBuilder) -> Result<(), Error> {
5286        let target = self.append_target()?;
5287        self.file.with_engine_mut(Change::InPlace, |engine| {
5288            engine.append_inplace_gathered(target, b, 4)
5289        })
5290    }
5291
5292    /// Fetch (locating on first use) this dataset's append geometry from the
5293    /// write session, which also applies every refusal that does not depend on
5294    /// the bytes being appended.
5295    pub(crate) fn append_geometry(&self) -> Result<AppendGeometry, Error> {
5296        // An in-place append rewrites bytes where they stand, and a dataset this
5297        // session has only staged has none. Refused here rather than deep in the
5298        // engine, so `append`, `append_raw` and `buffered_appender` all say the
5299        // same thing.
5300        self.refuse_if_pending()?;
5301        let target = self.append_target()?;
5302        self.file
5303            .with_engine_mut(Change::Nothing, |engine| engine.append_geometry(target))
5304    }
5305
5306    /// Immediate in-place append, driven batch by batch. The call is split into
5307    /// aligned batches — the trailing partial chunk is filled first, then
5308    /// whole-chunk batches under the session's byte budget — and `fill` builds
5309    /// each batch's bytes on demand, so a bounded session's peak memory holds
5310    /// one batch rather than the whole call. A session that keeps the whole file
5311    /// resident reports one unbounded batch, so the call stays a single
5312    /// crash-atomic apply there.
5313    ///
5314    /// Every predictable refusal (wrong datatype, ineligible dataset) is raised
5315    /// before the first batch is applied. The cached header and chunk cache are
5316    /// then refreshed so later reads on this handle observe the new length. A
5317    /// filtered dataset sitting on a partial trailing chunk is grown by the
5318    /// first batch, which re-encodes that chunk into a fresh allocation and
5319    /// leaves every later batch starting on a boundary.
5320    fn append_batches(
5321        &mut self,
5322        g: AppendGeometry,
5323        total_elems: u64,
5324        fill: impl Fn(&mut AppendBuilder, std::ops::Range<usize>),
5325    ) -> Result<(), Error> {
5326        // Worked out once for the whole call: every batch names the same
5327        // dataset, and an in-place append does not move it.
5328        let target = self.append_target()?;
5329        let mut dim = g.current_dim;
5330        let mut done = 0u64;
5331        loop {
5332            // An empty append still runs one (empty) engine call, so datatype
5333            // validation happens whether or not there are elements.
5334            let to_boundary = (g.chunk_elems - dim % g.chunk_elems) % g.chunk_elems;
5335            let take = (total_elems - done).min(to_boundary.saturating_add(g.full_batch_elems));
5336            let mut b = AppendBuilder::new();
5337            fill(&mut b, done.to_usize()?..(done + take).to_usize()?);
5338            self.file.with_engine_mut(Change::InPlace, |engine| {
5339                engine.append_inplace_gathered(target, &b, 4)
5340            })?;
5341            dim += take;
5342            done += take;
5343            if done >= total_elems {
5344                break;
5345            }
5346        }
5347        Ok(())
5348    }
5349
5350    /// Overwrite this dataset's values, staged until [`File::commit`]. The new
5351    /// data must match the dataset's existing shape and datatype.
5352    ///
5353    /// The file must have been opened with [`File::open_rw`], else
5354    /// [`Error::ReadOnly`]. Unlike [`append`](Self::append) (immediate), this is
5355    /// a staged edit applied on [`File::commit`].
5356    pub fn write<T: H5Element>(&mut self, data: &[T]) -> Result<(), Error> {
5357        // Build off the lock, as `write_staged` does: `write_into` is trait
5358        // code reached with no lock held, keeping both paths identical.
5359        self.check_staged_edit()?;
5360        let mut builder = DatasetBuilder::new("");
5361        T::write_into(&mut builder, data);
5362        self.with_session_mut(|session, path| session.stage_dataset_write(path, builder))
5363    }
5364
5365    /// Overwrite this dataset's values through its full [`DatasetBuilder`],
5366    /// staged until [`File::commit`] — the builder-level counterpart of
5367    /// [`write`](Self::write), and the only one of the two that can carry a
5368    /// **shape**. [`write`](Self::write) sends a flat `&[T]`, so it can overwrite
5369    /// a one-dimensional dataset only; a multi-dimensional one needs
5370    /// [`with_shape`](DatasetBuilder::with_shape) and so comes through here, as
5371    /// do compound, complex, and raw bytes under an explicit datatype.
5372    ///
5373    /// The replacement must match the on-disk datatype and shape exactly; a
5374    /// reshape or retype is refused on [`File::commit`].
5375    ///
5376    /// This overwrites element bytes and nothing else, so a builder asking for
5377    /// more than that is refused by **this call**, before anything is staged:
5378    /// chunking, filters or an extensible shape; an attribute; a fill value; and
5379    /// [`with_path_references`](DatasetBuilder::with_path_references), whose
5380    /// element bytes are placeholder addresses only a newly created dataset can
5381    /// resolve. Set those when the dataset is created.
5382    ///
5383    /// [`with_vlen_strings`](DatasetBuilder::with_vlen_strings) is **not**
5384    /// refused: overwriting a variable-length-string dataset places a fresh
5385    /// global heap collection for the new strings and resolves the staged
5386    /// element references against it. Overwriting the same dataset again
5387    /// reclaims the collection the previous overwrite placed, so rotating its
5388    /// strings in a session does not grow the file without bound. The
5389    /// collections it held when the session *opened* are not reclaimed — a
5390    /// collection can be shared between objects, and only this session's own
5391    /// placements are known not to be, so [`repack`](crate::repack()) is what
5392    /// recovers those.
5393    ///
5394    /// The reference refusal is on the builder, not on the datatype it produces:
5395    /// such a dataset can still be overwritten by supplying element bytes that
5396    /// need no resolving, with
5397    /// [`with_reference_data`](DatasetBuilder::with_reference_data) or
5398    /// [`with_raw_data`](DatasetBuilder::with_raw_data). An object reference
5399    /// supplied that way is screened at `commit` by *address*, against both what
5400    /// the same commit deletes and what it rewrites elsewhere, so it cannot be
5401    /// left naming storage the commit is vacating — the answer a target named as
5402    /// a path already got by name.
5403    ///
5404    /// The file must have been opened with [`File::open_rw`], else
5405    /// [`Error::ReadOnly`].
5406    ///
5407    /// ```no_run
5408    /// # use hdf5_pure::File;
5409    /// # fn main() -> Result<(), hdf5_pure::Error> {
5410    /// let file = File::open_rw("counters.h5")?;
5411    /// let mut ds = file.dataset("ticks")?;
5412    /// // Keep the dataset's own datatype and supply the replacement bytes it
5413    /// // describes — three little-endian 16-bit elements here.
5414    /// let dt = ds.datatype()?;
5415    /// ds.write_staged(|b| {
5416    ///     b.with_raw_data(dt, vec![1, 0, 2, 0, 3, 0], 3);
5417    /// })?;
5418    /// file.commit()?;
5419    /// # Ok(())
5420    /// # }
5421    /// ```
5422    /// The closure configures a standalone builder, not the file, so it may read
5423    /// the same [`File`]; nothing it stages resolves until [`File::commit`].
5424    pub fn write_staged(&mut self, build: impl FnOnce(&mut DatasetBuilder)) -> Result<(), Error> {
5425        // Report a read-only, sealed, or unaddressable dataset before running the
5426        // closure, then run it with no lock held; `stage_dataset_write` names the
5427        // builder from the dataset's path (issue #200).
5428        self.check_staged_edit()?;
5429        let mut builder = DatasetBuilder::new("");
5430        build(&mut builder);
5431        self.with_session_mut(|session, path| session.stage_dataset_write(path, builder))
5432    }
5433
5434    /// Stage an append to this dataset applied on [`File::commit`] — the staged,
5435    /// index-rebuilding counterpart of the immediate [`append`](Self::append).
5436    ///
5437    /// Unlike [`append`](Self::append) (immediate, amortized `O(1)`,
5438    /// Extensible-Array only), this rebuilds the chunk index on commit and so
5439    /// also grows datasets whose Extensible-Array index is not yet allocated.
5440    /// Like it, a dataset under a **lossy** pipeline (ZFP, or float D-scale
5441    /// scale-offset) whose length is not a whole multiple of its chunk length
5442    /// is refused: growing that trailing chunk would decode and re-encode
5443    /// values that are already committed, changing them. That one is refused
5444    /// *here*, by this call, rather than at the commit — nothing is staged, and
5445    /// edits staged beside it are unaffected.
5446    /// Configure the appended elements through `build` on the
5447    /// [`AppendBuilder`]; repeated calls within the builder concatenate in
5448    /// order. The dataset must be chunked, unlimited along axis 0,
5449    /// Extensible-Array indexed, rank 1, use a re-encodable filter pipeline, and
5450    /// have a single hard link, otherwise [`Error::AppendUnsupported`] is
5451    /// returned on [`File::commit`].
5452    ///
5453    /// The file must have been opened with [`File::open_rw`], else
5454    /// [`Error::ReadOnly`].
5455    /// The closure configures a standalone builder, not the file, so it may read
5456    /// the same [`File`]; nothing it stages resolves until [`File::commit`].
5457    pub fn append_staged(&mut self, build: impl FnOnce(&mut AppendBuilder)) -> Result<(), Error> {
5458        // A dataset *this handle* names and this session has not written has no
5459        // bytes to grow, so the elements are folded into the pending creation
5460        // instead (see the note above). `check_staged_edit` refuses that dataset,
5461        // so ask it only about one the file already holds; the file-mode and path
5462        // gates apply either way.
5463        //
5464        // The distinction is the handle's, not the path's: a handle onto the
5465        // object a staged creation *replaces* is not pending, and the session
5466        // refuses its append rather than growing the replacement under it.
5467        let pending = self.staged_meta()?.is_some();
5468        if pending {
5469            self.file.check_staged_writable()?;
5470        } else {
5471            self.check_staged_edit()?;
5472        }
5473        let mut builder = AppendBuilder::new();
5474        build(&mut builder);
5475        self.with_session_mut(|session, path| {
5476            if pending {
5477                session.stage_dataset_append_pending(path, builder)
5478            } else {
5479                session.stage_dataset_append(path, builder)
5480            }
5481        })
5482    }
5483
5484    /// Add or update an attribute on this dataset, staged until
5485    /// [`File::commit`]. Use [`remove_attr`](Self::remove_attr) to remove one.
5486    /// An attribute set too large for the object header is written to a fractal
5487    /// heap, exactly as [`Group::set_attr`] does.
5488    ///
5489    /// The file must have been opened with [`File::open_rw`], else
5490    /// [`Error::ReadOnly`].
5491    pub fn set_attr(&mut self, name: &str, value: AttrValue) -> Result<(), Error> {
5492        self.refuse_if_pending()?;
5493        self.with_session_mut(|session, path| session.set_dataset_attr(path, name, value))
5494    }
5495
5496    /// Remove an attribute from this dataset, staged until [`File::commit`].
5497    /// See [`set_attr`](Self::set_attr) for the file-mode rules.
5498    pub fn remove_attr(&mut self, name: &str) -> Result<(), Error> {
5499        self.refuse_if_pending()?;
5500        self.with_session_mut(|session, path| session.remove_dataset_attr(path, name))
5501    }
5502
5503    /// Gate a staged edit on this dataset *without* taking the session lock:
5504    /// the file must accept staged edits, this handle must have a resolvable
5505    /// path, and this dataset's elements must be ones the engine owns.
5506    ///
5507    /// The path check belongs here rather than only in
5508    /// [`with_session_mut`](Self::with_session_mut) so that every reason to
5509    /// refuse is reported *before* a user closure runs, not after. A handle
5510    /// reached by object reference ([`dereference`](Self::dereference)) has no
5511    /// path, and would otherwise have its closure run and its result discarded.
5512    ///
5513    /// This is the one gate every data-writing entry point shares — `write`,
5514    /// `write_staged`, `append_staged`, and the staged rewrite behind
5515    /// [`BufferedAppender`] — which is why the external-storage refusal is here
5516    /// and not on any one of them. Attribute edits do not come through here, and
5517    /// are unaffected: they change the object header, not the elements.
5518    fn check_staged_edit(&self) -> Result<(), Error> {
5519        self.file.check_staged_writable()?;
5520        if self.path.is_none() {
5521            return Err(Error::ReadOnly);
5522        }
5523        // Ahead of the external-storage question, which reads a header this
5524        // dataset does not have yet. `append_staged` is the one staged edit that
5525        // *is* supported on a pending dataset, and it re-admits itself below.
5526        self.refuse_if_pending()?;
5527        // The elements of an externally stored dataset are not in this file, and
5528        // the engine writes only this file. Its contiguous layout message records
5529        // no address, so a write took it for never-allocated storage, appended the
5530        // new bytes and pointed the layout at them — leaving the file with two
5531        // contradictory records of where the data lives, and the reference
5532        // library still reading the external files it had not touched. See
5533        // [`has_external_storage`](Self::has_external_storage).
5534        if self.has_external_storage()? {
5535            return Err(Error::EditUnsupported(
5536                "this dataset's elements live in external files (H5Pset_external), which this \
5537engine does not write; writing them into the HDF5 file would leave it disagreeing with \
5538the external files about where the data is. Delete the dataset and create it again in \
5539the same commit to replace it",
5540            ));
5541        }
5542        Ok(())
5543    }
5544
5545    /// Run `f` with the writable session and this dataset's path, then refresh
5546    /// the cached header so a later read on this handle reflects any immediate
5547    /// change (e.g. an append's new dimension). Returns [`Error::ReadOnly`] if
5548    /// the file is read-only or the handle has no resolvable path (reached by
5549    /// object reference).
5550    fn with_session_mut<R>(
5551        &mut self,
5552        f: impl FnOnce(&mut WriteEngine, &ObjectPathBuf) -> Result<R, Error>,
5553    ) -> Result<R, Error> {
5554        let path = self.path.clone().ok_or(Error::ReadOnly)?;
5555        self.file
5556            .with_engine_mut(Change::Relocating, |session| f(session, &path))
5557    }
5558
5559    /// The effective raw chunk-cache configuration for this dataset.
5560    ///
5561    /// This reflects the per-dataset [`DatasetAccessProperties`] override when one
5562    /// was supplied to [`File::dataset_with_options`] /
5563    /// [`Group::dataset_with_options`], otherwise the file-wide default. It is
5564    /// the read-side analogue of HDF5's `H5Pget_chunk_cache`.
5565    pub const fn chunk_cache_config(&self) -> ChunkCacheConfig {
5566        self.chunk_cache_config
5567    }
5568
5569    /// A point-in-time snapshot of this dataset handle's chunk-cache occupancy.
5570    ///
5571    /// Lets callers confirm a chunk-cache configuration (set with
5572    /// [`FileAccessProperties::with_chunk_cache`]) is taking effect: after a
5573    /// chunked read, an enabled cache reports a loaded index and retained
5574    /// chunks; a disabled one (or one over its budget) reports fewer or none.
5575    /// The cache is per-handle — though clones of one handle share it — so a
5576    /// freshly opened [`Dataset`] reports an empty snapshot until its first read.
5577    pub fn chunk_cache_stats(&self) -> ChunkCacheStats {
5578        // Notice any edit first, which is what drops the chunks it invalidated.
5579        // A snapshot taken before that would count chunks no read will ever be
5580        // served, and this exists to say what the cache is holding *for* a read.
5581        // An unresolvable handle has no live cache to report, and an empty
5582        // snapshot is the truthful answer for one.
5583        let _ = self.resolved();
5584        self.chunk_cache.stats()
5585    }
5586
5587    /// Zero this handle's cumulative chunk-cache counters, leaving the retained
5588    /// index and chunks in place.
5589    ///
5590    /// [`ChunkCacheStats`]'s occupancy figures are unaffected — this resets what
5591    /// the cache has *done*, not what it is *holding* — so a caller can measure
5592    /// one read on a cache an earlier read already warmed. The cache is
5593    /// per-handle, though clones of one handle share it, so this resets the
5594    /// counters those clones report too.
5595    pub fn reset_chunk_cache_stats(&self) {
5596        // Resolve first for the same reason [`Self::chunk_cache_stats`] does: an
5597        // edit drops the chunks it invalidated, and those belong in the
5598        // invalidation count of the window being reset, not the next one.
5599        let _ = self.resolved();
5600        self.chunk_cache.reset_stats();
5601    }
5602
5603    /// Returns the shape (dimensions) of the dataset.
5604    ///
5605    /// A dataset this session has staged and not yet committed answers from what
5606    /// was staged, which includes the elements
5607    /// [`append_staged`](Self::append_staged) has folded into it.
5608    pub fn shape(&self) -> Result<Vec<u64>, Error> {
5609        if let Some(meta) = self.staged_meta()? {
5610            return Ok(meta.dimensions);
5611        }
5612        let ds = self.dataspace()?;
5613        Ok(ds.dimensions.clone())
5614    }
5615
5616    /// Returns the dataset's maximum dimensions, when it is extensible.
5617    ///
5618    /// A dimension that grows without bound is [`MaxExtent::Unlimited`].
5619    /// Returns `Ok(None)` for a fixed-shape dataset: one with no
5620    /// maximum-dimensions record, and one whose record is the current shape.
5621    /// A dataset this session has staged and not yet committed reports what was
5622    /// staged.
5623    ///
5624    /// Together with [`is_chunked`](Self::is_chunked) and
5625    /// [`chunk_shape`](Self::chunk_shape), this lets a caller check up front
5626    /// whether a dataset is eligible for
5627    /// [`Dataset::append_staged`](crate::Dataset::append_staged), which needs a
5628    /// chunked dataset whose first maximum dimension is
5629    /// [`MaxExtent::Unlimited`].
5630    ///
5631    /// # Errors
5632    ///
5633    /// Returns [`Error::Format`] if the dataspace message cannot be parsed, and
5634    /// [`Error::StaleHandle`] for a handle an object reference produced once a
5635    /// commit has run under it.
5636    pub fn maxshape(&self) -> Result<Option<Vec<MaxExtent>>, Error> {
5637        if let Some(meta) = self.staged_meta()? {
5638            return Ok(meta.maxshape);
5639        }
5640        Ok(self
5641            .dataspace()?
5642            .extensible_max_dimensions()
5643            .map(<[MaxExtent]>::to_vec))
5644    }
5645
5646    /// Whether the dataset uses chunked storage (as opposed to contiguous or
5647    /// compact). Filtered datasets are always chunked. Returns `false` for a
5648    /// dataset with no data-layout message, for a non-chunked layout, and — like
5649    /// the other accessors that return no `Result` — for a handle that can no
5650    /// longer be resolved. A dataset this session has staged and not yet
5651    /// committed answers with the storage its builder selected.
5652    pub fn is_chunked(&self) -> bool {
5653        if let Ok(Some(meta)) = self.staged_meta() {
5654            return meta.chunked;
5655        }
5656        matches!(self.data_layout(), Ok(DataLayout::Chunked { .. }))
5657    }
5658
5659    /// The dataset's chunk dimensions (one per dataset rank), or `Ok(None)` when
5660    /// the dataset is not chunked. The element-size dimension the on-disk layout
5661    /// appends is stripped, so the result lines up with
5662    /// [`shape`](Self::shape) / [`maxshape`](Self::maxshape).
5663    pub fn chunk_shape(&self) -> Result<Option<Vec<u64>>, Error> {
5664        let DataLayout::Chunked {
5665            chunk_dimensions, ..
5666        } = self.data_layout()?
5667        else {
5668            return Ok(None);
5669        };
5670        let rank = self.dataspace()?.dimensions.len();
5671        if chunk_dimensions.len() <= rank {
5672            return Ok(None);
5673        }
5674        Ok(Some(chunk_dimensions[..rank].to_vec()))
5675    }
5676
5677    /// The HDF5 filter IDs applied to this dataset's chunks, in pipeline
5678    /// (application) order, or an empty vector when the dataset is unfiltered.
5679    /// The IDs are the registered HDF5 filter numbers — e.g. 1 = deflate,
5680    /// 2 = shuffle, 3 = fletcher32, 6 = scale-offset — so a caller can inspect
5681    /// the pipeline without decoding a chunk. A dataset this session has staged
5682    /// and not yet committed reports the pipeline its builder asked for.
5683    pub fn filters(&self) -> Vec<u16> {
5684        if let Ok(Some(meta)) = self.staged_meta() {
5685            return meta.filters.into_iter().map(|(id, _)| id).collect();
5686        }
5687        self.filter_pipeline_parsed()
5688            .map(|p| p.filters.iter().map(|f| f.filter_id).collect())
5689            .unwrap_or_default()
5690    }
5691
5692    /// How and where this dataset's raw data is stored: compact, contiguous,
5693    /// chunked, or virtual.
5694    ///
5695    /// The structured companion to [`is_chunked`](Self::is_chunked) and
5696    /// [`chunk_shape`](Self::chunk_shape), which it subsumes: one call that
5697    /// classifies the layout and, for a [`Layout::Contiguous`] dataset, gives the
5698    /// absolute address and byte size to seek to, or for a [`Layout::Chunked`]
5699    /// dataset the chunk shape and [`ChunkIndex`] kind. This parses only the
5700    /// data-layout message; it never walks the chunk index or reads any data —
5701    /// use [`chunks`](Self::chunks) for per-chunk locations. The curated analogue
5702    /// of `H5Pget_layout`.
5703    ///
5704    /// # Errors
5705    ///
5706    /// Returns [`Error::Format`] if the data-layout message, or the dataspace a
5707    /// chunked dataset's chunk shape comes from, cannot be parsed.
5708    pub fn layout(&self) -> Result<Layout, Error> {
5709        Ok(match self.data_layout()? {
5710            DataLayout::Compact { data } => Layout::Compact {
5711                size: data.len() as u64,
5712            },
5713            DataLayout::Contiguous { address, size } => Layout::Contiguous {
5714                address: self.absolute_address(address)?,
5715                size,
5716            },
5717            DataLayout::Chunked { index, .. } => Layout::Chunked {
5718                // Reuse `chunk_shape` so the two accessors can never disagree on
5719                // how the element-size dimension is stripped.
5720                chunk_shape: self.chunk_shape()?.unwrap_or_default(),
5721                index: ChunkIndex::from(index),
5722            },
5723            DataLayout::Virtual => Layout::Virtual,
5724        })
5725    }
5726
5727    /// The [`ChunkIndex`] kind of this chunked dataset, or `Ok(None)` when the
5728    /// dataset is not chunked.
5729    ///
5730    /// A convenience shortcut for the `index` of [`Layout::Chunked`], for the
5731    /// common up-front append-eligibility check
5732    /// ([`ChunkIndex::supports_inplace_append`]). Complements
5733    /// [`maxshape`](Self::maxshape) and [`chunk_shape`](Self::chunk_shape).
5734    ///
5735    /// # Errors
5736    ///
5737    /// Returns [`Error::Format`] if the data-layout message cannot be parsed.
5738    pub fn chunk_index(&self) -> Result<Option<ChunkIndex>, Error> {
5739        match self.data_layout()? {
5740            DataLayout::Chunked { index, .. } => Ok(Some(ChunkIndex::from(index))),
5741            _ => Ok(None),
5742        }
5743    }
5744
5745    /// Enumerate every allocated chunk of this chunked dataset — one [`Chunk`]
5746    /// (logical offset, absolute file address, on-disk stored size, filter mask)
5747    /// per chunk, in index order.
5748    ///
5749    /// This reads only the chunk index, not the chunk data, so a caller can seek
5750    /// to and decode chunks one at a time without materializing the whole
5751    /// dataset. The curated analogue of `H5Dget_num_chunks` + `H5Dget_chunk_info`
5752    /// (`chunks()?.len()` is the chunk count).
5753    ///
5754    /// Returns `Ok(vec![])` for a chunked dataset whose storage has not been
5755    /// allocated yet (including a not-yet-written dataset that will use a
5756    /// [`ChunkIndex::BTreeV2`] index). Returns `Err` if the dataset is not chunked
5757    /// (check [`layout`](Self::layout) or [`is_chunked`](Self::is_chunked) first),
5758    /// or if its allocated storage is indexed by a [`ChunkIndex::BTreeV2`] index,
5759    /// which has no enumerator yet.
5760    pub fn chunks(&self) -> Result<Vec<Chunk>, Error> {
5761        let rank = self.dataspace()?.dimensions.len();
5762        let base = self.file.addr_offset;
5763        self.raw_chunks()?
5764            .into_iter()
5765            .map(|c| {
5766                Ok(Chunk {
5767                    offset: c.offsets.into_iter().take(rank).collect(),
5768                    address: base.absolute(c.address)?,
5769                    storage_size: c.chunk_size.get(),
5770                    filter_mask: c.filter_mask,
5771                })
5772            })
5773            .collect()
5774    }
5775
5776    /// This dataset's filter pipeline as an ordered list of [`Filter`]s — each
5777    /// with its identifier, optional name, optional/mandatory flag, and client
5778    /// data — or an empty vector when the dataset is unfiltered.
5779    ///
5780    /// The detailed companion to [`filters`](Self::filters), which returns just
5781    /// the identifiers. Filters are listed in application (write) order — the
5782    /// on-disk pipeline order, matching [`filters`](Self::filters); a reader
5783    /// inverts them in the *reverse* of this order to decode a chunk. The curated
5784    /// analogue of `H5Pget_nfilters` + `H5Pget_filter2`.
5785    ///
5786    /// A dataset this session has staged and not yet committed reports each
5787    /// filter's identifier and optional flag, and no name or client data: those
5788    /// are derived from the dataset being written (element size, chunk geometry,
5789    /// fill value) when [`File::commit`] writes it.
5790    pub fn filter_pipeline(&self) -> Vec<Filter> {
5791        if let Ok(Some(meta)) = self.staged_meta() {
5792            return meta
5793                .filters
5794                .into_iter()
5795                .map(|(id, is_optional)| Filter {
5796                    id,
5797                    is_optional,
5798                    name: None,
5799                    client_data: Vec::new(),
5800                })
5801                .collect();
5802        }
5803        self.filter_pipeline_parsed()
5804            .map(|p| {
5805                p.filters
5806                    .into_iter()
5807                    .map(|f| Filter {
5808                        id: f.filter_id,
5809                        is_optional: f.is_optional(),
5810                        name: f.name,
5811                        client_data: f.client_data,
5812                    })
5813                    .collect()
5814            })
5815            .unwrap_or_default()
5816    }
5817
5818    /// Returns the absolute file position of `address`, and `None` where a layout message stores
5819    /// the undefined address.
5820    ///
5821    /// # Errors
5822    ///
5823    /// Returns [`FormatError::OffsetOverflow`] if the sum exceeds `u64`.
5824    fn absolute_address(&self, address: Option<StoredAddress>) -> Result<Option<u64>, Error> {
5825        match address {
5826            Some(stored) => Ok(Some(self.file.addr_offset.absolute(stored)?)),
5827            None => Ok(None),
5828        }
5829    }
5830
5831    /// Returns the simplified datatype of the dataset.
5832    ///
5833    /// A dataset this session has staged and not yet committed answers with the
5834    /// datatype its builder settled on.
5835    pub fn dtype(&self) -> Result<DType, Error> {
5836        let dt = self.datatype()?;
5837        Ok(classify_datatype(&dt))
5838    }
5839
5840    /// The size in bytes of one on-disk element of this dataset's datatype —
5841    /// HDF5's datatype storage size (`H5Tget_size`).
5842    ///
5843    /// This is the byte width of a single stored element: 8 for `f64`, the
5844    /// declared length for a fixed-length string, the record size for a compound
5845    /// type, or the reference/descriptor size for a variable-length type (whose
5846    /// payload lives separately in the file's global heaps).
5847    ///
5848    /// Multiplied by the element count from [`shape`](Self::shape), it is the
5849    /// exact number of raw bytes a full [`read_raw`](Self::read_raw)
5850    /// materializes. A caller reading an untrusted file can use it to bound that
5851    /// allocation up front rather than trusting the file's declared extent: a
5852    /// dataset can name a small element count yet a per-element size of billions
5853    /// of bytes, so the product — not the count alone — is what a read allocates.
5854    pub fn element_size(&self) -> Result<u64, Error> {
5855        Ok(u64::from(self.datatype()?.type_size()))
5856    }
5857
5858    /// The raw bytes of this dataset's user-defined fill value, encoded in its
5859    /// datatype, or `None` when no user-defined fill value is set (the library
5860    /// default or an explicitly undefined fill). Reads whichever Fill Value
5861    /// message the header carries — the current `0x0005` (versions 1/2/3) or the
5862    /// legacy `0x0004` — so files from this crate, the reference C library, and
5863    /// h5py are all handled.
5864    pub(crate) fn defined_fill_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
5865        let state = self.resolved()?;
5866        let msg = state
5867            .header
5868            .messages
5869            .iter()
5870            .find(|m| m.msg_type == MessageType::FILL_VALUE)
5871            .or_else(|| {
5872                state
5873                    .header
5874                    .messages
5875                    .iter()
5876                    .find(|m| m.msg_type == MessageType::FILL_VALUE_OLD)
5877            });
5878        match msg {
5879            Some(m) => Ok(crate::fill_value::parse_defined_fill_value(
5880                m.msg_type,
5881                &self.file.message_body(m)?,
5882            )?),
5883            None => Ok(None),
5884        }
5885    }
5886
5887    /// The fill bytes that *unallocated storage reads as* — which is not the
5888    /// same question [`defined_fill_bytes`](Self::defined_fill_bytes) answers.
5889    ///
5890    /// A dataset may declare a fill value and also declare, through the Fill
5891    /// Value Write Time, that the library never writes it
5892    /// (`H5D_FILL_TIME_NEVER`). Its unallocated storage then has no defined
5893    /// contents — the C library leaves the read buffer untouched — so this
5894    /// returns `None` and the region reads as deterministic zeros rather than as
5895    /// a value nothing ever put there. `fill_value` still reports the declared
5896    /// value, because it *is* declared; see [`fill_value_is_written`].
5897    ///
5898    /// [`fill_value_is_written`]: crate::fill_value::fill_value_is_written
5899    fn fill_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
5900        let state = self.resolved()?;
5901        let msg = state
5902            .header
5903            .messages
5904            .iter()
5905            .find(|m| m.msg_type == MessageType::FILL_VALUE)
5906            .or_else(|| {
5907                state
5908                    .header
5909                    .messages
5910                    .iter()
5911                    .find(|m| m.msg_type == MessageType::FILL_VALUE_OLD)
5912            });
5913        let Some(m) = msg else {
5914            return Ok(None);
5915        };
5916        let body = self.file.message_body(m)?;
5917        if !crate::fill_value::fill_value_is_written(m.msg_type, &body)? {
5918            return Ok(None);
5919        }
5920        Ok(crate::fill_value::parse_defined_fill_value(
5921            m.msg_type, &body,
5922        )?)
5923    }
5924
5925    /// Read the whole dataset with `decode`, sweeping it a row window at a time.
5926    ///
5927    /// A typed whole-dataset read used to be [`read_raw`](Self::read_raw)
5928    /// followed by a decode of the entire buffer, which held the stored bytes
5929    /// and the decoded values at the same time and so peaked at twice the
5930    /// dataset — a caller reading a 4 GiB array needed 8 GiB (issue #289).
5931    /// Decoding a window at a time leaves one window of stored bytes beside the
5932    /// output instead of a whole second copy of it, and the bytes are identical
5933    /// either way: a window returns exactly the rows [`read_raw`](Self::read_raw)
5934    /// would have put there.
5935    ///
5936    /// The output buffer is reserved once, at the size the whole dataset decodes
5937    /// to, so no window reallocates it — a growth step would put a second copy of
5938    /// the output alongside the first and give back what the windowing saved.
5939    ///
5940    /// A dataset that fits in one window — including one with no rows at all — is
5941    /// read whole. The empty case still runs `decode`, because a decoder is also
5942    /// what reports a datatype it cannot read, and a zero-element string dataset
5943    /// must go on failing a numeric read rather than answering with an empty
5944    /// vector.
5945    fn read_whole_typed<T, F>(&self, decode: F) -> Result<Vec<T>, Error>
5946    where
5947        F: Fn(&[u8], &Datatype, &mut Vec<T>) -> Result<(), FormatError>,
5948    {
5949        let dt = self.datatype()?;
5950        let ds = self.dataspace()?;
5951        let dl = self.read_layout()?;
5952        let pipeline = self.filter_pipeline_parsed();
5953        // See `read_raw`: an unparseable fill value message is carried into the
5954        // read rather than failing it up front.
5955        let fill_bytes = self.fill_bytes();
5956        let elem_size = dt.element_size_usize()?;
5957        let fill = match &fill_bytes {
5958            Ok(b) => FillPattern::new(b.as_deref(), elem_size),
5959            Err(_) => FillPattern::UNKNOWN,
5960        };
5961        let spec = RawReadSpec {
5962            layout: &dl,
5963            dataspace: &ds,
5964            datatype: &dt,
5965            pipeline: pipeline.as_ref(),
5966            fill,
5967        };
5968        // What a whole read checks before it reads a byte, and what a sweep would
5969        // otherwise skip: a compact or contiguous layout whose declared size
5970        // disagrees with the dataspace is refused. Reading in windows must not
5971        // turn that into a check that fires only on datasets small enough to be
5972        // read whole.
5973        spec.stored_byte_len()?;
5974
5975        // A window is cut by the *stored* element width, while a decoder slices
5976        // what it is handed by the width of the type it decodes — the base type,
5977        // for an enumeration. Those are the same width for every valid file, an
5978        // enumeration's size being its base's. A crafted file where they differ
5979        // must not get one verdict from a sweep and another from a whole read, so
5980        // it is read whole.
5981        let decoded_width = data_read::effective_numeric(&dt).type_size();
5982
5983        let mut out = Vec::new();
5984        let n0 = ds.dimensions.first().copied().unwrap_or(1);
5985        let rows = typed_window_rows(&dl, &ds, elem_size)?.get();
5986        if n0 <= rows || decoded_width != dt.type_size() {
5987            // No reservation here: `decode` sizes the output from the bytes it
5988            // was handed, which is exact.
5989            let raw = self.file.read_dataset_raw(spec, &self.chunk_cache)?;
5990            decode(&raw, &dt, &mut out)?;
5991            return Ok(out);
5992        }
5993
5994        let values = ds.num_elements().to_usize()?;
5995
5996        // One pass for the whole sweep, not one per window: the sweep visits each
5997        // chunk exactly once, so a window that offered its chunks to a cache
5998        // already full would copy and evict with no later reader for either. The
5999        // cache ends up holding what a whole read would have left it — the
6000        // chunks reached first. See [`CachePass`].
6001        let pass = self.chunk_cache.begin_pass();
6002        let mut start = 0;
6003        while start < n0 {
6004            let count = rows.min(n0 - start);
6005            let raw =
6006                self.file
6007                    .read_dataset_raw_rows(spec, &self.chunk_cache, pass, start, count)?;
6008            if start == 0 {
6009                // Reserved once, and only after a window has come back. This size
6010                // comes from the file: sizing an allocation from it before
6011                // reading anything lets a dataspace claiming a terabyte ask for a
6012                // terabyte over a file that cannot serve one row. `try_reserve`
6013                // for the same reason — a file-derived capacity that cannot be
6014                // had is an answer this reader owes its caller, not a panic.
6015                out.try_reserve(values)
6016                    .map_err(|source| FormatError::AllocationFailed { values, source })?;
6017            }
6018            decode(&raw, &dt, &mut out)?;
6019            start += count;
6020        }
6021        Ok(out)
6022    }
6023
6024    /// Read all data as `f64` values.
6025    ///
6026    /// This and the other typed whole-dataset readers decode a row window at a
6027    /// time, so the memory standing beside the returned `Vec` is one window of
6028    /// stored bytes — on the order of a mebibyte — rather than a second copy of
6029    /// the dataset. Reading a 4 GiB array costs about 4 GiB, not 8.
6030    ///
6031    /// The values are what [`read_raw`](Self::read_raw) returns, decoded: a
6032    /// dataset stored as a narrower or wider type is converted, so pick the
6033    /// reader that matches the stored type for a lossless read.
6034    pub fn read_f64(&self) -> Result<Vec<f64>, Error> {
6035        self.read_whole_typed(data_read::read_as_f64_into)
6036    }
6037
6038    /// Read all data as `f32` values.
6039    pub fn read_f32(&self) -> Result<Vec<f32>, Error> {
6040        self.read_whole_typed(data_read::read_as_f32_into)
6041    }
6042
6043    /// Read all data as `i32` values.
6044    pub fn read_i32(&self) -> Result<Vec<i32>, Error> {
6045        self.read_whole_typed(data_read::read_as_i32_into)
6046    }
6047
6048    /// Read all data as `i64` values.
6049    pub fn read_i64(&self) -> Result<Vec<i64>, Error> {
6050        self.read_whole_typed(data_read::read_as_i64_into)
6051    }
6052
6053    /// Read all data as `u64` values.
6054    pub fn read_u64(&self) -> Result<Vec<u64>, Error> {
6055        self.read_whole_typed(data_read::read_as_u64_into)
6056    }
6057
6058    /// Read all data as `u8` values.
6059    pub fn read_u8(&self) -> Result<Vec<u8>, Error> {
6060        self.read_whole_typed(data_read::read_as_u8_into)
6061    }
6062
6063    /// Read all data as `i8` values.
6064    pub fn read_i8(&self) -> Result<Vec<i8>, Error> {
6065        self.read_whole_typed(data_read::read_as_i8_into)
6066    }
6067
6068    /// Read all data as `i16` values.
6069    pub fn read_i16(&self) -> Result<Vec<i16>, Error> {
6070        self.read_whole_typed(data_read::read_as_i16_into)
6071    }
6072
6073    /// Read all data as `u16` values.
6074    pub fn read_u16(&self) -> Result<Vec<u16>, Error> {
6075        self.read_whole_typed(data_read::read_as_u16_into)
6076    }
6077
6078    /// Read all data as `u32` values.
6079    pub fn read_u32(&self) -> Result<Vec<u32>, Error> {
6080        self.read_whole_typed(data_read::read_as_u32_into)
6081    }
6082
6083    /// Read all data as `String` values.
6084    ///
6085    /// Fixed-length and variable-length HDF5 string datasets are both
6086    /// supported. Use [`read_vlen_strings`](Self::read_vlen_strings) when
6087    /// variable-length allocation limits are required.
6088    pub fn read_string(&self) -> Result<Vec<String>, Error> {
6089        let dt = self.datatype()?;
6090        if vl_data::is_vlen_string_datatype(&dt) {
6091            self.read_vlen_strings(VlenStringReadOptions::default())
6092        } else {
6093            let raw = self.read_raw()?;
6094            Ok(data_read::read_as_strings(&raw, &dt)?)
6095        }
6096    }
6097
6098    /// Return the total bytes referenced by this VL string dataset.
6099    ///
6100    /// This is the payload equivalent of HDF5's `H5Dvlen_get_buf_size`: it
6101    /// excludes `Vec<String>` and `String` allocation metadata.
6102    pub fn vlen_string_payload_size(&self) -> Result<u64, Error> {
6103        let datatype = self.datatype()?;
6104        if !vl_data::is_vlen_string_datatype(&datatype) {
6105            return Err(FormatError::TypeMismatch {
6106                expected: "VariableLength string",
6107                actual: "non-VariableLength string",
6108            }
6109            .into());
6110        }
6111        let dataspace = self.dataspace()?;
6112        let raw = self.read_raw()?;
6113        Ok(vl_data::vlen_string_payload_size(
6114            &raw,
6115            dataspace.num_elements(),
6116            self.file.offset_size(),
6117        )?)
6118    }
6119
6120    /// Read a VL string dataset with explicit allocation limits.
6121    ///
6122    /// Both limits are checked before any string payload is materialized.
6123    pub fn read_vlen_strings(&self, options: VlenStringReadOptions) -> Result<Vec<String>, Error> {
6124        let mut strings = Vec::new();
6125        self.visit_vlen_strings(options, |string| strings.push(string.to_owned()))?;
6126        Ok(strings)
6127    }
6128
6129    /// Visit a VL string dataset one element at a time.
6130    ///
6131    /// The string slice passed to `visitor` is valid only for the duration of
6132    /// that callback. This avoids retaining all decoded string payloads at once.
6133    ///
6134    /// On a read-write file ([`File::open_rw`]) the
6135    /// visitor runs while the file's engine lock is held, so it must not read
6136    /// or write through this file (or a clone / handle of it) — doing so
6137    /// deadlocks. Collect values and act on them after the call instead.
6138    pub fn visit_vlen_strings<F>(
6139        &self,
6140        options: VlenStringReadOptions,
6141        visitor: F,
6142    ) -> Result<(), Error>
6143    where
6144        F: FnMut(&str),
6145    {
6146        let datatype = self.datatype()?;
6147        if !vl_data::is_vlen_string_datatype(&datatype) {
6148            return Err(FormatError::TypeMismatch {
6149                expected: "VariableLength string",
6150                actual: "non-VariableLength string",
6151            }
6152            .into());
6153        }
6154        let dataspace = self.dataspace()?;
6155        if let Some(limit) = options.max_elements()
6156            && dataspace.num_elements() > limit as u64
6157        {
6158            return Err(FormatError::VariableLengthElementLimitExceeded {
6159                limit,
6160                actual: dataspace.num_elements(),
6161            }
6162            .into());
6163        }
6164        let raw = self.read_raw()?;
6165        self.file.with_source(|source| {
6166            Ok(vl_data::visit_vl_strings_from_source(
6167                source,
6168                &raw,
6169                dataspace.num_elements(),
6170                self.file.offset_size(),
6171                self.file.length_size(),
6172                self.file.addr_offset,
6173                options,
6174                visitor,
6175            )?)
6176        })
6177    }
6178
6179    /// Read a VL string dataset's exact heap bytes, preserving the
6180    /// null-vs-empty distinction and never lossily decoding.
6181    ///
6182    /// Unlike [`read_vlen_strings`](Self::read_vlen_strings), which returns
6183    /// `String`s via `from_utf8_lossy` and so cannot reproduce embedded NULs or
6184    /// non-UTF-8 payloads, this yields each element's raw bytes (or a null
6185    /// marker). It underpins faithful rewriting (e.g. repack) of VL strings.
6186    pub(crate) fn read_vlen_string_bytes(
6187        &self,
6188        options: VlenStringReadOptions,
6189    ) -> Result<Vec<vl_data::VlByteObject>, Error> {
6190        let datatype = self.datatype()?;
6191        if !vl_data::is_vlen_string_datatype(&datatype) {
6192            return Err(FormatError::TypeMismatch {
6193                expected: "VariableLength string",
6194                actual: "non-VariableLength string",
6195            }
6196            .into());
6197        }
6198        let dataspace = self.dataspace()?;
6199        if let Some(limit) = options.max_elements()
6200            && dataspace.num_elements() > limit as u64
6201        {
6202            return Err(FormatError::VariableLengthElementLimitExceeded {
6203                limit,
6204                actual: dataspace.num_elements(),
6205            }
6206            .into());
6207        }
6208        let raw = self.read_raw()?;
6209        self.file.with_source(|source| {
6210            Ok(vl_data::read_vl_byte_objects_from_source(
6211                source,
6212                &raw,
6213                dataspace.num_elements(),
6214                self.file.offset_size(),
6215                self.file.length_size(),
6216                self.file.addr_offset,
6217                1, // a VL string's base type is a single byte
6218                options,
6219            )?)
6220        })
6221    }
6222
6223    /// Read every element of a *non-string* variable-length (sequence) dataset as
6224    /// its exact heap bytes, alongside the base-type element size in bytes.
6225    ///
6226    /// Each element's heap object holds `length * element_size` bytes, where
6227    /// `length` is the stored element count and `element_size` is the byte width
6228    /// of the sequence's base type. Returning the raw bytes (not decoded values)
6229    /// keeps a faithful rewrite (repack) byte-exact for any base type whose bytes
6230    /// carry no embedded heap or file addresses. Errors with a
6231    /// [`TypeMismatch`](crate::FormatError::TypeMismatch) if the datatype is not a
6232    /// non-string VL datatype.
6233    pub(crate) fn read_vlen_sequence_bytes(
6234        &self,
6235        options: VlenStringReadOptions,
6236    ) -> Result<(Vec<vl_data::VlByteObject>, usize), Error> {
6237        let datatype = self.datatype()?;
6238        let Datatype::VariableLength { base_type, .. } = &datatype else {
6239            return Err(FormatError::TypeMismatch {
6240                expected: "non-string VariableLength",
6241                actual: "non-VariableLength",
6242            }
6243            .into());
6244        };
6245        if vl_data::is_vlen_string_datatype(&datatype) {
6246            return Err(FormatError::TypeMismatch {
6247                expected: "non-string VariableLength",
6248                actual: "VariableLength string",
6249            }
6250            .into());
6251        }
6252        let element_size = base_type.type_size() as usize;
6253        if element_size == 0 {
6254            return Err(
6255                FormatError::VlDataError("non-string VL base type has zero size".into()).into(),
6256            );
6257        }
6258        let dataspace = self.dataspace()?;
6259        if let Some(limit) = options.max_elements()
6260            && dataspace.num_elements() > limit as u64
6261        {
6262            return Err(FormatError::VariableLengthElementLimitExceeded {
6263                limit,
6264                actual: dataspace.num_elements(),
6265            }
6266            .into());
6267        }
6268        let raw = self.read_raw()?;
6269        let objects = self.file.with_source(|source| {
6270            vl_data::read_vl_byte_objects_from_source(
6271                source,
6272                &raw,
6273                dataspace.num_elements(),
6274                self.file.offset_size(),
6275                self.file.length_size(),
6276                self.file.addr_offset,
6277                element_size,
6278                options,
6279            )
6280        })?;
6281        Ok((objects, element_size))
6282    }
6283
6284    /// Read a dataset whose datatype *contains* variable-length references
6285    /// without being variable-length itself — a compound with a VL member, or an
6286    /// array of them (issue #201).
6287    ///
6288    /// Returns everything a rewrite needs: the element bytes, where each embedded
6289    /// reference sits within them, and the heap payload each one names. That lets
6290    /// the writer re-stage the payloads into a new file's global heap and rewrite
6291    /// the references in place, which is what keeps a rewrite from carrying the
6292    /// source file's heap addresses into the destination.
6293    ///
6294    /// The references are resolved one slot at a time, so `options`' limits apply
6295    /// per slot rather than across the whole dataset.
6296    pub(crate) fn read_embedded_vlen_bytes(
6297        &self,
6298        slots: &[vl_data::EmbeddedVlSlot],
6299        options: VlenStringReadOptions,
6300    ) -> Result<vl_data::EmbeddedVlData, Error> {
6301        let stride = self.datatype()?.type_size() as usize;
6302        let dataspace = self.dataspace()?;
6303        let n = dataspace.num_elements();
6304        if let Some(limit) = options.max_elements()
6305            && n > limit as u64
6306        {
6307            return Err(
6308                FormatError::VariableLengthElementLimitExceeded { limit, actual: n }.into(),
6309            );
6310        }
6311
6312        // A zero-element dataset owns no element bytes, so there is no storage
6313        // to visit: skip the read rather than open a dataset the C library
6314        // left unallocated only to receive the same empty buffer back.
6315        let raw = if n == 0 { Vec::new() } else { self.read_raw()? };
6316        let n_usize = n.to_usize()?;
6317        let needed = n_usize
6318            .checked_mul(stride)
6319            .ok_or(FormatError::OffsetOverflow {
6320                offset: n,
6321                length: stride as u64,
6322            })?;
6323        if raw.len() < needed {
6324            return Err(FormatError::UnexpectedEof {
6325                expected: needed,
6326                available: raw.len(),
6327            }
6328            .into());
6329        }
6330
6331        let mut offsets = Vec::with_capacity(n_usize * slots.len());
6332        let mut objects = Vec::with_capacity(n_usize * slots.len());
6333        for slot in slots {
6334            // Gather this slot's reference from every element into a dense buffer,
6335            // which is the shape the shared VL reader consumes. Each slot has its
6336            // own base-type width, so they are resolved a slot at a time rather
6337            // than in one pass.
6338            let mut dense = Vec::with_capacity(n_usize * VL_REF_SIZE);
6339            for e in 0..n_usize {
6340                let at = e * stride + slot.byte_offset;
6341                dense.extend_from_slice(&raw[at..at + VL_REF_SIZE]);
6342                offsets.push(at);
6343            }
6344            let resolved = self.file.with_source(|source| {
6345                vl_data::read_vl_byte_objects_from_source(
6346                    source,
6347                    &dense,
6348                    n,
6349                    self.file.offset_size(),
6350                    self.file.length_size(),
6351                    self.file.addr_offset,
6352                    slot.element_size,
6353                    options,
6354                )
6355            })?;
6356            objects.extend(resolved);
6357        }
6358        Ok(vl_data::EmbeddedVlData {
6359            raw,
6360            offsets,
6361            objects,
6362        })
6363    }
6364
6365    /// Read all attributes of this dataset.
6366    ///
6367    /// The variant of each value describes its on-disk encoding; see
6368    /// [`Group::attrs`] for what that means for matching on it, and prefer the
6369    /// [`AttrValue`] accessors.
6370    pub fn attrs(&self) -> Result<HashMap<String, AttrValue>, Error> {
6371        self.file.attrs_of(&self.resolved()?.header)
6372    }
6373
6374    /// The exact on-disk [`Datatype`] of every attribute on this dataset, keyed
6375    /// by name.
6376    ///
6377    /// See [`Group::attr_datatypes`] for what this channel carries that
6378    /// [`attrs`](Self::attrs) cannot, including how a boolean attribute is
6379    /// recognized. Note that this describes the *attributes*, not the dataset's
6380    /// own element type — that is [`datatype`](Self::datatype).
6381    pub fn attr_datatypes(&self) -> Result<HashMap<String, Datatype>, Error> {
6382        Ok(self
6383            .attr_messages()?
6384            .into_iter()
6385            .map(|a| (a.name, a.datatype))
6386            .collect())
6387    }
6388
6389    /// Every attribute message on this dataset as it is encoded on disk, in the
6390    /// order the header holds them.
6391    ///
6392    /// See [`Group::attr_messages`] for why repack reads these rather than the
6393    /// decoded map.
6394    pub(crate) fn attr_messages(&self) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
6395        self.file.attr_messages_of(&self.resolved()?.header)
6396    }
6397
6398    /// Returns the exact HDF5 datatype, including compound field offsets and
6399    /// total record size.
6400    ///
6401    /// A committed (`H5Tcommit`) element type — what netCDF-4 writes for a
6402    /// user-defined type, and what h5py writes for
6403    /// `create_dataset(..., dtype=f["t"])` — is stored as a reference to the
6404    /// datatype's own object header and is resolved to the type it names.
6405    pub fn datatype(&self) -> Result<Datatype, Error> {
6406        if let Some(meta) = self.staged_meta()? {
6407            return Ok(meta.datatype);
6408        }
6409        let state = self.resolved()?;
6410        let msg = find_message(&state.header, MessageType::DATATYPE)?;
6411        let (dt, _) = Datatype::parse(&self.file.message_body(msg)?)?;
6412        Ok(dt)
6413    }
6414
6415    /// The object-header address of this dataset's committed (shared) element
6416    /// type, or `None` when the type is written in the dataset's own header.
6417    ///
6418    /// [`datatype`](Self::datatype) resolves it either way, so this is for
6419    /// callers that must *reproduce* the dataset: writing the resolved type back
6420    /// inline loses the link every C-library reader reports by name, and the
6421    /// address is what says which committed object to name instead.
6422    pub(crate) fn committed_datatype_address(&self) -> Result<Option<u64>, Error> {
6423        let state = self.resolved()?;
6424        let msg = find_message(&state.header, MessageType::DATATYPE)?;
6425        self.file.shared_target_address(msg)
6426    }
6427
6428    pub(crate) fn dataspace(&self) -> Result<Dataspace, Error> {
6429        let state = self.resolved()?;
6430        let msg = find_message(&state.header, MessageType::DATASPACE)?;
6431        Ok(Dataspace::parse(
6432            &self.file.message_body(msg)?,
6433            self.file.length_size(),
6434        )?)
6435    }
6436
6437    pub(crate) fn data_layout(&self) -> Result<DataLayout, Error> {
6438        let state = self.resolved()?;
6439        let msg = find_message(&state.header, MessageType::DATA_LAYOUT)?;
6440        Ok(DataLayout::parse(
6441            &msg.data,
6442            self.file.offset_size(),
6443            self.file.length_size(),
6444        )?)
6445    }
6446
6447    /// A handle that can no longer be resolved reports no pipeline, the same
6448    /// answer an unfiltered dataset gives: this feeds the two infallible
6449    /// accessors ([`filters`](Self::filters) and
6450    /// [`filter_pipeline`](Self::filter_pipeline)), which have no way to say
6451    /// why. Every caller that *can* say uses a `Result` reader instead.
6452    pub(crate) fn filter_pipeline_parsed(&self) -> Option<FilterPipeline> {
6453        let state = self.resolved().ok()?;
6454        let msg = state
6455            .header
6456            .messages
6457            .iter()
6458            .find(|m| m.msg_type == MessageType::FILTER_PIPELINE)?;
6459        let body = self.file.message_body(msg).ok()?;
6460        FilterPipeline::parse(&body).ok()
6461    }
6462
6463    /// Whether this dataset's element bytes live in files outside this one
6464    /// (`H5Pset_external`, the External Data Files header message, type 7).
6465    ///
6466    /// Such a dataset carries a *contiguous* layout message whose data address
6467    /// is undefined — the same encoding a never-written dataset uses — so a
6468    /// caller that reads "no address" as "no storage" would call a dataset full
6469    /// of data empty. This crate does not follow the external files, so the only
6470    /// safe answer is to refuse: [`read_layout`](Self::read_layout) refuses a
6471    /// read of one rather than answering its fill value, and `repack` refuses to
6472    /// reproduce it without its data.
6473    pub(crate) fn has_external_storage(&self) -> Result<bool, Error> {
6474        Ok(self
6475            .resolved()?
6476            .header
6477            .messages
6478            .iter()
6479            .any(|m| m.msg_type == MessageType::EXTERNAL_DATA_FILES))
6480    }
6481
6482    /// The data layout to read element bytes through, as opposed to the one
6483    /// [`layout`](Self::layout) reports.
6484    ///
6485    /// [`data_layout`](Self::data_layout) answers what the message records;
6486    /// this answers whether those bytes are reachable at all. The two differ for
6487    /// exactly one kind of dataset: an externally stored one, whose contiguous layout
6488    /// with no address is also what a never-written dataset carries, so reading
6489    /// it would answer the fill value for every element it holds. Introspection
6490    /// keeps answering — the address-less layout is the evidence a caller needs
6491    /// — while every path that turns a layout into bytes comes through here and
6492    /// refuses.
6493    fn read_layout(&self) -> Result<DataLayout, Error> {
6494        if self.has_external_storage()? {
6495            return Err(FormatError::UnsupportedExternalStorage.into());
6496        }
6497        self.data_layout()
6498    }
6499
6500    /// Returns one [`ChunkInfo`](crate::chunked_read::ChunkInfo) per allocated chunk of this
6501    /// chunked dataset, the records the chunked reader walks before decompressing.
6502    ///
6503    /// Every address is in the frame the chunk index stores it in, so a caller reading the
6504    /// still-compressed chunk bytes from the file's own source adds the base address first,
6505    /// as `repack` does when it copies a chunk without decoding it.
6506    ///
6507    /// Returns `Err` if the layout is not chunked. Returns `Ok(vec![])` for an
6508    /// empty / never-allocated chunked dataset (no index address). Covers every
6509    /// index type the reader supports (v3 B-tree and v4 single-chunk, implicit,
6510    /// fixed-array, and extensible-array).
6511    pub(crate) fn raw_chunks(&self) -> Result<Vec<crate::chunked_read::ChunkInfo>, Error> {
6512        let DataLayout::Chunked {
6513            chunk_dimensions,
6514            index,
6515            ..
6516        } = self.data_layout()?
6517        else {
6518            return Err(Error::Format(crate::error::FormatError::ChunkedReadError(
6519                "chunk enumeration requires a chunked dataset".into(),
6520            )));
6521        };
6522        // An undefined index address means no storage is allocated yet.
6523        if index.address().is_none() {
6524            return Ok(Vec::new());
6525        }
6526        let dataspace = self.dataspace()?;
6527        let elem_size = self.datatype()?.element_size_usize()?;
6528        let base = self.file.addr_offset;
6529        // The chunk index stores base-relative addresses, in its root and in every internal
6530        // node, so the walk runs over a view of the file framed at the base. Every address it
6531        // reports stays in that frame.
6532        self.file.with_source(|source| {
6533            if base.is_zero() {
6534                return Ok(crate::chunked_read::collect_chunks_for_layout_from_source(
6535                    source,
6536                    index,
6537                    &chunk_dimensions,
6538                    &dataspace,
6539                    elem_size.get() as u64,
6540                    self.file.offset_size(),
6541                    self.file.length_size(),
6542                )?);
6543            }
6544            let framed = BaseOffsetSource {
6545                inner: source,
6546                base,
6547            };
6548            Ok(crate::chunked_read::collect_chunks_for_layout_from_source(
6549                &framed,
6550                index,
6551                &chunk_dimensions,
6552                &dataspace,
6553                elem_size.get() as u64,
6554                self.file.offset_size(),
6555                self.file.length_size(),
6556            )?)
6557        })
6558    }
6559
6560    /// The raw `FilterPipeline` message bytes from this dataset's object header,
6561    /// if it has one. Repack reuses this verbatim so that every filter — including
6562    /// ones this crate cannot itself apply (ZFP, SZIP, unknown) — is reproduced
6563    /// byte-for-byte in the repacked file's pipeline message.
6564    pub(crate) fn filter_pipeline_message_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
6565        let state = self.resolved()?;
6566        let Some(msg) = state
6567            .header
6568            .messages
6569            .iter()
6570            .find(|m| m.msg_type == MessageType::FILTER_PIPELINE)
6571        else {
6572            return Ok(None);
6573        };
6574        // A shared pipeline message's record body is an address, not a pipeline;
6575        // its resolved content is what a copy must carry. The content itself is
6576        // position-independent, so copying it verbatim stays faithful.
6577        Ok(Some(self.file.message_body(msg)?.into_owned()))
6578    }
6579
6580    /// Read the dataset's exact unfiltered element bytes.
6581    ///
6582    /// For compound datasets this preserves all file padding and uses the
6583    /// offsets reported by [`datatype`](Self::datatype).
6584    ///
6585    /// A dataset whose elements live in external files (`H5Pset_external`) is
6586    /// refused with `FormatError::UnsupportedExternalStorage` rather than read as
6587    /// unallocated storage, which is what its layout message alone says. This
6588    /// applies to every read here; its shape, datatype, and
6589    /// [`layout`](Self::layout) still read.
6590    pub fn read_raw(&self) -> Result<Vec<u8>, Error> {
6591        let dt = self.datatype()?;
6592        let ds = self.dataspace()?;
6593        let dl = self.read_layout()?;
6594        // The data layout's on-disk addresses are left base-relative here;
6595        // `read_dataset_raw` applies the base address centrally (for both
6596        // contiguous and chunked layouts) by reading from a base-relative view of
6597        // the file.
6598        let pipeline = self.filter_pipeline_parsed();
6599        // A fill value message this parser cannot read does not, by itself,
6600        // make the dataset unreadable: it only decides what *unallocated*
6601        // storage looks like. Carry the uncertainty into the read and let it
6602        // fail there, and only there. `Dataset::fill_value` still reports the
6603        // parse error to a caller asking about the value.
6604        let fill_bytes = self.fill_bytes();
6605        let fill = match &fill_bytes {
6606            Ok(b) => FillPattern::new(b.as_deref(), dt.element_size_usize()?),
6607            Err(_) => FillPattern::UNKNOWN,
6608        };
6609        let spec = RawReadSpec {
6610            layout: &dl,
6611            dataspace: &ds,
6612            datatype: &dt,
6613            pipeline: pipeline.as_ref(),
6614            fill,
6615        };
6616        Ok(self.file.read_dataset_raw(spec, &self.chunk_cache)?)
6617    }
6618
6619    /// Read the raw element bytes of the row window `[start_row, start_row + num_rows)`
6620    /// — a range along the first dimension.
6621    ///
6622    /// The windowed companion to [`read_raw`](Self::read_raw): only the storage the
6623    /// window overlaps is read — a bounded sub-read for compact and contiguous
6624    /// layouts, just the overlapping chunks for chunked layouts — so peak memory
6625    /// scales with the window, not the dataset. Use it to stream a large dataset a
6626    /// fixed number of rows at a time.
6627    ///
6628    /// Each row keeps its full inner shape, and the bytes match what
6629    /// [`read_raw`](Self::read_raw) produces for those rows, so the typed
6630    /// `read_*_rows` helpers decode a window like their whole-dataset forms. The
6631    /// window is clamped to the first dimension: a read past the end returns only
6632    /// the rows that exist, and a 0-D scalar is one row. A window covering every
6633    /// row delegates to [`read_raw`](Self::read_raw), so a full-range window never
6634    /// costs more than a whole read. Variable-length string
6635    /// bytes are heap references, not text — use
6636    /// [`read_string_rows`](Self::read_string_rows).
6637    pub fn read_raw_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u8>, Error> {
6638        let dt = self.datatype()?;
6639        let ds = self.dataspace()?;
6640        let dl = self.read_layout()?;
6641
6642        let n0 = ds.dimensions.first().copied().unwrap_or(1);
6643        let start = start_row.min(n0);
6644        let count = num_rows.min(n0 - start);
6645
6646        // A window covering every row is exactly a whole read: delegate, so it
6647        // never costs a window-shaped copy on top of one.
6648        // See `read_raw`: an unparseable fill value message is carried into the
6649        // read rather than failing it up front.
6650        let parsed_fill = self.fill_bytes();
6651        let fill = match &parsed_fill {
6652            Ok(b) => FillPattern::new(b.as_deref(), dt.element_size_usize()?),
6653            Err(_) => FillPattern::UNKNOWN,
6654        };
6655
6656        let pipeline = self.filter_pipeline_parsed();
6657        let spec = RawReadSpec {
6658            layout: &dl,
6659            dataspace: &ds,
6660            datatype: &dt,
6661            pipeline: pipeline.as_ref(),
6662            fill,
6663        };
6664
6665        if start == 0 && count == n0 {
6666            return Ok(self.file.read_dataset_raw(spec, &self.chunk_cache)?);
6667        }
6668
6669        // A lone window's successor is the adjacent one, and the chunk they share
6670        // is the one this read finishes on; `CachePass::LRU` is what retains it.
6671        Ok(self.file.read_dataset_raw_rows(
6672            spec,
6673            &self.chunk_cache,
6674            CachePass::LRU,
6675            start,
6676            count,
6677        )?)
6678    }
6679
6680    /// Windowed [`read_f64`](Self::read_f64) — decodes only the row window.
6681    pub fn read_f64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<f64>, Error> {
6682        let raw = self.read_raw_rows(start_row, num_rows)?;
6683        Ok(data_read::read_as_f64(&raw, &self.datatype()?)?)
6684    }
6685
6686    /// Windowed [`read_f32`](Self::read_f32) — decodes only the row window.
6687    pub fn read_f32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<f32>, Error> {
6688        let raw = self.read_raw_rows(start_row, num_rows)?;
6689        Ok(data_read::read_as_f32(&raw, &self.datatype()?)?)
6690    }
6691
6692    /// Windowed [`read_i8`](Self::read_i8) — decodes only the row window.
6693    pub fn read_i8_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i8>, Error> {
6694        let raw = self.read_raw_rows(start_row, num_rows)?;
6695        Ok(data_read::read_as_i8(&raw, &self.datatype()?)?)
6696    }
6697
6698    /// Windowed [`read_i16`](Self::read_i16) — decodes only the row window.
6699    pub fn read_i16_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i16>, Error> {
6700        let raw = self.read_raw_rows(start_row, num_rows)?;
6701        Ok(data_read::read_as_i16(&raw, &self.datatype()?)?)
6702    }
6703
6704    /// Windowed [`read_i32`](Self::read_i32) — decodes only the row window.
6705    pub fn read_i32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i32>, Error> {
6706        let raw = self.read_raw_rows(start_row, num_rows)?;
6707        Ok(data_read::read_as_i32(&raw, &self.datatype()?)?)
6708    }
6709
6710    /// Windowed [`read_i64`](Self::read_i64) — decodes only the row window.
6711    pub fn read_i64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i64>, Error> {
6712        let raw = self.read_raw_rows(start_row, num_rows)?;
6713        Ok(data_read::read_as_i64(&raw, &self.datatype()?)?)
6714    }
6715
6716    /// Windowed [`read_u8`](Self::read_u8) — reads only the row window.
6717    pub fn read_u8_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u8>, Error> {
6718        let raw = self.read_raw_rows(start_row, num_rows)?;
6719        Ok(data_read::read_as_u8(&raw, &self.datatype()?)?)
6720    }
6721
6722    /// Windowed [`read_u16`](Self::read_u16) — decodes only the row window.
6723    pub fn read_u16_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u16>, Error> {
6724        let raw = self.read_raw_rows(start_row, num_rows)?;
6725        Ok(data_read::read_as_u16(&raw, &self.datatype()?)?)
6726    }
6727
6728    /// Windowed [`read_u32`](Self::read_u32) — decodes only the row window.
6729    pub fn read_u32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u32>, Error> {
6730        let raw = self.read_raw_rows(start_row, num_rows)?;
6731        Ok(data_read::read_as_u32(&raw, &self.datatype()?)?)
6732    }
6733
6734    /// Windowed [`read_u64`](Self::read_u64) — decodes only the row window.
6735    pub fn read_u64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u64>, Error> {
6736        let raw = self.read_raw_rows(start_row, num_rows)?;
6737        Ok(data_read::read_as_u64(&raw, &self.datatype()?)?)
6738    }
6739
6740    /// Windowed [`read_string`](Self::read_string).
6741    ///
6742    /// Fixed-length strings decode straight from the window. Variable-length
6743    /// strings resolve only the window's heap references, so the window memory
6744    /// bound holds for them too: peak allocation is the window's references,
6745    /// its text, and the metadata of the heap collections it touches.
6746    pub fn read_string_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<String>, Error> {
6747        let dt = self.datatype()?;
6748        if vl_data::is_vlen_string_datatype(&dt) {
6749            // The window's heap references, read memory-bounded like any other
6750            // fixed-size element (4-byte length + collection address + 4-byte
6751            // object index), one row spanning its inner dimensions. Resolving
6752            // only those against the global heap keeps the bound — the same
6753            // resolution `read_string` runs over the whole dataset's references.
6754            let raw = self.read_raw_rows(start_row, num_rows)?;
6755            let ref_size = 4 + self.file.offset_size() as usize + 4;
6756            let num_elements = (raw.len() / ref_size) as u64;
6757            let mut strings = Vec::new();
6758            self.file.with_source(|source| -> Result<(), Error> {
6759                Ok(vl_data::visit_vl_strings_from_source(
6760                    source,
6761                    &raw,
6762                    num_elements,
6763                    self.file.offset_size(),
6764                    self.file.length_size(),
6765                    self.file.addr_offset,
6766                    VlenStringReadOptions::default(),
6767                    |string| strings.push(String::from(string)),
6768                )?)
6769            })?;
6770            return Ok(strings);
6771        }
6772        let raw = self.read_raw_rows(start_row, num_rows)?;
6773        Ok(data_read::read_as_strings(&raw, &dt)?)
6774    }
6775
6776    /// Interpret this dataset as an array of HDF5 object references
6777    /// (`H5R_OBJECT`) and resolve each, in storage order, to the [`Object`] it
6778    /// points at.
6779    ///
6780    /// MATLAB cell arrays and the `#subsystem#` machinery store their members
6781    /// this way: the dataset holds one object-header address per element, each
6782    /// naming an object elsewhere in the file (conventionally under the hidden
6783    /// `#refs#` group).
6784    ///
6785    /// # Errors
6786    ///
6787    /// - [`FormatError::TypeMismatch`] if this dataset's datatype is not an
6788    ///   object reference.
6789    /// - [`FormatError::InvalidObjectReference`] if an element is a null or
6790    ///   undefined reference, or does not point at a group or dataset.
6791    pub fn dereference(&self) -> Result<Vec<Object>, Error> {
6792        let dt = self.datatype()?;
6793        if !matches!(
6794            dt,
6795            Datatype::Reference {
6796                ref_type: ReferenceType::Object,
6797                ..
6798            }
6799        ) {
6800            return Err(FormatError::TypeMismatch {
6801                expected: "object reference",
6802                actual: "non-reference datatype",
6803            }
6804            .into());
6805        }
6806        // An object reference stores an 8-byte object-header address. Refuse a
6807        // sub-address-width element rather than read a truncated address.
6808        let elem_size = dt.type_size().to_usize()?;
6809        if elem_size < 8 {
6810            return Err(FormatError::TypeMismatch {
6811                expected: "8-byte object reference",
6812                actual: "object reference narrower than 8 bytes",
6813            }
6814            .into());
6815        }
6816        let raw = self.read_raw()?;
6817        if raw.is_empty() {
6818            return Ok(Vec::new());
6819        }
6820        if !raw.len().is_multiple_of(elem_size) {
6821            return Err(FormatError::DataSizeMismatch {
6822                expected: elem_size,
6823                actual: raw.len(),
6824            }
6825            .into());
6826        }
6827        // Read after the element bytes the addresses came out of, which is the
6828        // one place these could be taken too late: a commit landing between that
6829        // read and this one would move the headers the addresses name, and a
6830        // handle labelled with the later revisions would call them current.
6831        let revisions = self.file.revisions();
6832        let mut out = Vec::with_capacity(raw.len() / elem_size);
6833        for chunk in raw.chunks_exact(elem_size) {
6834            let addr = u64::from_le_bytes(chunk[..8].try_into().expect("chunk has >= 8 bytes"));
6835            out.push(FileInner::object_at_relative(&self.file, revisions, addr)?);
6836        }
6837        Ok(out)
6838    }
6839
6840    /// Decode all elements of a compound dataset field by field.
6841    ///
6842    /// Built-in implementations support numeric tuples with one through twelve
6843    /// fields. Decoding uses the file's field offsets rather than Rust's tuple
6844    /// memory layout, so padded compound records are supported safely.
6845    pub fn read_compound<T: CompoundType>(&self) -> Result<Vec<T>, Error> {
6846        let datatype = self.datatype()?;
6847        let element_size = datatype.element_size_usize()?;
6848        if !matches!(datatype, Datatype::Compound { .. }) {
6849            return Err(FormatError::TypeMismatch {
6850                expected: "Compound",
6851                actual: "non-Compound",
6852            }
6853            .into());
6854        }
6855        let raw = self.read_raw()?;
6856        if !raw.len().is_multiple_of(element_size.get()) {
6857            return Err(FormatError::DataSizeMismatch {
6858                expected: element_size.get(),
6859                actual: raw.len(),
6860            }
6861            .into());
6862        }
6863        raw.chunks_exact(element_size.get())
6864            .map(|bytes| T::decode(&datatype, bytes).map_err(Error::Format))
6865            .collect()
6866    }
6867
6868    /// Verify this dataset against its stored provenance hash.
6869    ///
6870    /// Recomputes the SHA-256 of the dataset's raw bytes and compares it with
6871    /// the `_provenance_sha256` attribute written by
6872    /// [`DatasetBuilder::with_provenance`](crate::DatasetBuilder::with_provenance).
6873    /// Returns [`VerifyResult::NoHash`](crate::VerifyResult::NoHash) when the
6874    /// dataset carries no provenance hash, so a missing hash is distinguishable
6875    /// from an actual mismatch.
6876    #[cfg(feature = "provenance")]
6877    pub fn verify_provenance(&self) -> Result<crate::provenance::VerifyResult, Error> {
6878        use crate::provenance::{ATTR_SHA256, VerifyResult, sha256_hex};
6879
6880        let attrs = self.attrs()?;
6881        let stored = match attrs.get(ATTR_SHA256).and_then(AttrValue::as_str) {
6882            Some(s) => s.trim_end_matches('\0').to_string(),
6883            None => return Ok(VerifyResult::NoHash),
6884        };
6885
6886        let computed = sha256_hex(&self.read_raw()?);
6887        if computed == stored {
6888            Ok(VerifyResult::Ok)
6889        } else {
6890            Ok(VerifyResult::Mismatch { stored, computed })
6891        }
6892    }
6893}
6894
6895// ---------------------------------------------------------------------------
6896// Helpers
6897// ---------------------------------------------------------------------------
6898
6899fn find_message(
6900    header: &ObjectHeader,
6901    msg_type: MessageType,
6902) -> Result<&crate::object_header::HeaderMessage, Error> {
6903    header
6904        .messages
6905        .iter()
6906        .find(|m| m.msg_type == msg_type)
6907        .ok_or(Error::MissingMessage(msg_type))
6908}
6909
6910fn has_message(header: &ObjectHeader, msg_type: MessageType) -> bool {
6911    header.messages.iter().any(|m| m.msg_type == msg_type)
6912}
6913
6914/// Whether an object header describes a committed (`H5Tcommit`) datatype: it
6915/// carries a datatype and is neither a dataset nor a group.
6916///
6917/// A dataset's header carries a datatype message too — its element type — so
6918/// "has a datatype message" is not the question, and a lookup that asked only
6919/// that answered a dataset's element type where it owed a refusal (issue #364).
6920/// The listing and the by-name lookups share this one predicate so they cannot
6921/// disagree about the same child.
6922///
6923/// The conjunction encodes the precedence the reference library gets from its
6924/// ordering: `H5O__obj_class_real` walks `H5O_obj_class_g` in reverse, so it
6925/// asks group, then dataset, then datatype, and that is what `H5Topen` gates on.
6926/// Two terms are read differently here. It calls a header a dataset for a
6927/// datatype beside a *dataspace* where this reads a datatype beside a data
6928/// layout, and a group for a symbol table or link info where this counts a bare
6929/// link message as well. Every object either library writes carries the messages
6930/// that make those agree, so the rules part only on a malformed header.
6931fn is_named_datatype(header: &ObjectHeader) -> bool {
6932    has_message(header, MessageType::DATATYPE)
6933        && !has_message(header, MessageType::DATA_LAYOUT)
6934        && !is_group(header)
6935}
6936
6937/// The root-relative path of a child named `name` under `parent`, or `None` if
6938/// the parent has no resolvable path (reached by object reference).
6939///
6940/// Free-standing rather than a method on [`Group`] so the member iterators can
6941/// build child paths from a closure that outlives the borrow of the group they
6942/// came from.
6943///
6944/// A member whose name is no path component, such as the empty name a malformed file can store,
6945/// has no [`LinkName`] for the iterators to pass, and they return its handle with `None` for a
6946/// path.
6947fn child_path_of(parent: Option<&ObjectPathBuf>, name: LinkName<'_>) -> Option<ObjectPathBuf> {
6948    Some(parent?.join(&LinkNameBuf::from(name)))
6949}
6950
6951/// Returns the root-relative path an error reports an object by, and the empty string for a
6952/// handle reached by object reference, which has no path.
6953fn reported_path(path: Option<&ObjectPathBuf>) -> String {
6954    path.map_or_else(String::new, ObjectPathBuf::to_string)
6955}
6956
6957/// The reason [`Group::child_edit_path`] gives for a path that spells no link.
6958const NO_LINK_TO_WRITE: &str = "a write needs a link name, and this path holds none";
6959
6960#[cfg(test)]
6961mod tests {
6962    use super::*;
6963    use crate::data_layout::{ChunkIndexLayout, ChunkedLayoutFlags};
6964    use crate::dataspace::DataspaceType;
6965    use crate::datatype::layout::FixedPointLayout;
6966    use crate::message_flags::MessageFlags;
6967    use crate::{DatatypeByteOrder, FileBuilder};
6968    use std::sync::atomic::AtomicUsize;
6969
6970    // -----------------------------------------------------------------------
6971    // Reporting the metadata cache (issue #353)
6972    // -----------------------------------------------------------------------
6973
6974    /// `SourceView` serves its metadata reads from the streaming backend's
6975    /// cache, so it has to forward the account of them as well. It is the one
6976    /// wrapper `File::metadata_cache_stats` does not itself go through (that
6977    /// dispatch uses `with_source`, which reaches the read-write backend too), so
6978    /// nothing else would notice the forward going missing.
6979    #[test]
6980    fn the_source_view_reports_the_cache_it_reads_through() {
6981        let backend = MetadataCachingSource::new(
6982            BytesSource::new((0..=255u8).collect::<Vec<u8>>()),
6983            MetadataCacheConfig::new(4096),
6984        );
6985        let view = SourceView::Stream(&backend);
6986
6987        assert_eq!(view.metadata_cache_stats().unwrap().reads(), 0);
6988        view.read_metadata_at(0, 64).unwrap();
6989        view.read_metadata_at(0, 64).unwrap();
6990        let stats = view
6991            .metadata_cache_stats()
6992            .expect("the backend has a cache, so the view reports it");
6993        assert_eq!((stats.hits(), stats.misses()), (1, 1));
6994
6995        view.reset_metadata_cache_stats();
6996        let cleared = view.metadata_cache_stats().unwrap();
6997        assert_eq!(cleared.hits(), 0);
6998        assert_eq!(cleared.entries(), 1, "a reset evicts nothing");
6999
7000        // A whole-file buffer is the cache; there is no second one to report.
7001        assert_eq!(SourceView::Mem(&[0u8; 16]).metadata_cache_stats(), None);
7002    }
7003
7004    // -----------------------------------------------------------------------
7005    // Handle re-validation across an edit (issue #351)
7006    // -----------------------------------------------------------------------
7007
7008    /// A file with two chunked datasets whose trailing chunk is partial, one
7009    /// contiguous dataset, and one subgroup.
7010    fn revalidation_fixture(path: &std::path::Path) {
7011        let mut b = FileBuilder::new();
7012        for ds in ["log", "other"] {
7013            b.create_dataset(ds)
7014                .with_i32_data(&[0, 1])
7015                .with_shape(&[2])
7016                .with_maxshape(&[MaxExtent::Unlimited])
7017                .with_chunks(&[4]);
7018        }
7019        b.create_dataset("plain").with_i32_data(&[7, 8, 9]);
7020        let g = b.create_group("g");
7021        b.add_group(g.finish());
7022        b.write(path).unwrap();
7023    }
7024
7025    /// The issue itself: a handle taken before a commit goes on answering for
7026    /// the object after it, rather than for the copy the commit left behind.
7027    #[test]
7028    fn a_handle_follows_its_object_across_a_commit() {
7029        let dir = tempfile::tempdir().unwrap();
7030        let path = dir.path().join("follow.h5");
7031        revalidation_fixture(&path);
7032
7033        let file = File::open_rw(&path).unwrap();
7034        let mut ds = file.dataset("plain").unwrap();
7035        let group = file.group("g").unwrap();
7036
7037        // Both edits relocate an object header: the dataset's own, and — since
7038        // the new child is linked into it — the group's.
7039        ds.set_attr("units", AttrValue::AsciiString("m".into()))
7040            .unwrap();
7041        group
7042            .create_dataset("child", |b| {
7043                b.with_i32_data(&[4, 5]);
7044            })
7045            .unwrap();
7046        file.commit().unwrap();
7047
7048        assert_eq!(
7049            sorted(ds.attrs()),
7050            vec![r#"units=AsciiString("m")"#.to_string()],
7051            "the dataset handle must report the attribute the commit added"
7052        );
7053        assert_eq!(
7054            group.datasets().unwrap(),
7055            vec!["child".to_string()],
7056            "the group handle must report the child the commit added"
7057        );
7058        assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
7059        assert_eq!(
7060            group.dataset("child").unwrap().read_i32().unwrap(),
7061            vec![4, 5]
7062        );
7063        file.close().unwrap();
7064    }
7065
7066    /// Two handles on one dataset are two views of one object, not two objects:
7067    /// an append through either is what the other reads next, chunk cache and
7068    /// all. The appended elements land in a chunk the reader already holds, so a
7069    /// retained one would answer with what stood there before.
7070    #[test]
7071    fn an_append_through_one_handle_is_what_another_reads() {
7072        let dir = tempfile::tempdir().unwrap();
7073        let path = dir.path().join("two_handles.h5");
7074        revalidation_fixture(&path);
7075
7076        let file = File::open_rw(&path).unwrap();
7077        let reader = file.dataset("log").unwrap();
7078        let mut writer = file.dataset("log").unwrap();
7079
7080        assert_eq!(reader.read_i32().unwrap(), vec![0, 1]);
7081        assert!(
7082            reader.chunk_cache_stats().cached_chunks() > 0,
7083            "the read must leave the partial trailing chunk cached, or this \
7084             test cannot tell a retained chunk from a re-read one"
7085        );
7086
7087        writer.append(&[2i32, 3]).unwrap();
7088
7089        assert_eq!(
7090            reader.chunk_cache_stats().cached_chunks(),
7091            0,
7092            "the snapshot must report what the cache holds for a read, and the \
7093             append left it holding nothing a read will be served"
7094        );
7095        assert_eq!(reader.shape().unwrap(), vec![4]);
7096        assert_eq!(reader.read_i32().unwrap(), vec![0, 1, 2, 3]);
7097        file.close().unwrap();
7098    }
7099
7100    /// A clone is a second handle to the same object, and follows it the same
7101    /// way. It shares the chunk cache, so the edit that drops one drops both.
7102    #[test]
7103    fn a_clone_is_a_second_handle_to_the_same_object() {
7104        let dir = tempfile::tempdir().unwrap();
7105        let path = dir.path().join("clone.h5");
7106        revalidation_fixture(&path);
7107
7108        let file = File::open_rw(&path).unwrap();
7109        let mut ds = file.dataset("log").unwrap();
7110        let copy = ds.clone();
7111        let root = file.root();
7112        let root_copy = root.clone();
7113
7114        assert_eq!(copy.read_i32().unwrap(), vec![0, 1]);
7115        assert!(copy.chunk_cache_stats().cached_chunks() > 0);
7116        assert_eq!(
7117            ds.chunk_cache_stats().cached_chunks(),
7118            copy.chunk_cache_stats().cached_chunks(),
7119            "clones share one cache: a chunk read through either is warm for both"
7120        );
7121
7122        ds.append(&[2i32, 3]).unwrap();
7123        assert_eq!(copy.read_i32().unwrap(), vec![0, 1, 2, 3]);
7124
7125        root.create_group("later").unwrap();
7126        file.commit().unwrap();
7127        assert!(
7128            root_copy.groups().unwrap().contains(&"later".to_string()),
7129            "a cloned group handle follows its group across a commit too"
7130        );
7131        file.close().unwrap();
7132    }
7133
7134    /// A handle to an object a commit deleted has nothing to answer for. The
7135    /// bytes it vacated still parse as the dataset that left them, so reading
7136    /// them would answer with data no longer in the file.
7137    #[test]
7138    fn a_handle_to_a_deleted_object_refuses_rather_than_reading_what_it_left() {
7139        let dir = tempfile::tempdir().unwrap();
7140        let path = dir.path().join("deleted.h5");
7141        revalidation_fixture(&path);
7142
7143        let file = File::open_rw(&path).unwrap();
7144        let ds = file.dataset("plain").unwrap();
7145        assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
7146
7147        file.root().delete("plain").unwrap();
7148        file.commit().unwrap();
7149
7150        assert!(
7151            matches!(
7152                ds.read_i32(),
7153                Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "plain"
7154            ),
7155            "reading a deleted dataset must fail the way opening it does, got {:?}",
7156            ds.read_i32()
7157        );
7158        file.close().unwrap();
7159    }
7160
7161    /// A handle reached by object reference has no name to look itself up by, so
7162    /// it pins to the address the reference gave it. An immediate append leaves
7163    /// that address alone and it keeps reading; a commit can move the header and
7164    /// it stops.
7165    #[test]
7166    fn a_reference_handle_reads_until_a_commit_could_have_moved_its_object() {
7167        let dir = tempfile::tempdir().unwrap();
7168        let path = dir.path().join("by_ref.h5");
7169        let mut b = FileBuilder::new();
7170        b.create_dataset("log")
7171            .with_i32_data(&[0, 1])
7172            .with_shape(&[2])
7173            .with_maxshape(&[MaxExtent::Unlimited])
7174            .with_chunks(&[4]);
7175        b.create_dataset("refs").with_path_references(&["log"]);
7176        b.write(&path).unwrap();
7177
7178        let file = File::open_rw(&path).unwrap();
7179        let mut by_ref = match file
7180            .dataset("refs")
7181            .unwrap()
7182            .dereference()
7183            .unwrap()
7184            .remove(0)
7185        {
7186            Object::Dataset(ds) => *ds,
7187            other => panic!("expected a dataset, got {other:?}"),
7188        };
7189        assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1]);
7190
7191        // An in-place append rewrites the header where it stands, so the address
7192        // is still the object's and the handle reads its own new elements.
7193        by_ref.append(&[2i32, 3]).unwrap();
7194        assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1, 2, 3]);
7195
7196        // A commit can put the header somewhere else, and nothing on disk marks
7197        // the bytes it vacated as dead.
7198        file.root().create_group("g").unwrap();
7199        file.commit().unwrap();
7200        assert!(
7201            matches!(by_ref.read_i32(), Err(Error::StaleHandle)),
7202            "unexpected: {:?}",
7203            by_ref.read_i32()
7204        );
7205
7206        // And the recovery the error names: dereference again, against the file
7207        // the commit left.
7208        let fresh = match file
7209            .dataset("refs")
7210            .unwrap()
7211            .dereference()
7212            .unwrap()
7213            .remove(0)
7214        {
7215            Object::Dataset(ds) => *ds,
7216            other => panic!("expected a dataset, got {other:?}"),
7217        };
7218        assert_eq!(fresh.read_i32().unwrap(), vec![0, 1, 2, 3]);
7219        file.close().unwrap();
7220    }
7221
7222    /// The counters are what every handle trusts, so every entry point that
7223    /// reaches the write engine has to declare what it does to them. This is
7224    /// that declaration, written out entry point by entry point and checked
7225    /// against the code: a [`Change::Relocating`] advances both counters, an
7226    /// [`Change::InPlace`] only the content one, and a [`Change::Nothing`]
7227    /// neither.
7228    ///
7229    /// The table is the point. An entry point classified too weakly leaves a
7230    /// handle memoizing a header it moved; one classified too strongly ends
7231    /// every by-reference handle in the session for nothing — which is what
7232    /// `File::sync` did until its line was written here.
7233    #[test]
7234    fn every_write_entry_point_declares_what_it_changes() {
7235        let dir = tempfile::tempdir().unwrap();
7236
7237        type Step = (&'static str, Change, fn(&File));
7238        let steps: Vec<Step> = vec![
7239            ("File::sync", Change::Nothing, |f| {
7240                f.sync().unwrap();
7241            }),
7242            ("Dataset::chunk_cache_stats", Change::Nothing, |f| {
7243                let _ = f.dataset("log").unwrap().chunk_cache_stats();
7244            }),
7245            ("Dataset::reset_chunk_cache_stats", Change::Nothing, |f| {
7246                f.dataset("log").unwrap().reset_chunk_cache_stats();
7247            }),
7248            ("BufferedAppender::new", Change::Nothing, |f| {
7249                let mut ds = f.dataset("log").unwrap();
7250                let app = ds.buffered_appender().unwrap();
7251                app.discard();
7252            }),
7253            ("Dataset::append", Change::InPlace, |f| {
7254                f.dataset("log").unwrap().append(&[9i32]).unwrap();
7255            }),
7256            ("Dataset::append_raw", Change::InPlace, |f| {
7257                f.dataset("log")
7258                    .unwrap()
7259                    .append_raw(&7i32.to_le_bytes())
7260                    .unwrap();
7261            }),
7262            ("BufferedAppender::flush", Change::InPlace, |f| {
7263                let mut ds = f.dataset("log").unwrap();
7264                let mut app = ds.buffered_appender().unwrap();
7265                app.append(&[5i32, 6, 7, 8]).unwrap();
7266                app.flush().unwrap();
7267            }),
7268            ("Dataset::write", Change::Relocating, |f| {
7269                f.dataset("plain").unwrap().write(&[1i32, 2, 3]).unwrap();
7270            }),
7271            ("Dataset::set_attr", Change::Relocating, |f| {
7272                f.dataset("plain")
7273                    .unwrap()
7274                    .set_attr("a", AttrValue::I32(1))
7275                    .unwrap();
7276            }),
7277            ("Dataset::remove_attr", Change::Relocating, |f| {
7278                let mut ds = f.dataset("plain").unwrap();
7279                ds.set_attr("gone", AttrValue::I32(1)).unwrap();
7280                f.commit().unwrap();
7281                ds.remove_attr("gone").unwrap();
7282            }),
7283            ("Dataset::write_staged", Change::Relocating, |f| {
7284                f.dataset("plain")
7285                    .unwrap()
7286                    .write_staged(|b| {
7287                        b.with_i32_data(&[4, 5, 6]);
7288                    })
7289                    .unwrap();
7290            }),
7291            ("Dataset::append_staged", Change::Relocating, |f| {
7292                f.dataset("log")
7293                    .unwrap()
7294                    .append_staged(|b| {
7295                        b.append_i32(&[3]);
7296                    })
7297                    .unwrap();
7298            }),
7299            ("Group::create_group", Change::Relocating, |f| {
7300                f.root().create_group("fresh").unwrap();
7301            }),
7302            ("Group::create_dataset", Change::Relocating, |f| {
7303                f.root()
7304                    .create_dataset("made", |b| {
7305                        b.with_i32_data(&[1]);
7306                    })
7307                    .unwrap();
7308            }),
7309            ("Group::delete", Change::Relocating, |f| {
7310                f.root().delete("plain").unwrap();
7311            }),
7312            ("Group::set_attr", Change::Relocating, |f| {
7313                f.root().set_attr("a", AttrValue::I32(1)).unwrap();
7314            }),
7315            ("Group::remove_attr", Change::Relocating, |f| {
7316                f.root().set_attr("gone", AttrValue::I32(1)).unwrap();
7317                f.commit().unwrap();
7318                f.root().remove_attr("gone").unwrap();
7319            }),
7320            ("Group::create_group_with", Change::Relocating, |f| {
7321                f.root()
7322                    .create_group_with("built", |g| {
7323                        g.set_attr("a", AttrValue::I32(1));
7324                    })
7325                    .unwrap();
7326            }),
7327            ("File::copy", Change::Relocating, |f| {
7328                f.copy("plain", "copied").unwrap();
7329            }),
7330            ("File::commit", Change::Relocating, |f| {
7331                f.root().create_group("committed").unwrap();
7332                f.commit().unwrap();
7333            }),
7334        ];
7335
7336        let revisions = |f: &File| (f.inner.content_revision(), f.inner.address_revision());
7337        let declared = |c: Change| match c {
7338            Change::Relocating => "Relocating",
7339            Change::InPlace => "InPlace",
7340            Change::Nothing => "Nothing",
7341        };
7342        for (name, change, step) in steps {
7343            let path = dir.path().join(format!("{}.h5", name.replace("::", "_")));
7344            revalidation_fixture(&path);
7345            let file = File::open_rw(&path).unwrap();
7346            let before = revisions(&file);
7347            step(&file);
7348            let after = revisions(&file);
7349            let observed = match (after.0 > before.0, after.1 > before.1) {
7350                (true, true) => "Relocating",
7351                (true, false) => "InPlace",
7352                (false, false) => "Nothing",
7353                (false, true) => "an address move with no content change",
7354            };
7355            assert_eq!(
7356                observed,
7357                declared(change),
7358                "{name} is declared here as one thing and behaves as another \
7359                 ({before:?} -> {after:?})"
7360            );
7361            file.close().unwrap();
7362        }
7363
7364        // `close` consumes the file, so it cannot be a row above. It is two
7365        // operations: the commit it makes, which relocates like any other, and
7366        // the teardown, which re-homes free space and releases status flags
7367        // where they stand. Both counters therefore move, and the content one
7368        // moves twice.
7369        let path = dir.path().join("File_close.h5");
7370        revalidation_fixture(&path);
7371        let file = File::open_rw(&path).unwrap();
7372        let before = revisions(&file);
7373        let by_path = file.dataset("plain").unwrap();
7374        let by_ref = {
7375            let mut b = FileBuilder::new();
7376            b.create_dataset("d").with_i32_data(&[1, 2]);
7377            b.create_dataset("refs").with_path_references(&["d"]);
7378            let refs_path = dir.path().join("File_close_refs.h5");
7379            b.write(&refs_path).unwrap();
7380            let refs = File::open_rw(&refs_path).unwrap();
7381            let handle = match refs
7382                .dataset("refs")
7383                .unwrap()
7384                .dereference()
7385                .unwrap()
7386                .remove(0)
7387            {
7388                Object::Dataset(ds) => *ds,
7389                other => panic!("expected a dataset, got {other:?}"),
7390            };
7391            refs.close().unwrap();
7392            handle
7393        };
7394        // `close` consumes its `File`; the counters live on the shared inner
7395        // state every handle holds, so read them back through one of those.
7396        let inner = Arc::clone(&file.inner);
7397        file.close().unwrap();
7398        let after = (inner.content_revision(), inner.address_revision());
7399        assert_eq!(
7400            (after.0 - before.0, after.1 - before.1),
7401            (2, 1),
7402            "File::close is a Relocating commit and an InPlace teardown"
7403        );
7404        assert_eq!(
7405            by_path.read_i32().unwrap(),
7406            vec![7, 8, 9],
7407            "`close` promises reads through surviving handles still work"
7408        );
7409        assert!(
7410            matches!(by_ref.read_i32(), Err(Error::StaleHandle)),
7411            "the one handle that cannot follow the commit `close` makes: {:?}",
7412            by_ref.read_i32()
7413        );
7414    }
7415
7416    /// What the *second* counter buys, which nothing else pins: an edit that
7417    /// moves no object header leaves a by-reference handle — the one kind that
7418    /// cannot look itself up again — still able to read. Collapse the two
7419    /// counters into one and every case here becomes `StaleHandle`.
7420    #[test]
7421    fn an_edit_that_moves_no_header_leaves_a_reference_handle_reading() {
7422        let dir = tempfile::tempdir().unwrap();
7423
7424        // Named for the same reason `Step` above is: a bare tuple of a name and
7425        // a function pointer reads as noise at the call site.
7426        type Case = (&'static str, fn(&File));
7427        let cases: Vec<Case> = vec![
7428            (
7429                "an append through another handle to the same dataset",
7430                |f| {
7431                    f.dataset("log").unwrap().append(&[9i32]).unwrap();
7432                },
7433            ),
7434            ("an append to a different dataset", |f| {
7435                f.dataset("other").unwrap().append(&[9i32]).unwrap();
7436            }),
7437            ("a durability barrier", |f| {
7438                f.sync().unwrap();
7439            }),
7440        ];
7441
7442        for (name, edit) in cases {
7443            let path = dir.path().join(format!("{}.h5", name.replace(' ', "_")));
7444            let mut b = FileBuilder::new();
7445            for ds in ["log", "other"] {
7446                b.create_dataset(ds)
7447                    .with_i32_data(&[0, 1])
7448                    .with_shape(&[2])
7449                    .with_maxshape(&[MaxExtent::Unlimited])
7450                    .with_chunks(&[4]);
7451            }
7452            b.create_dataset("refs").with_path_references(&["log"]);
7453            b.write(&path).unwrap();
7454
7455            let file = File::open_rw(&path).unwrap();
7456            let by_ref = match file
7457                .dataset("refs")
7458                .unwrap()
7459                .dereference()
7460                .unwrap()
7461                .remove(0)
7462            {
7463                Object::Dataset(ds) => *ds,
7464                other => panic!("expected a dataset, got {other:?}"),
7465            };
7466            assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1], "{name}: before");
7467            edit(&file);
7468            assert!(
7469                by_ref.read_i32().is_ok(),
7470                "{name} moves no object header, so it must not end a handle that \
7471                 names its dataset by address: {:?}",
7472                by_ref.read_i32()
7473            );
7474            file.close().unwrap();
7475        }
7476    }
7477
7478    /// A commit can put something else at a handle's path — issue #305 makes a
7479    /// dataset and a group interchangeable in one commit. The refusal has to
7480    /// hold on *every* call: a header memoized and then refused is the answer
7481    /// each later call short-circuits on, and this handle would go on serving
7482    /// the other object's header without an error.
7483    #[test]
7484    fn a_handle_whose_path_becomes_a_group_keeps_refusing() {
7485        let dir = tempfile::tempdir().unwrap();
7486        let path = dir.path().join("replaced.h5");
7487        revalidation_fixture(&path);
7488
7489        let file = File::open_rw(&path).unwrap();
7490        let ds = file.dataset("plain").unwrap();
7491        assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
7492
7493        file.root().delete("plain").unwrap();
7494        file.root()
7495            .create_group_with("plain", |g| {
7496                g.set_attr("i_am_a_group", AttrValue::I32(42));
7497            })
7498            .unwrap();
7499        file.commit().unwrap();
7500
7501        for call in 1..=3 {
7502            assert!(
7503                matches!(ds.attrs(), Err(Error::NotADataset(ref p)) if p == "plain"),
7504                "call {call} answered {:?}",
7505                ds.attrs()
7506            );
7507            assert!(matches!(ds.read_i32(), Err(Error::NotADataset(_))));
7508            assert!(matches!(ds.shape(), Err(Error::NotADataset(_))));
7509        }
7510        file.close().unwrap();
7511    }
7512
7513    /// A read-only file cannot change under a handle, so nothing a reader does
7514    /// may move the counters — and no handle on one ever pays to re-resolve.
7515    #[test]
7516    fn reading_never_moves_the_file_on() {
7517        let dir = tempfile::tempdir().unwrap();
7518        let path = dir.path().join("readonly.h5");
7519        revalidation_fixture(&path);
7520
7521        let file = File::open(&path).unwrap();
7522        let _ = read_everything(&file);
7523        let ds = file.dataset("log").unwrap();
7524        let _ = ds.read_i32().unwrap();
7525        let _ = ds.attrs().unwrap();
7526        let _ = ds.layout().unwrap();
7527        let _ = file.root().groups().unwrap();
7528        assert_eq!(
7529            (file.inner.content_revision(), file.inner.address_revision()),
7530            (0, 0),
7531            "a read must not tell every handle its memo has expired"
7532        );
7533    }
7534
7535    // -----------------------------------------------------------------------
7536    // Opening an object as the wrong kind (issue #352)
7537    // -----------------------------------------------------------------------
7538
7539    /// A dataset at the root and a dataset one level down inside a group.
7540    ///
7541    /// Shared by the two sections below: opening one of those datasets *as* a
7542    /// group is issue #352, and resolving a path *through* one is issue #365, so
7543    /// the sibling refusals are provably about the same file.
7544    fn nested_dataset_bytes() -> Vec<u8> {
7545        let mut b = FileBuilder::new();
7546        b.create_dataset("plain").with_i32_data(&[1]);
7547        let mut g = b.create_group("g");
7548        g.create_dataset("inner").with_i32_data(&[2]);
7549        b.add_group(g.finish());
7550        b.finish().unwrap()
7551    }
7552
7553    /// The issue: a by-name group lookup took whatever the name resolved to.
7554    /// `H5Gopen` fails on a non-group, and so must this — at the lookup, where
7555    /// the caller can act on it, rather than at some later call on a handle that
7556    /// was never a group.
7557    ///
7558    /// The refusal matters most on the calls that did *not* fail: `attrs()`
7559    /// through such a handle answered with the dataset's attributes, which is a
7560    /// wrong answer rather than an error.
7561    #[test]
7562    fn opening_a_dataset_as_a_group_is_refused() {
7563        let file = File::from_bytes(nested_dataset_bytes()).unwrap();
7564        let nested = file.group("g").unwrap();
7565
7566        // Both by-name forms — from the file by path, and from a group by child
7567        // name — at the root and one level down. Each is its own lookup, and
7568        // each was missing the check. The refusal names the object, which is
7569        // what the old `PathNotFound("object header is not a group")` could not.
7570        for (label, named, got) in [
7571            ("File::group", "plain", file.group("plain")),
7572            ("Group::group", "plain", file.root().group("plain")),
7573            ("File::group nested", "g/inner", file.group("g/inner")),
7574            (
7575                "Group::group from a subgroup",
7576                "g/inner",
7577                nested.group("inner"),
7578            ),
7579        ] {
7580            assert!(
7581                matches!(&got, Err(Error::NotAGroup(p)) if p == named),
7582                "{label} answered {:?}",
7583                got.map(|_| "a group")
7584            );
7585        }
7586
7587        // The name it reports is the normalized one, so the same object refused
7588        // at a lookup and refused through a live handle names itself the same
7589        // way — a handle holds only the normalized path.
7590        assert!(matches!(file.group("/plain/"), Err(Error::NotAGroup(ref p)) if p == "plain"));
7591
7592        // A name that resolves to nothing stays distinct from one that resolves
7593        // to the wrong kind: the second reports what is there.
7594        assert!(matches!(
7595            file.group("absent"),
7596            Err(Error::Format(FormatError::PathNotFound(_)))
7597        ));
7598        assert!(matches!(
7599            file.root().group("absent"),
7600            Err(Error::Format(FormatError::PathNotFound(_)))
7601        ));
7602
7603        // And a real group still opens, by either form.
7604        assert!(file.group("g").is_ok());
7605        assert!(file.root().group("g").is_ok());
7606    }
7607
7608    /// A v1 symbol-table group must keep opening by name.
7609    ///
7610    /// The predicate that decides a lookup was, until this change, only a filter
7611    /// over a listing, where failing to recognise a form merely left a group out.
7612    /// Gating the lookup on it makes each form it names load-bearing, and the v1
7613    /// form is the one with no writer here to produce it — the bytes are a
7614    /// fixture, and a classifier that forgot the symbol table would refuse every
7615    /// group in every file written before the 1.8 format.
7616    #[test]
7617    fn a_symbol_table_group_still_opens_by_name() {
7618        let file =
7619            File::from_bytes(include_bytes!("../tests/data/unattributed/two_groups.h5").to_vec())
7620                .unwrap();
7621
7622        // Names, not a count: this file holds two one-child groups, so a lookup
7623        // that classified correctly and then took its sibling's address would
7624        // pass any count worth asserting.
7625        for lookup in [file.group("group1"), file.root().group("group1")] {
7626            assert_eq!(lookup.unwrap().datasets().unwrap(), ["values"]);
7627        }
7628    }
7629
7630    /// A committed datatype is neither a dataset nor a group, so it separates
7631    /// "is a group" from "is not a dataset" — a check written as the latter
7632    /// would let this one through.
7633    #[test]
7634    fn opening_a_named_datatype_as_a_group_is_refused() {
7635        let mut b = FileBuilder::new();
7636        b.commit_datatype("mytype", crate::make_i32_type());
7637        let file = File::from_bytes(b.finish().unwrap()).unwrap();
7638
7639        assert_eq!(file.root().named_datatypes().unwrap(), vec!["mytype"]);
7640        assert!(matches!(file.group("mytype"), Err(Error::NotAGroup(_))));
7641        assert!(matches!(
7642            file.root().group("mytype"),
7643            Err(Error::NotAGroup(_))
7644        ));
7645    }
7646
7647    // -----------------------------------------------------------------------
7648    // Opening something else as a named datatype (issue #364)
7649    // -----------------------------------------------------------------------
7650
7651    /// An object header carrying exactly `types`, and nothing that would make it
7652    /// parse: the predicate below reads message types and no message body.
7653    fn header_of(types: &[MessageType]) -> ObjectHeader {
7654        ObjectHeader {
7655            version: 2,
7656            messages: types
7657                .iter()
7658                .map(|&msg_type| crate::object_header::HeaderMessage {
7659                    msg_type,
7660                    size: 0,
7661                    flags: MessageFlags::NONE,
7662                    creation_order: None,
7663                    data: Vec::new(),
7664                })
7665                .collect(),
7666            reference_count: None,
7667            flags: 0,
7668            access_time: None,
7669            modification_time: None,
7670            change_time: None,
7671            birth_time: None,
7672        }
7673    }
7674
7675    /// The rule the listing and both by-name lookups now share, stated over the
7676    /// message combinations rather than over one file's children.
7677    ///
7678    /// Two of these cannot be produced by any writer, here or in the reference
7679    /// library, which is why this is a predicate test and not another fixture. A
7680    /// header carrying links *and* a datatype is a group to the C library, which
7681    /// asks whether it is a group before asking whether it is a datatype; a
7682    /// header carrying neither is no object class at all, and must not become a
7683    /// datatype by default.
7684    #[test]
7685    fn a_committed_datatype_is_a_datatype_that_is_neither_dataset_nor_group() {
7686        for (label, types, expected) in [
7687            ("a committed datatype", &[MessageType::DATATYPE][..], true),
7688            (
7689                "a dataset, whose element type is a datatype message too",
7690                &[MessageType::DATATYPE, MessageType::DATA_LAYOUT],
7691                false,
7692            ),
7693            (
7694                "a group with a link table",
7695                &[MessageType::LINK_INFO],
7696                false,
7697            ),
7698            (
7699                "a group with a symbol table",
7700                &[MessageType::SYMBOL_TABLE],
7701                false,
7702            ),
7703            (
7704                "a group carrying a datatype",
7705                &[MessageType::LINK_INFO, MessageType::DATATYPE],
7706                false,
7707            ),
7708            (
7709                "a header with no datatype at all",
7710                &[MessageType::DATASPACE],
7711                false,
7712            ),
7713        ] {
7714            assert_eq!(is_named_datatype(&header_of(types)), expected, "{label}");
7715        }
7716    }
7717
7718    /// The issue: the by-name datatype lookups asked only whether the child had
7719    /// a datatype message. Every dataset does — its element type — so a dataset
7720    /// answered, and the two entry points disagreed with the
7721    /// `named_datatypes()` listing about the same child.
7722    #[test]
7723    fn a_child_that_is_not_a_committed_datatype_is_refused_by_name() {
7724        let mut b = FileBuilder::new();
7725        b.commit_datatype("mytype", crate::make_i32_type());
7726        b.create_dataset("typed")
7727            .with_i32_data(&[1, 2, 3])
7728            .with_committed_datatype("mytype");
7729        b.create_dataset("plain").with_f64_data(&[1.0]);
7730        let g = b.create_group("g").finish();
7731        b.add_group(g);
7732        let file = File::from_bytes(b.finish().unwrap()).unwrap();
7733        let root = file.root();
7734
7735        // The listing is the contract both lookups now share, so it is what the
7736        // refusals below have to agree with.
7737        assert_eq!(root.named_datatypes().unwrap(), ["mytype"]);
7738
7739        // A dataset, a dataset carrying that very type, and a group: three kinds
7740        // that are not a committed datatype, against both entry points. Neither
7741        // had the check, so each needs its own assertion.
7742        for name in ["typed", "plain", "g"] {
7743            let got = root.named_datatype(name);
7744            assert!(
7745                matches!(&got, Err(Error::NotANamedDatatype(p)) if p == name),
7746                "named_datatype({name:?}) answered {got:?}"
7747            );
7748            let got = root.named_datatype_references(name);
7749            assert!(
7750                matches!(&got, Err(Error::NotANamedDatatype(p)) if p == name),
7751                "named_datatype_references({name:?}) answered {got:?}"
7752            );
7753        }
7754
7755        // A name that reaches nothing stays distinct from one that reaches the
7756        // wrong kind, as it is for `group` and `dataset`.
7757        assert!(matches!(
7758            root.named_datatype("absent"),
7759            Err(Error::Format(FormatError::PathNotFound(_)))
7760        ));
7761        assert!(matches!(
7762            root.named_datatype_references("absent"),
7763            Err(Error::Format(FormatError::PathNotFound(_)))
7764        ));
7765
7766        // And the committed type still reads, by both entry points — the value,
7767        // not merely `is_ok`, since a lookup that refused everything would pass
7768        // every assertion above.
7769        assert_eq!(
7770            root.named_datatype("mytype").unwrap(),
7771            crate::make_i32_type()
7772        );
7773        assert_eq!(root.named_datatype_references("mytype").unwrap(), 2);
7774    }
7775
7776    /// The mirror of [`a_handle_whose_path_becomes_a_group_keeps_refusing`]: a
7777    /// commit can leave a live group handle's path naming a dataset (issue
7778    /// #305), which is the one way past the lookup check above. The handle
7779    /// re-resolves, finds the wrong kind, and reports it on every call rather
7780    /// than serving the dataset's header as a group's.
7781    #[test]
7782    fn a_group_handle_whose_path_becomes_a_dataset_keeps_refusing() {
7783        let dir = tempfile::tempdir().unwrap();
7784        let path = dir.path().join("replaced_group.h5");
7785        revalidation_fixture(&path);
7786
7787        let file = File::open_rw(&path).unwrap();
7788        let group = file.group("g").unwrap();
7789        assert!(group.datasets().unwrap().is_empty());
7790
7791        file.root().delete("g").unwrap();
7792        file.root()
7793            .create_dataset("g", |b| {
7794                b.with_i32_data(&[7]);
7795            })
7796            .unwrap();
7797        file.commit().unwrap();
7798
7799        // `attrs` every time round: it is the call that answered with the
7800        // dataset's attributes instead of failing, and a check installed after
7801        // the memo rather than before it would let the second call through.
7802        for call in 1..=3 {
7803            assert!(
7804                matches!(group.attrs(), Err(Error::NotAGroup(ref p)) if p == "g"),
7805                "call {call} answered {:?}",
7806                group.attrs()
7807            );
7808        }
7809        // The rest of the read surface funnels through the same re-resolve, so
7810        // once each is enough to say the refusal is the group's, not `attrs`'s.
7811        assert!(matches!(group.datasets(), Err(Error::NotAGroup(_))));
7812        assert!(matches!(group.groups(), Err(Error::NotAGroup(_))));
7813        assert!(matches!(
7814            group.dataset("anything"),
7815            Err(Error::NotAGroup(_))
7816        ));
7817
7818        // The path still names something, and that something still opens as
7819        // what it now is.
7820        assert_eq!(file.dataset("g").unwrap().read_i32().unwrap(), vec![7]);
7821        file.close().unwrap();
7822    }
7823
7824    // -----------------------------------------------------------------------
7825    // Resolving a path *through* something that is not a group (issue #365)
7826    // -----------------------------------------------------------------------
7827
7828    /// The issue: resolution opens each component in turn to look the next one
7829    /// up inside it, and reported a component that is not a group as
7830    /// `PathNotFound("object header is not a group")` — one string for every
7831    /// such path, naming no component at all. It read as "this path does not
7832    /// exist" where the truth was "`plain` is a dataset".
7833    ///
7834    /// It is now the same [`Error::NotAGroup`] a *final* component that is not a
7835    /// group returns (issue #352), so one match covers a path that goes wrong
7836    /// anywhere along it, and it names the object that stopped the walk rather
7837    /// than the path that was asked for.
7838    #[test]
7839    fn a_path_through_a_non_group_names_the_object_that_stopped_it() {
7840        let file = File::from_bytes(nested_dataset_bytes()).unwrap();
7841
7842        // Two entry points, because each resolves the path for itself, and two
7843        // depths, because the name is the whole prefix walked rather than the
7844        // one component: `g/inner` is a path the caller can go and open, where a
7845        // bare `inner` would not say where to find it.
7846        for (asked, stopper) in [("plain/sub", "plain"), ("g/inner/deeper", "g/inner")] {
7847            for (entry, got) in [
7848                ("File::group", file.group(asked).map(|_| ())),
7849                ("File::dataset", file.dataset(asked).map(|_| ())),
7850            ] {
7851                assert!(
7852                    matches!(&got, Err(Error::NotAGroup(p)) if p == stopper),
7853                    "{entry}({asked:?}) answered {got:?}, expected the stop at {stopper:?}"
7854                );
7855            }
7856        }
7857
7858        // A component that names nothing at all stays a `PathNotFound` naming
7859        // it. The two are different facts, and reading differently is the whole
7860        // point of the change.
7861        assert!(matches!(
7862            file.group("absent/sub"),
7863            Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "absent"
7864        ));
7865
7866        // Empty components are dropped before the walk, so the object is named
7867        // the same way however the path was spelled.
7868        assert!(matches!(
7869            file.group("/plain//sub/"),
7870            Err(Error::NotAGroup(ref p)) if p == "plain"
7871        ));
7872
7873        // The name reaches a caller that only prints the error, too.
7874        let printed = file
7875            .group("g/inner/deeper")
7876            .map(|_| ())
7877            .unwrap_err()
7878            .to_string();
7879        assert!(printed.contains("g/inner"), "the message read {printed:?}");
7880
7881        // And a path that really does run through groups still resolves, so the
7882        // classification has not turned the walk itself into a refusal.
7883        assert_eq!(file.dataset("g/inner").unwrap().read_i32().unwrap(), [2]);
7884
7885        // A committed datatype is neither a dataset nor a group, so it separates
7886        // "is not a group" from "is a dataset" for an intermediate component the
7887        // way it does for a final one.
7888        let mut b = FileBuilder::new();
7889        b.commit_datatype("mytype", crate::make_i32_type());
7890        let typed = File::from_bytes(b.finish().unwrap()).unwrap();
7891        assert!(matches!(
7892            typed.group("mytype/sub"),
7893            Err(Error::NotAGroup(ref p)) if p == "mytype"
7894        ));
7895    }
7896
7897    /// The streaming walk is a second copy of the same loop, reading each header
7898    /// from a `Source`. A fix applied to one and not the other would leave the
7899    /// backend that exists for files too large to buffer reporting the old
7900    /// string.
7901    #[test]
7902    fn the_streaming_walk_names_the_object_that_stopped_it_too() {
7903        let dir = tempfile::tempdir().unwrap();
7904        let path = dir.path().join("nested.h5");
7905        std::fs::write(&path, nested_dataset_bytes()).unwrap();
7906
7907        let file = File::open_streaming(&path).unwrap();
7908        for (asked, stopper) in [("plain/sub", "plain"), ("g/inner/deeper", "g/inner")] {
7909            let got = file.group(asked).map(|_| ());
7910            assert!(
7911                matches!(&got, Err(Error::NotAGroup(p)) if p == stopper),
7912                "group({asked:?}) answered {got:?}"
7913            );
7914        }
7915        assert!(matches!(
7916            file.group("absent/sub"),
7917            Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "absent"
7918        ));
7919        assert_eq!(file.dataset("g/inner").unwrap().read_i32().unwrap(), [2]);
7920        // Before the directory goes: an open file blocks its removal on Windows.
7921        drop(file);
7922    }
7923
7924    /// The root is the one group handle nothing classifies: `File::root` takes
7925    /// the address from the superblock, and no open validates that it names a
7926    /// group. So a file whose superblock points the root at a dataset reaches
7927    /// the refusal in `Group::child_address` that every other handle is kept
7928    /// away from — and names the root by the empty path it carries.
7929    #[test]
7930    fn a_root_that_is_not_a_group_refuses_rather_than_being_searched() {
7931        let mut bytes = nested_dataset_bytes();
7932        let sig = crate::signature::find_signature(&bytes).unwrap();
7933        let mut sb = crate::superblock::Superblock::parse(&bytes, sig).unwrap();
7934        // The fixture has no userblock, so its base address is zero and the
7935        // absolute address a walk returns is also the stored one the superblock
7936        // field wants.
7937        assert_eq!(sb.base_address, BaseAddress::ZERO);
7938        sb.root_group_address = group_v2::resolve_path_any(
7939            &bytes,
7940            AccessMode::ReadOnly,
7941            &sb,
7942            &ObjectPath::parse("plain"),
7943        )
7944        .unwrap();
7945        let rewritten = sb.serialize();
7946        bytes[sig..sig + rewritten.len()].copy_from_slice(&rewritten);
7947
7948        let file = File::from_bytes(bytes).unwrap();
7949        let got = file.root().dataset("anything").map(|_| ());
7950        assert!(
7951            matches!(&got, Err(Error::NotAGroup(p)) if p.is_empty()),
7952            "a root that is not a group must refuse, got {got:?}"
7953        );
7954    }
7955
7956    /// Read everything a read-write file can serve through the paired read
7957    /// paths, as comparable text.
7958    ///
7959    /// Each entry exercises a different `with_engine` call site: path
7960    /// resolution, object-header parsing, group listing, attribute reads (both
7961    /// the compact and the dense form), a whole-dataset read, and a row-range
7962    /// read. Errors are formatted rather than unwrapped so that a *divergence in
7963    /// which error* is reported also fails the comparison.
7964    fn read_everything(file: &File) -> Vec<String> {
7965        let mut out = Vec::new();
7966        out.push(format!("root groups: {:?}", file.root().groups()));
7967        out.push(format!("root datasets: {:?}", file.root().datasets()));
7968        out.push(format!("root attrs: {:?}", sorted(file.root().attrs())));
7969
7970        // `plain/nope` runs the walk through a dataset (issue #365): the two
7971        // backends must refuse it with the same error as well as agree on the
7972        // reads that succeed.
7973        for path in [
7974            "plain",
7975            "g/nested",
7976            "many_attrs",
7977            "missing",
7978            "g/missing",
7979            "plain/nope",
7980        ] {
7981            match file.dataset(path) {
7982                Ok(ds) => {
7983                    out.push(format!("{path}: shape {:?}", ds.shape()));
7984                    out.push(format!("{path}: attrs {:?}", sorted(ds.attrs())));
7985                    out.push(format!("{path}: all {:?}", ds.read_i32()));
7986                    out.push(format!("{path}: rows {:?}", ds.read_i32_rows(1, 2)));
7987                    out.push(format!("{path}: raw rows {:?}", ds.read_raw_rows(0, 1)));
7988                }
7989                Err(e) => out.push(format!("{path}: error {e}")),
7990            }
7991        }
7992        out
7993    }
7994
7995    /// Attribute maps compare only after ordering; `HashMap`'s `Debug` is not
7996    /// deterministic, and an ordering difference here would be noise rather
7997    /// than the divergence this is looking for.
7998    fn sorted(attrs: Result<HashMap<String, AttrValue>, Error>) -> Vec<String> {
7999        match attrs {
8000            Ok(map) => {
8001                let mut v: Vec<String> =
8002                    map.iter().map(|(k, val)| format!("{k}={val:?}")).collect();
8003                v.sort();
8004                v
8005            }
8006            Err(e) => vec![format!("error {e}")],
8007        }
8008    }
8009
8010    /// Drive `bytes` down both forms of every read a read-write file serves and
8011    /// require identical answers.
8012    ///
8013    /// Every read has a slice form (walking the whole-file mirror) and a
8014    /// `Source` form, and until a mirrorless backing lands (issue #198) only the
8015    /// slice form ever runs. This makes the other form reachable now, so it
8016    /// cannot quietly drift as its twin is edited.
8017    fn assert_both_read_paths_agree(bytes: &[u8], what: &str) {
8018        let dir = tempfile::tempdir().unwrap();
8019        let path = dir.path().join("both.h5");
8020        std::fs::write(&path, bytes).unwrap();
8021
8022        // One session at a time: `open_rw` takes an exclusive lock, and holding
8023        // two over one path fails outright where OS locks are mandatory.
8024        let via_mirror = {
8025            let f = File::open_rw(&path).unwrap();
8026            read_everything(&f)
8027        };
8028        let via_source = {
8029            let f = File::open_rw_source_only(&path).unwrap();
8030            read_everything(&f)
8031        };
8032
8033        assert_eq!(
8034            via_mirror.len(),
8035            via_source.len(),
8036            "{what}: the two read paths produced different numbers of results"
8037        );
8038        for (m, s) in via_mirror.iter().zip(&via_source) {
8039            assert_eq!(m, s, "{what}: slice and Source read paths disagree");
8040        }
8041        // Guard the guard: a helper that read nothing would make the comparison
8042        // vacuous, and a file whose datasets all failed to open would too.
8043        assert!(
8044            via_mirror.iter().any(|r| r.contains("all Ok(")),
8045            "{what}: no dataset read succeeded, so this compared nothing"
8046        );
8047    }
8048
8049    /// A file exercising each paired read: a plain dataset, a nested one behind
8050    /// a group (path resolution), and one carrying enough attributes to force
8051    /// the dense (fractal-heap) attribute layout rather than compact messages.
8052    fn both_paths_file_bytes(userblock: Option<u64>) -> Vec<u8> {
8053        let mut b = FileBuilder::new();
8054        if let Some(ub) = userblock {
8055            b.with_userblock(ub);
8056        }
8057        b.create_dataset("plain")
8058            .with_i32_data(&(0..24).collect::<Vec<i32>>())
8059            .with_shape(&[6, 4])
8060            .set_attr("units", AttrValue::String("m".into()));
8061        // Well past the eight-attribute compact limit, so the header converts to
8062        // the dense layout and the dense extraction path is the one that runs.
8063        {
8064            let ds = b
8065                .create_dataset("many_attrs")
8066                .with_i32_data(&(0..8).collect::<Vec<i32>>());
8067            for i in 0..24 {
8068                ds.set_attr(&format!("attr_{i:02}"), AttrValue::I64(i));
8069            }
8070        }
8071        let mut g = b.create_group("g");
8072        g.create_dataset("nested")
8073            .with_i32_data(&(100..112).collect::<Vec<i32>>())
8074            .with_shape(&[3, 4]);
8075        b.add_group(g.finish());
8076        b.finish().unwrap()
8077    }
8078
8079    #[test]
8080    fn both_read_paths_agree() {
8081        assert_both_read_paths_agree(&both_paths_file_bytes(None), "no userblock");
8082    }
8083
8084    /// The userblock case is the one where the two forms are built differently:
8085    /// the slice form reframes by slicing at the base address, the `Source` form
8086    /// wraps in a `BaseOffsetSource`. A file with a nonzero base is the only way
8087    /// to compare them.
8088    #[test]
8089    fn both_read_paths_agree_with_a_userblock() {
8090        assert_both_read_paths_agree(&both_paths_file_bytes(Some(512)), "512-byte userblock");
8091    }
8092
8093    /// One 256-element i32 dataset, chunked into 32-element chunks, in memory.
8094    fn chunked_file_bytes() -> Vec<u8> {
8095        let data: Vec<i32> = (0..256).collect();
8096        let mut b = FileBuilder::new();
8097        b.create_dataset("chunked")
8098            .with_i32_data(&data)
8099            .with_shape(&[256])
8100            .with_chunks(&[32]);
8101        b.finish().unwrap()
8102    }
8103
8104    // The DAPL override must drive the *live* `ChunkCache`, not merely the value
8105    // reported by `chunk_cache_config()`. These assertions reach the crate's
8106    // `#[cfg(test)]` cache introspection (unavailable to integration tests), so
8107    // they fail if the resolved config ever stops flowing into the real cache.
8108
8109    #[test]
8110    fn enabled_override_populates_live_cache_over_disabled_file_default() {
8111        let file = File::from_bytes_with_options(
8112            chunked_file_bytes(),
8113            FileAccessProperties::new().with_chunk_cache(ChunkCacheConfig::disabled()),
8114        )
8115        .unwrap();
8116
8117        let ds = file
8118            .dataset_with_options(
8119                "chunked",
8120                DatasetAccessProperties::new().with_chunk_cache(ChunkCacheConfig::new()),
8121            )
8122            .unwrap();
8123        assert_eq!(ds.read_i32().unwrap(), (0..256).collect::<Vec<i32>>());
8124
8125        // The enabled override built the chunk index and retained chunks; the
8126        // disabled file default would have left both empty.
8127        assert!(ds.chunk_cache_stats().index_loaded());
8128        assert!(ds.chunk_cache_stats().cached_chunks() > 0);
8129    }
8130
8131    #[test]
8132    fn disabled_override_suppresses_live_cache_over_enabled_file_default() {
8133        let file = File::from_bytes_with_options(
8134            chunked_file_bytes(),
8135            FileAccessProperties::new().with_chunk_cache(ChunkCacheConfig::new()),
8136        )
8137        .unwrap();
8138
8139        let ds = file
8140            .dataset_with_options(
8141                "chunked",
8142                DatasetAccessProperties::new().with_chunk_cache(ChunkCacheConfig::disabled()),
8143            )
8144            .unwrap();
8145        assert_eq!(ds.read_i32().unwrap(), (0..256).collect::<Vec<i32>>());
8146
8147        // The disabled override suppressed the index and chunk retention; the
8148        // enabled file default would have populated both.
8149        assert!(!ds.chunk_cache_stats().index_loaded());
8150        assert_eq!(ds.chunk_cache_stats().cached_chunks(), 0);
8151    }
8152
8153    /// A group child whose stored (base-relative) object-header address overflows
8154    /// `u64` once the base address is added must be rejected, not wrapped or
8155    /// panicked on. Reaching this needs a nonzero base address, so the file
8156    /// carries a userblock; the child link's stored address is then rewritten to
8157    /// `HADDR_UNDEF` (all ones) so adding the base to it overflows.
8158    #[test]
8159    fn group_child_address_base_overflow_is_rejected() {
8160        const UB: u64 = 512;
8161        let mut b = FileBuilder::new();
8162        b.with_userblock(UB);
8163        let mut child = b.create_group("child");
8164        child.create_dataset("inner").with_i32_data(&[1, 2, 3]);
8165        b.add_group(child.finish());
8166        let mut bytes = b.finish().unwrap();
8167
8168        // Baseline: the file reads and the subgroup is listed.
8169        let file = File::from_bytes(bytes.clone()).unwrap();
8170        assert_eq!(file.root().groups().unwrap(), vec!["child".to_string()]);
8171
8172        // Rewrite the child's stored object-header address to HADDR_UNDEF. It is
8173        // stored base-relative (absolute minus the userblock base) and, for this
8174        // single-child file, appears exactly once in the bytes. The link lives in
8175        // the root object header's chunk-0.
8176        let stored = file
8177            .root()
8178            .group("child")
8179            .unwrap()
8180            .header_address()
8181            .unwrap()
8182            - UB;
8183        let needle = stored.to_le_bytes();
8184        let matches: Vec<usize> = bytes
8185            .windows(8)
8186            .enumerate()
8187            .filter(|(_, w)| *w == needle)
8188            .map(|(i, _)| i)
8189            .collect();
8190        assert_eq!(
8191            matches.len(),
8192            1,
8193            "stored child address {stored:#x} was not uniquely locatable: {matches:?}"
8194        );
8195        bytes[matches[0]..matches[0] + 8].copy_from_slice(&u64::MAX.to_le_bytes());
8196
8197        // The v2 object header is checksum-protected, so a real crafted file would
8198        // carry a matching checksum; recompute the root header's over the edited
8199        // bytes so parsing reaches the address normalization rather than failing on
8200        // the checksum first. Mirrors the chunk-0 extent from `parse_v2`.
8201        #[cfg(feature = "checksum")]
8202        {
8203            let root_addr = file.root().header_address().unwrap() as usize;
8204            assert_eq!(&bytes[root_addr..root_addr + 4], b"OHDR");
8205            let flags = bytes[root_addr + 5];
8206            let mut pos = root_addr + 6;
8207            if flags & 0x20 != 0 {
8208                pos += 16;
8209            }
8210            if flags & 0x10 != 0 {
8211                pos += 4;
8212            }
8213            let width = 1usize << (flags & 0x03);
8214            let chunk0 = (0..width).fold(0usize, |acc, i| {
8215                acc | ((bytes[pos + i] as usize) << (8 * i))
8216            });
8217            pos += width;
8218            let chunk0_end = pos + chunk0;
8219            assert!(
8220                matches[0] < chunk0_end,
8221                "patched link address is outside the root header's chunk-0"
8222            );
8223            let cs = crate::checksum::jenkins_lookup3(&bytes[root_addr..chunk0_end]);
8224            bytes[chunk0_end..chunk0_end + 4].copy_from_slice(&cs.to_le_bytes());
8225        }
8226
8227        // Iterating the root now normalizes `u64::MAX + base` and must surface the
8228        // overflow as a format error rather than panicking or wrapping.
8229        let file = File::from_bytes(bytes).unwrap();
8230        match file.root().groups() {
8231            Err(Error::Format(FormatError::OffsetOverflow { offset, length })) => {
8232                assert_eq!(offset, u64::MAX);
8233                assert_eq!(length, UB);
8234            }
8235            other => panic!("expected group-child address overflow, got {other:?}"),
8236        }
8237    }
8238
8239    /// Recompute a version-2 object header's checksum over its chunk 0, after a
8240    /// test has edited a message inside it.
8241    ///
8242    /// A crafted file a reader must survive carries a *valid* checksum — an
8243    /// attacker recomputes it — so a test that edits a header and leaves the old
8244    /// one measures the checksum rather than the thing it meant to.
8245    #[cfg(feature = "checksum")]
8246    fn refresh_v2_header_checksum(bytes: &mut [u8], header_addr: usize) -> std::ops::Range<usize> {
8247        assert_eq!(&bytes[header_addr..header_addr + 4], b"OHDR");
8248        let flags = bytes[header_addr + 5];
8249        let mut pos = header_addr + 6;
8250        if flags & 0x20 != 0 {
8251            pos += 16;
8252        }
8253        if flags & 0x10 != 0 {
8254            pos += 4;
8255        }
8256        let width = 1usize << (flags & 0x03);
8257        let chunk0 = (0..width).fold(0usize, |acc, i| {
8258            acc | ((bytes[pos + i] as usize) << (8 * i))
8259        });
8260        pos += width;
8261        let chunk0_end = pos + chunk0;
8262        let cs = crate::checksum::jenkins_lookup3(&bytes[header_addr..chunk0_end]);
8263        bytes[chunk0_end..chunk0_end + 4].copy_from_slice(&cs.to_le_bytes());
8264        header_addr..chunk0_end
8265    }
8266
8267    /// A contiguous layout whose declared size disagrees with its dataspace is
8268    /// refused, whatever the dataset's size and whichever read asks.
8269    ///
8270    /// The whole-dataset readers have always refused it. A typed read now takes a
8271    /// large dataset a row window at a time, and a window only ever checks that
8272    /// its *own* rows are inside the declared storage — so without the shared
8273    /// check this refusal would have applied to small datasets, which are still
8274    /// read whole, and not to large ones. A validation that fires depending on
8275    /// the size of the input is the kind that surfaces years later as an
8276    /// inconsistent bug report, which is why both sizes are here.
8277    #[cfg(feature = "checksum")]
8278    #[test]
8279    fn a_layout_size_disagreeing_with_the_dataspace_is_refused_at_every_dataset_size() {
8280        // One dataset below the typed read's window budget and read whole; one
8281        // above it and swept.
8282        for n in [1000usize, 200_000] {
8283            let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
8284            let mut b = crate::writer::FileBuilder::new();
8285            b.create_dataset("t")
8286                .with_f64_data(&data)
8287                .with_shape(&[n as u64]);
8288            let mut bytes = b.finish().unwrap();
8289            // Taken before the edit: an edited header fails its checksum, and the
8290            // address is needed to recompute it.
8291            let header_addr = {
8292                let file = File::from_bytes(bytes.clone()).unwrap();
8293                file.dataset("t").unwrap().header_address().unwrap() as usize
8294            };
8295
8296            // The layout message's size field: the dataset's byte length, which
8297            // appears once in the file. The assertion is the fixture's own guard —
8298            // patching some other field would test nothing in particular.
8299            let declared = (n * 8) as u64;
8300            let needle = declared.to_le_bytes();
8301            let at: Vec<usize> = bytes
8302                .windows(8)
8303                .enumerate()
8304                .filter(|(_, w)| *w == needle)
8305                .map(|(i, _)| i)
8306                .collect();
8307            assert_eq!(
8308                at.len(),
8309                1,
8310                "the stored size {declared} was not uniquely locatable in a {n}-element file: {at:?}"
8311            );
8312            bytes[at[0]..at[0] + 8].copy_from_slice(&(declared * 2).to_le_bytes());
8313
8314            let chunk0 = refresh_v2_header_checksum(&mut bytes, header_addr);
8315            assert!(
8316                chunk0.contains(&at[0]),
8317                "the patched size is outside the header chunk whose checksum was refreshed"
8318            );
8319
8320            let file = File::from_bytes(bytes).unwrap();
8321            let ds = file.dataset("t").unwrap();
8322            let expected = FormatError::DataSizeMismatch {
8323                expected: n * 8,
8324                actual: n * 16,
8325            };
8326            for (what, err) in [
8327                ("read_raw", ds.read_raw().unwrap_err()),
8328                ("read_f64", ds.read_f64().unwrap_err()),
8329                ("read_i32", ds.read_i32().unwrap_err()),
8330            ] {
8331                match err {
8332                    Error::Format(got) => assert_eq!(
8333                        format!("{got:?}"),
8334                        format!("{expected:?}"),
8335                        "{what} over {n} elements reported the wrong mismatch"
8336                    ),
8337                    other => {
8338                        panic!("{what} over {n} elements: expected a format error, got {other:?}")
8339                    }
8340                }
8341            }
8342        }
8343    }
8344
8345    /// A zero-row window returns `Ok(empty)` uniformly across layouts, including
8346    /// over unallocated storage. Unallocated storage now reads as the fill value
8347    /// rather than erroring, so this no longer guards a cross-layout divergence
8348    /// in the error; what it still pins is that a window of no rows is an *empty*
8349    /// buffer and not a zero-length fill, which is what a caller iterating past
8350    /// the end of a dataset sees.
8351    #[test]
8352    fn read_rows_framed_zero_row_window_is_ok_even_when_unallocated() {
8353        let dl = DataLayout::Contiguous {
8354            address: None,
8355            size: 0,
8356        };
8357        let ds = Dataspace {
8358            space_type: crate::dataspace::DataspaceType::Simple,
8359            rank: 1,
8360            dimensions: vec![0],
8361            max_dimensions: None,
8362        };
8363        let dt = Datatype::FixedPoint {
8364            size: 8,
8365            byte_order: DatatypeByteOrder::LittleEndian,
8366            layout: FixedPointLayout {
8367                signed: false,
8368                bit_offset: 0,
8369                bit_precision: 64,
8370            },
8371        };
8372        let cache = ChunkCache::new();
8373        let out = read_rows_framed(
8374            &BytesSource::new(b""),
8375            RawReadSpec::plain(&dl, &ds, &dt),
8376            8,
8377            8,
8378            &cache,
8379            CachePass::LRU,
8380            0,
8381            0,
8382            8,
8383        )
8384        .expect("a zero-row window must be Ok(empty)");
8385        assert!(out.is_empty());
8386
8387        // A Virtual layout is unsupported and must still error for a zero-row
8388        // window, matching `read_raw`, rather than being swallowed by the early
8389        // return.
8390        let virtual_dl = DataLayout::Virtual;
8391        let err = read_rows_framed(
8392            &BytesSource::new(b""),
8393            RawReadSpec::plain(&virtual_dl, &ds, &dt),
8394            8,
8395            8,
8396            &cache,
8397            CachePass::LRU,
8398            0,
8399            0,
8400            8,
8401        )
8402        .expect_err("a virtual layout must error even for a zero-row window");
8403        assert!(
8404            matches!(err, FormatError::UnsupportedVirtualLayout),
8405            "expected UnsupportedVirtualLayout, got {err:?}"
8406        );
8407    }
8408
8409    #[test]
8410    fn read_rows_framed_reads_a_compact_row_window() {
8411        let dl = DataLayout::Compact {
8412            data: (0..16u8).collect(),
8413        };
8414        let ds = Dataspace {
8415            space_type: DataspaceType::Simple,
8416            rank: 2,
8417            dimensions: vec![4, 4],
8418            max_dimensions: None,
8419        };
8420        let dt = Datatype::FixedPoint {
8421            size: 1,
8422            byte_order: DatatypeByteOrder::LittleEndian,
8423            layout: FixedPointLayout {
8424                signed: false,
8425                bit_offset: 0,
8426                bit_precision: 8,
8427            },
8428        };
8429        let cache = ChunkCache::new();
8430        let rows = |start_row, num_rows| {
8431            read_rows_framed(
8432                &BytesSource::new(b""),
8433                RawReadSpec::plain(&dl, &ds, &dt),
8434                8,
8435                8,
8436                &cache,
8437                CachePass::LRU,
8438                start_row,
8439                num_rows,
8440                4,
8441            )
8442        };
8443
8444        let out = rows(2, 1).expect("the third row of a compact dataset");
8445        assert_eq!(out, vec![8, 9, 10, 11]);
8446
8447        let err = rows(4, 1).expect_err("a window past the inline bytes");
8448        assert!(
8449            matches!(
8450                err,
8451                FormatError::DataSizeMismatch {
8452                    expected: 20,
8453                    actual: 16
8454                }
8455            ),
8456            "expected DataSizeMismatch, got {err:?}"
8457        );
8458    }
8459
8460    /// The window a typed whole-dataset read sweeps in is a budget in *stored
8461    /// bytes* resolved against the dataset's own geometry, and a chunked dataset
8462    /// adds a second rule on top: whole chunk bands.
8463    ///
8464    /// A window that ended mid-band would make the next window decode the band
8465    /// again — the cost windowing exists to avoid — so the budget is rounded down
8466    /// to a multiple of the band, and *up* to one whole band when even one band
8467    /// is over budget. The three ways a window can come out are one rule with
8468    /// different inputs, which is why they are asserted together.
8469    #[test]
8470    fn a_typed_read_windows_in_whole_chunk_bands() {
8471        const BUDGET: u64 = TYPED_READ_WINDOW_BYTES;
8472        let ds1 = |dims: &[u64]| Dataspace {
8473            space_type: crate::dataspace::DataspaceType::Simple,
8474            rank: dims.len() as u8,
8475            dimensions: dims.to_vec(),
8476            max_dimensions: None,
8477        };
8478        let contiguous = DataLayout::Contiguous {
8479            address: Some(StoredAddress::new(0)),
8480            size: 0,
8481        };
8482        let chunked = |band: u32| DataLayout::Chunked {
8483            flags: ChunkedLayoutFlags::NONE,
8484            chunk_dimensions: vec![u64::from(band), 8],
8485            index: ChunkIndexLayout::BTreeV1 {
8486                address: Some(StoredAddress::new(0)),
8487            },
8488        };
8489        let elem = NonZeroUsize::new(8).unwrap();
8490
8491        // Unchunked: the budget, divided by the row width, exactly.
8492        assert_eq!(
8493            typed_window_rows(&contiguous, &ds1(&[1 << 20]), elem)
8494                .unwrap()
8495                .get(),
8496            BUDGET / 8
8497        );
8498
8499        // A band that divides the budget takes it unchanged; one that does not
8500        // is rounded down to a whole number of bands, never up.
8501        assert_eq!(
8502            typed_window_rows(&chunked(512), &ds1(&[1 << 20]), elem)
8503                .unwrap()
8504                .get(),
8505            BUDGET / 8
8506        );
8507        let rows = typed_window_rows(&chunked(300), &ds1(&[1 << 20]), elem)
8508            .unwrap()
8509            .get();
8510        assert_eq!(rows % 300, 0, "a window must end on a chunk band");
8511        assert!(
8512            rows <= BUDGET / 8 && rows > BUDGET / 8 - 300,
8513            "a window must be the largest whole number of bands within the \
8514             budget, not a smaller one: {rows} rows against {} in budget",
8515            BUDGET / 8
8516        );
8517
8518        // One band over budget: the window is that band, since a narrower one
8519        // would decode it twice.
8520        assert_eq!(
8521            typed_window_rows(&chunked(1 << 20), &ds1(&[1 << 21]), elem)
8522                .unwrap()
8523                .get(),
8524            1 << 20
8525        );
8526
8527        // Rows wider than the whole budget: one row, which is the least a window
8528        // can be and still make progress.
8529        assert_eq!(
8530            typed_window_rows(&contiguous, &ds1(&[4, 1 << 20]), elem)
8531                .unwrap()
8532                .get(),
8533            1
8534        );
8535
8536        // Rank 0. A chunked layout message carries rank + 1 dimensions, so a
8537        // scalar's only entry is the element-size trailer and the "band" read
8538        // out of it is not a band at all. Nothing rests on it: a scalar has one
8539        // row, so `n0 <= rows` sends it to the whole read whatever this says.
8540        // Asserted so that a later reading of `first()` as the leading extent
8541        // has to account for this case rather than discover it.
8542        let scalar = Dataspace {
8543            space_type: crate::dataspace::DataspaceType::Scalar,
8544            rank: 0,
8545            dimensions: Vec::new(),
8546            max_dimensions: None,
8547        };
8548        assert!(typed_window_rows(&chunked(8), &scalar, elem).unwrap().get() >= 1);
8549
8550        // A zero inner dimension makes a row zero bytes wide, and the dataset
8551        // has no elements at all: one window covers it, and nothing divides by
8552        // zero on the way there.
8553        assert_eq!(
8554            typed_window_rows(&contiguous, &ds1(&[4, 0]), elem)
8555                .unwrap()
8556                .get(),
8557            u64::MAX
8558        );
8559    }
8560
8561    /// The two channels a caller has for one object's attributes.
8562    struct AttrChannels {
8563        owner: &'static str,
8564        /// What `attrs` decoded — the values, lossily.
8565        values: HashMap<String, AttrValue>,
8566        /// What `attr_datatypes` reported — the encodings, exactly.
8567        datatypes: HashMap<String, Datatype>,
8568    }
8569
8570    /// One written file: its bytes, and both channels read back from each owner.
8571    struct AttrFile {
8572        /// The file as written, so a test can assert which storage form it got.
8573        bytes: Vec<u8>,
8574        owners: Vec<AttrChannels>,
8575    }
8576
8577    /// Put the same attributes on the root group and on a dataset, write the
8578    /// file, and read both channels back from each owner.
8579    ///
8580    /// Both owners, because `Group` and `Dataset` reach their attribute messages
8581    /// by different routes: one parses an object header by address, the other
8582    /// already holds one.
8583    fn attr_channels(
8584        values: &[(&str, AttrValue)],
8585        verbatim: &[crate::attribute::AttributeMessage],
8586    ) -> AttrFile {
8587        let mut b = FileBuilder::new();
8588        for (name, value) in values {
8589            b.set_attr(name, value.clone());
8590        }
8591        for message in verbatim {
8592            b.set_attr_verbatim(message.clone());
8593        }
8594        {
8595            let ds = b.create_dataset("data").with_f64_data(&[1.0]);
8596            for (name, value) in values {
8597                ds.set_attr(name, value.clone());
8598            }
8599            for message in verbatim {
8600                ds.set_attr_verbatim(message.clone());
8601            }
8602        }
8603        let bytes = b.finish().unwrap();
8604        let file = File::from_bytes(bytes.clone()).unwrap();
8605        let root = file.root();
8606        let dataset = file.dataset("data").unwrap();
8607        AttrFile {
8608            bytes,
8609            owners: vec![
8610                AttrChannels {
8611                    owner: "root group",
8612                    values: root.attrs().unwrap(),
8613                    datatypes: root.attr_datatypes().unwrap(),
8614                },
8615                AttrChannels {
8616                    owner: "dataset",
8617                    values: dataset.attrs().unwrap(),
8618                    datatypes: dataset.attr_datatypes().unwrap(),
8619                },
8620            ],
8621        }
8622    }
8623
8624    /// The two channels on the axes each one carries.
8625    ///
8626    /// Both now report an integer's width: the value channel keeps it (#350),
8627    /// so `count` is `I32` and its datatype is the 4-byte signed type it is
8628    /// stored as. What the value channel still cannot say is how those bytes are
8629    /// laid out — `be` holds the same number big-endian and decodes to the same
8630    /// `I32`, so the datatype channel is the only record that re-encoding from
8631    /// the value would flip its byte order. `AttrValue` is documented as lossy;
8632    /// this is where the loss is (#248).
8633    #[test]
8634    fn attr_datatypes_reports_the_byte_order_attrs_normalizes() {
8635        let be = crate::attribute::AttributeMessage {
8636            name: "be".into(),
8637            datatype: Datatype::FixedPoint {
8638                size: 4,
8639                byte_order: DatatypeByteOrder::BigEndian,
8640                layout: FixedPointLayout {
8641                    signed: true,
8642                    bit_offset: 0,
8643                    bit_precision: 32,
8644                },
8645            },
8646            dataspace: Dataspace {
8647                space_type: crate::dataspace::DataspaceType::Scalar,
8648                rank: 0,
8649                dimensions: vec![],
8650                max_dimensions: None,
8651            },
8652            raw_data: (-7i32).to_be_bytes().to_vec(),
8653            datatype_location: crate::shared_message::DatatypeLocation::Inline,
8654        };
8655
8656        for c in attr_channels(&[("count", AttrValue::I32(-7))], std::slice::from_ref(&be)).owners {
8657            assert_eq!(
8658                c.values.get("count"),
8659                Some(&AttrValue::I32(-7)),
8660                "{}: the value channel keeps the width the attribute was written at",
8661                c.owner
8662            );
8663            let Some(Datatype::FixedPoint {
8664                size,
8665                byte_order: _,
8666                layout: FixedPointLayout { signed, .. },
8667            }) = c.datatypes.get("count")
8668            else {
8669                panic!(
8670                    "{}: expected a fixed-point datatype, got {:?}",
8671                    c.owner,
8672                    c.datatypes.get("count")
8673                );
8674            };
8675            assert_eq!(
8676                (*size, *signed),
8677                (4, true),
8678                "{}: the datatype channel must report the width on disk",
8679                c.owner
8680            );
8681
8682            assert_eq!(
8683                c.values.get("be"),
8684                Some(&AttrValue::I32(-7)),
8685                "{}: a big-endian attribute decodes to the value it holds",
8686                c.owner
8687            );
8688            let Some(Datatype::FixedPoint { byte_order, .. }) = c.datatypes.get("be") else {
8689                panic!(
8690                    "{}: expected a fixed-point datatype, got {:?}",
8691                    c.owner,
8692                    c.datatypes.get("be")
8693                );
8694            };
8695            assert_eq!(
8696                *byte_order,
8697                DatatypeByteOrder::BigEndian,
8698                "{}: the datatype channel is the only record of the byte order",
8699                c.owner
8700            );
8701        }
8702    }
8703
8704    /// Every attribute message is reported, including one `attrs` drops because
8705    /// no `AttrValue` can carry it.
8706    ///
8707    /// That is what lets a caller tell a dropped attribute from an absent one. An
8708    /// omission with nothing to compare against is invisible, which is how every
8709    /// `np.bool_` attribute in an h5py file went missing without a trace (#248).
8710    #[test]
8711    fn attr_datatypes_reports_an_attribute_attrs_omits() {
8712        let opaque = Datatype::Opaque {
8713            size: 3,
8714            tag: b"rgb".to_vec(),
8715        };
8716        let raw = crate::attribute::AttributeMessage {
8717            name: "raw".into(),
8718            datatype: opaque.clone(),
8719            dataspace: Dataspace {
8720                space_type: crate::dataspace::DataspaceType::Scalar,
8721                rank: 0,
8722                dimensions: vec![],
8723                max_dimensions: None,
8724            },
8725            raw_data: vec![1, 2, 3],
8726            datatype_location: crate::shared_message::DatatypeLocation::Inline,
8727        };
8728
8729        for c in attr_channels(&[("count", AttrValue::I32(1))], std::slice::from_ref(&raw)).owners {
8730            assert!(
8731                !c.values.contains_key("raw"),
8732                "{}: an opaque attribute has no `AttrValue`, so `attrs` omits it — \
8733                 if that changes, this test is measuring the wrong thing",
8734                c.owner
8735            );
8736            assert_eq!(
8737                c.datatypes.get("raw"),
8738                Some(&opaque),
8739                "{}: the datatype channel must report an attribute `attrs` omits",
8740                c.owner
8741            );
8742            // Specific to the one attribute: a channel that reported only the
8743            // undecodable one, or dropped its neighbour, would pass the above.
8744            assert!(
8745                c.values.contains_key("count") && c.datatypes.contains_key("count"),
8746                "{}: the attribute beside it must appear in both channels",
8747                c.owner
8748            );
8749        }
8750    }
8751
8752    /// The refusal reaches a dataset too, not only the attribute the issue was
8753    /// reported through: the same decoders back `Dataset::read_*`, by both the
8754    /// whole-dataset and the windowed route.
8755    #[test]
8756    fn a_wide_dataset_is_refused_by_the_typed_readers() {
8757        let dt = Datatype::FixedPoint {
8758            size: 16,
8759            byte_order: DatatypeByteOrder::LittleEndian,
8760            layout: FixedPointLayout {
8761                signed: false,
8762                bit_offset: 0,
8763                bit_precision: 128,
8764            },
8765        };
8766        let mut b = FileBuilder::new();
8767        b.create_dataset("wide")
8768            .with_raw_data(dt.clone(), vec![0xFF; 32], 2);
8769        let file = File::from_bytes(b.finish().unwrap()).unwrap();
8770        let ds = file.dataset("wide").unwrap();
8771
8772        assert_eq!(
8773            ds.datatype().unwrap(),
8774            dt,
8775            "the datatype still reads: it is the values that have no answer"
8776        );
8777        assert!(matches!(
8778            ds.read_u64(),
8779            Err(Error::Format(FormatError::NumericElementTooWide {
8780                size: 16
8781            }))
8782        ));
8783        assert!(matches!(
8784            ds.read_u64_rows(0, 1),
8785            Err(Error::Format(FormatError::NumericElementTooWide {
8786                size: 16
8787            }))
8788        ));
8789        // What is refused is the decode, not the data: the bytes are still
8790        // there for a caller willing to read the width itself.
8791        assert_eq!(ds.read_raw().unwrap().len(), 32);
8792    }
8793
8794    /// A fixed-point attribute wider than the 64-bit value the readers decode
8795    /// into joins the attributes `attrs` omits, rather than appearing there
8796    /// holding part of its value.
8797    ///
8798    /// This one used to be *present* in the values channel: nine bytes holding
8799    /// 2^64 read back as `U64(0)`, a value indistinguishable from an attribute
8800    /// that really holds zero. Omitting it puts it where the opaque attribute
8801    /// above already sits — absent from the values, reported in full by the
8802    /// datatypes — so a caller can see that something was dropped (#361).
8803    #[test]
8804    fn an_attribute_too_wide_to_decode_is_omitted_rather_than_truncated() {
8805        let wide = Datatype::FixedPoint {
8806            size: 9,
8807            byte_order: DatatypeByteOrder::LittleEndian,
8808            layout: FixedPointLayout {
8809                signed: false,
8810                bit_offset: 0,
8811                bit_precision: 72,
8812            },
8813        };
8814        // 2^64 exactly, so every one of the low 64 bits is zero.
8815        let mut raw_data = vec![0u8; 9];
8816        raw_data[8] = 1;
8817        let huge = crate::attribute::AttributeMessage {
8818            name: "huge".into(),
8819            datatype: wide.clone(),
8820            dataspace: Dataspace {
8821                space_type: crate::dataspace::DataspaceType::Scalar,
8822                rank: 0,
8823                dimensions: vec![],
8824                max_dimensions: None,
8825            },
8826            raw_data,
8827            datatype_location: crate::shared_message::DatatypeLocation::Inline,
8828        };
8829
8830        for c in attr_channels(&[("count", AttrValue::I32(1))], std::slice::from_ref(&huge)).owners
8831        {
8832            assert!(
8833                !c.values.contains_key("huge"),
8834                "{}: decoding this attribute would report 0 for a value of 2^64",
8835                c.owner
8836            );
8837            assert_eq!(
8838                c.datatypes.get("huge"),
8839                Some(&wide),
8840                "{}: the datatype channel must still report the width on disk",
8841                c.owner
8842            );
8843            assert!(
8844                c.values.contains_key("count") && c.datatypes.contains_key("count"),
8845                "{}: the attribute beside it must still decode",
8846                c.owner
8847            );
8848        }
8849    }
8850
8851    /// The channel must cover dense (fractal-heap) attribute storage, not only
8852    /// the compact form that lives in the object header.
8853    ///
8854    /// The two tests above use three attributes, which is well inside the
8855    /// writer's compact threshold, so on their own they leave every dense path
8856    /// unpinned: an implementation that walked `hdr.messages` directly and never
8857    /// touched the heap would pass both and report *nothing* for a real file with
8858    /// many attributes. Twelve is past the threshold, and the heap signature is
8859    /// asserted so the fixture cannot quietly revert to compact storage and take
8860    /// the coverage with it.
8861    #[test]
8862    fn attr_datatypes_covers_dense_attribute_storage() {
8863        let names: Vec<String> = (0..12).map(|i| format!("a{i:02}")).collect();
8864        let values: Vec<(&str, AttrValue)> = names
8865            .iter()
8866            .enumerate()
8867            .map(|(i, n)| {
8868                (
8869                    n.as_str(),
8870                    #[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
8871                    AttrValue::I32(i as i32),
8872                )
8873            })
8874            .collect();
8875
8876        let f = attr_channels(&values, &[]);
8877        assert!(
8878            f.bytes.windows(4).any(|w| w == b"FRHP"),
8879            "the fixture must really use dense storage, or this test proves \
8880             nothing beyond the compact path the other tests already cover"
8881        );
8882
8883        for c in f.owners {
8884            assert_eq!(
8885                c.datatypes.len(),
8886                names.len(),
8887                "{}: every attribute in the heap must be reported, got {:?}",
8888                c.owner,
8889                c.datatypes.keys().collect::<Vec<_>>()
8890            );
8891            // The two channels must agree on which attributes exist: all of these
8892            // decode, so neither one has anything to omit here.
8893            let mut from_values: Vec<&String> = c.values.keys().collect();
8894            let mut from_types: Vec<&String> = c.datatypes.keys().collect();
8895            from_values.sort();
8896            from_types.sort();
8897            assert_eq!(
8898                from_values, from_types,
8899                "{}: the channels disagree",
8900                c.owner
8901            );
8902            for name in &names {
8903                assert!(
8904                    matches!(
8905                        c.datatypes.get(name),
8906                        Some(Datatype::FixedPoint { size: 4, .. })
8907                    ),
8908                    "{}: {name} must keep its 4-byte width through the heap, got {:?}",
8909                    c.owner,
8910                    c.datatypes.get(name)
8911                );
8912            }
8913        }
8914    }
8915
8916    // -----------------------------------------------------------------------
8917    // Coalesced chunk reads (see `crate::chunk_span`)
8918    // -----------------------------------------------------------------------
8919
8920    /// A [`Source`] over a file image that counts what the reader asks the file
8921    /// for, so a test can assert read *volume* and not only the values returned.
8922    struct CountingSource {
8923        bytes: Vec<u8>,
8924        reads: Arc<AtomicUsize>,
8925        bytes_read: Arc<AtomicUsize>,
8926    }
8927
8928    impl Source for CountingSource {
8929        fn len(&self) -> u64 {
8930            self.bytes.len() as u64
8931        }
8932
8933        fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
8934            self.reads.fetch_add(1, Ordering::Relaxed);
8935            self.bytes_read.fetch_add(buf.len(), Ordering::Relaxed);
8936            BytesSource::new(&self.bytes).read_at(offset, buf)
8937        }
8938    }
8939
8940    /// Counters for one measured read.
8941    #[derive(Default)]
8942    struct ReadCounts {
8943        reads: Arc<AtomicUsize>,
8944        bytes: Arc<AtomicUsize>,
8945    }
8946
8947    impl ReadCounts {
8948        /// Run `f` and report `(reads, bytes)` it cost.
8949        fn measure<R>(&self, f: impl FnOnce() -> R) -> (usize, usize) {
8950            let r0 = self.reads.load(Ordering::Relaxed);
8951            let b0 = self.bytes.load(Ordering::Relaxed);
8952            f();
8953            (
8954                self.reads.load(Ordering::Relaxed) - r0,
8955                self.bytes.load(Ordering::Relaxed) - b0,
8956            )
8957        }
8958    }
8959
8960    /// A streaming [`File`] over `bytes` whose reads are counted, built the way
8961    /// [`File::open_streaming_with_options`] builds one over a file handle.
8962    fn counting_streaming_file(
8963        bytes: Vec<u8>,
8964        counts: &ReadCounts,
8965        access: FileAccessProperties,
8966    ) -> File {
8967        File::from_source_with_options(
8968            CountingSource {
8969                bytes,
8970                reads: Arc::clone(&counts.reads),
8971                bytes_read: Arc::clone(&counts.bytes),
8972            },
8973            access,
8974        )
8975        .expect("open from source")
8976    }
8977
8978    /// An `n`-element f64 dataset in chunks of `chunk` elements.
8979    fn chunked_f64_file(n: usize, chunk: u64) -> (Vec<u8>, Vec<f64>) {
8980        let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
8981        let mut builder = FileBuilder::new();
8982        builder
8983            .create_dataset("d")
8984            .with_f64_data(&data)
8985            .with_shape(&[n as u64])
8986            .with_chunks(&[chunk]);
8987        (builder.finish().expect("write file"), data)
8988    }
8989
8990    /// One chunk per row is what a writer that appends as data arrives
8991    /// produces; the rows land next to each other, so the streaming reader must
8992    /// fetch them in a few spans rather than one read each.
8993    #[test]
8994    fn a_streaming_read_coalesces_a_run_of_small_chunks() {
8995        let n = 1024usize;
8996        let (bytes, data) = chunked_f64_file(n, 1);
8997        let counts = ReadCounts::default();
8998        let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8999
9000        let mut got = Vec::new();
9001        let (reads, _) = counts.measure(|| got = file.dataset("d").unwrap().read_f64().unwrap());
9002        assert_eq!(got, data, "the coalesced read must return the same values");
9003
9004        // Reading each of the 1024 chunks on its own would cost at least that
9005        // many reads; the whole run plus its metadata fits in far fewer.
9006        assert!(
9007            reads < n / 8,
9008            "expected the {n} chunks to be coalesced into few reads, got {reads}"
9009        );
9010    }
9011
9012    /// A windowed read must coalesce only the chunks its window overlaps: a
9013    /// plan built over the dataset's whole chunk list would put the rows on
9014    /// either side of the window inside a span and read them for nothing.
9015    #[test]
9016    fn a_windowed_streaming_read_fetches_only_its_own_window() {
9017        let rows = 4096usize;
9018        let (bytes, data) = chunked_f64_file(rows, 4);
9019        let counts = ReadCounts::default();
9020        let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
9021        let ds = file.dataset("d").unwrap();
9022
9023        // The first window walks (and caches on this handle) the chunk index,
9024        // so measure the second: what it reads is the window's own chunks.
9025        assert_eq!(ds.read_f64_rows(0, 8).unwrap(), data[0..8]);
9026        let (_, window_bytes) =
9027            counts.measure(|| assert_eq!(ds.read_f64_rows(2048, 8).unwrap(), data[2048..2056]));
9028
9029        // Eight rows are two 32-byte chunks.
9030        assert!(
9031            window_bytes < 256,
9032            "an 8-row window read {window_bytes} bytes; it needs 64"
9033        );
9034    }
9035
9036    /// The read volume of a whole-dataset read must not depend on which read it
9037    /// is. The chunk list comes from the handle's cached index on every read
9038    /// after the first, and that index is a map: it yields no address order at
9039    /// all. A reader holding one coalesced span re-reads a whole span each time
9040    /// an unordered walk crosses back, so the second read of a dataset spanning
9041    /// more than one span cost two orders of magnitude more than the first.
9042    ///
9043    /// A regression here is probabilistic rather than certain — the map's order
9044    /// is seeded per process, and a run that happened to be sorted would pass —
9045    /// but with hundreds of chunks over two spans the chance of that is nil.
9046    /// Correct code passes deterministically.
9047    #[test]
9048    fn a_second_whole_read_costs_what_the_first_did() {
9049        // 512 KiB of f64 in 1 KiB chunks: more than one 256 KiB span, so an
9050        // unordered walk has somewhere to thrash between.
9051        let n = 64 * 1024usize;
9052        let dataset_bytes = n * 8;
9053        let (bytes, data) = chunked_f64_file(n, 128);
9054        let counts = ReadCounts::default();
9055        let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
9056        let ds = file.dataset("d").unwrap();
9057
9058        counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
9059        let (_, second) = counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
9060
9061        assert!(
9062            second <= dataset_bytes,
9063            "the second read fetched {second} bytes of a {dataset_bytes}-byte dataset"
9064        );
9065    }
9066
9067    /// The same, for row windows: the [streaming guide](crate::_guide::streaming) walks a
9068    /// dataset in windows on one handle, so every window but the first takes its chunk list from
9069    /// the cached index.
9070    ///
9071    /// Each window here covers more than one span, which is what it takes to
9072    /// see the defect: a window whose chunks all fit a single span is served
9073    /// out of that one buffer whatever order it walks them in.
9074    #[test]
9075    fn a_window_loop_costs_the_dataset_once() {
9076        // 2 MiB of f64 in 1 KiB chunks, read in 512 KiB windows — two spans
9077        // each.
9078        let rows = 256 * 1024usize;
9079        let dataset_bytes = rows * 8;
9080        let (bytes, data) = chunked_f64_file(rows, 128);
9081        let counts = ReadCounts::default();
9082        let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
9083        let ds = file.dataset("d").unwrap();
9084
9085        let window = 64 * 1024;
9086        let (_, total) = counts.measure(|| {
9087            for lo in (0..rows).step_by(window) {
9088                let got = ds.read_f64_rows(lo as u64, window as u64).unwrap();
9089                assert_eq!(got, data[lo..lo + window]);
9090            }
9091        });
9092
9093        assert!(
9094            total <= 2 * dataset_bytes,
9095            "a window loop over a {dataset_bytes}-byte dataset read {total} bytes"
9096        );
9097    }
9098
9099    /// A span must not cover a chunk the chunk cache already holds: the read
9100    /// skips that chunk, so those bytes would be fetched for nothing.
9101    ///
9102    /// The cache here is given room for the whole dataset. At the default
9103    /// sixteen slots a dataset this size evicts its own warm chunks as it
9104    /// walks, so every chunk misses on the second read and the plan has nothing
9105    /// to leave out — a config that cannot show the difference either way.
9106    #[test]
9107    fn a_warm_chunk_cache_is_not_re_fetched() {
9108        // 32 KiB of f64 in 32 chunks of 1 KiB, laid end to end.
9109        let n = 4096usize;
9110        let chunk_bytes = 1024usize;
9111        let (bytes, data) = chunked_f64_file(n, 128);
9112        let counts = ReadCounts::default();
9113        let file = counting_streaming_file(
9114            bytes,
9115            &counts,
9116            FileAccessProperties::new()
9117                .with_chunk_cache(ChunkCacheConfig::new().with_max_slots(64)),
9118        );
9119        let ds = file.dataset("d").unwrap();
9120
9121        // Warm the second half of the dataset: chunks 16..31.
9122        assert_eq!(ds.read_f64_rows(2048, 2048).unwrap(), data[2048..]);
9123        assert_eq!(
9124            ds.chunk_cache_stats().cached_chunks(),
9125            16,
9126            "the window's own chunks stay cached"
9127        );
9128
9129        let (_, second) = counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
9130        assert_eq!(
9131            second,
9132            16 * chunk_bytes,
9133            "only the cold half was needed; a span over the whole dataset would \
9134             have fetched {} bytes",
9135            32 * chunk_bytes
9136        );
9137    }
9138
9139    // -----------------------------------------------------------------------
9140    // Group member iterators (`iter_datasets` / `iter_groups`)
9141    // -----------------------------------------------------------------------
9142
9143    /// A root holding `datasets` datasets, `groups` subgroups (each with one
9144    /// dataset of its own) and one committed datatype, so a member walk has all
9145    /// three child kinds to sort apart.
9146    ///
9147    /// Names are not zero-padded, so lexical order and insertion order disagree
9148    /// past the tenth member: a walk that silently sorted would show up here.
9149    fn mixed_member_file(datasets: usize, groups: usize) -> Vec<u8> {
9150        let mut b = FileBuilder::new();
9151        b.commit_datatype("a_type", crate::make_i32_type());
9152        for i in 0..datasets {
9153            b.create_dataset(&format!("ds{i}"))
9154                .with_i32_data(&[i as i32, -(i as i32)]);
9155        }
9156        for i in 0..groups {
9157            let mut g = b.create_group(&format!("g{i}"));
9158            g.create_dataset("inner").with_i32_data(&[i as i32]);
9159            g.create_dataset("other").with_i32_data(&[-1]);
9160            b.add_group(g.finish());
9161        }
9162        b.finish().expect("write the fixture")
9163    }
9164
9165    /// The iterator must report exactly what opening each name reports: the same
9166    /// members, in the same order, resolving to the same objects. Anything the
9167    /// two disagree on is a member a caller would see differently for having
9168    /// chosen the cheaper walk.
9169    #[test]
9170    fn iter_datasets_agrees_with_opening_each_name() {
9171        let file = File::from_bytes(mixed_member_file(12, 3)).unwrap();
9172
9173        for group in [file.root(), file.group("g1").unwrap()] {
9174            let names = group.datasets().unwrap();
9175            assert!(
9176                !names.is_empty(),
9177                "the fixture must have members to compare"
9178            );
9179
9180            let iterated: Vec<(String, Dataset)> = group.iter_datasets().unwrap().collect();
9181            assert_eq!(
9182                iterated.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
9183                names,
9184                "the iterator must yield the members `datasets` lists, in that order"
9185            );
9186
9187            for (name, ds) in &iterated {
9188                let opened = group.dataset(name).unwrap();
9189                assert_eq!(
9190                    ds.header_address().unwrap(),
9191                    opened.header_address().unwrap(),
9192                    "{name}"
9193                );
9194                assert_eq!(ds.shape().unwrap(), opened.shape().unwrap(), "{name}");
9195                assert_eq!(ds.read_i32().unwrap(), opened.read_i32().unwrap(), "{name}");
9196            }
9197        }
9198    }
9199
9200    /// The subgroup counterpart, including that a handle it yields can be walked
9201    /// again — recursion through `iter_groups` is the shape it exists for.
9202    #[test]
9203    fn iter_groups_agrees_with_opening_each_name() {
9204        let file = File::from_bytes(mixed_member_file(4, 5)).unwrap();
9205        let root = file.root();
9206
9207        let names = root.groups().unwrap();
9208        let iterated: Vec<(String, Group)> = root.iter_groups().unwrap().collect();
9209        assert_eq!(
9210            iterated.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
9211            names,
9212            "the iterator must yield the subgroups `groups` lists, in that order"
9213        );
9214        assert_eq!(names.len(), 5);
9215
9216        for (name, group) in &iterated {
9217            assert_eq!(
9218                group.header_address().unwrap(),
9219                root.group(name).unwrap().header_address().unwrap(),
9220                "{name}"
9221            );
9222            let mut inner = group
9223                .iter_datasets()
9224                .unwrap()
9225                .map(|(n, _)| n)
9226                .collect::<Vec<_>>();
9227            inner.sort();
9228            assert_eq!(inner, ["inner", "other"], "{name} must be walkable in turn");
9229        }
9230    }
9231
9232    /// Each iterator must claim only its own kind of child. A committed datatype
9233    /// is the child that belongs to neither, and the one a walk asking only for
9234    /// datasets and groups would otherwise be free to mis-sort into either.
9235    #[test]
9236    fn the_member_iterators_sort_the_child_kinds_apart() {
9237        let file = File::from_bytes(mixed_member_file(3, 2)).unwrap();
9238        let root = file.root();
9239
9240        let datasets: Vec<String> = root.iter_datasets().unwrap().map(|(n, _)| n).collect();
9241        let groups: Vec<String> = root.iter_groups().unwrap().map(|(n, _)| n).collect();
9242
9243        assert_eq!(datasets, ["ds0", "ds1", "ds2"]);
9244        assert_eq!(groups, ["g0", "g1"]);
9245        assert_eq!(root.named_datatypes().unwrap(), ["a_type"]);
9246        assert!(
9247            !datasets.contains(&"a_type".to_string()) && !groups.contains(&"a_type".to_string()),
9248            "a committed datatype is neither a dataset nor a group"
9249        );
9250    }
9251
9252    /// The bytes a walk of `n` members reads, by each route: opening every name,
9253    /// and iterating handles.
9254    fn member_walk_bytes(n: usize) -> (usize, usize) {
9255        let mut b = FileBuilder::new();
9256        for i in 0..n {
9257            b.create_dataset(&format!("ds{i}"))
9258                .with_i32_data(&[i as i32]);
9259        }
9260        let bytes = b.finish().expect("write the fixture");
9261
9262        let counts = ReadCounts::default();
9263        let file = counting_streaming_file(bytes.clone(), &counts, FileAccessProperties::new());
9264        let (_, by_name) = counts.measure(|| {
9265            let root = file.root();
9266            for name in root.datasets().unwrap() {
9267                root.dataset(&name).unwrap();
9268            }
9269        });
9270
9271        let counts = ReadCounts::default();
9272        let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
9273        let (_, iterated) =
9274            counts.measure(|| for (_, _ds) in file.root().iter_datasets().unwrap() {});
9275
9276        (by_name, iterated)
9277    }
9278
9279    /// Opening members by name re-walks the group's link structure once per
9280    /// member; iterating handles walks it once. Bytes is the metric that
9281    /// separates them: a group this size keeps its links inline in the object
9282    /// header, so that header grows with the member count and re-reading it per
9283    /// member is quadratic, while the *number* of reads stays linear either way
9284    /// and would show almost nothing.
9285    ///
9286    /// Asserted as how the cost scales rather than as one fixture's byte count,
9287    /// since a fixed number would pass just as well on a walk that stayed
9288    /// quadratic with a smaller constant.
9289    #[test]
9290    fn iterating_members_enumerates_the_group_once() {
9291        let (_, iterated_16) = member_walk_bytes(16);
9292        let (by_name_64, iterated_64) = member_walk_bytes(64);
9293
9294        // The rule this test exists for, and the only assertion here that is
9295        // about `iter_datasets` itself.
9296        assert!(
9297            iterated_64 <= 5 * iterated_16,
9298            "one enumeration plus one header per member is linear, so four times \
9299             the members must cost about four times the bytes: {iterated_16} -> \
9300             {iterated_64}"
9301        );
9302
9303        // Contrast, not a property of this code: it holds because `Group::dataset`
9304        // re-enumerates. If a future change makes that route cheap enough to turn
9305        // this red, nothing here has regressed — confirm the scaling assertion
9306        // above still holds and then drop this one.
9307        assert!(
9308            iterated_64 < by_name_64,
9309            "at 64 members the one-enumeration walk should still be the cheaper: \
9310             {iterated_64} bytes against {by_name_64}"
9311        );
9312    }
9313
9314    /// A yielded handle must carry the chunk-cache configuration the file was
9315    /// opened with, the same one `dataset` resolves for it.
9316    ///
9317    /// Nothing about the values read would show a handle that quietly ignored
9318    /// it: the cache decides how often the chunk index is re-parsed and how much
9319    /// decompressed data is retained, not what comes back. So the configuration
9320    /// has to be asserted directly, or dropping it here is a silent regression.
9321    #[test]
9322    fn a_member_handle_carries_the_files_chunk_cache_config() {
9323        let configured = ChunkCacheConfig::new()
9324            .with_max_slots(17)
9325            .with_max_bytes(4096)
9326            .with_index_cache(false);
9327        let file = File::from_bytes_with_options(
9328            mixed_member_file(3, 0),
9329            FileAccessProperties::new().with_chunk_cache(configured),
9330        )
9331        .unwrap();
9332        let root = file.root();
9333
9334        let members: Vec<(String, Dataset)> = root.iter_datasets().unwrap().collect();
9335        assert_eq!(members.len(), 3);
9336        for (name, ds) in &members {
9337            assert_eq!(
9338                ds.chunk_cache_config(),
9339                root.dataset(name).unwrap().chunk_cache_config(),
9340                "{name} must resolve its cache the way `dataset` does"
9341            );
9342            assert_eq!(
9343                ds.chunk_cache_config(),
9344                configured,
9345                "{name} must carry the configuration the file was opened with"
9346            );
9347        }
9348    }
9349
9350    /// A file whose root holds `inner` and `sub`, and a group `g0` holding
9351    /// children of those same names, plus a `refs` dataset pointing at `g0`.
9352    ///
9353    /// The duplicated names are the trap: a member handle that wrongly took a
9354    /// root-relative path would address a real object rather than fail, so the
9355    /// mistake would look like a successful write.
9356    fn dereferenced_group_file() -> Vec<u8> {
9357        let mut b = FileBuilder::new();
9358        b.create_dataset("inner").with_i32_data(&[0]);
9359        let mut root_sub = b.create_group("sub");
9360        root_sub.create_dataset("x").with_i32_data(&[0]);
9361        b.add_group(root_sub.finish());
9362
9363        let mut g = b.create_group("g0");
9364        g.create_dataset("inner").with_i32_data(&[1]);
9365        let mut nested = g.create_group("sub");
9366        nested.create_dataset("x").with_i32_data(&[1]);
9367        g.add_group(nested.finish());
9368        b.add_group(g.finish());
9369
9370        b.create_dataset("refs").with_path_references(&["g0"]);
9371        b.finish().expect("write the fixture")
9372    }
9373
9374    /// A group reached by object reference has no resolvable path, so neither can
9375    /// its members: there is nothing for a write through one to address. The
9376    /// iterators must carry that `None` across rather than fall back to a
9377    /// root-relative path, which here would reach a different, real object.
9378    #[test]
9379    fn members_of_a_dereferenced_group_have_no_path() {
9380        let dir = tempfile::tempdir().unwrap();
9381        let path = dir.path().join("refs.h5");
9382        std::fs::write(&path, dereferenced_group_file()).unwrap();
9383
9384        let file = File::open_rw(&path).unwrap();
9385        let mut objects = file.dataset("refs").unwrap().dereference().unwrap();
9386        let group = match objects.remove(0) {
9387            Object::Group(g) => g,
9388            other => panic!("expected a group, got {other:?}"),
9389        };
9390
9391        // The file is writable and both names exist at the root, so a refusal
9392        // here can only come from the handle having no path to address.
9393        let (name, mut member) = group.iter_datasets().unwrap().next().unwrap();
9394        assert_eq!(name, "inner");
9395        assert!(
9396            matches!(
9397                member.set_attr("tag", AttrValue::I64(1)),
9398                Err(Error::ReadOnly)
9399            ),
9400            "a member of a path-less group must refuse a write, not address `/inner`"
9401        );
9402
9403        let (name, subgroup) = group.iter_groups().unwrap().next().unwrap();
9404        assert_eq!(name, "sub");
9405        assert!(
9406            matches!(
9407                subgroup.set_attr("tag", AttrValue::I64(1)),
9408                Err(Error::ReadOnly)
9409            ),
9410            "and so must a subgroup of one, not address `/sub`"
9411        );
9412    }
9413
9414    /// A yielded handle must carry the same root-relative path as one opened by
9415    /// name, or a write through it would address the wrong object — or no object
9416    /// at all. A member of a *subgroup* is the case that separates them, since
9417    /// its path has a prefix to get right.
9418    #[test]
9419    fn a_member_handle_resolves_to_its_own_path() {
9420        let dir = tempfile::tempdir().unwrap();
9421        let path = dir.path().join("members.h5");
9422        std::fs::write(&path, mixed_member_file(2, 2)).unwrap();
9423
9424        let file = File::open_rw(&path).unwrap();
9425        let group = file.group("g1").unwrap();
9426        let (name, mut ds) = group
9427            .iter_datasets()
9428            .unwrap()
9429            .find(|(n, _)| n == "inner")
9430            .expect("g1/inner");
9431        assert_eq!(name, "inner");
9432        ds.set_attr("tag", AttrValue::I64(7)).unwrap();
9433        file.commit().unwrap();
9434        // Windows holds the write lock until the session is dropped.
9435        drop(ds);
9436        drop(group);
9437        drop(file);
9438
9439        let file = File::open(&path).unwrap();
9440        assert_eq!(
9441            file.dataset("g1/inner")
9442                .unwrap()
9443                .attrs()
9444                .unwrap()
9445                .get("tag")
9446                .and_then(AttrValue::as_i64),
9447            Some(7),
9448            "the attribute must land on the member the handle came from"
9449        );
9450        assert!(
9451            !file
9452                .dataset("g0/inner")
9453                .unwrap()
9454                .attrs()
9455                .unwrap()
9456                .contains_key("tag"),
9457            "and on no other group's member of the same name"
9458        );
9459    }
9460
9461    /// Every read-write entry point has to hand the fapl's `fsync` cadence to
9462    /// the session it opens, and none of them can be checked from outside: a
9463    /// skipped barrier writes the same bytes as an issued one (issue #263).
9464    ///
9465    /// One entry point missing the funnel is the whole failure mode, so this
9466    /// asserts the property at each of them rather than at the funnel.
9467    #[test]
9468    fn every_read_write_open_carries_the_fapl_sync_policy() {
9469        use tempfile::tempdir;
9470
9471        let dir = tempdir().unwrap();
9472        let fixture = |name: &str| {
9473            let path = dir.path().join(name);
9474            let mut b = FileBuilder::new();
9475            b.create_dataset("d")
9476                .with_i32_data(&[1, 2, 3, 4])
9477                .with_shape(&[4])
9478                .with_maxshape(&[MaxExtent::Unlimited])
9479                .with_chunks(&[2]);
9480            b.write(&path).unwrap();
9481            path
9482        };
9483        let props = || FileAccessProperties::new().with_sync_policy(SyncPolicy::OnClose);
9484        let policy_of = |file: &File| match &file.inner.backend {
9485            Backend::Edit(m) => m
9486                .lock()
9487                .unwrap_or_else(std::sync::PoisonError::into_inner)
9488                .sync_policy(),
9489            _ => panic!("a read-write open must build an editing session"),
9490        };
9491
9492        let opened = File::open_rw_with_options(fixture("open_rw.h5"), props()).unwrap();
9493        assert_eq!(policy_of(&opened), SyncPolicy::OnClose, "File::open_rw");
9494        // Windows OS locks are mandatory: release each session before the next
9495        // open touches the same directory's files.
9496        drop(opened);
9497
9498        let created = File::create_with_options(
9499            dir.path().join("created.h5"),
9500            crate::FileCreateProperties::new(),
9501            props(),
9502        )
9503        .unwrap();
9504        assert_eq!(policy_of(&created), SyncPolicy::OnClose, "File::create");
9505        drop(created);
9506
9507        let swmr = File::open_swmr_writer_with_options(fixture("swmr.h5"), props()).unwrap();
9508        assert_eq!(
9509            policy_of(&swmr),
9510            SyncPolicy::OnClose,
9511            "File::open_swmr_writer"
9512        );
9513        drop(swmr);
9514
9515        let bounded = File::open_rw_with_options(
9516            fixture("bounded.h5"),
9517            props().with_memory_strategy(MemoryStrategy::Bounded),
9518        )
9519        .unwrap();
9520        assert_eq!(
9521            policy_of(&bounded),
9522            SyncPolicy::OnClose,
9523            "File::open_rw_with_options (bounded)"
9524        );
9525    }
9526
9527    /// The page-buffer property is refused where it cannot be honored, rather
9528    /// than accepted and ignored (issues #288 and #308).
9529    ///
9530    /// Each refusal has a different reason and none stands in for the others: a
9531    /// budget under one page is a buffer that drains on every page it touches, a
9532    /// *paged* file that persists no free space can neither commit nor append, a
9533    /// pre-version-3 superblock carries a status-flags byte no library reads back
9534    /// so the crash mark would announce nothing, and the SWMR writer's readers
9535    /// observe the order its writes become visible in. The first is where the C
9536    /// library refuses `H5Pset_page_buffer_size` too; the rest are this crate's.
9537    ///
9538    /// Two shapes are **not** among them, and the accepted cases here are what
9539    /// pin that. An unpaged file: `H5PB_create` requires the paged allocator
9540    /// because the C page buffer is a page cache with per-kind reservations, and
9541    /// this gatherer has neither (issue #357). And a budget below the 1 MiB a
9542    /// session already gathers under, which is a request for less resident
9543    /// memory paid for in writes rather than an unhonorable pair (issue #391).
9544    ///
9545    /// Every refusal also asserts the file is left byte-identical. Each one
9546    /// fires with a read-write session already open, and the version-3 one fires
9547    /// from the same function that raises the mark — so a refusal ordered after
9548    /// the raise would leave a file marked in use by a session that never
9549    /// existed, which is a file nothing can open until `clear_swmr_flag`.
9550    #[test]
9551    fn a_page_buffer_is_refused_where_it_cannot_be_honored() {
9552        use tempfile::tempdir;
9553
9554        let dir = tempdir().unwrap();
9555        let fixture = |name: &str, paged: bool| {
9556            let path = dir.path().join(name);
9557            let mut b = FileBuilder::new();
9558            if paged {
9559                b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 1)
9560                    .with_file_space_page_size(16 * 1024);
9561            }
9562            b.create_dataset("d")
9563                .with_i32_data(&[1, 2, 3, 4])
9564                .with_shape(&[4]);
9565            b.write(&path).unwrap();
9566            path
9567        };
9568        let buffered = |bytes| {
9569            FileAccessProperties::new()
9570                .with_sync_policy(SyncPolicy::OnClose)
9571                .with_page_buffer_size(bytes)
9572        };
9573
9574        // Each refusal fires after the session is already open read-write, so the
9575        // file it declined must be left exactly as it was — and still openable.
9576        let untouched = |label: &str, path: &std::path::Path, before: &[u8]| {
9577            assert_eq!(
9578                std::fs::read(path).unwrap(),
9579                before,
9580                "{label}: a refused open changed the file"
9581            );
9582            assert!(
9583                File::open(path).is_ok(),
9584                "{label}: a refused open left the file unopenable"
9585            );
9586        };
9587
9588        // The two that are *accepted*, kept here beside their former siblings so
9589        // reinstating either refusal fails this test rather than only the
9590        // behavior tests one module over.
9591        let unpaged = fixture("unpaged.h5", false);
9592        let accepted = File::open_rw_with_options(&unpaged, buffered(1 << 20));
9593        assert!(
9594            accepted.is_ok(),
9595            "a page buffer on an unpaged file must be accepted, got {accepted:?}"
9596        );
9597        accepted.unwrap().close().unwrap();
9598
9599        // 256 KiB on a 16 KiB-paged file: a budget the session would not have
9600        // gathered under, which is the point of asking for it.
9601        let small_budget = fixture("small_budget.h5", true);
9602        let accepted = File::open_rw_with_options(&small_budget, buffered(256 * 1024));
9603        assert!(
9604            accepted.is_ok(),
9605            "a page buffer below the byte budget a session gathers under must be \
9606             accepted, got {accepted:?}"
9607        );
9608        accepted.unwrap().close().unwrap();
9609
9610        let paged = fixture("paged.h5", true);
9611        let before = std::fs::read(&paged).unwrap();
9612        let refused = File::open_rw_with_options(&paged, buffered(8192));
9613        assert!(
9614            matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("page size")),
9615            "a budget below one page must be refused, got {refused:?}"
9616        );
9617        untouched("budget under one page", &paged, &before);
9618
9619        // And the pairing a caller reaches by doing nothing, since Always is the
9620        // default: every barrier there is an fsync that flushes the buffer.
9621        let refused = File::open_rw_with_options(
9622            &paged,
9623            FileAccessProperties::new().with_page_buffer_size(1 << 20),
9624        );
9625        assert!(
9626            matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("SyncPolicy::Always")),
9627            "a page buffer under SyncPolicy::Always must be refused, got {refused:?}"
9628        );
9629        untouched("page buffer under Always", &paged, &before);
9630
9631        // A version-2 superblock. This crate's writer refuses `Page` below the
9632        // 1.10 format, so the file is built at version 3 and its superblock
9633        // rewritten — the v2 and v3 layouts are identical apart from the version
9634        // byte and what the flags byte means, which is exactly the point.
9635        let old_format = dir.path().join("v2.h5");
9636        std::fs::copy(&paged, &old_format).unwrap();
9637        {
9638            let mut bytes = std::fs::read(&old_format).unwrap();
9639            let sig = crate::signature::find_signature(&bytes).unwrap();
9640            let mut sb = crate::superblock::Superblock::parse(&bytes, sig).unwrap();
9641            assert_eq!(sb.version, 3, "the fixture must start at the newer format");
9642            sb.version = 2;
9643            let rewritten = sb.serialize();
9644            bytes[sig..sig + rewritten.len()].copy_from_slice(&rewritten);
9645            std::fs::write(&old_format, &bytes).unwrap();
9646        }
9647        let before = std::fs::read(&old_format).unwrap();
9648        assert!(
9649            File::open(&old_format).is_ok(),
9650            "the version-2 fixture must be a readable file, or the refusal below \
9651             could be about anything"
9652        );
9653        let refused = File::open_rw_with_options(&old_format, buffered(1 << 20));
9654        assert!(
9655            matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("version-3 superblock")),
9656            "a page buffer on a pre-v3 superblock must be refused, got {refused:?}"
9657        );
9658        untouched("version-2 superblock", &old_format, &before);
9659
9660        // A paged file whose free space is not persisted, reached two ways: asked
9661        // for outright, and produced by a userblock, for which persistence is
9662        // declined however the creation properties were written. Such a session
9663        // can neither commit nor append, so the buffer would hold nothing while
9664        // its mark blocked every reader.
9665        for (label, name, userblock) in [
9666            ("paged, not persisting", "no_persist.h5", 0u64),
9667            ("paged with a userblock", "ub_paged.h5", 4096),
9668        ] {
9669            let path = dir.path().join(name);
9670            let mut b = FileBuilder::new();
9671            if userblock != 0 {
9672                b.with_userblock(userblock);
9673            }
9674            b.with_file_space_strategy(crate::FileSpaceStrategy::Page, userblock != 0, 1)
9675                .with_file_space_page_size(4096);
9676            b.create_dataset("d")
9677                .with_i32_data(&[1, 2, 3, 4])
9678                .with_shape(&[4]);
9679            b.write(&path).unwrap();
9680            let before = std::fs::read(&path).unwrap();
9681            let refused = File::open_rw_with_options(
9682                &path,
9683                buffered(1 << 20).with_memory_strategy(MemoryStrategy::Mirrored),
9684            );
9685            assert!(
9686                matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("persisted")),
9687                "{label}: a page buffer on a session that cannot write must be refused, \
9688                 got {refused:?}"
9689            );
9690            untouched(label, &path, &before);
9691        }
9692
9693        let swmr = fixture("swmr.h5", false);
9694        let before = std::fs::read(&swmr).unwrap();
9695        let refused = File::open_swmr_writer_with_options(&swmr, buffered(1 << 20));
9696        assert!(
9697            matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("SWMR")),
9698            "the SWMR writer must refuse a page buffer, got {refused:?}"
9699        );
9700        // The sharpest of the three: `open_swmr_writer` raises the on-disk
9701        // SWMR-write flag, and a refusal that fired after it would leave every
9702        // later open reporting `FileMarkedInUse`.
9703        untouched("swmr", &swmr, &before);
9704
9705        // And an unset page buffer refuses none of the three.
9706        for (name, paged) in [("ok_unpaged.h5", false), ("ok_paged.h5", true)] {
9707            let f = File::open_rw_with_options(fixture(name, paged), FileAccessProperties::new());
9708            assert!(f.is_ok(), "{name}: an unset page buffer refuses nothing");
9709        }
9710        let f = File::open_swmr_writer_with_options(
9711            fixture("ok_swmr.h5", false),
9712            FileAccessProperties::new(),
9713        );
9714        assert!(f.is_ok(), "swmr: an unset page buffer refuses nothing");
9715    }
9716
9717    /// `File::create_with_options` refuses a creation/access pair whose file it
9718    /// could write but not then open, rather than writing it and failing the open
9719    /// it promised (issue #288).
9720    ///
9721    /// A page buffer needs a budget of at least the file's page size and a
9722    /// version-3 superblock, and both of those are properties of the file being
9723    /// *created* — so the refusal belongs before the bytes are written, not in
9724    /// the reopen. The assertion that matters here is the `!path.exists()`: an
9725    /// error alone would pass with the file already on disk, which is the defect.
9726    ///
9727    /// The version-3 case is the one issue #357 made reachable. While a page
9728    /// buffer required a paged file it could not be: this crate's builder refuses
9729    /// `FileSpaceStrategy::Page` below the 1.10 format outright, so no pair got
9730    /// this far. An unpaged file at `LibVer::V18` is an ordinary buildable file,
9731    /// and without a check here it would be written and only then refused.
9732    #[test]
9733    fn create_with_options_refuses_a_page_buffer_it_could_not_reopen_with() {
9734        use tempfile::tempdir;
9735
9736        let dir = tempdir().unwrap();
9737        let paged = |page: u64| {
9738            crate::FileCreateProperties::new()
9739                .with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 1)
9740                .with_file_space_page_size(page)
9741        };
9742        // 8192 clears the format's 4096 default and still falls short of this
9743        // file's 16 KiB page, so it fails only against the page size actually
9744        // read from the file.
9745        //
9746        // The last case is the one a caller reaches by doing nothing: `Always` is
9747        // the default policy, and every other property in that pair is honorable.
9748        // It is here because it was missing — the refusal used to sit at the fapl
9749        // rather than with its siblings, so this function did not restate it and
9750        // the file was written before the open failed.
9751        let cases: [(&str, crate::FileCreateProperties, usize, SyncPolicy); 5] = [
9752            // A page larger than the budget, on a file that does not exist yet:
9753            // the page size has to come from the creation properties, which is
9754            // what this pins.
9755            (
9756                "page larger than the budget",
9757                paged(2 << 20),
9758                1 << 20,
9759                SyncPolicy::OnClose,
9760            ),
9761            (
9762                "the 1.8 format",
9763                crate::FileCreateProperties::new()
9764                    .with_libver_bounds(crate::LibVer::Earliest, crate::LibVer::V18),
9765                1 << 20,
9766                SyncPolicy::OnClose,
9767            ),
9768            (
9769                "budget under one page",
9770                paged(16 * 1024),
9771                8192,
9772                SyncPolicy::OnClose,
9773            ),
9774            // The unpaged arm of the same check, which decides on its own now
9775            // that a budget below the session's gather budget is honored (issue
9776            // #391): an unpaged file has no page size, so it is held to the
9777            // format's 4 KiB default and 2 KiB falls short of it.
9778            (
9779                "unpaged budget under the default page",
9780                crate::FileCreateProperties::new(),
9781                2048,
9782                SyncPolicy::OnClose,
9783            ),
9784            (
9785                "the default sync policy",
9786                paged(16 * 1024),
9787                1 << 20,
9788                SyncPolicy::Always,
9789            ),
9790        ];
9791
9792        for (label, create, budget, policy) in cases {
9793            let path = dir
9794                .path()
9795                .join(std::format!("{}.h5", label.replace(' ', "_")));
9796            let result = File::create_with_options(
9797                &path,
9798                create,
9799                FileAccessProperties::new()
9800                    .with_sync_policy(policy)
9801                    .with_page_buffer_size(budget),
9802            );
9803            assert!(
9804                matches!(result, Err(Error::EditUnsupported(_))),
9805                "{label}: expected a refusal, got {result:?}"
9806            );
9807            assert!(
9808                !path.exists(),
9809                "{label}: the file was written and only then refused"
9810            );
9811        }
9812
9813        // The honorable pairs still create — including the unpaged one, which is
9814        // the default creation properties and so the pair a caller reaches by
9815        // asking for nothing but the buffer, and the 256 KiB one, which is below
9816        // the byte budget a session gathers under (issue #391).
9817        for (label, create, budget) in [
9818            ("paged", paged(16 * 1024), 1 << 20),
9819            ("unpaged", crate::FileCreateProperties::new(), 1 << 20),
9820            ("paged_small_budget", paged(16 * 1024), 256 * 1024),
9821        ] {
9822            let ok = File::create_with_options(
9823                dir.path().join(std::format!("ok_{label}.h5")),
9824                create,
9825                FileAccessProperties::new()
9826                    .with_sync_policy(SyncPolicy::OnClose)
9827                    .with_page_buffer_size(budget),
9828            );
9829            assert!(
9830                ok.is_ok(),
9831                "{label}: an honorable budget must create: {ok:?}"
9832            );
9833            ok.unwrap().close().unwrap();
9834        }
9835    }
9836
9837    /// `close` and `drop` issue their barrier under *every* policy, on both the
9838    /// ordinary and the SWMR branch — the four sites where this crate writes
9839    /// after the last point a caller could have ordered anything (issue #263).
9840    ///
9841    /// This is the half of the contract `SyncPolicy` cannot express: the two
9842    /// `drop` sites are unreachable by any caller discipline at all, since the
9843    /// handle that would have issued `File::sync` is gone by the time they run.
9844    /// Asserted through a counting image, because the difference between a
9845    /// forced barrier and a skipped one is invisible in the bytes.
9846    #[test]
9847    fn close_and_drop_force_their_barrier_under_every_policy() {
9848        use crate::edit::WriteEngine;
9849        use std::sync::Arc;
9850        use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
9851        use tempfile::tempdir;
9852
9853        let dir = tempdir().unwrap();
9854        // `swmr` picks the teardown branch; `explicit` picks `close` over `drop`.
9855        let teardown = |name: &str, swmr: bool, explicit: bool| -> u64 {
9856            let path = dir.path().join(name);
9857            let mut b = FileBuilder::new();
9858            b.create_dataset("d")
9859                .with_i32_data(&(0..8).collect::<Vec<_>>())
9860                .with_shape(&[8])
9861                .with_maxshape(&[MaxExtent::Unlimited])
9862                .with_chunks(&[4]);
9863            // Persisting, so the ordinary branch has manager re-homing to do:
9864            // an immediate append below leaves the on-disk managers mid-file,
9865            // and settling them is the write no earlier sync could cover.
9866            b.with_file_space_strategy(crate::FileSpaceStrategy::FsmAggr, true, 1);
9867            b.write(&path).unwrap();
9868
9869            let syncs = Arc::new(AtomicU64::new(0));
9870            let session =
9871                WriteEngine::open_sync_counting(&path, SyncPolicy::OnClose, Arc::clone(&syncs))
9872                    .unwrap();
9873            let mut inner = FileInner::from_rw_session(
9874                session,
9875                FileAccessProperties::new().with_sync_policy(SyncPolicy::OnClose),
9876            )
9877            .unwrap();
9878            inner.swmr_write = swmr;
9879            let file = File {
9880                inner: Arc::new(inner),
9881            };
9882            if !swmr {
9883                // The SWMR branch stages nothing and appends through its own
9884                // path; give the ordinary branch real work to settle.
9885                file.dataset("d")
9886                    .unwrap()
9887                    .append(&[8i32, 9, 10, 11])
9888                    .unwrap();
9889            }
9890            assert_eq!(
9891                syncs.load(AtomicOrdering::Relaxed),
9892                0,
9893                "nothing before teardown may sync under OnClose ({name})"
9894            );
9895
9896            if explicit {
9897                file.close().unwrap();
9898            } else {
9899                drop(file);
9900            }
9901            syncs.load(AtomicOrdering::Relaxed)
9902        };
9903
9904        for (name, swmr, explicit) in [
9905            ("close_plain.h5", false, true),
9906            ("close_swmr.h5", true, true),
9907            ("drop_plain.h5", false, false),
9908            ("drop_swmr.h5", true, false),
9909        ] {
9910            assert!(
9911                teardown(name, swmr, explicit) > 0,
9912                "{name} must force its barrier: the writes it makes are past the \
9913                 last point a caller could have ordered them"
9914            );
9915        }
9916    }
9917}