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::address::BaseAddress;
11use crate::edit::{
12    AppendBuilder, AppendGeometry, AppendTarget, EditBacking, MemoryStrategy, SpaceAccounting,
13    StagedChild, StagedKind, StagedMeta, StagedObject, SyncPolicy, WriteEngine,
14};
15use crate::element::H5Element;
16use crate::type_builders::{DatasetBuilder, VL_REF_SIZE};
17
18use crate::appender::BufferedAppender;
19use crate::attribute::{extract_attributes_full, extract_attributes_full_from_source};
20use crate::chunk_cache::{CachePass, ChunkCache, ChunkCacheConfig, ChunkCacheStats};
21use crate::compound::CompoundType;
22use crate::convert::TryToUsize;
23use crate::data_layout::DataLayout;
24use crate::data_read;
25use crate::dataspace::Dataspace;
26use crate::datatype::{Datatype, ReferenceType};
27use crate::error::{Error, FormatError};
28use crate::file_create_properties::FileCreateProperties;
29use crate::file_lock::{self, FileLocking, OpenIntent, OpenTarget, WriteMarkPolicy};
30use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy};
31use crate::fill_value::FillPattern;
32use crate::filter_pipeline::FilterPipeline;
33use crate::free_space_manager;
34use crate::group_v1::GroupEntry;
35use crate::group_v2::{self, ChildLookup, is_group};
36use crate::layout_info::{Chunk, ChunkIndex, Filter, Layout};
37use crate::libver::LibVer;
38use crate::message_type::MessageType;
39use crate::object_header::ObjectHeader;
40use crate::read_spec::RawReadSpec;
41use crate::shared_message::{self, BufferedResolver, SharedResolver, SourceResolver};
42use crate::signature;
43use crate::source::{
44    BaseOffsetSource, BytesSource, MetadataCacheConfig, MetadataCacheStats, MetadataCachingSource,
45    ReadSeekSource, Source, ValidatedSource, frame,
46};
47use crate::superblock::Superblock;
48use crate::vl_data::{self, VlenStringReadOptions};
49
50use crate::types::{AttrValue, DType, attrs_to_map, classify_datatype};
51
52// ---------------------------------------------------------------------------
53// File
54// ---------------------------------------------------------------------------
55
56/// Backing store for a [`File`]: either the whole file buffered in memory, or a
57/// lazy [`Source`] that reads regions on demand (see [`File::open_streaming`]).
58enum Backend {
59    InMemory(Vec<u8>),
60    Streaming(Box<dyn Source + Send + Sync>),
61    /// A read-write file opened with [`File::open_rw`]: a [`WriteEngine`] (exclusive OS lock + staged
62    /// edit queues + append geometry cache) behind a lock, so owned handles can
63    /// both read and mutate in place. Handle write methods route to the engine,
64    /// and `File::commit` applies staged structural edits.
65    ///
66    /// Either backing — a whole-file mirror, or positioned I/O against the
67    /// handle — appears here as the same `WriteEngine`; which one an open
68    /// resolved to is the engine's own business rather than the backend's
69    /// (issue #198). Reads
70    /// borrow the mirror's slice when there is one and go through the image's
71    /// `Source` otherwise; see [`with_engine`](FileInner::with_engine). Boxed to
72    /// keep the `Backend` enum small (a `WriteEngine` is far larger than the
73    /// other variants).
74    Edit(Box<Mutex<WriteEngine>>),
75}
76
77/// What an operation through a file's write session can do to it, which is what
78/// decides how much of an object handle's memo it invalidates — and, with it,
79/// which edit surface the operation is using.
80///
81/// One variant carries both because they coincide: the *staged* surface is the
82/// one that commits, and a commit is the only thing that moves an object header,
83/// so [`Relocating`](Self::Relocating) is exactly the surface a SWMR writer
84/// refuses. An operation off that surface that could relocate would break the
85/// pairing and would have to say which it was separately.
86#[derive(Clone, Copy)]
87enum Change {
88    /// Rewrites object headers and can move them: a commit. The staging of one
89    /// counts too, though it writes nothing — a pending edit is a header move
90    /// this session has not made yet, and an address is worth no more against
91    /// one than against the commit that will apply it. Invalidates a handle's
92    /// address as well as the header it parsed.
93    ///
94    /// This is the staged surface, which a SWMR writer refuses.
95    Relocating,
96    /// Changes bytes without moving any object header: an immediate
97    /// [`Dataset::append`], which rewrites the dataset's dimension where its
98    /// header stands, and the free-space and status-flag bookkeeping a session's
99    /// teardown rewrites where it stands. Invalidates the parsed header alone,
100    /// which is what lets a handle reached by object reference — the one kind
101    /// with no name to look itself up by — go on appending.
102    InPlace,
103    /// Changes nothing a handle can observe: a durability barrier over writes
104    /// the operations that made them already accounted for, or a question whose
105    /// answer the engine caches. Invalidates no memo.
106    ///
107    /// Distinct from not going through the gate at all, which is what a *read*
108    /// does: these still need the write session, and still need the file to be
109    /// open for writing and unsealed.
110    Nothing,
111}
112
113/// Where an object handle's header sits, and what that answer holds as of.
114///
115/// A handle names its object — by path, or by the address a reference gave it —
116/// and this is a memo of what that name last resolved to. See
117/// [`FileInner::locate`], which takes one, and the two counters it reads.
118#[derive(Clone, Copy)]
119struct Resolution {
120    content_revision: u64,
121    address_revision: u64,
122    address: u64,
123}
124
125/// The pair of revisions read *before* a resolution is worked out, which the
126/// answer is then labelled with.
127///
128/// Splitting the reads from the address is what keeps the order right at every
129/// call site: there is no way to label an address with a revision taken after
130/// it, which would claim a freshness the address does not have. See
131/// [`FileInner::locate`].
132#[derive(Clone, Copy)]
133struct Revisions {
134    content: u64,
135    address: u64,
136}
137
138impl Revisions {
139    /// Label `address` as worked out at these revisions.
140    const fn at(self, address: u64) -> Resolution {
141        Resolution {
142            content_revision: self.content,
143            address_revision: self.address,
144            address,
145        }
146    }
147}
148
149/// Where a [`Dataset`] or [`Group`] handle stands with respect to the staged
150/// set, worked out by [`FileInner::staged_standing`].
151///
152/// A handle names its object by path, and a path can mean an object in the
153/// file, an object this session has staged, or — for a handle *born* onto a
154/// staged creation — nothing at all, once that creation is withdrawn. The third
155/// is why the handle carries a mark of its own: without one, a withdrawal is
156/// indistinguishable from a commit, and the handle would silently start
157/// answering for whatever the file holds at the path, which in the case that
158/// produces it is the object the session is deleting.
159#[derive(Clone, Copy, PartialEq, Eq, Debug)]
160enum Standing {
161    /// Resolve the path against the file, as a handle opened by name does.
162    Live,
163    /// A creation this session staged owns the path and no commit has written
164    /// it: [`Error::NotCommitted`] for anything needing its bytes.
165    Pending,
166    /// This handle was made onto a staged creation that has since been
167    /// withdrawn: [`Error::StagingWithdrawn`].
168    Withdrawn,
169}
170
171/// A borrowed `Source` view over a [`File`]'s backend, used by the
172/// streaming-capable read paths so one call site serves both backends.
173pub(crate) enum SourceView<'a> {
174    Mem(&'a [u8]),
175    Stream(&'a (dyn Source + Send + Sync)),
176}
177
178impl Source for SourceView<'_> {
179    fn len(&self) -> u64 {
180        match self {
181            SourceView::Mem(b) => b.len() as u64,
182            SourceView::Stream(s) => s.len(),
183        }
184    }
185    fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
186        match self {
187            SourceView::Mem(b) => BytesSource::new(*b).read_at(offset, buf),
188            SourceView::Stream(s) => s.read_at(offset, buf),
189        }
190    }
191
192    fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
193        match self {
194            SourceView::Mem(b) => BytesSource::new(*b).read_metadata_at(offset, len),
195            SourceView::Stream(s) => s.read_metadata_at(offset, len),
196        }
197    }
198
199    fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
200        match self {
201            // A whole-file buffer is already the cache, and holds no second one.
202            SourceView::Mem(_) => None,
203            SourceView::Stream(s) => s.metadata_cache_stats(),
204        }
205    }
206
207    fn reset_metadata_cache_stats(&self) {
208        match self {
209            SourceView::Mem(_) => {}
210            SourceView::Stream(s) => s.reset_metadata_cache_stats(),
211        }
212    }
213}
214
215/// File-access properties applied when opening an HDF5 file.
216///
217/// This is the `hdf5-pure` analogue of an HDF5 **file access property list**
218/// (`fapl`): one value carrying every access-time setting, built once and passed
219/// to whichever open a caller reaches for, exactly as a `fapl` is handed to
220/// `H5Fopen`. Every `*_with_options` constructor on [`File`] accepts it, so a
221/// read path and a read-write path can share one configuration.
222///
223/// The `Properties` suffix means the type stands in for one whole HDF5 property
224/// list, so every setting on it has a C counterpart to look up. It is a stand-in
225/// and not a port: a plain `Copy` value, with no handle to create or close, no
226/// runtime property registry, and no setter that can fail. `fapl` and each
227/// `H5Pset_*` it models are doc aliases, so a search for either lands here.
228///
229/// - The metadata cache (`H5Pset_mdc_config`) applies to the streaming and
230///   bounded backends; an in-memory open already holds the whole file in one
231///   buffer.
232/// - The chunk cache (`H5Pset_cache`) is the file-wide default for datasets
233///   opened from any backend, overridable per dataset with
234///   [`DatasetAccessProperties`].
235/// - The locking policy (`H5Pset_file_locking`) applies to the read-write opens.
236///   Readers and the SWMR writer take no lock by design, so they ignore it.
237/// - The write-mark policy applies to the read-only opens, and has no C
238///   counterpart: `H5Fopen` refuses a file marked open for write with no
239///   override, where [`with_write_mark_policy`](Self::with_write_mark_policy)
240///   can admit a snapshot read of one.
241///
242/// See the [property-support reference] for the full property-by-property map.
243///
244/// [property-support reference]: https://github.com/CramBL/hdf5-pure/blob/main/docs/reference/property-support.md
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
246#[doc(alias = "fapl")]
247pub struct FileAccessProperties {
248    metadata_cache: MetadataCacheConfig,
249    chunk_cache: ChunkCacheConfig,
250    locking: FileLocking,
251    memory_strategy: Option<MemoryStrategy>,
252    libver_bounds: Option<(LibVer, LibVer)>,
253    sync_policy: SyncPolicy,
254    page_buffer_size: usize,
255    write_mark_policy: WriteMarkPolicy,
256}
257
258impl FileAccessProperties {
259    /// A value carrying the crate's default access behavior.
260    pub const fn new() -> Self {
261        Self {
262            metadata_cache: MetadataCacheConfig::disabled(),
263            chunk_cache: ChunkCacheConfig::new(),
264            locking: FileLocking::Enabled,
265            memory_strategy: None,
266            libver_bounds: None,
267            sync_policy: SyncPolicy::Always,
268            page_buffer_size: 0,
269            write_mark_policy: WriteMarkPolicy::Refuse,
270        }
271    }
272
273    /// Configure the bounded streaming metadata cache.
274    #[doc(alias = "H5Pset_mdc_config")]
275    pub const fn with_metadata_cache(mut self, metadata_cache: MetadataCacheConfig) -> Self {
276        self.metadata_cache = metadata_cache;
277        self
278    }
279
280    /// Configure the per-dataset raw chunk cache used by datasets opened from
281    /// this file. This is the `H5Pset_cache`-style file-wide default.
282    #[doc(alias = "H5Pset_cache")]
283    pub const fn with_chunk_cache(mut self, chunk_cache: ChunkCacheConfig) -> Self {
284        self.chunk_cache = chunk_cache;
285        self
286    }
287
288    /// Set the OS advisory file-locking policy for the read-write opens.
289    ///
290    /// Defaults to [`FileLocking::Enabled`]. Use [`FileLocking::Disabled`] only
291    /// when an external mechanism already guarantees single-writer access, or
292    /// [`FileLocking::BestEffort`] on a filesystem (such as some network mounts)
293    /// where the OS lock is unavailable. Setting `HDF5_USE_FILE_LOCKING` in the
294    /// environment overrides this, as in the C library.
295    ///
296    /// Readers and [`File::open_swmr_writer`] take no lock by design and ignore
297    /// this.
298    #[doc(alias = "H5Pset_file_locking")]
299    pub const fn with_locking(mut self, locking: FileLocking) -> Self {
300        self.locking = locking;
301        self
302    }
303
304    /// Set how much memory a read-write open may use to hold the file.
305    ///
306    /// Unset by default, which lets the entry point choose:
307    /// [`File::open_rw`] uses [`MemoryStrategy::Auto`], preferring the bounded
308    /// engine and falling back to the whole-file mirror for a file it cannot
309    /// edit. Setting this overrides that default, in either direction, so
310    /// [`MemoryStrategy::Bounded`] refuses such a file rather than quietly
311    /// spending `O(file size)` memory on a caller who asked not to;
312    /// [`MemoryStrategy::Mirrored`] takes
313    /// the whole-file mirror unconditionally, as `open_rw` did before it learned
314    /// to dispatch.
315    ///
316    /// The read-only opens ignore this: they build no editing session at all, and
317    /// their own names say what memory they spend. [`File::open_swmr_writer`]
318    /// does build one, and always mirrors: it accepts
319    /// [`MemoryStrategy::Auto`] and [`MemoryStrategy::Mirrored`], both of which
320    /// the mirror satisfies, and refuses an explicit [`MemoryStrategy::Bounded`]
321    /// with [`Error::EditUnsupported`] rather than quietly not honoring it. Ask a
322    /// `File` which backend it resolved to with [`File::edit_backing`].
323    pub const fn with_memory_strategy(mut self, memory_strategy: MemoryStrategy) -> Self {
324        self.memory_strategy = Some(memory_strategy);
325        self
326    }
327
328    /// Constrain the on-disk format an editing session may write, mirroring
329    /// HDF5's `H5Pset_libver_bounds` — which the C library classes as a *file
330    /// access* property for exactly this reason: it governs what a later write
331    /// to an existing file is allowed to add.
332    ///
333    /// Unset by default, which keeps [`File::open_rw`] adding whatever the
334    /// content needs. That default is what lets a file the C library wrote under
335    /// its own bounds be edited at all, but it means a session can add content
336    /// only a newer library can read *without changing the superblock*, and the
337    /// caller has no way to see it happen: adding a chunked, filtered, or
338    /// resizable dataset to an HDF5 1.8 file needs the version 4 data-layout
339    /// message and a 1.10 chunk index, since this crate does not write the
340    /// version 1 B-tree index that 1.8 used.
341    ///
342    /// Setting a `high` below [`LibVer::V110`] refuses that addition with
343    /// [`FormatError::LibverTooOldForContent`](crate::FormatError::LibverTooOldForContent)
344    /// at [`File::commit`] instead — the same refusal
345    /// [`FileBuilder::with_libver_bounds`](crate::FileBuilder::with_libver_bounds)
346    /// gives when writing a whole file, so a `.mat` bounded to 1.8 for MATLAB
347    /// stays loadable by MATLAB after an edit.
348    ///
349    /// `low` only rules formats out — as in the C library it licenses newer
350    /// encodings without requiring them — so a lower bound of [`LibVer::V112`],
351    /// [`LibVer::V114`] or [`LibVer::LATEST`] leaves the session writing the 1.10
352    /// format rather than failing, provided `high` reaches it. An inverted range
353    /// such as `V114..=V110` is refused with
354    /// [`FormatError::LibverBoundsUnsatisfiable`](crate::FormatError::LibverBoundsUnsatisfiable).
355    ///
356    /// The read-only opens ignore this: they write nothing. [`File::open_swmr_writer`]
357    /// requires a version 3 superblock, so it refuses a `high` below
358    /// [`LibVer::V110`] up front rather than accepting a bound it cannot honor.
359    #[doc(alias = "H5Pset_libver_bounds")]
360    pub const fn with_libver_bounds(mut self, low: LibVer, high: LibVer) -> Self {
361        self.libver_bounds = Some((low, high));
362        self
363    }
364
365    /// Choose who owns this session's `fsync` cadence — this crate, or the
366    /// application through [`File::sync`].
367    ///
368    /// Defaults to [`SyncPolicy::Always`]: every commit and every immediate
369    /// [`Dataset::append`](crate::Dataset::append) forces its writes to durable
370    /// storage before returning. [`SyncPolicy::OnClose`] issues no `fsync` at all,
371    /// which is what the reference C library does; the writes still reach the
372    /// operating system by the time the operation making them returns, so only
373    /// power-loss durability moves to the caller. [`with_page_buffer_size`](Self::with_page_buffer_size),
374    /// off by default, is the one setting that changes that — and it requires
375    /// this policy.
376    ///
377    /// The read-only opens ignore this: they write nothing.
378    pub const fn with_sync_policy(mut self, sync_policy: SyncPolicy) -> Self {
379        self.sync_policy = sync_policy;
380        self
381    }
382
383    /// Let a read-write session's writes accumulate in a page buffer of
384    /// `bytes`, so repeated small updates landing in the same page cost one
385    /// write rather than one each.
386    ///
387    /// Defaults to `0`, which is off — as `H5Pset_page_buffer_size` defaults to
388    /// off — and leaves the gathering every read-write session already does: one
389    /// write per dirty page per *ordering barrier*, so a commit or an append
390    /// still reaches the operating system in full before it returns. What this
391    /// buys on top is letting a dirty page survive those barriers, which is where
392    /// a workload of many small appends into a few pages does most of its
393    /// repeating. Measured on a paged file, 32 chunk appends into eight datasets
394    /// followed by a commit: **188 writes with the default gathering and 5 with a
395    /// page buffer**, of which two are the mark below going up and coming down.
396    /// The appends issue nothing at all until the session ends.
397    ///
398    /// **It pays off over a long session, and costs on a short one.** The crash
399    /// mark below is two `fsync`s per session whatever the session then does, so
400    /// there is a break-even: measured on an Apple M1 Max (APFS) with 256-byte
401    /// appends into eight datasets, 400 appends ran 0.75x — slower — 800 broke
402    /// even, and 6,400 ran 1.64x. The ratio climbs with session length, because
403    /// the same pages are re-dirtied more often, and narrows to about 1.1x once
404    /// 64 KiB payloads rather than metadata churn dominate. One host's numbers,
405    /// and the short end is noisy; re-measure on the one that matters with
406    /// `cargo bench --bench hot_paths -- page_buffer`, which runs both sides of
407    /// the crossing.
408    ///
409    /// # What it costs, and what pays for it
410    ///
411    /// Gathered writes go out in address order, and every publish point sits
412    /// below the content it reaches, so all of them are issued first. A write
413    /// that fails, or a process that dies, mid-flush can therefore leave a file
414    /// whose superblock, dataset length or object header names bytes that never
415    /// arrived — and two of those read back **clean**, as fill values or as a
416    /// deleted object's data, with every checksum verifying. That is what a
417    /// write-back page buffer is, rather than a fault in this one:
418    /// `H5Pset_page_buffer_size` reorders the same way and makes no
419    /// crash-consistency claim either.
420    ///
421    /// So this session raises superblock status-flag bit 0
422    /// (`H5F_SUPER_WRITE_ACCESS`) for its whole life, `fsync`ed once at open and
423    /// cleared on a clean [`File::close`] or drop — the mark the reference C
424    /// library raises for *any* writer. A session that dies with pages in memory
425    /// leaves that byte standing, and a file carrying it is refused by this
426    /// crate, by `H5Fopen` and by h5py alike, with
427    /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse). The silent
428    /// wrong answer becomes a refusal, and
429    /// [`File::clear_swmr_flag`](crate::File::clear_swmr_flag) — the `h5clear -s`
430    /// equivalent — is how to look at such a file anyway, knowing what it may
431    /// hold. A completed commit's bytes may also still be in this process's
432    /// memory when it returns.
433    ///
434    /// # Refusals
435    ///
436    /// Four, each refused with
437    /// [`Error::EditUnsupported`](crate::Error::EditUnsupported) rather than
438    /// quietly ignored:
439    ///
440    /// - a budget below the page the session merges within: the file's own
441    ///   file-space page size when it was created with
442    ///   [`FileSpaceStrategy::Page`](crate::FileSpaceStrategy::Page), and the
443    ///   format's 4 KiB default otherwise. A buffer that cannot hold one page
444    ///   drains on every page it touches;
445    /// - a **paged** file whose free space is not persisted, which can be neither
446    ///   committed to nor appended to, so the buffer would hold nothing while its
447    ///   mark blocked every reader;
448    /// - a superblock older than version 3, whose status-flags byte no library
449    ///   reads back, so the mark above would announce nothing;
450    /// - [`SyncPolicy::Always`](crate::SyncPolicy::Always), the default, where
451    ///   every barrier is an `fsync` that flushes the buffer on its way out — so
452    ///   it would hold nothing while still costing the mark. Pair this with
453    ///   [`with_sync_policy(SyncPolicy::OnClose)`](Self::with_sync_policy).
454    ///
455    /// [`File::create_with_options`] refuses a creation/access pair it could not
456    /// then reopen with, rather than writing the file first, and
457    /// [`File::open_swmr_writer`] refuses a page buffer outright: its readers
458    /// observe the order its writes become visible in, which is exactly what a
459    /// buffer coalesces away.
460    ///
461    /// # Choosing a budget
462    ///
463    /// Any budget of at least one page is honored. One below 1 MiB is an explicit
464    /// request for less resident memory, not a mistake — a writer inside a tight
465    /// memory cap can ask for 256 KiB and get it — and what it buys that memory
466    /// with is writes: the budget is the point at which everything held is
467    /// flushed, so a long contiguous run is flushed and restarted once per
468    /// budget's worth of it. Writes issued on a 4 KiB-paged file:
469    ///
470    /// | workload | unset | 4 KiB | 64 KiB | 1 MiB |
471    /// | --- | --- | --- | --- | --- |
472    /// | 32 chunk appends into 8 datasets, then a commit | 188 | 25 | 4 | 4 |
473    /// | one 4 MiB append | 131 | 1,094 | 74 | 10 |
474    ///
475    /// On the scattered workload this property exists for, a small budget costs
476    /// little; on the long run 64 KiB is seven times the writes of 1 MiB.
477    ///
478    /// The memory comparison against leaving this unset is not the one the table
479    /// suggests. A session that sets nothing already gathers up to 1 MiB of dirty
480    /// bytes **per operation**, and releases it at every ordering barrier; a page
481    /// buffer holds its budget **across** operations, until the budget is spent,
482    /// an `fsync`, or [`File::close`]. So 1 MiB here trades a per-operation peak
483    /// for a continuous residency of the same size, and a budget below 1 MiB
484    /// lowers both.
485    ///
486    /// # How this differs from `H5Pset_page_buffer_size`
487    ///
488    /// **A paged file is not required, where the C library requires one.**
489    /// `H5PB_create` refuses an unpaged file because the C page buffer is a page
490    /// *cache*, and its `min_meta_perc` / `min_raw_perc` reservations are counted
491    /// in pages that the paged allocator keeps segregated by kind. This is a
492    /// write gatherer: it merges runs within a page-sized window and flushes
493    /// whole, so a window is all it needs, and an unpaged file gets the same
494    /// 4 KiB one that every read-write session already gathers under. Since
495    /// unpaged is the default strategy, requiring `Page` put this property out of
496    /// reach of most files for no reason this implementation had.
497    ///
498    /// **A small budget costs writes here, where it costs none in C.**
499    /// `H5PB_write` sends any I/O of a page or more straight to the driver, so a
500    /// small `page_buf_size` there caps memory without throttling a long write.
501    /// Nothing bypasses this buffer — the budget is the point at which everything
502    /// held is flushed — so a small one turns a single long run into repeated
503    /// flushes. Both libraries accept the budget; only this one charges for it.
504    /// See [Choosing a budget](#choosing-a-budget) for what it charges.
505    ///
506    /// **A sub-page budget is refused rather than rounded.** `H5Fopen` rounds it
507    /// up to one page silently, and `H5Fcreate` refuses it. A property quietly
508    /// ignored is worse than one refused.
509    ///
510    /// The read-only opens ignore this setting; they write nothing.
511    ///
512    /// Only the budget of `H5Pset_page_buffer_size` is modeled; its
513    /// `min_meta_perc` / `min_raw_perc` reservations are not, since this buffer
514    /// does not evict — it flushes whole.
515    #[doc(alias = "H5Pset_page_buffer_size")]
516    pub const fn with_page_buffer_size(mut self, bytes: usize) -> Self {
517        self.page_buffer_size = bytes;
518        self
519    }
520
521    /// Let a read-only open proceed past a superblock marked open for write by a
522    /// writer that is not a SWMR writer — status-flag bit 0 alone, which is what
523    /// [`with_page_buffer_size`](Self::with_page_buffer_size) raises for a
524    /// session's whole life.
525    ///
526    /// Defaults to [`WriteMarkPolicy::Refuse`], which is what `H5Fopen` does with
527    /// the same byte: [`File::open`], [`File::open_streaming`] and
528    /// [`File::from_source`] all report
529    /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse).
530    /// [`WriteMarkPolicy::AllowSnapshot`] reads the file as it stands instead,
531    /// through whichever of those opens is passed these properties.
532    ///
533    /// # What the caller is asserting
534    ///
535    /// That the writer has flushed: it called [`File::sync`], or it stopped
536    /// after a flush and the mark stands only because nothing cleared it (a
537    /// clean [`File::close`] takes the mark down, and leaves nothing to opt past).
538    /// The mark is durable and says nothing about *when* — a live writer
539    /// mid-operation and one that exited without closing carry the same byte —
540    /// so this crate cannot check the assertion, and passing this value is how a
541    /// caller states it. It is exactly true for a writer under
542    /// [`SyncPolicy::OnClose`](crate::SyncPolicy::OnClose) that syncs at the
543    /// points it wants readable, and it is what the mark exists to guard against
544    /// when it is false: a page-buffered session's publish points are written
545    /// before the content they name, so a snapshot taken mid-flush can show a
546    /// dataset that reads clean and returns fill values, with every checksum
547    /// verifying.
548    ///
549    /// The snapshot is of the bytes on disk at open. A buffered open takes it
550    /// whole; a streaming open reads regions on demand, so a writer that carries
551    /// on writing can move bytes under it — reach for
552    /// [`File::open_with_options`] when the writer may continue, and for
553    /// [`File::open_streaming_with_options`] when it has stopped and the file is
554    /// too large to buffer.
555    ///
556    /// # What it does not unlock
557    ///
558    /// - a **SWMR pair** (both bits): that file has a reader of its own, and
559    ///   [`File::open_swmr`] follows it — including across the writer's later
560    ///   appends, which a snapshot cannot;
561    /// - [`File::open_rw`] and [`File::open_swmr_writer`], which are refused
562    ///   whatever this says. A second writer must not join a file a writer
563    ///   already holds;
564    /// - the OS advisory lock, a separate guard with its own policy
565    ///   ([`with_locking`](Self::with_locking)).
566    ///
567    /// A file left marked by a writer that *crashed* is a different question,
568    /// and this is not the answer to it: it reads such a file as willingly as a
569    /// flushed one, and leaves the mark standing for the next reader to meet.
570    /// [`File::clear_swmr_flag`] — the `h5clear -s` equivalent — is the recovery
571    /// there, and it records the decision by clearing the byte.
572    ///
573    /// The C library offers no counterpart: `H5Fopen` refuses the byte with no
574    /// override, and `h5clear` is its only way through. This is the narrower one,
575    /// since it changes nothing on disk.
576    pub const fn with_write_mark_policy(mut self, policy: WriteMarkPolicy) -> Self {
577        self.write_mark_policy = policy;
578        self
579    }
580
581    /// Return the configured streaming metadata cache.
582    pub const fn metadata_cache(&self) -> MetadataCacheConfig {
583        self.metadata_cache
584    }
585
586    /// Return the configured library-version bounds, or `None` when an editing
587    /// session may write whatever its content needs.
588    pub const fn libver_bounds(&self) -> Option<(LibVer, LibVer)> {
589        self.libver_bounds
590    }
591
592    /// Return the configured per-dataset chunk cache.
593    pub const fn chunk_cache(&self) -> ChunkCacheConfig {
594        self.chunk_cache
595    }
596
597    /// Return the configured file-locking policy.
598    pub const fn locking(&self) -> FileLocking {
599        self.locking
600    }
601
602    /// Return the configured memory strategy, or `None` when none was asked for
603    /// and the entry point's own default applies. This is what was *requested*;
604    /// for which backend an open resolved to, see [`File::edit_backing`].
605    ///
606    /// The `Option` distinguishes "no preference stated" from an explicit
607    /// [`MemoryStrategy::Auto`], which is what lets an entry point supply its own
608    /// default without overriding a caller who asked for one; `None` resolves to
609    /// [`MemoryStrategy::Auto`], the only default any entry point now supplies
610    /// rather than as a second break on this accessor.
611    pub const fn memory_strategy(&self) -> Option<MemoryStrategy> {
612        self.memory_strategy
613    }
614
615    /// Return the configured `fsync` policy.
616    pub const fn sync_policy(&self) -> SyncPolicy {
617        self.sync_policy
618    }
619
620    /// Return the configured page-buffer budget in bytes; `0` when none was
621    /// asked for.
622    pub const fn page_buffer_size(&self) -> usize {
623        self.page_buffer_size
624    }
625
626    /// Return the configured write-mark policy.
627    pub const fn write_mark_policy(&self) -> WriteMarkPolicy {
628        self.write_mark_policy
629    }
630}
631
632/// Dataset-access properties applied when opening a single dataset.
633///
634/// This is the `hdf5-pure` analogue of an HDF5 **dataset access property list**
635/// (`dapl`). Its chunk cache corresponds to `H5Pset_chunk_cache`: it overrides,
636/// for this one dataset, the file-wide chunk-cache default configured with
637/// [`FileAccessProperties::with_chunk_cache`] (the `H5Pset_cache` analogue). When
638/// left unset, the dataset inherits that file-wide default — matching the `dapl`
639/// default sentinels (`H5D_CHUNK_CACHE_*_DEFAULT`), which also mean "use the
640/// file's setting".
641///
642/// The `Properties` suffix means the type stands in for one whole HDF5 property
643/// list, so every setting on it has a C counterpart to look up. It is a stand-in
644/// and not a port: a plain `Copy` value, with no handle to create or close, no
645/// runtime property registry, and no setter that can fail. `dapl` and each
646/// `H5Pset_*` it models are doc aliases, so a search for either lands here.
647/// The chunk cache is the one `dapl` property modeled; see the
648/// [property-support reference] for the rest.
649///
650/// [`ChunkCacheConfig`] maps `H5Pset_chunk_cache`'s `rdcc_nslots` and
651/// `rdcc_nbytes`; its `rdcc_w0` preemption policy is not modeled, for the reason
652/// on [`ChunkCacheConfig::from_h5p_cache`].
653///
654/// Pass it to [`File::dataset_with_options`] or [`Group::dataset_with_options`].
655///
656/// [property-support reference]: https://github.com/CramBL/hdf5-pure/blob/main/docs/reference/property-support.md
657#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
658#[doc(alias = "dapl")]
659pub struct DatasetAccessProperties {
660    chunk_cache: Option<ChunkCacheConfig>,
661}
662
663impl DatasetAccessProperties {
664    /// A value that inherits every file-wide access default.
665    pub const fn new() -> Self {
666        Self { chunk_cache: None }
667    }
668
669    /// Override the raw chunk cache for this one dataset, ignoring the file-wide
670    /// default. This is the `H5Pset_chunk_cache` analogue.
671    #[doc(alias = "H5Pset_chunk_cache")]
672    pub const fn with_chunk_cache(mut self, chunk_cache: ChunkCacheConfig) -> Self {
673        self.chunk_cache = Some(chunk_cache);
674        self
675    }
676
677    /// Return the chunk-cache override, or `None` when the dataset inherits the
678    /// file-wide default.
679    pub const fn chunk_cache(&self) -> Option<ChunkCacheConfig> {
680        self.chunk_cache
681    }
682
683    /// Resolve the effective chunk-cache config: the per-dataset override if one
684    /// was set, otherwise the file-wide `default`.
685    const fn resolved_chunk_cache(&self, default: ChunkCacheConfig) -> ChunkCacheConfig {
686        match self.chunk_cache {
687            Some(config) => config,
688            None => default,
689        }
690    }
691}
692
693/// Test whether a file looks like an HDF5 file, without reading it whole.
694///
695/// This is the spelling of the C library's `H5Fis_accessible` /
696/// `H5Fis_hdf5`: it opens the file and scans only the 8-byte candidate windows
697/// where the HDF5 signature is permitted (offsets 0, 512, 1024, 2048, …), so it
698/// never buffers the whole file. Returns:
699///
700/// - `Ok(true)` — the HDF5 signature was found,
701/// - `Ok(false)` — the file opened but has no HDF5 signature,
702/// - `Err(..)` — the file could not be opened (missing, permissions, …).
703///
704/// It validates only the signature, not the rest of the format; a truncated or
705/// corrupt file past the signature still reports `true`. Use [`File::open`] to
706/// fully parse and validate.
707pub fn is_hdf5<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<bool> {
708    let handle = std::fs::File::open(path)?;
709    let source = ReadSeekSource::new(handle).map_err(std::io::Error::other)?;
710    match signature::find_signature_in(&source) {
711        Ok(_) => Ok(true),
712        Err(FormatError::SignatureNotFound) => Ok(false),
713        Err(e) => Err(std::io::Error::other(e)),
714    }
715}
716
717/// Test whether an in-memory buffer begins (at a permitted offset) with the
718/// HDF5 signature. The buffer-backed counterpart of [`is_hdf5`].
719pub fn is_hdf5_bytes(data: &[u8]) -> bool {
720    signature::find_signature(data).is_ok()
721}
722
723/// An open HDF5 file for reading.
724struct FileInner {
725    backend: Backend,
726    superblock: Superblock,
727    /// Byte offset to add to all relative addresses (= original base_address).
728    addr_offset: BaseAddress,
729    /// Live file handle, retained only when the file was opened with
730    /// [`File::open_swmr`] so [`File::refresh`] can re-read appended data.
731    handle: Option<std::fs::File>,
732    /// File Space Info parsed from the superblock extension, if the file records
733    /// one. Best-effort: a malformed or unreadable extension leaves this `None`
734    /// rather than failing the open.
735    file_space_info: Option<FileSpaceInfo>,
736    /// The shared object header message (SOHM) table, if the superblock
737    /// extension records one. `None` for almost every file: no common producer
738    /// enables shared-message indexes. Best-effort like `file_space_info`, so a
739    /// file whose table cannot be read still opens and still reads every object
740    /// that shares nothing; what fails is following a reference into the heap,
741    /// with [`FormatError::UnsupportedSohmReference`].
742    ///
743    /// Boxed because it is absent on essentially every file, and this struct is
744    /// allocated once per open: one pointer costs less here than the table
745    /// inline, and the absent case allocates nothing at all.
746    sohm_table: Option<Box<crate::sohm::SohmTable>>,
747    access_properties: FileAccessProperties,
748    /// Set by [`File::close`] to seal a read-write file: after it, a write
749    /// through any surviving [`Dataset`]/[`Group`] handle or [`File`] clone
750    /// returns [`Error::FileClosed`]. Reads still work. Only ever set on a
751    /// `Backend::Edit` file.
752    closed: AtomicBool,
753    /// How many times a write session has been given the chance to change this
754    /// file's bytes, counted so an owned [`Dataset`] handle can tell that the
755    /// object header it parsed no longer says what the file says.
756    ///
757    /// Advanced by every operation that reaches the write engine; see
758    /// [`FileInner::with_engine_mut`], which is the only thing that advances
759    /// either counter. Never advances for a read-only or streaming file, whose
760    /// bytes cannot move under a handle at all.
761    content_revision: AtomicU64,
762    /// How many times a write session has been given the chance to *move* an
763    /// object header, which is the narrower question of whether an address a
764    /// handle is holding still names its object.
765    ///
766    /// [`Change::InPlace`] leaves this alone: an immediate [`Dataset::append`]
767    /// rewrites a dataset's header where it stands, so an address stays good
768    /// across one. That is what lets a handle reached by object reference — the
769    /// one kind with no name to look itself up by — go on appending, while a
770    /// commit ends it.
771    address_revision: AtomicU64,
772    /// True for a file opened with [`File::open_swmr_writer`]: no OS lock is held,
773    /// the superblock's SWMR-write flag is raised, only immediate
774    /// [`Dataset::append`] is permitted (the staged surface is refused), and the
775    /// flag is cleared on [`File::close`] / `Drop`. `false` for every other file.
776    swmr_write: bool,
777}
778
779impl Drop for FileInner {
780    /// Best-effort cleanup for a writer dropped without an explicit
781    /// [`File::close`], running only when the last `Arc<FileInner>` clone drops;
782    /// a clean `close` already did this work and set `closed`, so this is
783    /// idempotent and skipped in that case.
784    ///
785    /// - A SWMR writer clears the superblock's SWMR-write flag (mirroring
786    ///   `File::close`).
787    /// - A read-write file that persists its free space rewrites its on-disk
788    ///   free-space managers into canonical shape (issue #173), so a
789    ///   dropped-without-`close` handle leaves the same file a clean `close`
790    ///   would (a no-op unless an immediate append grew the file past them). A
791    ///   true crash (`SIGKILL`, power loss) skips `drop` entirely; the appended
792    ///   data is still durable, under the default
793    ///   [`SyncPolicy::Always`](crate::SyncPolicy).
794    ///
795    /// Staged edits are *not* committed here: dropping a handle discards them,
796    /// which is what `close` exists to distinguish.
797    fn drop(&mut self) {
798        if self.closed.load(Ordering::Acquire) {
799            return;
800        }
801        let Backend::Edit(m) = &self.backend else {
802            return;
803        };
804        let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
805        // Both branches write, and this is the last moment anything can order
806        // those writes: the handle is gone once this returns, so `File::sync` is
807        // not an option the caller still has. The barrier is therefore forced
808        // rather than left to the session's `SyncPolicy` — see
809        // [`SyncPolicy::OnClose`](crate::SyncPolicy::OnClose).
810        // SWMR stages nothing and persists no free space, so it skips the
811        // re-homing; everything after that is the same teardown for both.
812        if !self.swmr_write {
813            let _ = session.finalize_persist();
814        }
815        // Only if the flush actually succeeded. The flags say this session's
816        // writes may still be in memory, and a failed `force_sync` is precisely
817        // the case where that is still true — taking them down there would
818        // publish the file as complete over a drain that did not finish.
819        if session.force_sync().is_ok() {
820            let _ = session.release_status_flags();
821        }
822    }
823}
824
825impl FileInner {
826    /// Open an HDF5 file from a filesystem path.
827    ///
828    /// Reads the file into memory once. To follow a file that a concurrent
829    /// single writer is appending to (SWMR), use [`File::open_swmr`] instead.
830    /// To read a file larger than memory (e.g. on a 32-bit host) without
831    /// buffering it, use [`File::open_streaming`].
832    pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
833        Self::open_with_options(path, FileAccessProperties::new())
834    }
835
836    /// Open an HDF5 file from a filesystem path with explicit access properties.
837    ///
838    /// Like [`open`](Self::open), this buffers the whole file in memory. Use
839    /// [`open_streaming_with_options`](Self::open_streaming_with_options) when
840    /// the metadata cache budget should apply to lazy metadata reads.
841    pub fn open_with_options<P: AsRef<std::path::Path>>(
842        path: P,
843        properties: FileAccessProperties,
844    ) -> Result<Self, Error> {
845        let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
846        let write_mark = properties.write_mark_policy;
847        let inner = Self::from_bytes_with_options(bytes, properties)?;
848        // The status-flag check belongs to every open that reads the live file
849        // — this one, `open_streaming` and `from_source` — and not to
850        // `from_bytes_with_options` (issue #245). The line is snapshot against
851        // live, not path against no path: a caller who already holds the bytes
852        // has taken its own snapshot, and there is no live file left to
853        // coordinate over. This is a deliberate divergence from the C library,
854        // which checks under its in-memory core driver too.
855        file_lock::check_status_flags(
856            &inner.superblock,
857            OpenIntent::Read(write_mark),
858            OpenTarget::Path(path.as_ref()),
859        )?;
860        Ok(inner)
861    }
862
863    /// Open an HDF5 file for **streaming** reads, fetching regions on demand from
864    /// the file instead of buffering it whole.
865    ///
866    /// This lets a host read a file larger than its address space — the original
867    /// motivation being 32-bit targets reading multi-gigabyte files (issue #27).
868    /// Metadata and dataset chunks are read through a `ReadSeekSource`, so peak
869    /// memory stays close to one chunk plus the metadata being parsed. Chunks
870    /// that sit next to each other on disk are fetched together, in reads of at
871    /// most 256 KiB (a larger chunk is read on its own), which is what makes a
872    /// file written a row at a time — thousands of chunks of a few dozen bytes
873    /// — read at a sensible speed. See [`crate::chunk_span`].
874    ///
875    /// Reads match the buffered [`File::open`]: every storage layout and chunk
876    /// index type, both group forms (v2 and v1 symbol-table), and compact,
877    /// dense, shared, and variable-length attributes. What differs:
878    /// [`as_bytes`](Self::as_bytes) returns an empty slice (there is no
879    /// whole-file buffer), [`persisted_free_space`](Self::persisted_free_space)
880    /// returns no regions, a streaming file cannot be the *source* of a
881    /// cross-file copy, and chunk decompression is sequential (the `parallel`
882    /// feature accelerates only buffered reads).
883    pub fn open_streaming<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
884        Self::open_streaming_with_options(path, FileAccessProperties::new())
885    }
886
887    /// Open an HDF5 file for streaming reads with explicit access properties.
888    pub fn open_streaming_with_options<P: AsRef<std::path::Path>>(
889        path: P,
890        properties: FileAccessProperties,
891    ) -> Result<Self, Error> {
892        let handle = std::fs::File::open(path.as_ref()).map_err(Error::Io)?;
893        let source = ReadSeekSource::new(handle).map_err(Error::Format)?;
894        Self::streaming(source, properties, OpenTarget::Path(path.as_ref()))
895    }
896
897    /// Open an HDF5 file from any [`Source`], reading metadata and chunks on
898    /// demand as [`open_streaming`](Self::open_streaming) does.
899    pub fn from_source<S: Source + Send + Sync + 'static>(source: S) -> Result<Self, Error> {
900        Self::from_source_with_options(source, FileAccessProperties::new())
901    }
902
903    /// Open an HDF5 file from any [`Source`] with explicit access properties.
904    pub fn from_source_with_options<S: Source + Send + Sync + 'static>(
905        source: S,
906        properties: FileAccessProperties,
907    ) -> Result<Self, Error> {
908        // The only source that reaches the parsers from outside the crate, and
909        // so the only one whose reads are length-checked: see `ValidatedSource`.
910        Self::streaming(ValidatedSource::new(source), properties, OpenTarget::Source)
911    }
912
913    /// The body both streaming opens share.
914    ///
915    /// Wraps the source in whatever metadata cache the properties ask for,
916    /// parses the superblock through it, and refuses a file a writer holds.
917    /// The two differ in where the source came from and in `target`, which does
918    /// nothing but name the file in that refusal; one body is what keeps the
919    /// rest of it from drifting apart.
920    fn streaming<S: Source + Send + Sync + 'static>(
921        source: S,
922        properties: FileAccessProperties,
923        target: OpenTarget<'_>,
924    ) -> Result<Self, Error> {
925        let source: Box<dyn Source + Send + Sync> = if properties.metadata_cache.is_enabled() {
926            Box::new(MetadataCachingSource::new(
927                source,
928                properties.metadata_cache,
929            ))
930        } else {
931            Box::new(source)
932        };
933        let (superblock, addr_offset) = Self::parse_superblock_source(source.as_ref())?;
934        file_lock::check_status_flags(
935            &superblock,
936            OpenIntent::Read(properties.write_mark_policy),
937            target,
938        )?;
939        Ok(Self::from_parts(
940            Backend::Streaming(source),
941            superblock,
942            addr_offset,
943            None,
944            properties,
945        ))
946    }
947
948    /// Open an HDF5 file for SWMR (single-writer/multiple-reader) reading.
949    ///
950    /// Like [`File::open`], but retains a live handle to the file so that
951    /// [`File::refresh`] can re-read data appended by a concurrent writer
952    /// (whether produced by this crate's append writer, the reference HDF5 C
953    /// library, or h5py in SWMR mode). The initial view is a consistent
954    /// snapshot; call [`File::refresh`] to advance to a newer one.
955    ///
956    /// Only the `std` build supports this (it requires a live filesystem
957    /// handle); the in-memory [`File::from_bytes`] path cannot refresh.
958    pub fn open_swmr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
959        Self::open_swmr_with_options(path, FileAccessProperties::new())
960    }
961
962    /// Open an HDF5 file for SWMR reading with explicit access properties.
963    ///
964    /// SWMR reads currently keep an in-memory mirror for refresh semantics, so
965    /// only the per-dataset chunk-cache settings affect this backend.
966    pub fn open_swmr_with_options<P: AsRef<std::path::Path>>(
967        path: P,
968        properties: FileAccessProperties,
969    ) -> Result<Self, Error> {
970        let mut handle = std::fs::File::open(path.as_ref()).map_err(Error::Io)?;
971        let mut data = Vec::new();
972        handle.read_to_end(&mut data).map_err(Error::Io)?;
973        let (superblock, addr_offset) = Self::parse_superblock(&data)?;
974        file_lock::check_status_flags(
975            &superblock,
976            OpenIntent::SwmrRead,
977            OpenTarget::Path(path.as_ref()),
978        )?;
979        Ok(Self::from_parts(
980            Backend::InMemory(data),
981            superblock,
982            addr_offset,
983            Some(handle),
984            properties,
985        ))
986    }
987
988    /// Open an HDF5 file from an in-memory byte vector.
989    pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
990        Self::from_bytes_with_options(data, FileAccessProperties::new())
991    }
992
993    /// Open an HDF5 file from an in-memory byte vector with explicit access properties.
994    pub fn from_bytes_with_options(
995        data: Vec<u8>,
996        properties: FileAccessProperties,
997    ) -> Result<Self, Error> {
998        let (superblock, addr_offset) = Self::parse_superblock(&data)?;
999        Ok(Self::from_parts(
1000            Backend::InMemory(data),
1001            superblock,
1002            addr_offset,
1003            None,
1004            properties,
1005        ))
1006    }
1007
1008    /// Open an existing HDF5 file for reading **and** in-place editing, applying
1009    /// `properties` (its [`FileLocking`] policy governs the OS file lock held for
1010    /// the file's life, and its chunk cache is the file-wide default).
1011    fn open_rw<P: AsRef<std::path::Path>>(
1012        path: P,
1013        properties: FileAccessProperties,
1014    ) -> Result<Self, Error> {
1015        Self::open_rw_with_default(path, properties, MemoryStrategy::Auto)
1016    }
1017
1018    /// Open read-write under the properties' memory strategy, falling back to
1019    /// `default` when the caller expressed none. The two public read-write entry
1020    /// [`File::open_rw`] passes [`MemoryStrategy::Auto`]: prefer the bounded
1021    /// engine, but take the mirror for a file the bounded engine cannot edit
1022    /// (issue #198, step 4). A caller who states a strategy overrides it.
1023    fn open_rw_with_default<P: AsRef<std::path::Path>>(
1024        path: P,
1025        properties: FileAccessProperties,
1026        default: MemoryStrategy,
1027    ) -> Result<Self, Error> {
1028        let session = WriteEngine::open_rw_with_strategy(
1029            path.as_ref(),
1030            properties.metadata_cache,
1031            properties.locking,
1032            properties.memory_strategy.unwrap_or(default),
1033        )?;
1034        Self::from_rw_session(session, properties)
1035    }
1036
1037    /// Wrap an opened [`WriteEngine`] as a read-write [`Backend::Edit`] file.
1038    fn from_rw_session(
1039        mut session: WriteEngine,
1040        properties: FileAccessProperties,
1041    ) -> Result<Self, Error> {
1042        // The one funnel every read-write session passes through, so the fapl's
1043        // format bound and `fsync` cadence reach the engine no matter which entry
1044        // point opened it — the SWMR writer included, which
1045        // `WriteEngine::open_swmr_writer` says why.
1046        session.set_libver_bounds(properties.libver_bounds)?;
1047        session.set_sync_policy(properties.sync_policy);
1048        // Every page-buffer refusal lives in `set_page_buffer_size`, including the
1049        // `SyncPolicy::Always` one — which is why `set_sync_policy` must precede
1050        // this call rather than merely happening to.
1051        session.set_page_buffer_size(properties.page_buffer_size)?;
1052        // The engine parsed and normalized this at open; take it rather than
1053        // re-parsing, so the image need not be able to hand out a slice.
1054        let superblock = session.superblock().clone();
1055        let addr_offset = superblock.base_address;
1056        Ok(Self::from_parts(
1057            Backend::Edit(Box::new(Mutex::new(session))),
1058            superblock,
1059            addr_offset,
1060            None,
1061            properties,
1062        ))
1063    }
1064
1065    /// Open for SWMR writing: no OS lock, superblock SWMR-write flag raised.
1066    fn open_swmr_writer<P: AsRef<std::path::Path>>(
1067        path: P,
1068        properties: FileAccessProperties,
1069    ) -> Result<Self, Error> {
1070        // The SWMR writer always mirrors. `Auto` and unset are *satisfied* by
1071        // that — they ask for the bounded engine where it applies and accept the
1072        // mirror where it does not — but `Bounded` is a guarantee, and honoring a
1073        // guarantee by ignoring it is how a caller ends up spending `O(file size)`
1074        // memory it asked not to. Refusing is also the permissive direction to be
1075        // wrong in: if this writer ever runs bounded, the refusal stops firing,
1076        // which breaks nobody.
1077        if properties.memory_strategy == Some(MemoryStrategy::Bounded) {
1078            return Err(Error::EditUnsupported(
1079                "the SWMR writer always holds the file in a whole-file mirror; leave \
1080                 MemoryStrategy unset, or pass MemoryStrategy::Auto or MemoryStrategy::Mirrored, \
1081                 to open it",
1082            ));
1083        }
1084        // A library-version bound below 1.10 is the same shape of unhonorable
1085        // guarantee. SWMR needs a version 3 superblock — neither library reads
1086        // the SWMR-write flag back on an older one — so a caller asking for the
1087        // 1.8 format here is asking for a file this writer cannot produce.
1088        if let Some((low, high)) = properties.libver_bounds
1089            && LibVer::resolve_writable(Some((low, high))).map_err(Error::Format)? < LibVer::V110
1090        {
1091            return Err(Error::EditUnsupported(
1092                "the SWMR writer requires a version 3 superblock, which is the v1.10 format; \
1093                 raise the FileAccessProperties library-version bound to open it",
1094            ));
1095        }
1096        // And a page buffer is the third. A SWMR reader follows the writer's
1097        // ordered phases as they become visible, so coalescing those writes is
1098        // not a slower or larger file but a reader that sees a state the phases
1099        // exist to keep it from seeing.
1100        if properties.page_buffer_size != 0 {
1101            return Err(Error::EditUnsupported(
1102                "the SWMR writer cannot buffer its writes: its readers observe the order they \
1103                 become visible in; leave FileAccessProperties::with_page_buffer_size unset to \
1104                 open it",
1105            ));
1106        }
1107        let session = WriteEngine::open_swmr_writer(path, properties.sync_policy)?;
1108        let mut inner = Self::from_rw_session(session, properties)?;
1109        inner.swmr_write = true;
1110        Ok(inner)
1111    }
1112
1113    /// After the caller has confirmed a [`Backend::Edit`] backend, gate the
1114    /// mutation: refuse a sealed file with [`Error::FileClosed`], and in
1115    /// SWMR-writer mode refuse a staged edit (`staged = true`) with
1116    /// [`Error::SwmrStagedUnsupported`] — only immediate appends are allowed.
1117    fn check_mutable(&self, staged: bool) -> Result<(), Error> {
1118        if self.closed.load(Ordering::Acquire) {
1119            return Err(Error::FileClosed);
1120        }
1121        if staged && self.swmr_write {
1122            return Err(Error::SwmrStagedUnsupported);
1123        }
1124        Ok(())
1125    }
1126
1127    /// Gate a staged edit *without* taking the session lock: the backend must
1128    /// offer the staged surface, and the file must still be mutable.
1129    ///
1130    /// This is the same gate the locking helpers apply before locking, split out
1131    /// so a public method taking a user closure can report a read-only or sealed
1132    /// file up front, run the closure with no lock held, and take the lock only
1133    /// to record the result (issue #200).
1134    fn check_staged_writable(&self) -> Result<(), Error> {
1135        match &self.backend {
1136            Backend::Edit(_) => self.check_mutable(true),
1137            _ => Err(Error::ReadOnly),
1138        }
1139    }
1140
1141    /// Lock this file's write session for an operation that may change the
1142    /// file, and record afterwards that it had the chance to.
1143    ///
1144    /// Every path that can change the file's bytes — [`File::commit`] and the
1145    /// copies, every staged and immediate edit a [`Dataset`] or [`Group`] handle
1146    /// makes, and the session teardown — goes through here, so a new entry point
1147    /// cannot change the file without classifying what it did. Two write the
1148    /// file without passing here, both because no handle can be alive to see it:
1149    /// [`FileInner::drop`], which runs when the last `Arc` goes, and
1150    /// [`File::refresh`], which takes `&mut self` through `Arc::get_mut` and
1151    /// advances the counters itself.
1152    ///
1153    /// The appender's claim bookkeeping ([`Dataset::claim_for_appender`] and its
1154    /// pair) locks the engine directly and rightly notes nothing: it records who
1155    /// is appending, not what the file holds — and it must keep working from a
1156    /// `Drop` on a sealed file, which this gate refuses.
1157    ///
1158    /// The counters advance whether `f` succeeded or not, and for a staged edit
1159    /// that changes no bytes at all. Both are deliberate: a refused commit can
1160    /// still have written and rolled back (issues #316 and #344), and the cost
1161    /// of a revision that did not need advancing is one re-read on the next use
1162    /// of a handle, where the cost of one that needed advancing and did not is a
1163    /// wrong answer.
1164    fn with_engine_mut<R>(
1165        &self,
1166        change: Change,
1167        f: impl FnOnce(&mut WriteEngine) -> Result<R, Error>,
1168    ) -> Result<R, Error> {
1169        let Backend::Edit(m) = &self.backend else {
1170            return Err(Error::ReadOnly);
1171        };
1172        self.check_mutable(matches!(change, Change::Relocating))?;
1173        let out = {
1174            let mut engine = m.lock().unwrap_or_else(PoisonError::into_inner);
1175            f(&mut engine)
1176        };
1177        self.note(change);
1178        out
1179    }
1180
1181    /// Record that an operation of kind `change` has run against this file.
1182    ///
1183    /// The address counter moves *first*, against [`revisions`](Self::revisions)
1184    /// reading it second. A reader that sees the new content revision has
1185    /// therefore already synchronized with this release, so it cannot then read
1186    /// an address revision from before it and conclude that its memoized address
1187    /// outlived a commit that moved it.
1188    fn note(&self, change: Change) {
1189        match change {
1190            Change::Relocating => {
1191                self.address_revision.fetch_add(1, Ordering::Release);
1192                self.content_revision.fetch_add(1, Ordering::Release);
1193            }
1194            Change::InPlace => {
1195                self.content_revision.fetch_add(1, Ordering::Release);
1196            }
1197            Change::Nothing => {}
1198        }
1199    }
1200
1201    /// How many times a write session has been given the chance to change this
1202    /// file's bytes. A [`Dataset`] handle whose header was parsed at this value
1203    /// still holds what the file holds.
1204    fn content_revision(&self) -> u64 {
1205        self.content_revision.load(Ordering::Acquire)
1206    }
1207
1208    /// How many times a write session has been given the chance to move an
1209    /// object header. An address worked out at this value still names its
1210    /// object.
1211    fn address_revision(&self) -> u64 {
1212        self.address_revision.load(Ordering::Acquire)
1213    }
1214
1215    /// Work out where a handle's object header sits now, and say what that
1216    /// answer holds as of.
1217    ///
1218    /// One rule, in the order it is written. An address nothing has moved since
1219    /// still names its object, whichever way the handle names it — which is what
1220    /// keeps an in-place append anywhere in the file from making every handle
1221    /// walk its path again. Past that, a handle opened by `path` looks its
1222    /// object up, which follows it wherever a commit put it, and one reached by
1223    /// object reference has no name to look up: `memo` held the only address it
1224    /// will ever have, and the bytes a relocated header vacates still parse as
1225    /// the object that left them, so there is nothing left to read.
1226    ///
1227    /// Both counters are read *before* the resolution, never after: a value
1228    /// taken afterwards could name a state the address predates, and a handle
1229    /// that memoized that pairing would go on answering from a header a commit
1230    /// had already moved. Taken beforehand, a concurrent change makes the
1231    /// pairing merely stale — which the next use notices, so long as it is the
1232    /// next *use*. A commit running on another thread between this read and the
1233    /// bytes it labels is not ordered against either, so a handle shared across
1234    /// threads can still serve one read from a header a concurrent commit had
1235    /// moved. What these counters order is a handle against edits already made,
1236    /// not against one in flight. The callers that classify what they resolved
1237    /// to — [`Dataset::resolved`], [`Group::header_address`] — parse that header
1238    /// in the same unordered window, so a commit landing inside it can also make
1239    /// a live handle report [`Error::NotADataset`](crate::Error::NotADataset) or
1240    /// [`Error::NotAGroup`](crate::Error::NotAGroup) for an object whose kind
1241    /// never changed. That is the same staleness reporting itself instead of
1242    /// answering, which is the better half of the trade.
1243    fn locate(&self, path: Option<&str>, memo: Option<Resolution>) -> Result<Resolution, Error> {
1244        let revisions = self.revisions();
1245        // A handle with no memo has never resolved — it names an object this
1246        // session staged — so there is nothing to short-circuit on, and its path
1247        // is walked afresh every time until a commit gives it a header.
1248        Ok(revisions.at(match (path, memo) {
1249            (_, Some(memo)) if revisions.address == memo.address_revision => memo.address,
1250            (Some(path), _) => self.resolve_path(path)?,
1251            (None, _) => return Err(Error::StaleHandle),
1252        }))
1253    }
1254
1255    /// [`locate`](Self::locate), reported as [`Error::NotCommitted`] when this
1256    /// session has the path *staged* rather than written, and as
1257    /// [`Error::StagingWithdrawn`] when the staging a handle was born onto is
1258    /// gone.
1259    ///
1260    /// Every handle onto a staged object comes through here, so the distinction
1261    /// between "there is no such object", "there is one, and `commit` has not
1262    /// written it yet" and "the one this handle names has been withdrawn" is
1263    /// made in one place rather than at each caller.
1264    ///
1265    /// The staged set is consulted *before* the file for a handle that has never
1266    /// resolved, because a staged creation can sit on a path the file still
1267    /// holds: a delete and a create in one commit replace the object there
1268    /// (issue #305), and until that commit runs the old bytes are still
1269    /// perfectly readable. Answering from them would hand the caller the object
1270    /// their handle was explicitly not opened onto.
1271    ///
1272    /// `birth` is the handle's own [`Standing`] input — `None` for one opened
1273    /// onto an object in the file. This question is keyed by path alone, so a
1274    /// born handle whose creation was withdrawn and replaced by one of the other
1275    /// kind at the same path reports `NotCommitted` here where
1276    /// [`staged_dataset_view`](Self::staged_dataset_view) tells it apart; both
1277    /// refuse, and neither answers from the file.
1278    fn locate_staged(
1279        &self,
1280        path: Option<&str>,
1281        memo: Option<Resolution>,
1282        birth: Option<u64>,
1283    ) -> Result<Resolution, Error> {
1284        if let (None, Some(named)) = (memo, path) {
1285            match self.staged_standing(named, birth) {
1286                Standing::Pending => return Err(Error::NotCommitted(named.to_string())),
1287                Standing::Withdrawn => return Err(Error::StagingWithdrawn(named.to_string())),
1288                Standing::Live => {}
1289            }
1290        }
1291        match self.locate(path, memo) {
1292            Err(Error::Format(FormatError::PathNotFound(missing))) => {
1293                let named = path.unwrap_or_default();
1294                match self.staged_object(named) {
1295                    Some(_) => Err(Error::NotCommitted(named.to_string())),
1296                    None => Err(Error::Format(FormatError::PathNotFound(missing))),
1297                }
1298            }
1299            other => other,
1300        }
1301    }
1302
1303    /// Run `f` against this file's write session, or `None` when there is none
1304    /// (a read-only file stages nothing, so it has nothing to be asked about).
1305    fn query_engine<R>(&self, f: impl FnOnce(&WriteEngine) -> R) -> Option<R> {
1306        match &self.backend {
1307            Backend::Edit(m) => Some(f(&m.lock().unwrap_or_else(PoisonError::into_inner))),
1308            _ => None,
1309        }
1310    }
1311
1312    /// What this session has staged at `path`, if anything. See
1313    /// [`WriteEngine::staged_object`] for the rule.
1314    fn staged_object(&self, path: &str) -> Option<StagedObject> {
1315        self.query_engine(|e| e.staged_object(path)).flatten()
1316    }
1317
1318    /// Where a handle naming `path` stands: whether the object it addresses is
1319    /// one the file holds, one this session has staged, or one whose staging has
1320    /// been withdrawn under it.
1321    ///
1322    /// `birth` is the session's [staged generation](WriteEngine::staged_generation)
1323    /// as of the moment the handle was made onto a staged creation, and `None`
1324    /// for every handle opened onto an object in the file. It is what separates
1325    /// the two ways a staged path can stop being staged — the commit published
1326    /// it, or a [`Group::delete`] withdrew it — which otherwise look identical
1327    /// from here, and which a handle must not confuse: a withdrawn creation
1328    /// leaves the file's own object at that path, and it is the object the
1329    /// session is *deleting*.
1330    ///
1331    /// Both halves come from one lock, so a commit cannot land between them and
1332    /// pair a generation with a staged set from the other side of it.
1333    ///
1334    /// A file with no write session stages nothing, so every handle onto one is
1335    /// live. That includes the `birth`-carrying arm, which such a file cannot
1336    /// produce: a handle holds its [`FileInner`] alive and a backend never
1337    /// changes under one, so the case is unreachable rather than merely unlikely.
1338    fn staged_standing(&self, path: &str, birth: Option<u64>) -> Standing {
1339        let Some((generation, staged)) =
1340            self.query_engine(|e| (e.staged_generation(), e.staged_object(path).is_some()))
1341        else {
1342            return Standing::Live;
1343        };
1344        match birth {
1345            // A commit has taken this session's staged set since the handle was
1346            // made, so what it names is in the file (or was deleted from it
1347            // afterwards, which resolving the path reports).
1348            Some(birth) if birth != generation => Standing::Live,
1349            Some(_) if !staged => Standing::Withdrawn,
1350            _ if staged => Standing::Pending,
1351            _ => Standing::Live,
1352        }
1353    }
1354
1355    /// What a dataset staged at `path` says about itself before it is written,
1356    /// for a handle whose staged generation at birth was `birth`.
1357    ///
1358    /// `Ok(None)` means the handle should read the file: either nothing is
1359    /// staged there, or a commit has published what was. The
1360    /// [`Error::StagingWithdrawn`] arm is [`staged_standing`](Self::staged_standing)'s,
1361    /// decided under the same lock — and told apart by *kind* here, since a
1362    /// dataset creation withdrawn and replaced by a group at the same path
1363    /// leaves nothing for this handle to answer from either.
1364    fn staged_dataset_view(
1365        &self,
1366        path: &str,
1367        birth: Option<u64>,
1368    ) -> Result<Option<StagedMeta>, Error> {
1369        let Some((generation, meta)) =
1370            self.query_engine(|e| (e.staged_generation(), e.staged_dataset_meta(path)))
1371        else {
1372            return Ok(None);
1373        };
1374        match birth {
1375            Some(birth) if birth != generation => Ok(None),
1376            Some(_) if meta.is_none() => Err(Error::StagingWithdrawn(path.to_string())),
1377            _ => Ok(meta),
1378        }
1379    }
1380
1381    /// The session's staged generation now, for a handle being made onto a
1382    /// creation staged in it. `None` on a file with no write session, which
1383    /// stages nothing and so makes no such handle.
1384    fn staged_generation(&self) -> Option<u64> {
1385        self.query_engine(WriteEngine::staged_generation)
1386    }
1387
1388    /// The direct children `parent` gains from this session's staged creations.
1389    fn staged_children(&self, parent: &str) -> StagedChildren {
1390        self.query_engine(|e| e.staged_children(parent))
1391            .unwrap_or_default()
1392    }
1393
1394    /// The revisions to label a resolution that is about to be worked out with.
1395    fn revisions(&self) -> Revisions {
1396        Revisions {
1397            content: self.content_revision(),
1398            address: self.address_revision(),
1399        }
1400    }
1401
1402    /// A `Source` view over the backend, for the streaming-capable paths.
1403    pub(crate) fn source(&self) -> SourceView<'_> {
1404        match &self.backend {
1405            Backend::InMemory(v) => SourceView::Mem(v),
1406            Backend::Streaming(s) => SourceView::Stream(s.as_ref()),
1407            // A mirror or bounded file's bytes live behind a lock and cannot be
1408            // lent out as a borrowed view; the read paths that reach every
1409            // backend go through [`with_source`](Self::with_source) instead.
1410            Backend::Edit(_) => SourceView::Mem(&[]),
1411        }
1412    }
1413
1414    /// Run `f` with a random-access view of this file's bytes, taking the
1415    /// write-engine lock when the backend requires one. Unlike
1416    /// [`source`](Self::source) — which cannot lend a borrowed view out of a
1417    /// lock and returns an empty view for the mirror and bounded backends —
1418    /// this serves every backend, so it is the dispatch for read paths (heap
1419    /// reads for variable-length data, chunk enumeration) that must also work
1420    /// on a read-write file. `f` must not re-enter this file's backend (the
1421    /// engine lock is held while it runs).
1422    pub(crate) fn with_source<R>(&self, f: impl FnOnce(&dyn Source) -> R) -> R {
1423        match &self.backend {
1424            Backend::InMemory(v) => f(&BytesSource::new(v.as_slice())),
1425            Backend::Streaming(s) => f(s.as_ref()),
1426            Backend::Edit(m) => {
1427                let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1428                f(core.image())
1429            }
1430        }
1431    }
1432
1433    /// Run a read against a read-write session's file image, choosing the form
1434    /// its backing can serve: `on_slice` when the session holds the whole file
1435    /// in memory, so a slice-walking parser borrows the bytes instead of copying
1436    /// them, and `on_source` otherwise.
1437    ///
1438    /// Both closures must compute the same thing. The pair exists because a
1439    /// mirror can hand out a whole-file slice and a file-backed image cannot,
1440    /// not because the two backings answer differently (issue #198).
1441    fn with_engine<R>(
1442        engine: &Mutex<WriteEngine>,
1443        on_slice: impl FnOnce(&[u8]) -> R,
1444        on_source: impl FnOnce(&dyn Source) -> R,
1445    ) -> R {
1446        let core = engine
1447            .lock()
1448            .unwrap_or_else(std::sync::PoisonError::into_inner);
1449        match core.image_slice() {
1450            Some(data) => on_slice(data),
1451            None => on_source(core.image()),
1452        }
1453    }
1454
1455    /// Parse the superblock from `data`, returning it (with `root_group_address`
1456    /// normalized to an absolute offset) and the base-address offset.
1457    fn parse_superblock(data: &[u8]) -> Result<(Superblock, BaseAddress), Error> {
1458        let sig_offset = signature::find_signature(data)?;
1459        let mut superblock = Superblock::parse(data, sig_offset)?;
1460        let addr_offset = superblock.base_address;
1461        // Normalize root_group_address to absolute so resolve_path_any works.
1462        superblock.root_group_address = addr_offset.absolute(superblock.root_group_address)?;
1463        Ok((superblock, addr_offset))
1464    }
1465
1466    /// Streaming counterpart of [`parse_superblock`]: locate and parse the
1467    /// superblock by reading only small windows from the source.
1468    fn parse_superblock_source<S: Source + ?Sized>(
1469        source: &S,
1470    ) -> Result<(Superblock, BaseAddress), Error> {
1471        let sig_offset = signature::find_signature_in(source)?;
1472        let mut superblock = Superblock::parse_from_source(source, sig_offset)?;
1473        let addr_offset = superblock.base_address;
1474        superblock.root_group_address = addr_offset.absolute(superblock.root_group_address)?;
1475        Ok((superblock, addr_offset))
1476    }
1477
1478    /// Assemble a [`File`] from parsed parts, then load the File Space Info from
1479    /// the superblock extension (best-effort, so a bad extension never fails the
1480    /// open).
1481    fn from_parts(
1482        backend: Backend,
1483        superblock: Superblock,
1484        addr_offset: BaseAddress,
1485        handle: Option<std::fs::File>,
1486        access_properties: FileAccessProperties,
1487    ) -> Self {
1488        let mut file = FileInner {
1489            backend,
1490            superblock,
1491            addr_offset,
1492            handle,
1493            file_space_info: None,
1494            sohm_table: None,
1495            access_properties,
1496            closed: AtomicBool::new(false),
1497            content_revision: AtomicU64::new(0),
1498            address_revision: AtomicU64::new(0),
1499            swmr_write: false,
1500        };
1501        file.file_space_info = file.read_file_space_info();
1502        file.sohm_table = file.read_sohm_table();
1503        file
1504    }
1505
1506    /// Parse the File Space Info message from the superblock extension, if the
1507    /// file records one and it can be read. Best-effort: any failure (no
1508    /// extension, unreadable object header, malformed message) yields `None`.
1509    fn read_file_space_info(&self) -> Option<FileSpaceInfo> {
1510        let rel = self.superblock.superblock_extension_address?;
1511        if rel == u64::MAX {
1512            return None;
1513        }
1514        let abs = self.addr_offset.absolute(rel).ok()?;
1515        let header = self.parse_header(abs).ok()?;
1516        let msg = header
1517            .messages
1518            .iter()
1519            .find(|m| m.msg_type == MessageType::FileSpaceInfo)?;
1520        FileSpaceInfo::parse(
1521            &msg.data,
1522            self.superblock.offset_size,
1523            self.superblock.length_size,
1524        )
1525        .ok()
1526    }
1527
1528    /// Parse the Shared Message Table message from the superblock extension and
1529    /// read the master table it names, if the file records one.
1530    ///
1531    /// Best-effort in the same way and for the same reason as
1532    /// [`Self::read_file_space_info`]: a file whose shared-message table is
1533    /// unreadable still opens, and every object in it that shares no message
1534    /// still reads.
1535    fn read_sohm_table(&self) -> Option<Box<crate::sohm::SohmTable>> {
1536        let rel = self.superblock.superblock_extension_address?;
1537        if rel == u64::MAX {
1538            return None;
1539        }
1540        let abs = self.addr_offset.absolute(rel).ok()?;
1541        let header = self.parse_header(abs).ok()?;
1542        let msg = header
1543            .messages
1544            .iter()
1545            .find(|m| m.msg_type == MessageType::SharedMessageTable)?;
1546        let message =
1547            crate::sohm::SharedMessageTableMessage::parse(&msg.data, self.superblock.offset_size)
1548                .ok()?;
1549        // The table's address, like every address in a header message, is stored
1550        // relative to the file's base address, so the read is framed the same way
1551        // every other metadata walk here is.
1552        let base = self.addr_offset;
1553        let os = self.superblock.offset_size;
1554        match &self.backend {
1555            Backend::InMemory(v) => {
1556                crate::sohm::SohmTable::read(frame(v, base).ok()?, &message, os).ok()
1557            }
1558            Backend::Streaming(s) if base.is_zero() => {
1559                crate::sohm::SohmTable::read_from_source(s.as_ref(), &message, os).ok()
1560            }
1561            Backend::Streaming(s) => crate::sohm::SohmTable::read_from_source(
1562                &BaseOffsetSource {
1563                    inner: s.as_ref(),
1564                    base,
1565                },
1566                &message,
1567                os,
1568            )
1569            .ok(),
1570            Backend::Edit(m) => Self::with_engine(
1571                m,
1572                |d| Ok::<_, Error>(crate::sohm::SohmTable::read(frame(d, base)?, &message, os)?),
1573                |s| {
1574                    if base.is_zero() {
1575                        Ok(crate::sohm::SohmTable::read_from_source(s, &message, os)?)
1576                    } else {
1577                        Ok(crate::sohm::SohmTable::read_from_source(
1578                            &BaseOffsetSource { inner: s, base },
1579                            &message,
1580                            os,
1581                        )?)
1582                    }
1583                },
1584            )
1585            .ok(),
1586        }
1587        .map(Box::new)
1588    }
1589
1590    /// Re-read the file from disk to pick up data appended by a concurrent
1591    /// writer, then re-parse the superblock.
1592    ///
1593    /// This is the SWMR reader's refresh primitive (analogous to the C library's
1594    /// `H5Drefresh` / h5py's `Dataset.refresh()`): after it returns, newly
1595    /// fetched [`Dataset`]/[`Group`] handles observe the writer's appended
1596    /// chunks and extended dimensions, because they re-parse object headers at
1597    /// their (stable) addresses against the refreshed bytes. Existing handles
1598    /// borrow `&self`, so they must be dropped before calling this; re-fetch
1599    /// them afterward.
1600    ///
1601    /// Returns [`Error::SwmrUnsupported`] if the file was not opened with
1602    /// [`File::open_swmr`]. The superblock is checksum-validated on every
1603    /// re-read; a transient parse failure (a writer caught mid-flush) is
1604    /// retried a bounded number of times before being surfaced.
1605    ///
1606    /// Cost: each call re-reads the entire file from disk (`O(file size)`).
1607    /// That keeps the implementation simple and correct, but when following a
1608    /// large, steadily growing log it is the cost paid per refresh; budget
1609    /// refresh frequency accordingly.
1610    pub fn refresh(&mut self) -> Result<(), Error> {
1611        let handle = self.handle.as_mut().ok_or(Error::SwmrUnsupported)?;
1612
1613        // A writer only appends (the file grows) and updates a few fixed-size,
1614        // individually checksummed structures in place (superblock EOF, object
1615        // header dimensions, array header counts). Re-reading the whole file and
1616        // re-validating the superblock checksum yields a consistent view; if the
1617        // superblock is caught mid-update, retry.
1618        const MAX_ATTEMPTS: u32 = 100;
1619        let mut last_err = None;
1620        for attempt in 0..MAX_ATTEMPTS {
1621            let mut data = Vec::new();
1622            handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
1623            handle.read_to_end(&mut data).map_err(Error::Io)?;
1624            match Self::parse_superblock(&data) {
1625                Ok((superblock, addr_offset)) => {
1626                    self.backend = Backend::InMemory(data);
1627                    self.superblock = superblock;
1628                    self.addr_offset = addr_offset;
1629                    self.file_space_info = self.read_file_space_info();
1630                    self.sohm_table = self.read_sohm_table();
1631                    // Every byte just moved. `File::refresh` takes `&mut self`
1632                    // through `Arc::get_mut`, so no handle can be alive to see
1633                    // it — but the counters are the file's statement about its
1634                    // own bytes, and leaving them behind here would make that
1635                    // statement false.
1636                    *self.content_revision.get_mut() += 1;
1637                    *self.address_revision.get_mut() += 1;
1638                    return Ok(());
1639                }
1640                Err(e) => {
1641                    last_err = Some(e);
1642                    // Brief backoff before re-reading; the writer's in-place
1643                    // updates are tiny, so a short pause clears the window. Skip
1644                    // it on the final attempt, where there is no re-read to come.
1645                    if attempt + 1 < MAX_ATTEMPTS {
1646                        std::thread::sleep(std::time::Duration::from_micros(
1647                            50 * (attempt + 1) as u64,
1648                        ));
1649                    }
1650                }
1651            }
1652        }
1653        // The loop always runs at least once and only reaches here via the
1654        // `Err` arm, so `last_err` is always `Some`; surface the real error.
1655        Err(last_err.expect("refresh retried at least once before failing"))
1656    }
1657
1658    /// Resolve a path to an object-header address, dispatching on the backend.
1659    fn resolve_path(&self, path: &str) -> Result<u64, Error> {
1660        Ok(match &self.backend {
1661            Backend::InMemory(v) => group_v2::resolve_path_any(v, &self.superblock, path)?,
1662            Backend::Streaming(s) => {
1663                group_v2::resolve_path_any_from_source(s.as_ref(), &self.superblock, path)?
1664            }
1665            // A staged commit can relocate the object tree's root, so this
1666            // file's cached superblock may name a stale one; resolve against the
1667            // session's own superblock, which the commit updates.
1668            Backend::Edit(m) => {
1669                let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1670                let sb = core.superblock().clone();
1671                match core.image_slice() {
1672                    Some(data) => group_v2::resolve_path_any(data, &sb, path)?,
1673                    None => group_v2::resolve_path_any_from_source(core.image(), &sb, path)?,
1674                }
1675            }
1676        })
1677    }
1678
1679    /// The current root-group address (base-adjusted, absolute). For a read-write
1680    /// [`Backend::Edit`] file a prior relocating commit can have moved the
1681    /// root, so take the session's own superblock, which the commit updates;
1682    /// other backends use this file's cached one.
1683    fn mirror_root_address(&self) -> u64 {
1684        if let Backend::Edit(m) = &self.backend {
1685            let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1686            return core.superblock().root_group_address;
1687        }
1688        self.superblock.root_group_address
1689    }
1690
1691    /// Returns the raw file bytes for an in-memory file, or an empty slice for a
1692    /// streaming file (which has no whole-file buffer).
1693    pub fn as_bytes(&self) -> &[u8] {
1694        match &self.backend {
1695            Backend::InMemory(v) => v,
1696            // A streaming, mirror, or bounded file has no borrowable whole-file
1697            // buffer.
1698            Backend::Streaming(_) | Backend::Edit(_) => &[],
1699        }
1700    }
1701
1702    /// Return the access properties used when opening this file.
1703    pub const fn access_properties(&self) -> FileAccessProperties {
1704        self.access_properties
1705    }
1706
1707    /// The backend this file's editing session resolved to, or `None` when there
1708    /// is no editing session to ask. Always [`EditBacking::Mirrored`] for the
1709    /// SWMR writer, which builds a session but does not dispatch on the strategy.
1710    fn edit_backing(&self) -> Option<EditBacking> {
1711        match &self.backend {
1712            Backend::Edit(m) => Some(m.lock().unwrap_or_else(|e| e.into_inner()).edit_backing()),
1713            _ => None,
1714        }
1715    }
1716
1717    /// What this file's metadata cache has done, or `None` where the backend
1718    /// holds none.
1719    fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
1720        self.with_source(|source| source.metadata_cache_stats())
1721    }
1722
1723    /// Zero those counters, keeping the cached entries.
1724    fn reset_metadata_cache_stats(&self) {
1725        self.with_source(|source| source.reset_metadata_cache_stats());
1726    }
1727
1728    /// Returns a reference to the parsed superblock.
1729    pub fn superblock(&self) -> &Superblock {
1730        &self.superblock
1731    }
1732
1733    /// The whole-file byte image when this file is buffered in memory
1734    /// ([`open`](Self::open) / [`from_bytes`](Self::from_bytes)); `None` for a
1735    /// streaming file ([`open_streaming`](Self::open_streaming)). Cross-file
1736    /// object copy ([`File::copy_from`](crate::File::copy_from)) uses this to read
1737    /// source objects by absolute address.
1738    pub(crate) fn in_memory_image(&self) -> Option<&[u8]> {
1739        match &self.backend {
1740            Backend::InMemory(data) => Some(data),
1741            Backend::Streaming(_) | Backend::Edit(_) => None,
1742        }
1743    }
1744
1745    /// The base address (`H5F` superblock base address), i.e. the byte offset
1746    /// added to every stored relative address. Zero for a file with no
1747    /// userblock.
1748    pub(crate) fn base_address(&self) -> BaseAddress {
1749        self.addr_offset
1750    }
1751
1752    /// The file-space management strategy this file records in its superblock
1753    /// extension (set with `H5Pset_file_space_strategy`), or `None` if the file
1754    /// records none — the default, which the C library also writes as "no
1755    /// message". See [`file_space_info`](Self::file_space_info) for the full
1756    /// record (persist flag, threshold, page size).
1757    pub fn file_space_strategy(&self) -> Option<FileSpaceStrategy> {
1758        self.file_space_info.as_ref().map(|info| info.strategy)
1759    }
1760
1761    /// The full [`FileSpaceInfo`] recorded in this file's superblock extension,
1762    /// if present and readable.
1763    pub fn file_space_info(&self) -> Option<&FileSpaceInfo> {
1764        self.file_space_info.as_ref()
1765    }
1766
1767    /// The free regions a file persists on disk in its free-space managers (when
1768    /// written with `H5Pset_file_space_strategy(..., persist = true)`), as
1769    /// `(address, length)` pairs sorted by address.
1770    ///
1771    /// Empty when the file does not persist free space, or for the streaming
1772    /// backend (which does not load the manager blocks). The addresses are file
1773    /// offsets (relative to the base address); reading data is unaffected by the
1774    /// presence or absence of these managers.
1775    pub fn persisted_free_space(&self) -> Vec<(u64, u64)> {
1776        let Some(info) = &self.file_space_info else {
1777            return Vec::new();
1778        };
1779        if !info.persist {
1780            return Vec::new();
1781        }
1782        let Backend::InMemory(data) = &self.backend else {
1783            return Vec::new();
1784        };
1785        let mut sections = free_space_manager::read_persisted_sections(
1786            data,
1787            &info.manager_addrs,
1788            self.addr_offset,
1789            self.superblock.offset_size,
1790        )
1791        .unwrap_or_default();
1792        // Distinct sections have distinct addresses in any well-formed file, so
1793        // the tie-break never arises; only a malformed manager can advertise one
1794        // address twice, and which of the pair is reported first is already
1795        // unspecified. No `debug_assert` here: this parses untrusted bytes, which
1796        // must not panic a debug build.
1797        sections.sort_unstable_by_key(|s| s.addr);
1798        sections.into_iter().map(|s| (s.addr, s.size)).collect()
1799    }
1800
1801    /// The size of the underlying file in bytes (the HDF5 `H5Fget_filesize`).
1802    ///
1803    /// This is the total byte length of the backing store — for a streaming
1804    /// file the length reported by its source, for an in-memory file the length
1805    /// of its buffer. It includes any userblock prefix and trailing bytes, so it
1806    /// may exceed the superblock's logical end-of-file address; compare against
1807    /// `Superblock::eof_address` (reachable via
1808    /// [`File::superblock`]) to detect appended or unaccounted tail bytes.
1809    pub fn file_size(&self) -> u64 {
1810        match &self.backend {
1811            Backend::Edit(m) => {
1812                let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1813                core.image().len()
1814            }
1815            _ => self.source().len(),
1816        }
1817    }
1818
1819    /// The minimum library version required to read this file, derived from its
1820    /// superblock version (the *low bound* of HDF5's `H5Fget_libver_bounds`).
1821    ///
1822    /// A version 3 superblock, for example, reports [`LibVer::V110`] because it
1823    /// was introduced in HDF5 1.10.
1824    pub fn libver_bound(&self) -> LibVer {
1825        LibVer::from_superblock_version(self.superblock.version)
1826    }
1827
1828    fn parse_header(&self, address: u64) -> Result<ObjectHeader, FormatError> {
1829        let os = self.superblock.offset_size;
1830        let ls = self.superblock.length_size;
1831        match &self.backend {
1832            Backend::InMemory(v) => {
1833                ObjectHeader::parse_with_base(v, address.to_usize()?, os, ls, self.addr_offset)
1834            }
1835            Backend::Streaming(s) => {
1836                ObjectHeader::parse_from_source(s.as_ref(), address, os, ls, self.addr_offset)
1837            }
1838            Backend::Edit(m) => Self::with_engine(
1839                m,
1840                |d| ObjectHeader::parse_with_base(d, address.to_usize()?, os, ls, self.addr_offset),
1841                |s| ObjectHeader::parse_from_source(s, address, os, ls, self.addr_offset),
1842            ),
1843        }
1844    }
1845
1846    /// Resolve a base-relative object-header address (the value stored in an
1847    /// HDF5 `H5R_OBJECT` reference element) to the [`Object`] it points at.
1848    ///
1849    /// The stored address is relative to the superblock base address, so any
1850    /// MAT-file userblock is accounted for here. A null (`0`) or undefined
1851    /// (`HADDR_UNDEF`) address, or one whose object header is neither a dataset
1852    /// nor a group, yields [`FormatError::InvalidObjectReference`].
1853    fn object_at_relative(
1854        file: &Arc<FileInner>,
1855        revisions: Revisions,
1856        rel_addr: u64,
1857    ) -> Result<Object, Error> {
1858        // HADDR_UNDEF and the null address never name a real object. (Relative
1859        // address 0 is where the superblock sits, not an object header.)
1860        if rel_addr == u64::MAX || rel_addr == 0 {
1861            return Err(FormatError::InvalidObjectReference(rel_addr).into());
1862        }
1863        let abs = file
1864            .addr_offset
1865            .absolute(rel_addr)
1866            .map_err(|_| FormatError::InvalidObjectReference(rel_addr))?;
1867        let at = revisions.at(abs);
1868        let hdr = file.parse_header(abs)?;
1869        if has_message(&hdr, MessageType::DataLayout) {
1870            let chunk_cache = DatasetAccessProperties::new()
1871                .resolved_chunk_cache(file.access_properties.chunk_cache);
1872            Ok(Object::Dataset(Box::new(Dataset::new(
1873                file.clone(),
1874                at,
1875                hdr,
1876                chunk_cache,
1877                None,
1878            ))))
1879        } else if is_group(&hdr) {
1880            Ok(Object::Group(Group::new(file.clone(), at, None)))
1881        } else {
1882            Err(FormatError::InvalidObjectReference(rel_addr).into())
1883        }
1884    }
1885
1886    fn offset_size(&self) -> u8 {
1887        self.superblock.offset_size
1888    }
1889
1890    fn length_size(&self) -> u8 {
1891        self.superblock.length_size
1892    }
1893
1894    /// Resolve the children of a group object header, dispatching on the backend
1895    /// and converting link addresses to absolute.
1896    fn group_children(&self, hdr: &ObjectHeader) -> Result<Vec<GroupEntry>, Error> {
1897        let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
1898        let mut entries = match &self.backend {
1899            Backend::InMemory(v) => group_v2::resolve_group_entries(v, hdr, os, ls, base),
1900            Backend::Streaming(s) => {
1901                group_v2::resolve_group_entries_from_source(s.as_ref(), hdr, os, ls, base)
1902            }
1903            Backend::Edit(m) => Self::with_engine(
1904                m,
1905                |d| group_v2::resolve_group_entries(d, hdr, os, ls, base),
1906                |s| group_v2::resolve_group_entries_from_source(s, hdr, os, ls, base),
1907            ),
1908        }
1909        .map_err(Error::Format)?;
1910        for entry in &mut entries {
1911            // The stored address is relative to the base address; normalize to an
1912            // absolute file offset. A crafted entry (e.g. the HADDR_UNDEF sentinel)
1913            // must not wrap or panic.
1914            entry.object_header_address = base.absolute(entry.object_header_address)?;
1915        }
1916        Ok(entries)
1917    }
1918
1919    /// The child named `name`, at the absolute file address
1920    /// [`ChildLookup::Found`] carries.
1921    ///
1922    /// The by-name counterpart of [`group_children`](Self::group_children), and
1923    /// the one to reach for when a single child is wanted: it stops at the match
1924    /// rather than building an entry, and an owned name, for every other child
1925    /// of the group (issue #228).
1926    fn group_child(&self, group_address: u64, name: &str) -> Result<ChildLookup, Error> {
1927        let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
1928        let addr = group_address;
1929        match &self.backend {
1930            Backend::InMemory(v) => group_v2::find_child_address(v, addr, os, ls, base, name),
1931            Backend::Streaming(s) => {
1932                group_v2::find_child_address_from_source(s.as_ref(), addr, os, ls, base, name)
1933            }
1934            Backend::Edit(m) => Self::with_engine(
1935                m,
1936                |d| group_v2::find_child_address(d, addr, os, ls, base, name),
1937                |s| group_v2::find_child_address_from_source(s, addr, os, ls, base, name),
1938            ),
1939        }
1940        .map_err(Error::Format)
1941    }
1942
1943    /// Read all attributes attached to an object header, dispatching on the
1944    /// backend.
1945    fn attrs_of(&self, hdr: &ObjectHeader) -> Result<HashMap<String, AttrValue>, Error> {
1946        let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
1947        let attr_msgs = self.attr_messages_of(hdr)?;
1948        match &self.backend {
1949            Backend::Edit(m) => Ok(Self::with_engine(
1950                m,
1951                |d| attrs_to_map(&attr_msgs, &BytesSource::new(d), os, ls, base),
1952                |s| attrs_to_map(&attr_msgs, s, os, ls, base),
1953            )),
1954            _ => Ok(attrs_to_map(&attr_msgs, &self.source(), os, ls, base)),
1955        }
1956    }
1957
1958    /// The content of a header message, following the reference when the record
1959    /// marks the message *shared*.
1960    ///
1961    /// A shared record's body is not the message: it is an address, and a
1962    /// committed (`H5Tcommit`) datatype is stored exactly that way. Decoding the
1963    /// body directly turns a named `H5T_STD_I32LE` into a zero-width time type
1964    /// with no error anywhere, so every read of a message that HDF5 permits to be
1965    /// shared — datatype, dataspace, fill value, filter pipeline — goes through
1966    /// here. The borrowed case allocates nothing, which is every message this
1967    /// crate writes and nearly every one it reads.
1968    fn message_body<'m>(
1969        &self,
1970        msg: &'m crate::object_header::HeaderMessage,
1971    ) -> Result<Cow<'m, [u8]>, Error> {
1972        if !shared_message::is_shared(msg.flags) {
1973            return Ok(Cow::Borrowed(&msg.data));
1974        }
1975        let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
1976        let sohm = self.sohm_table.as_deref();
1977        // A shared reference stores its address relative to the base address, so
1978        // frame the file at `base` exactly as [`Self::attr_messages_of`] does.
1979        let resolved = match &self.backend {
1980            Backend::InMemory(v) => BufferedResolver::new(frame(v, base)?, os, ls, sohm)
1981                .resolve(&msg.data, msg.msg_type),
1982            Backend::Streaming(s) if base.is_zero() => {
1983                SourceResolver::new(s.as_ref(), os, ls, sohm).resolve(&msg.data, msg.msg_type)
1984            }
1985            Backend::Streaming(s) => SourceResolver::new(
1986                &BaseOffsetSource {
1987                    inner: s.as_ref(),
1988                    base,
1989                },
1990                os,
1991                ls,
1992                sohm,
1993            )
1994            .resolve(&msg.data, msg.msg_type),
1995            Backend::Edit(m) => Self::with_engine(
1996                m,
1997                |d| {
1998                    BufferedResolver::new(frame(d, base)?, os, ls, sohm)
1999                        .resolve(&msg.data, msg.msg_type)
2000                },
2001                |s| {
2002                    if base.is_zero() {
2003                        SourceResolver::new(s, os, ls, sohm).resolve(&msg.data, msg.msg_type)
2004                    } else {
2005                        SourceResolver::new(&BaseOffsetSource { inner: s, base }, os, ls, sohm)
2006                            .resolve(&msg.data, msg.msg_type)
2007                    }
2008                },
2009            ),
2010        }?;
2011        Ok(Cow::Owned(resolved))
2012    }
2013
2014    /// The object-header address a *shared* header message names, or `None` when
2015    /// the record carries its own content or names the shared-message heap.
2016    ///
2017    /// [`Self::message_body`] answers what the message says; this answers which
2018    /// object says it. A rewrite needs both: the content to reproduce the type,
2019    /// and the address to tell which users share one committed object rather than
2020    /// each naming a type of their own. A heap-stored message has no such object
2021    /// — it is one anonymous copy rather than a named one — so it answers `None`
2022    /// and a rewrite spells the message out, which is what the file already
2023    /// means.
2024    pub(crate) fn shared_target_address(
2025        &self,
2026        msg: &crate::object_header::HeaderMessage,
2027    ) -> Result<Option<u64>, Error> {
2028        if !shared_message::is_shared(msg.flags) {
2029            return Ok(None);
2030        }
2031        let reference =
2032            shared_message::parse_shared_ref(&msg.data, self.offset_size(), self.length_size())?;
2033        match reference.location {
2034            shared_message::SharedLocation::ObjectHeader(addr) => Ok(Some(addr)),
2035            shared_message::SharedLocation::SohmHeap(_) => Ok(None),
2036        }
2037    }
2038
2039    /// Extract every attribute message attached to an object header (compact,
2040    /// shared, and dense storage), dispatching on the backend.
2041    pub(crate) fn attr_messages_of(
2042        &self,
2043        hdr: &ObjectHeader,
2044    ) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
2045        let (os, ls) = (self.offset_size(), self.length_size());
2046        // Compact attributes come out of `hdr`, but the two addresses this walk
2047        // follows are read from message bodies and so are stored relative to the
2048        // base address: the Attribute Info message's fractal-heap address, and a
2049        // shared attribute's message address. Frame the file at `base` exactly as
2050        // [`Self::read_dataset_raw`] does, so both index it directly. For a plain
2051        // file (`base == 0`) this is the identity; without it, a userblock file's
2052        // dense attributes are looked for one userblock too early.
2053        let base = self.addr_offset;
2054        let sohm = self.sohm_table.as_deref();
2055        match &self.backend {
2056            Backend::InMemory(v) => {
2057                Ok(extract_attributes_full(frame(v, base)?, hdr, os, ls, sohm)?)
2058            }
2059            Backend::Streaming(s) if base.is_zero() => Ok(extract_attributes_full_from_source(
2060                s.as_ref(),
2061                hdr,
2062                os,
2063                ls,
2064                sohm,
2065            )?),
2066            Backend::Streaming(s) => {
2067                let framed = BaseOffsetSource {
2068                    inner: s.as_ref(),
2069                    base,
2070                };
2071                Ok(extract_attributes_full_from_source(
2072                    &framed, hdr, os, ls, sohm,
2073                )?)
2074            }
2075            Backend::Edit(m) => Self::with_engine(
2076                m,
2077                |d| Ok(extract_attributes_full(frame(d, base)?, hdr, os, ls, sohm)?),
2078                |s| {
2079                    if base.is_zero() {
2080                        Ok(extract_attributes_full_from_source(s, hdr, os, ls, sohm)?)
2081                    } else {
2082                        let framed = BaseOffsetSource { inner: s, base };
2083                        Ok(extract_attributes_full_from_source(
2084                            &framed, hdr, os, ls, sohm,
2085                        )?)
2086                    }
2087                },
2088            ),
2089        }
2090    }
2091
2092    /// Read a dataset's raw bytes for the given layout, dispatching on the backend.
2093    fn read_dataset_raw(
2094        &self,
2095        spec: RawReadSpec<'_>,
2096        cache: &ChunkCache,
2097    ) -> Result<Vec<u8>, FormatError> {
2098        let (os, ls) = (self.offset_size(), self.length_size());
2099        // Every on-disk address in `dl` — the contiguous data address, the chunk
2100        // index root, and (followed deeper in the chunked reader) every B-tree /
2101        // fixed-array / extensible-array node and chunk-data address — is stored
2102        // relative to the base address. Present the payload reader a base-relative
2103        // view of the file so all of them index it directly: slice the in-memory
2104        // buffer at `base`, or wrap the streaming source to add `base` to each
2105        // read. For a plain file (`base == 0`) this is the identity.
2106        let base = self.addr_offset;
2107        match &self.backend {
2108            Backend::InMemory(v) => {
2109                data_read::read_raw_data_cached(frame(v, base)?, spec, os, ls, cache)
2110            }
2111            Backend::Streaming(s) if base.is_zero() => {
2112                data_read::read_raw_data_cached_from_source(s.as_ref(), spec, os, ls, cache)
2113            }
2114            Backend::Streaming(s) => {
2115                let framed = BaseOffsetSource {
2116                    inner: s.as_ref(),
2117                    base,
2118                };
2119                data_read::read_raw_data_cached_from_source(&framed, spec, os, ls, cache)
2120            }
2121            Backend::Edit(m) => Self::with_engine(
2122                m,
2123                |data| {
2124                    let framed = frame(data, base)?;
2125                    data_read::read_raw_data_cached(framed, spec, os, ls, cache)
2126                },
2127                |s| {
2128                    let framed = BaseOffsetSource { inner: s, base };
2129                    data_read::read_raw_data_cached_from_source(&framed, spec, os, ls, cache)
2130                },
2131            ),
2132        }
2133    }
2134
2135    /// Windowed counterpart of [`read_dataset_raw`](Self::read_dataset_raw): read
2136    /// the raw element bytes of the row window `[start_row, start_row + num_rows)`,
2137    /// touching only the storage it overlaps. Reads through the same base-framed
2138    /// `Source`, so on-disk addresses resolve the same way. The caller clamps
2139    /// the window to the dataset.
2140    fn read_dataset_raw_rows(
2141        &self,
2142        spec: RawReadSpec<'_>,
2143        cache: &ChunkCache,
2144        pass: CachePass,
2145        start_row: u64,
2146        num_rows: u64,
2147    ) -> Result<Vec<u8>, FormatError> {
2148        let (os, ls) = (self.offset_size(), self.length_size());
2149        let (dl, ds, dt) = (spec.layout, spec.dataspace, spec.datatype);
2150        let elem_size = dt.element_size_usize()?;
2151        // Elements per row (product of inner dims; 1 when 0-D or 1-D). Checked so
2152        // a crafted dataspace whose inner dims overflow `usize` errors instead of
2153        // panicking (debug) or wrapping (release).
2154        let row_elems: usize = ds.dimensions.iter().skip(1).try_fold(1usize, |acc, &d| {
2155            acc.checked_mul(d.to_usize()?)
2156                .ok_or(FormatError::OffsetOverflow {
2157                    offset: acc as u64,
2158                    length: d,
2159                })
2160        })?;
2161        let row_bytes =
2162            row_elems
2163                .checked_mul(elem_size.get())
2164                .ok_or(FormatError::OffsetOverflow {
2165                    offset: row_elems as u64,
2166                    length: elem_size.get() as u64,
2167                })?;
2168
2169        // Compact data is inline in the layout message — no I/O, no framing.
2170        if let DataLayout::Compact { data } = dl {
2171            let start = start_row.to_usize()?.checked_mul(row_bytes);
2172            let len = num_rows.to_usize()?.checked_mul(row_bytes);
2173            let (Some(start), Some(len)) = (start, len) else {
2174                return Err(FormatError::OffsetOverflow {
2175                    offset: start_row,
2176                    length: row_bytes as u64,
2177                });
2178            };
2179            let end = start.checked_add(len).ok_or(FormatError::OffsetOverflow {
2180                offset: start as u64,
2181                length: len as u64,
2182            })?;
2183            return data
2184                .get(start..end)
2185                .map(<[u8]>::to_vec)
2186                .ok_or(FormatError::DataSizeMismatch {
2187                    expected: end,
2188                    actual: data.len(),
2189                });
2190        }
2191
2192        let base = self.addr_offset;
2193        match &self.backend {
2194            Backend::InMemory(v) => {
2195                let framed = frame(v, base)?;
2196                read_rows_framed(
2197                    &BytesSource::new(framed),
2198                    spec,
2199                    os,
2200                    ls,
2201                    cache,
2202                    pass,
2203                    start_row,
2204                    num_rows,
2205                    row_bytes,
2206                )
2207            }
2208            Backend::Streaming(s) if base.is_zero() => read_rows_framed(
2209                s.as_ref(),
2210                spec,
2211                os,
2212                ls,
2213                cache,
2214                pass,
2215                start_row,
2216                num_rows,
2217                row_bytes,
2218            ),
2219            Backend::Streaming(s) => {
2220                let framed = BaseOffsetSource {
2221                    inner: s.as_ref(),
2222                    base,
2223                };
2224                read_rows_framed(
2225                    &framed, spec, os, ls, cache, pass, start_row, num_rows, row_bytes,
2226                )
2227            }
2228            Backend::Edit(m) => Self::with_engine(
2229                m,
2230                |data| {
2231                    let framed = frame(data, base)?;
2232                    read_rows_framed(
2233                        &BytesSource::new(framed),
2234                        spec,
2235                        os,
2236                        ls,
2237                        cache,
2238                        pass,
2239                        start_row,
2240                        num_rows,
2241                        row_bytes,
2242                    )
2243                },
2244                |s| {
2245                    let framed = BaseOffsetSource { inner: s, base };
2246                    read_rows_framed(
2247                        &framed, spec, os, ls, cache, pass, start_row, num_rows, row_bytes,
2248                    )
2249                },
2250            ),
2251        }
2252    }
2253}
2254
2255/// Read a row window through an already base-framed `Source`. Contiguous
2256/// layouts are one bounded sub-read; chunked layouts use the windowed chunk
2257/// reader (only the rank-0 crafted-file corner falls back to a whole read
2258/// plus slice).
2259fn read_rows_framed<S: Source + ?Sized>(
2260    source: &S,
2261    spec: RawReadSpec<'_>,
2262    os: u8,
2263    ls: u8,
2264    cache: &ChunkCache,
2265    pass: CachePass,
2266    start_row: u64,
2267    num_rows: u64,
2268    row_bytes: usize,
2269) -> Result<Vec<u8>, FormatError> {
2270    let (dl, fill) = (spec.layout, spec.fill);
2271    // A zero-row window reads nothing, uniformly across the *supported* layouts.
2272    // A `Virtual` layout is unsupported and must still error like `read_raw`
2273    // does, so it is excluded here and falls through to the match.
2274    if num_rows == 0 && !matches!(dl, DataLayout::Virtual { .. }) {
2275        return Ok(Vec::new());
2276    }
2277    match dl {
2278        DataLayout::Compact { .. } => unreachable!("compact is handled before framing"),
2279        DataLayout::Contiguous { address, size } => {
2280            // Unallocated storage: the window reads as the fill value, the same
2281            // answer the whole-dataset readers give for it.
2282            let Some(addr) = *address else {
2283                let len = num_rows.to_usize()?.saturating_mul(row_bytes);
2284                return fill.buffer(len);
2285            };
2286            let start =
2287                start_row
2288                    .checked_mul(row_bytes as u64)
2289                    .ok_or(FormatError::OffsetOverflow {
2290                        offset: start_row,
2291                        length: row_bytes as u64,
2292                    })?;
2293            let len =
2294                num_rows
2295                    .to_usize()?
2296                    .checked_mul(row_bytes)
2297                    .ok_or(FormatError::OffsetOverflow {
2298                        offset: num_rows,
2299                        length: row_bytes as u64,
2300                    })?;
2301            // Never read past the dataset's own contiguous storage.
2302            if start.saturating_add(len as u64) > *size {
2303                return Err(FormatError::DataSizeMismatch {
2304                    expected: start.to_usize()?.saturating_add(len),
2305                    actual: (*size).to_usize()?,
2306                });
2307            }
2308            let off = addr.checked_add(start).ok_or(FormatError::OffsetOverflow {
2309                offset: addr,
2310                length: start,
2311            })?;
2312            source.read_exact_at(off, len)
2313        }
2314        DataLayout::Chunked { .. } => {
2315            match crate::chunked_read::read_chunked_rows_from_source(
2316                source, spec, os, ls, cache, pass, start_row, num_rows,
2317            )? {
2318                Some(bytes) => Ok(bytes),
2319                // Rank-0 chunked (a crafted-file corner): fall back to a whole
2320                // read, then slice.
2321                None => {
2322                    let full =
2323                        data_read::read_raw_data_cached_from_source(source, spec, os, ls, cache)?;
2324                    let start = start_row.to_usize()? * row_bytes;
2325                    let len = num_rows.to_usize()? * row_bytes;
2326                    full.get(start..start + len).map(<[u8]>::to_vec).ok_or(
2327                        FormatError::DataSizeMismatch {
2328                            expected: start + len,
2329                            actual: full.len(),
2330                        },
2331                    )
2332                }
2333            }
2334        }
2335        DataLayout::Virtual { .. } => Err(FormatError::UnsupportedVirtualLayout),
2336    }
2337}
2338
2339impl std::fmt::Debug for FileInner {
2340    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2341        f.debug_struct("File")
2342            .field("size", &self.file_size())
2343            .field("superblock_version", &self.superblock.version)
2344            .finish()
2345    }
2346}
2347
2348/// An open HDF5 file.
2349///
2350/// A `File` is an owned, cheaply cloneable handle to an open file: cloning it (or
2351/// deriving a [`Dataset`]/[`Group`] from it) shares one underlying open file
2352/// rather than re-reading it. Object handles returned by [`dataset`](Self::dataset),
2353/// [`group`](Self::group), and [`root`](Self::root) are **owned** — they keep the
2354/// file open for as long as they live and carry no borrow of the `File`, so they
2355/// can be stored in a struct, cached, cloned, and moved across threads. They stay
2356/// usable across a [`commit`](Self::commit), which is what makes caching one
2357/// worthwhile; see [`commit`](Self::commit) for the two cases that report
2358/// instead.
2359#[derive(Clone)]
2360pub struct File {
2361    inner: Arc<FileInner>,
2362}
2363
2364impl std::fmt::Debug for File {
2365    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2366        std::fmt::Debug::fmt(&*self.inner, f)
2367    }
2368}
2369
2370impl File {
2371    /// Open an HDF5 file from a filesystem path.
2372    ///
2373    /// Reads the file into memory once. To follow a file that a concurrent
2374    /// single writer is appending to (SWMR), use [`File::open_swmr`] instead.
2375    /// To read a file larger than memory (e.g. on a 32-bit host) without
2376    /// buffering it, use [`File::open_streaming`].
2377    ///
2378    /// A file whose superblock marks it as held by a writer is refused with
2379    /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse) — the check
2380    /// `H5Fopen` makes of the same byte. That means a live writer or one that
2381    /// exited without closing the file; clear a stale flag with
2382    /// [`clear_swmr_flag`](Self::clear_swmr_flag), and follow a live SWMR writer
2383    /// with [`open_swmr`](Self::open_swmr). [`from_bytes`](Self::from_bytes) does
2384    /// not check, since its caller already holds the bytes — which is also the
2385    /// way to read a flagged file on a read-only mount, where clearing the flag
2386    /// would need write access.
2387    pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2388        Ok(File {
2389            inner: Arc::new(FileInner::open(path)?),
2390        })
2391    }
2392
2393    /// Open an HDF5 file from a filesystem path with explicit access properties.
2394    pub fn open_with_options<P: AsRef<std::path::Path>>(
2395        path: P,
2396        properties: FileAccessProperties,
2397    ) -> Result<Self, Error> {
2398        Ok(File {
2399            inner: Arc::new(FileInner::open_with_options(path, properties)?),
2400        })
2401    }
2402
2403    /// Open an HDF5 file for **streaming** reads, fetching regions on demand from
2404    /// the file instead of buffering it whole.
2405    ///
2406    /// This lets a host read a file larger than its address space. Metadata and
2407    /// dataset chunks are read through a `ReadSeekSource`, so peak memory stays
2408    /// close to one chunk plus the metadata being parsed; chunks adjacent on
2409    /// disk are fetched together, in reads of at most 256 KiB, and a chunk
2410    /// larger than that is read on its own. Attribute reading and v1
2411    /// symbol-table groups on the resolved path are not yet supported on this
2412    /// backend.
2413    ///
2414    /// Like [`open`](Self::open), this refuses a file whose superblock marks it
2415    /// as held by a writer.
2416    pub fn open_streaming<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2417        Ok(File {
2418            inner: Arc::new(FileInner::open_streaming(path)?),
2419        })
2420    }
2421
2422    /// Open an HDF5 file for streaming reads with explicit access properties.
2423    pub fn open_streaming_with_options<P: AsRef<std::path::Path>>(
2424        path: P,
2425        properties: FileAccessProperties,
2426    ) -> Result<Self, Error> {
2427        Ok(File {
2428            inner: Arc::new(FileInner::open_streaming_with_options(path, properties)?),
2429        })
2430    }
2431
2432    /// Open an HDF5 file from any [`Source`], reading metadata and chunks on
2433    /// demand exactly as [`open_streaming`](Self::open_streaming) does.
2434    ///
2435    /// This is the streaming open for a caller whose bytes are not a path: an
2436    /// object store addressed by HTTP range request, a sandboxed guest that
2437    /// receives byte ranges from its host, a decrypting layer. A [`Source`]
2438    /// supplies a length and reads at an absolute offset, which is all the
2439    /// reader asks of a file, so peak memory stays at the metadata being parsed
2440    /// plus the chunks a read touches — not the file. Wrap a `Read + Seek` in
2441    /// [`ReadSeekSource`] rather than writing that impl again.
2442    ///
2443    /// A file marked as held by a writer is refused here as it is by
2444    /// [`open_streaming`](Self::open_streaming); with no path to report, the
2445    /// error names the source instead. Recovering such a file in place needs a
2446    /// path, through [`File::clear_swmr_flag`], so a caller that has none is
2447    /// left with [`File::from_bytes`](Self::from_bytes) and the whole file in
2448    /// memory.
2449    ///
2450    /// The metadata cache is **off** unless
2451    /// [`from_source_with_options`](Self::from_source_with_options) turns it
2452    /// on, which matters more here than it does for a local file: without one,
2453    /// every read a parser makes is a round trip. See
2454    /// [`MetadataCacheConfig`].
2455    ///
2456    /// ```no_run
2457    /// use hdf5_pure::{File, FormatError, Source};
2458    ///
2459    /// // However the bytes actually arrive: a range request, a host call, a
2460    /// // decrypting layer over a file.
2461    /// # fn fetch(offset: u64, len: usize) -> Result<Vec<u8>, String> { unimplemented!() }
2462    ///
2463    /// struct Remote {
2464    ///     len: u64,
2465    /// }
2466    ///
2467    /// impl Source for Remote {
2468    ///     fn len(&self) -> u64 {
2469    ///         self.len
2470    ///     }
2471    ///
2472    ///     fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
2473    ///         // Fill the whole request or fail: a short read is an error.
2474    ///         let bytes = fetch(offset, buf.len()).map_err(FormatError::Source)?;
2475    ///         buf.copy_from_slice(&bytes);
2476    ///         Ok(())
2477    ///     }
2478    /// }
2479    ///
2480    /// let file = File::from_source(Remote { len: 1 << 30 })?;
2481    /// let rows = file.dataset("frames")?.read_f64_rows(0, 64)?;
2482    /// # Ok::<(), hdf5_pure::Error>(())
2483    /// ```
2484    ///
2485    /// A `Read + Seek` needs none of that: wrap it in [`ReadSeekSource`].
2486    /// Reading a *file* that way is [`open_streaming`](Self::open_streaming),
2487    /// which does the wrapping for you.
2488    pub fn from_source<S: Source + Send + Sync + 'static>(source: S) -> Result<Self, Error> {
2489        Ok(File {
2490            inner: Arc::new(FileInner::from_source(source)?),
2491        })
2492    }
2493
2494    /// Open an HDF5 file from any [`Source`] with explicit access properties.
2495    ///
2496    /// The metadata cache is what a remote source wants tuned: every parser
2497    /// read becomes a round trip without one. See [`MetadataCacheConfig`].
2498    pub fn from_source_with_options<S: Source + Send + Sync + 'static>(
2499        source: S,
2500        properties: FileAccessProperties,
2501    ) -> Result<Self, Error> {
2502        Ok(File {
2503            inner: Arc::new(FileInner::from_source_with_options(source, properties)?),
2504        })
2505    }
2506
2507    /// Open an HDF5 file for SWMR (single-writer/multiple-reader) reading.
2508    ///
2509    /// Like [`File::open`], but retains a live handle to the file so that
2510    /// [`File::refresh`] can re-read data appended by a concurrent writer.
2511    ///
2512    /// This is the open that *follows* a file marked as held by a SWMR writer,
2513    /// where [`open`](Self::open) refuses one. Only a half-set mark is refused
2514    /// here, with [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse):
2515    /// either bit without the other. Write access alone is what a plain
2516    /// (non-SWMR) writer leaves, and there is no protocol for following a writer
2517    /// that is not publishing consistent prefixes; the SWMR bit alone is a state
2518    /// no writer produces. Both bits is the live SWMR writer this exists to
2519    /// follow, and neither is a quiescent file.
2520    #[doc(alias = "H5F_ACC_SWMR_READ")]
2521    pub fn open_swmr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2522        Ok(File {
2523            inner: Arc::new(FileInner::open_swmr(path)?),
2524        })
2525    }
2526
2527    /// Open an HDF5 file for SWMR reading with explicit access properties.
2528    pub fn open_swmr_with_options<P: AsRef<std::path::Path>>(
2529        path: P,
2530        properties: FileAccessProperties,
2531    ) -> Result<Self, Error> {
2532        Ok(File {
2533            inner: Arc::new(FileInner::open_swmr_with_options(path, properties)?),
2534        })
2535    }
2536
2537    /// Open an HDF5 file from an in-memory byte vector.
2538    pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
2539        Ok(File {
2540            inner: Arc::new(FileInner::from_bytes(data)?),
2541        })
2542    }
2543
2544    /// Open an HDF5 file from an in-memory byte vector with explicit access properties.
2545    pub fn from_bytes_with_options(
2546        data: Vec<u8>,
2547        properties: FileAccessProperties,
2548    ) -> Result<Self, Error> {
2549        Ok(File {
2550            inner: Arc::new(FileInner::from_bytes_with_options(data, properties)?),
2551        })
2552    }
2553
2554    /// Open an existing HDF5 file for reading **and** in-place editing.
2555    ///
2556    /// Unlike [`open`](Self::open) (read-only, buffered), this takes an exclusive
2557    /// OS file lock held for the file's life and lets owned handles modify the
2558    /// file — immediate [`Dataset::append`]s, plus [`Dataset::write`]/`set_attr`,
2559    /// [`Group::create_dataset`]/`create_group`/`delete`/`set_attr`, and
2560    /// [`copy`](Self::copy)/[`copy_from`](Self::copy_from) staged until
2561    /// [`commit`](Self::commit). The file must use 8-byte offsets and lengths and
2562    /// keep its superblock at its base address (a canonical userblock, as in a
2563    /// MATLAB `.mat` file, is supported); anything else is refused with
2564    /// [`Error::EditUnsupported`](crate::Error::EditUnsupported).
2565    ///
2566    /// The fast immediate [`Dataset::append`] additionally requires a
2567    /// latest-format (version-2/3) file with no userblock and an
2568    /// Extensible-Array-indexed dataset; [`Dataset::append_staged`] covers the
2569    /// general case.
2570    ///
2571    /// Two things can turn this open away because another writer holds the file:
2572    /// the exclusive OS lock, reported as
2573    /// [`Error::FileLocked`](crate::Error::FileLocked), and the superblock's
2574    /// status-flags byte, reported as
2575    /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse). The second
2576    /// covers what the first cannot — a SWMR writer takes no lock, and a writer
2577    /// that exited without closing the file leaves the flag behind; recover a
2578    /// stale one with [`clear_swmr_flag`](Self::clear_swmr_flag).
2579    ///
2580    /// # Memory
2581    ///
2582    /// This picks its backing from the file rather than making the caller pick a
2583    /// function (issue #198): a latest-format file with no userblock is edited
2584    /// **bounded**, holding only the metadata being parsed plus the configured
2585    /// caches plus what an edit is building, so resident memory does not scale
2586    /// with the file; anything else falls back to a whole-file in-memory mirror,
2587    /// which is what makes a pre-v2 or userblock file editable at all. The two
2588    /// backings are the same engine over different storage and offer the same
2589    /// edit surface, differing in one trade: the bounded one applies a large
2590    /// immediate append in whole-chunk batches, each crash-atomic on its own, so
2591    /// a crash mid-call leaves a valid shorter dataset rather than none of the
2592    /// append. Ask a file which it got with
2593    /// [`edit_backing`](Self::edit_backing), and demand one with
2594    /// [`FileAccessProperties::with_memory_strategy`] —
2595    /// [`MemoryStrategy::Mirrored`] restores the unconditional mirror this
2596    /// entry point used before it learned to dispatch.
2597    #[doc(alias = "H5Fopen")]
2598    pub fn open_rw<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2599        Self::open_rw_with_options(path, FileAccessProperties::new())
2600    }
2601
2602    /// Open an existing file for reading and in-place editing with explicit
2603    /// access properties — see [`open_rw`](Self::open_rw).
2604    ///
2605    /// The properties carry the locking policy (the `H5Pset_file_locking` analogue,
2606    /// [`FileAccessProperties::with_locking`]), the memory strategy
2607    /// ([`FileAccessProperties::with_memory_strategy`], which overrides the
2608    /// dispatch described on [`open_rw`](Self::open_rw)), the `fsync` cadence
2609    /// ([`FileAccessProperties::with_sync_policy`]), the metadata cache used by
2610    /// the bounded backing, and the file-wide chunk-cache default applied to
2611    /// datasets opened from this file. Because one [`FileAccessProperties`] value
2612    /// serves every open, the same configuration can be shared with a read path.
2613    pub fn open_rw_with_options<P: AsRef<std::path::Path>>(
2614        path: P,
2615        properties: FileAccessProperties,
2616    ) -> Result<Self, Error> {
2617        Ok(File {
2618            inner: Arc::new(FileInner::open_rw(path, properties)?),
2619        })
2620    }
2621
2622    /// Open exactly as [`open_rw`](Self::open_rw) does, but behind an image that
2623    /// withholds its whole-file slice, so every read takes the `Source` path
2624    /// rather than the slice fast path.
2625    ///
2626    /// Each read this file serves has two forms (see `with_engine`), and only
2627    /// the slice form runs in production until a mirrorless backing lands
2628    /// (issue #198). Opening the same file both ways and comparing is what
2629    /// holds the other form to the same answers in the meantime.
2630    #[cfg(test)]
2631    pub(crate) fn open_rw_source_only(path: &std::path::Path) -> Result<Self, Error> {
2632        Ok(File {
2633            inner: Arc::new(FileInner::from_rw_session(
2634                WriteEngine::open_source_only(path)?,
2635                FileAccessProperties::new(),
2636            )?),
2637        })
2638    }
2639
2640    /// Open an existing file for **SWMR** (single-writer/multiple-reader)
2641    /// appending: take **no** OS lock (so concurrent readers, and Windows'
2642    /// mandatory locks, are never blocked) and raise the superblock's SWMR-write
2643    /// flag so a reader may attach with [`File::open_swmr`], the C library's
2644    /// `H5F_ACC_SWMR_READ`, or h5py `swmr=True`.
2645    ///
2646    /// Only immediate [`Dataset::append`] is permitted, and only over the SWMR
2647    /// subset — an **unfiltered**, chunk-aligned append, so a concurrent reader
2648    /// only ever observes a consistent prefix; a filtered or non-chunk-aligned
2649    /// append returns [`Error::SwmrAppendUnsupported`](crate::Error::SwmrAppendUnsupported).
2650    /// The staged edit surface (`write`/`set_attr`/`create_*`/`delete`/`copy`/
2651    /// `commit`) returns
2652    /// [`Error::SwmrStagedUnsupported`](crate::Error::SwmrStagedUnsupported).
2653    /// [`close`](Self::close) clears the SWMR-write flag; a writer that exits
2654    /// without a clean close leaves it set — recover with
2655    /// [`clear_swmr_flag`](Self::clear_swmr_flag). While the flag stands, this
2656    /// open is refused with
2657    /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse), which is what
2658    /// keeps a second writer off a file SWMR gives only one (no OS lock is held
2659    /// to do it).
2660    ///
2661    /// Requires a latest-format (version-3 superblock) file with no userblock
2662    /// and no persisted free-space; other files are refused with
2663    /// [`Error::SwmrAppendUnsupported`](crate::Error::SwmrAppendUnsupported).
2664    /// The version-3 requirement is the C library's: neither library reads the
2665    /// SWMR-write flag back on an older superblock, so raising one there would
2666    /// announce the writer to nobody.
2667    #[doc(alias = "H5F_ACC_SWMR_WRITE")]
2668    pub fn open_swmr_writer<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2669        Self::open_swmr_writer_with_options(path, FileAccessProperties::new())
2670    }
2671
2672    /// Open for SWMR appending with explicit access properties — see
2673    /// [`open_swmr_writer`](Self::open_swmr_writer).
2674    ///
2675    /// The properties' chunk cache is the file-wide default for datasets opened
2676    /// from this file. Its locking policy is ignored, which costs the caller
2677    /// nothing: SWMR takes no OS lock by design, which is stronger than any
2678    /// locking a caller could ask for. Its memory strategy is *not* ignored the
2679    /// same way — this writer always mirrors, so an explicit
2680    /// [`MemoryStrategy::Bounded`] is a guarantee it cannot meet and is refused
2681    /// with [`Error::EditUnsupported`]; [`MemoryStrategy::Auto`] and
2682    /// [`MemoryStrategy::Mirrored`] are both satisfied by the mirror.
2683    ///
2684    /// Its [`SyncPolicy`](crate::SyncPolicy) applies here as to any other
2685    /// read-write session, the SWMR-write flag included; a reader on this
2686    /// machine is unaffected either way, since the barriers carry the write
2687    /// order across power loss rather than across processes.
2688    pub fn open_swmr_writer_with_options<P: AsRef<std::path::Path>>(
2689        path: P,
2690        properties: FileAccessProperties,
2691    ) -> Result<Self, Error> {
2692        Ok(File {
2693            inner: Arc::new(FileInner::open_swmr_writer(path, properties)?),
2694        })
2695    }
2696
2697    /// Clear a stale status flag left in `path` by a writer that exited without a
2698    /// clean [`close`](Self::close) — the `h5clear -s` equivalent, for recovering
2699    /// a file that both this crate and the reference C library otherwise refuse
2700    /// to open ([`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse)). A
2701    /// no-op if the flag is already clear.
2702    ///
2703    /// It takes the exclusive OS lock first, so it cannot clear the flag out
2704    /// from under a *live* [`open_rw`](Self::open_rw) writer. A live SWMR writer
2705    /// holds no lock, so make sure it is really gone: clearing the flag under
2706    /// one leaves its readers with no record that it is publishing.
2707    ///
2708    /// It also clears the crash mark a page-buffered session raises
2709    /// ([`FileAccessProperties::with_page_buffer_size`]), and there the warning is
2710    /// sharper. That mark stands for pages that were still in memory, so a file
2711    /// still carrying it was left by a writer that did not finish: clearing it
2712    /// hands back a file whose datasets may read clean and return fill values or
2713    /// a deleted object's bytes, with every checksum verifying. Clear it to
2714    /// salvage what is there, not to resume trusting it. `h5clear` makes the same
2715    /// trade for the same reason.
2716    pub fn clear_swmr_flag<P: AsRef<std::path::Path>>(path: P) -> Result<(), Error> {
2717        crate::file_lock::clear_swmr_flag_at(path.as_ref())
2718    }
2719
2720    /// Create a new, empty HDF5 file at `path` and open it for reading and
2721    /// writing, so its contents can be built entirely through owned handles
2722    /// ([`Group::create_dataset`]/[`create_group`](Group::create_group), then
2723    /// [`commit`](Self::commit)).
2724    ///
2725    /// Overwrites any existing file at `path`. For an all-at-once write, use
2726    /// [`FileBuilder`](crate::FileBuilder) instead.
2727    #[doc(alias = "H5Fcreate")]
2728    pub fn create<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
2729        Self::create_with_options(
2730            path,
2731            FileCreateProperties::new(),
2732            FileAccessProperties::new(),
2733        )
2734    }
2735
2736    /// Create a new, empty HDF5 file with explicit creation and access properties,
2737    /// then open it for reading and writing — see [`create`](Self::create).
2738    ///
2739    /// Mirrors `H5Fcreate(name, flags, fcpl_id, fapl_id)`: `create` carries the
2740    /// creation properties recorded in the new file (userblock, file-space
2741    /// strategy, library-version bounds), and `access` the properties governing
2742    /// the handle returned (locking policy, `fsync` cadence, chunk cache). Both are values, so a
2743    /// layout defined once can be reused across every file an application writes.
2744    ///
2745    /// A creation property is validated as the file is written, so an invalid
2746    /// userblock or page size surfaces here rather than when the properties were
2747    /// built. A file created with [`FileSpaceStrategy::Page`] can be grown
2748    /// through either editor, by an immediate [`Dataset::append`] or a staged
2749    /// commit, provided it also persists its free space (issue #198).
2750    pub fn create_with_options<P: AsRef<std::path::Path>>(
2751        path: P,
2752        create: FileCreateProperties,
2753        access: FileAccessProperties,
2754    ) -> Result<Self, Error> {
2755        // Refuse a pair the reopen below would refuse, before anything is
2756        // written: this call promises a file *and* an open handle, and half of
2757        // that is worse than neither.
2758        if let Some(reason) = crate::edit::create_would_refuse_reopen(&create, &access) {
2759            return Err(Error::EditUnsupported(reason));
2760        }
2761        let mut builder = crate::writer::FileBuilder::new();
2762        builder.with_create_properties(create);
2763        let bytes = builder.finish()?;
2764        std::fs::write(path.as_ref(), bytes).map_err(Error::Io)?;
2765        Self::open_rw_with_options(path, access)
2766    }
2767
2768    /// Apply all staged structural edits made through this file's handles —
2769    /// [`Dataset::write`]/`set_attr`/`remove_attr` and
2770    /// [`Group::create_group`]/`delete` — as one transaction. Immediate
2771    /// [`Dataset::append`]s need no commit.
2772    ///
2773    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
2774    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2775    ///
2776    /// Outstanding [`Dataset`] and [`Group`] handles stay usable: a commit
2777    /// relocates object headers, and each handle looks its object up again by
2778    /// path on its first use afterwards, so a long-lived handle answers for the
2779    /// file the commit left rather than for the copy it moved away from. Two
2780    /// exceptions, both of which report rather than answer wrongly. A *read*
2781    /// through a handle onto an object the commit deleted — or replaced with one
2782    /// of a different kind, which is
2783    /// [`Error::NotADataset`](crate::Error::NotADataset) or
2784    /// [`Error::NotAGroup`](crate::Error::NotAGroup) — fails the way opening it
2785    /// would; its write methods still address the file by path, so they stage
2786    /// and the commit refuses them. And a handle reached by object reference
2787    /// ([`Dataset::dereference`]) has no path to look up, so it returns
2788    /// [`Error::StaleHandle`](crate::Error::StaleHandle) — not only after a
2789    /// commit but after anything staged, synced or torn down, since only an
2790    /// immediate [`Dataset::append`] is known to leave every header where it
2791    /// stands. Dereference again from a fresh read.
2792    ///
2793    /// A handle onto an object this commit *publishes* — one
2794    /// [`Group::create_group`], [`Group::create_group_with`] or
2795    /// [`Group::create_dataset`] handed back, or a lookup of a staged name found
2796    /// — starts reading its object here. Until then it answers
2797    /// [`Error::NotCommitted`](crate::Error::NotCommitted) for anything needing
2798    /// bytes, and a refused commit leaves it doing so.
2799    ///
2800    /// The commit is durable when it returns, under the default
2801    /// [`SyncPolicy::Always`]; under
2802    /// [`SyncPolicy::OnClose`](crate::SyncPolicy::OnClose) it has reached the
2803    /// operating system and waits for a [`sync`](Self::sync).
2804    ///
2805    /// **A commit refused before it publishes leaves every dataset reading what
2806    /// it read before.** Almost everything such a commit writes lands where
2807    /// nothing reaches it until the commit's linearization point; the one edit
2808    /// that does not is a same-length [`Dataset::write`], which overwrites the
2809    /// dataset's existing block, and the refusal writes those bytes back on its
2810    /// way out. A refusal raised before the first write keeps the staged batch
2811    /// too, so it can be corrected and committed again (issue #316).
2812    ///
2813    /// # Errors
2814    ///
2815    /// Two failures do not carry that promise, and both call for **re-reading**
2816    /// the datasets the batch named rather than for a retry:
2817    ///
2818    /// - [`Error::CommitPartiallyApplied`](crate::Error::CommitPartiallyApplied),
2819    ///   where the restore itself failed, so a dataset may hold either value.
2820    /// - An error from a step *after* the commit published — repointing the
2821    ///   object references that named a moved object is the one that can raise
2822    ///   it. The batch is in the file and stays there; what failed is work the
2823    ///   commit owed afterwards. The file is valid either way.
2824    pub fn commit(&self) -> Result<(), Error> {
2825        self.with_mirror_session(Change::Relocating, |session| session.commit())
2826    }
2827
2828    /// Copy the object at `src` to `dst` within this file (the in-file
2829    /// `H5Ocopy`), staged until [`commit`](Self::commit).
2830    ///
2831    /// A dataset whose storage was never allocated is copied as the storage it
2832    /// has — none — rather than as the fill value reading it answers with, so a
2833    /// schema-only dataset stays one. A dataset whose elements live in external
2834    /// files (`H5Pset_external`) carries that same empty storage while holding
2835    /// data this crate does not read, and is refused with
2836    /// [`Error::EditUnsupported`](crate::Error::EditUnsupported) rather than
2837    /// copied without it.
2838    ///
2839    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
2840    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2841    pub fn copy(&self, src: &str, dst: &str) -> Result<(), Error> {
2842        self.with_mirror_session(Change::Relocating, |session| {
2843            session.copy(&normalize_path(src), &normalize_path(dst))
2844        })
2845    }
2846
2847    /// Copy the object at `src` in `source` — a separate, buffered read-only
2848    /// file — into this file at `dst`: the cross-file `H5Ocopy`, staged until
2849    /// [`commit`](Self::commit).
2850    ///
2851    /// `source` must be a buffered file ([`File::open`] or [`File::from_bytes`],
2852    /// not [`File::open_streaming`]) that uses 8-byte offsets and has no
2853    /// userblock; anything else is refused with
2854    /// [`Error::EditUnsupported`](crate::Error::EditUnsupported). The source
2855    /// subtree is read and validated eagerly, so `source` need not outlive this
2856    /// call — and so a source this cannot reproduce, external storage included,
2857    /// is refused by this call. Refusals that concern the *destination* — `dst`
2858    /// already exists, or its parent group does not — still come from `commit`.
2859    /// Requires a read-write destination ([`File::open_rw`]); a read-only
2860    /// one returns [`Error::ReadOnly`](crate::Error::ReadOnly).
2861    pub fn copy_from(&self, source: &File, src: &str, dst: &str) -> Result<(), Error> {
2862        self.with_mirror_session(Change::Relocating, |session| {
2863            session.copy_from(source, src, dst)
2864        })
2865    }
2866
2867    /// Report whether this file has structural edits staged but not yet applied
2868    /// by [`commit`](Self::commit) — [`Dataset::write`]/`set_attr`/`remove_attr`,
2869    /// [`Dataset::append_staged`], [`Group::create_group`]/`create_dataset`/
2870    /// `delete`/`set_attr`/`remove_attr`, and [`copy`](Self::copy)/
2871    /// [`copy_from`](Self::copy_from). Immediate [`Dataset::append`]s are never
2872    /// staged and do not count. Always `false` for a read-only file.
2873    ///
2874    /// A `commit` that refuses puts the staged set back untouched, so this still
2875    /// answers `true` afterwards and the same batch can be committed again — to
2876    /// the same refusal, until the session is dropped.
2877    pub fn has_staged_edits(&self) -> bool {
2878        match &self.inner.backend {
2879            Backend::Edit(m) => {
2880                let session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2881                session.has_staged_edits()
2882            }
2883            _ => false,
2884        }
2885    }
2886
2887    /// Report this read-write file's live space usage as a [`SpaceAccounting`] —
2888    /// the current logical size, total reusable free bytes, and reusable free
2889    /// regions. It reflects committed state plus immediate in-place appends, not
2890    /// edits still staged for [`commit`](Self::commit).
2891    ///
2892    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
2893    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2894    pub fn space_accounting(&self) -> Result<SpaceAccounting, Error> {
2895        match &self.inner.backend {
2896            Backend::Edit(m) => {
2897                let session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2898                Ok(session.space_accounting())
2899            }
2900            _ => Err(Error::ReadOnly),
2901        }
2902    }
2903
2904    /// Force everything written to this file so far to durable storage — the
2905    /// `fsync` the application issues at its own cadence under
2906    /// [`SyncPolicy::OnClose`], and a redundant one under the default
2907    /// [`SyncPolicy::Always`]. A SWMR-writer file syncs the same way.
2908    ///
2909    /// This is a durability barrier, not a flush: it writes nothing itself.
2910    /// Staged edits are not applied ([`commit`](Self::commit) does that, and a
2911    /// `sync` before one makes only the *previous* state durable), and elements
2912    /// held by a live [`BufferedAppender`](crate::BufferedAppender) have not
2913    /// reached the file at all — flush it first.
2914    ///
2915    /// There is no need to call it before [`close`](Self::close): `close` — and
2916    /// dropping the last handle — issues its own barrier under every policy,
2917    /// because both write and both destroy the handle that would have ordered
2918    /// those writes. This is the mid-session checkpoint, not the closing one.
2919    ///
2920    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
2921    /// [`Error::ReadOnly`](crate::Error::ReadOnly), and a sealed one
2922    /// [`Error::FileClosed`](crate::Error::FileClosed) — a closed file has
2923    /// already been synced.
2924    #[doc(alias = "fsync")]
2925    pub fn sync(&self) -> Result<(), Error> {
2926        // A barrier over writes the operations that made them already accounted
2927        // for, so it invalidates no handle. Classing it as a change would end
2928        // every by-reference handle in the session because the caller asked for
2929        // an `fsync`.
2930        self.with_mirror_session(Change::Nothing, |session| session.force_sync())
2931    }
2932
2933    /// Commit any staged edits and seal this file. The exclusive OS lock is
2934    /// released once the last handle derived from this file is also dropped.
2935    ///
2936    /// After `close`, a write through any surviving [`Dataset`]/[`Group`] handle
2937    /// or [`File`] clone returns [`Error::FileClosed`](crate::Error::FileClosed);
2938    /// reads still work. `close` commits, so the one handle a commit ends ends
2939    /// here too: one reached by [`Dataset::dereference`] reports
2940    /// [`Error::StaleHandle`](crate::Error::StaleHandle) afterwards, where a
2941    /// handle opened by path re-resolves and keeps reading.
2942    pub fn close(self) -> Result<(), Error> {
2943        if matches!(self.inner.backend, Backend::Edit(_)) {
2944            // SWMR mode stages nothing — the staged surface is refused — so there
2945            // is nothing to commit, and it persists no free space, so there is
2946            // nothing to re-home.
2947            let swmr = self.inner.swmr_write;
2948            if !swmr {
2949                self.commit()?;
2950            }
2951            // Free-space managers and status flags, both rewritten where they
2952            // stand. No object header moves, so a handle that could read through
2953            // this file before `close` still can — which is what `close`'s own
2954            // documentation promises.
2955            self.with_mirror_session(Change::InPlace, |session| {
2956                // Immediate appends grow the file past any persisted free-space
2957                // managers without running a commit tail, so re-home them here. A
2958                // no-op unless this session left them stale.
2959                if !swmr {
2960                    session.finalize_persist()?;
2961                }
2962                // Forced under every policy, and covering everything above: this
2963                // call consumes the handle, so it is the last point at which any
2964                // of these writes can be ordered at all.
2965                session.force_sync()?;
2966                // Last, and only after that sync. A session's status flags stand
2967                // for writes that may still have been in memory — a SWMR writer's
2968                // pair, a page buffer's crash mark — and this is the point at
2969                // which none are.
2970                session.release_status_flags()
2971            })?;
2972            self.inner.closed.store(true, Ordering::Release);
2973        }
2974        Ok(())
2975    }
2976
2977    /// Run `f` with the locked write session of a read-write file. `staged`
2978    /// distinguishes an edit applied by [`commit`](Self::commit) from an immediate
2979    /// one. Returns [`Error::ReadOnly`](crate::Error::ReadOnly) for a read-only
2980    /// file, [`Error::FileClosed`](crate::Error::FileClosed) once the file is
2981    /// sealed by [`close`](Self::close), and
2982    /// [`Error::SwmrStagedUnsupported`](crate::Error::SwmrStagedUnsupported) for a
2983    /// staged edit on a SWMR-writer file.
2984    fn with_mirror_session<R>(
2985        &self,
2986        change: Change,
2987        f: impl FnOnce(&mut WriteEngine) -> Result<R, Error>,
2988    ) -> Result<R, Error> {
2989        self.inner.with_engine_mut(change, f)
2990    }
2991
2992    /// Returns an owned handle to the root group.
2993    pub fn root(&self) -> Group {
2994        let revisions = self.inner.revisions();
2995        Group::new(
2996            self.inner.clone(),
2997            // A relocating commit on a read-write file can move the root, so
2998            // resolve it from the live mirror rather than the cached superblock.
2999            revisions.at(self.inner.mirror_root_address()),
3000            Some(String::new()),
3001        )
3002    }
3003
3004    /// Resolve a path and return an owned [`Dataset`] handle.
3005    ///
3006    /// The dataset uses the file-wide chunk-cache default (configured with
3007    /// [`FileAccessProperties::with_chunk_cache`]). To override the cache for this
3008    /// one dataset, use [`dataset_with_options`](Self::dataset_with_options).
3009    ///
3010    /// Returns [`Error::NotADataset`] if the path names something that is not a
3011    /// dataset, and [`Error::NotAGroup`] if a component *along* the path is not
3012    /// a group: resolving `a/b/c` opens `a` and then `a/b` to look inside them,
3013    /// so a dataset at `a/b` reports `NotAGroup("a/b")` (issue #365).
3014    pub fn dataset(&self, path: &str) -> Result<Dataset, Error> {
3015        self.dataset_with_options(path, DatasetAccessProperties::new())
3016    }
3017
3018    /// Resolve a path and return an owned [`Dataset`] handle, applying per-dataset
3019    /// [`DatasetAccessProperties`] that override file-wide access defaults.
3020    ///
3021    /// This is the dataset-open-with-access-property-list path (HDF5's `dapl`):
3022    /// the properties' chunk cache corresponds to `H5Pset_chunk_cache` and takes
3023    /// precedence, for this dataset only, over the `H5Pset_cache`-style
3024    /// file-wide default.
3025    pub fn dataset_with_options(
3026        &self,
3027        path: &str,
3028        properties: DatasetAccessProperties,
3029    ) -> Result<Dataset, Error> {
3030        let chunk_cache = properties.resolved_chunk_cache(self.inner.access_properties.chunk_cache);
3031        let normalized = normalize_path(path);
3032        match self.inner.staged_object(&normalized).map(|o| o.kind) {
3033            Some(StagedKind::Dataset) => {
3034                return Ok(Dataset::pending(
3035                    self.inner.clone(),
3036                    chunk_cache,
3037                    normalized,
3038                ));
3039            }
3040            Some(StagedKind::Group) => return Err(Error::NotADataset(normalized)),
3041            None => {}
3042        }
3043        let revisions = self.inner.revisions();
3044        let addr = self.inner.resolve_path(path)?;
3045        let hdr = self.inner.parse_header(addr)?;
3046        if !has_message(&hdr, MessageType::DataLayout) {
3047            return Err(Error::NotADataset(path.to_string()));
3048        }
3049        Ok(Dataset::new(
3050            self.inner.clone(),
3051            revisions.at(addr),
3052            hdr,
3053            chunk_cache,
3054            Some(normalized),
3055        ))
3056    }
3057
3058    /// Resolve a path and return an owned [`Group`] handle.
3059    ///
3060    /// Returns [`Error::NotAGroup`] if the path names an object that is not a
3061    /// group, the way [`dataset`](Self::dataset) returns
3062    /// [`Error::NotADataset`] for the mirror case, and
3063    /// [`FormatError::PathNotFound`] if it names nothing.
3064    ///
3065    /// The same error reports a component *along* the path that is not a group,
3066    /// naming that component's own path rather than the one asked for: `a/b/c`
3067    /// stopped by a dataset at `a/b` reports `NotAGroup("a/b")` (issue #365).
3068    pub fn group(&self, path: &str) -> Result<Group, Error> {
3069        let normalized = normalize_path(path);
3070        match self.inner.staged_object(&normalized).map(|o| o.kind) {
3071            Some(StagedKind::Group) => {
3072                return Ok(Group::pending(self.inner.clone(), normalized));
3073            }
3074            Some(StagedKind::Dataset) => return Err(Error::NotAGroup(normalized)),
3075            None => {}
3076        }
3077        let revisions = self.inner.revisions();
3078        let addr = self.inner.resolve_path(path)?;
3079        if !is_group(&self.inner.parse_header(addr)?) {
3080            // Normalized, so that the same object refused here and refused by a
3081            // live handle below names itself the same way: a handle knows only
3082            // the normalized path it memoized.
3083            return Err(Error::NotAGroup(normalized));
3084        }
3085        Ok(Group::new(
3086            self.inner.clone(),
3087            revisions.at(addr),
3088            Some(normalized),
3089        ))
3090    }
3091
3092    /// Re-read the file from disk to pick up data appended by a concurrent
3093    /// writer, then re-parse the superblock.
3094    ///
3095    /// This is the SWMR reader's refresh primitive. Returns
3096    /// [`Error::SwmrUnsupported`] if the file was not opened with
3097    /// [`File::open_swmr`], and [`Error::HandlesOutstanding`] if any owned
3098    /// [`Dataset`]/[`Group`] handle (or a clone of this `File`) is still alive —
3099    /// drop them before refreshing, then re-fetch them afterward, since they
3100    /// observe the new bytes only when re-derived from the refreshed file.
3101    pub fn refresh(&mut self) -> Result<(), Error> {
3102        let inner = Arc::get_mut(&mut self.inner).ok_or(Error::HandlesOutstanding)?;
3103        inner.refresh()
3104    }
3105
3106    // --- delegating value getters (forward to the shared inner state) ---
3107
3108    /// Returns the raw file bytes for an in-memory file, or an empty slice for a
3109    /// streaming file (which has no whole-file buffer).
3110    pub fn as_bytes(&self) -> &[u8] {
3111        self.inner.as_bytes()
3112    }
3113
3114    /// Return the access properties used when opening this file.
3115    pub fn access_properties(&self) -> FileAccessProperties {
3116        self.inner.access_properties()
3117    }
3118
3119    /// Which backend this file's read-write session resolved to:
3120    /// [`EditBacking::Bounded`] when it reads through a handle, or
3121    /// [`EditBacking::Mirrored`] when it holds a whole-file image.
3122    ///
3123    /// This is how a caller who opened with [`MemoryStrategy::Auto`] finds out
3124    /// whether the fallback was taken, and so whether memory scales with the
3125    /// file. A file with no editing session — a read-only open, a streaming open
3126    /// — reports `None`.
3127    ///
3128    /// The answer is an [`EditBacking`] rather than the [`MemoryStrategy`] that
3129    /// was asked for, because `Auto` is a preference between the two backends and
3130    /// not an outcome either can report; `.into()` converts back when a later
3131    /// reopen should be pinned to what this one got.
3132    pub fn edit_backing(&self) -> Option<EditBacking> {
3133        self.inner.edit_backing()
3134    }
3135
3136    /// What this file's metadata cache has done, and what it is holding.
3137    ///
3138    /// [`FileAccessProperties::with_metadata_cache`] sets a byte budget before
3139    /// any read has happened; this is how a caller finds out whether it was the
3140    /// right one. See [`MetadataCacheStats`] for which figure answers which
3141    /// question. Together the two are the `hdf5-pure` counterpart of HDF5's
3142    /// `H5Fget_mdc_hit_rate` and `H5Fget_mdc_size`.
3143    ///
3144    /// `None` where there is no metadata cache to report on: a buffered
3145    /// [`open`](Self::open) or [`from_bytes`](Self::from_bytes), which already
3146    /// holds the whole file; a mirrored read-write session, for the same reason;
3147    /// or a streaming or bounded open left at the default disabled budget.
3148    ///
3149    /// ```no_run
3150    /// # fn main() -> Result<(), hdf5_pure::Error> {
3151    /// use hdf5_pure::{File, FileAccessProperties, MetadataCacheConfig};
3152    ///
3153    /// let properties =
3154    ///     FileAccessProperties::new().with_metadata_cache(MetadataCacheConfig::new(8 << 20));
3155    /// let file = File::open_streaming_with_options("data.h5", properties)?;
3156    /// for name in file.root().datasets()? {
3157    ///     let _ = file.dataset(&name)?.read_raw()?;
3158    /// }
3159    ///
3160    /// let stats = file.metadata_cache_stats().expect("the budget enabled a cache");
3161    /// println!("{:?} over {} reads, {} evicted", stats.hit_rate(), stats.reads(), stats.evictions());
3162    /// # Ok(())
3163    /// # }
3164    /// ```
3165    pub fn metadata_cache_stats(&self) -> Option<MetadataCacheStats> {
3166        self.inner.metadata_cache_stats()
3167    }
3168
3169    /// Zero this file's metadata-cache counters, keeping every cached entry.
3170    ///
3171    /// HDF5's `H5Freset_mdc_hit_rate_stats`, for measuring one phase of a
3172    /// program rather than a whole run: the reads that populate a cache miss by
3173    /// definition, so a hit rate taken over the run charges the steady state for
3174    /// the warm-up. Reset after warming to measure the part that repeats.
3175    ///
3176    /// It evicts nothing: occupancy, which
3177    /// [`metadata_cache_stats`](Self::metadata_cache_stats) also reports, is a
3178    /// measurement of the cache rather than a tally of its history. A file with
3179    /// no metadata cache ignores the call.
3180    pub fn reset_metadata_cache_stats(&self) {
3181        self.inner.reset_metadata_cache_stats();
3182    }
3183
3184    /// Returns a reference to the parsed superblock.
3185    pub fn superblock(&self) -> &Superblock {
3186        self.inner.superblock()
3187    }
3188
3189    /// The file-space management strategy this file records in its superblock
3190    /// extension, or `None` if it records none.
3191    pub fn file_space_strategy(&self) -> Option<FileSpaceStrategy> {
3192        self.inner.file_space_strategy()
3193    }
3194
3195    /// The full [`FileSpaceInfo`] recorded in this file's superblock extension,
3196    /// if present and readable.
3197    pub fn file_space_info(&self) -> Option<&FileSpaceInfo> {
3198        self.inner.file_space_info()
3199    }
3200
3201    /// The free regions a file persists on disk in its free-space managers, as
3202    /// `(address, length)` pairs sorted by address.
3203    pub fn persisted_free_space(&self) -> Vec<(u64, u64)> {
3204        self.inner.persisted_free_space()
3205    }
3206
3207    /// The size of the underlying file in bytes (the HDF5 `H5Fget_filesize`).
3208    pub fn file_size(&self) -> u64 {
3209        self.inner.file_size()
3210    }
3211
3212    /// The minimum library version required to read this file, derived from its
3213    /// superblock version (the *low bound* of HDF5's `H5Fget_libver_bounds`).
3214    pub fn libver_bound(&self) -> LibVer {
3215        self.inner.libver_bound()
3216    }
3217
3218    /// A `Source` view over the backend, for the streaming-capable paths.
3219    pub(crate) fn source(&self) -> SourceView<'_> {
3220        self.inner.source()
3221    }
3222
3223    /// The whole-file byte image when this file is buffered in memory; `None`
3224    /// for a streaming file. Used by cross-file object copy.
3225    pub(crate) fn in_memory_image(&self) -> Option<&[u8]> {
3226        self.inner.in_memory_image()
3227    }
3228
3229    /// The base address (superblock base address) added to every stored relative
3230    /// address. Zero for a file with no userblock.
3231    pub(crate) fn base_address(&self) -> BaseAddress {
3232        self.inner.base_address()
3233    }
3234}
3235
3236// ---------------------------------------------------------------------------
3237// Object reference target
3238// ---------------------------------------------------------------------------
3239
3240/// The resolved target of an HDF5 object reference (`H5R_OBJECT`): either a
3241/// group or a dataset.
3242///
3243/// Produced by [`Dataset::dereference`]. MATLAB `.mat` files use object
3244/// references pervasively — a cell array stores one reference per element, and
3245/// the `#subsystem#` machinery references its payloads — so resolving a
3246/// reference to the group or dataset it names is the foundation for reading
3247/// those structures.
3248///
3249/// The [`Dataset`](Object::Dataset) handle is boxed: it carries a parsed object
3250/// header and is much larger than a [`Group`](Object::Group) handle, so boxing
3251/// keeps `Object` (and a `Vec<Object>`) compact without a size disparity. The
3252/// `Box` derefs transparently, so `&obj_dataset` is usable wherever a
3253/// `&Dataset` is expected.
3254///
3255/// Non-exhaustive: a reference can name an object kind this crate does not yet
3256/// resolve — a committed (named) datatype is refused with
3257/// [`FormatError::InvalidObjectReference`](crate::FormatError::InvalidObjectReference)
3258/// today — so match with a `_` arm.
3259#[non_exhaustive]
3260pub enum Object {
3261    /// The reference points at a group's object header.
3262    Group(Group),
3263    /// The reference points at a dataset's object header.
3264    Dataset(Box<Dataset>),
3265}
3266
3267impl std::fmt::Debug for Object {
3268    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3269        match self {
3270            Object::Group(_) => f.write_str("Object::Group"),
3271            Object::Dataset(_) => f.write_str("Object::Dataset"),
3272        }
3273    }
3274}
3275
3276// ---------------------------------------------------------------------------
3277// Group handle
3278// ---------------------------------------------------------------------------
3279
3280/// A group that exists only as a staged edit, handed to
3281/// [`Group::create_group_with`]'s closure so a whole subtree — attributes,
3282/// nested groups, datasets — can be described in one call.
3283///
3284/// It is a convenience, not the only way in: [`Group::create_group`] and
3285/// [`Group::create_group_with`] both return a live [`Group`] handle onto the
3286/// staged group, and everything this offers can be staged through that handle
3287/// instead. Reach for the closure when the shape of the subtree is known at the
3288/// call, and for the handle when it is built up by code that takes a `&Group`.
3289///
3290/// Every method stages; nothing is written until [`File::commit`].
3291///
3292/// The closure holding this records into a buffer rather than into the file's
3293/// writable session, so nothing is locked while it runs. The recorded operations
3294/// are applied together when it returns, which is why an object staged here is
3295/// addressable only once the closure has returned.
3296pub struct StagedGroup<'a> {
3297    ops: &'a mut Vec<StagedOp>,
3298    path: String,
3299}
3300
3301impl StagedGroup<'_> {
3302    /// Stage an attribute on this group, applied with its creation on
3303    /// [`File::commit`].
3304    pub fn set_attr(&mut self, name: &str, value: AttrValue) -> &mut Self {
3305        self.ops.push(StagedOp::SetGroupAttr {
3306            path: self.path.clone(),
3307            name: name.to_string(),
3308            value,
3309        });
3310        self
3311    }
3312
3313    /// Stage an empty subgroup of this group.
3314    ///
3315    /// To configure it in the same commit, use
3316    /// [`create_group_with`](Self::create_group_with). To get a [`Group`] handle
3317    /// onto it, look it up by name once this closure has returned, or stage it
3318    /// through [`Group::create_group`] instead, which hands one back.
3319    pub fn create_group(&mut self, name: &str) -> &mut Self {
3320        self.create_group_with(name, |_| {})
3321    }
3322
3323    /// Stage a subgroup of this group, configured through `build`.
3324    pub fn create_group_with(
3325        &mut self,
3326        name: &str,
3327        build: impl FnOnce(&mut StagedGroup<'_>),
3328    ) -> &mut Self {
3329        let child = format!("{}/{}", self.path, name);
3330        self.ops.push(StagedOp::CreateGroup(child.clone()));
3331        let mut staged = StagedGroup {
3332            ops: &mut *self.ops,
3333            path: child,
3334        };
3335        build(&mut staged);
3336        self
3337    }
3338
3339    /// Stage a dataset in this group, configured through `build`.
3340    pub fn create_dataset(
3341        &mut self,
3342        name: &str,
3343        build: impl FnOnce(&mut DatasetBuilder),
3344    ) -> &mut Self {
3345        let mut builder = DatasetBuilder::new(name);
3346        build(&mut builder);
3347        self.ops.push(StagedOp::CreateDataset {
3348            path: format!("{}/{}", self.path, name),
3349            builder: Box::new(builder),
3350        });
3351        self
3352    }
3353}
3354
3355/// One edit recorded by a [`StagedGroup`] closure, replayed onto the writable
3356/// session after the closure returns.
3357///
3358/// The indirection is what keeps user code off the session lock: the closure
3359/// touches only this buffer, so calling back into the same [`File`] from inside
3360/// it is at worst wrongly ordered rather than a deadlock (issue #200).
3361enum StagedOp {
3362    CreateGroup(String),
3363    SetGroupAttr {
3364        path: String,
3365        name: String,
3366        value: AttrValue,
3367    },
3368    CreateDataset {
3369        path: String,
3370        /// Boxed because a `DatasetBuilder` dwarfs the other variants, and a
3371        /// closure staging many groups would otherwise pay its size per entry.
3372        builder: Box<DatasetBuilder>,
3373    },
3374}
3375
3376impl StagedOp {
3377    /// Record this edit on the session. Applied in the order the closure made
3378    /// the calls, so a group is always staged before its own attributes and
3379    /// children.
3380    fn apply(self, session: &mut WriteEngine) -> Result<(), Error> {
3381        match self {
3382            StagedOp::CreateGroup(path) => session.create_group(&path),
3383            StagedOp::SetGroupAttr { path, name, value } => {
3384                session.set_group_attr(&path, &name, value)
3385            }
3386            StagedOp::CreateDataset { path, builder } => {
3387                session.stage_created_dataset(&path, *builder)
3388            }
3389        }
3390    }
3391}
3392
3393/// An owned handle to an HDF5 group.
3394///
3395/// The handle names the group by its root-relative path and remembers where that
3396/// path resolved to. A [`File::commit`] rewrites and relocates object headers,
3397/// so the memo is worked out again on the first use after any edit and the
3398/// handle goes on answering for the same group — see [`File::commit`] for the
3399/// two cases that report instead. Cloning gives a second handle to the same
3400/// group.
3401pub struct Group {
3402    file: Arc<FileInner>,
3403    /// Where this group's object header sits, as of the file revision it was
3404    /// resolved at. Re-resolved on first use after an edit could have moved it;
3405    /// see [`Group::header_address`]. A group carries no parsed header of its
3406    /// own — it re-reads one per call — so the address is the whole memo, and the
3407    /// content revision the [`Resolution`] carries beside it names no header this
3408    /// handle read and is never read back.
3409    ///
3410    /// `None` for a group this session has *staged* and not yet committed: there
3411    /// is no header to name one. Such a handle resolves its path on every use,
3412    /// and installs a memo the first time a commit gives it something to point
3413    /// at.
3414    state: RwLock<Option<Resolution>>,
3415    /// Root-relative path of this group (e.g. `""` for the root, `"a/b"`), used
3416    /// to address the group and its children for write operations on a
3417    /// read-write file, and to find it again after an edit moved it. `None` for
3418    /// a group reached by object reference ([`Dataset::dereference`]), which has
3419    /// no resolvable path.
3420    path: Option<String>,
3421    /// The session's staged generation when this handle was made onto a staged
3422    /// creation, and `None` for a handle opened onto a group in the file.
3423    ///
3424    /// It is what keeps such a handle from being retargeted at a different
3425    /// object: see [`Standing`] and [`FileInner::staged_standing`].
3426    staged_birth: Option<u64>,
3427}
3428
3429impl Clone for Group {
3430    /// Clones share nothing but the open file: the clone is a second handle to
3431    /// the same group, resolved as of the same revision.
3432    fn clone(&self) -> Self {
3433        Self {
3434            file: Arc::clone(&self.file),
3435            state: RwLock::new(*self.state.read().unwrap_or_else(PoisonError::into_inner)),
3436            path: self.path.clone(),
3437            staged_birth: self.staged_birth,
3438        }
3439    }
3440}
3441
3442impl std::fmt::Debug for Group {
3443    /// Reports the handle as it stands, without re-resolving: a `Debug` that
3444    /// read the file could fail, and one that failed would have nothing to
3445    /// print. `staged` is true for a group this session has created and not yet
3446    /// committed, which has no address to report.
3447    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3448        let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
3449        f.debug_struct("Group")
3450            .field("path", &self.path)
3451            .field("staged", &state.is_none())
3452            .finish()
3453    }
3454}
3455
3456impl Group {
3457    /// Build a handle for a group whose header was found at `at`.
3458    fn new(file: Arc<FileInner>, at: Resolution, path: Option<String>) -> Self {
3459        Self {
3460            file,
3461            state: RwLock::new(Some(at)),
3462            path,
3463            staged_birth: None,
3464        }
3465    }
3466
3467    /// Build a handle for a group this session has staged and not yet
3468    /// committed, which has no header to memoize.
3469    ///
3470    /// It addresses the group by name from the moment it is staged: further
3471    /// creations, deletions and attribute edits under it are staged through it
3472    /// like any other handle. Anything that has to *read* the group reports
3473    /// [`Error::NotCommitted`](crate::Error::NotCommitted) until
3474    /// [`File::commit`], after which the first use resolves the path and the
3475    /// handle behaves as if it had been opened by name.
3476    ///
3477    /// The session's staged generation is taken here so that the handle can tell
3478    /// that commit from a *withdrawal* of the same staging, which leaves the
3479    /// path meaning whatever the file holds — see [`Standing`].
3480    fn pending(file: Arc<FileInner>, path: String) -> Self {
3481        Self {
3482            staged_birth: file.staged_generation(),
3483            file,
3484            state: RwLock::new(None),
3485            path: Some(path),
3486        }
3487    }
3488
3489    /// This group's object-header address (base-adjusted, file-absolute), worked
3490    /// out again from its path if an edit could have moved it since the memo was
3491    /// taken. Also what resolves an object reference that points at this group.
3492    ///
3493    /// Returns [`Error::StaleHandle`](crate::Error::StaleHandle) for a handle
3494    /// that has no path to re-resolve — one an object reference produced — once
3495    /// a commit has run under it, and the resolution's own error (a
3496    /// `PathNotFound`, say, for a group a commit deleted) when the path no
3497    /// longer names anything. A group this session has staged and not committed
3498    /// has no header at all, and that is
3499    /// [`Error::NotCommitted`](crate::Error::NotCommitted). A commit that
3500    /// replaces this group with a dataset of the same name (issue #305) leaves
3501    /// the path naming something that is not a group, and that is
3502    /// [`Error::NotAGroup`](crate::Error::NotAGroup).
3503    pub(crate) fn header_address(&self) -> Result<u64, Error> {
3504        let memo = *self.state.read().unwrap_or_else(PoisonError::into_inner);
3505        if let Some(memo) = memo {
3506            if memo.address_revision == self.file.address_revision() {
3507                return Ok(memo.address);
3508            }
3509        }
3510        let at = self
3511            .file
3512            .locate_staged(self.path.as_deref(), memo, self.staged_birth)?;
3513        // Checked before it is memoized. The short-circuit above does not
3514        // re-check, so an address installed and then refused would be the
3515        // answer every later call returns without looking at it again.
3516        if !is_group(&self.file.parse_header(at.address)?) {
3517            // A path-less handle never reaches here: it failed the short-circuit
3518            // above, and `locate` answers `StaleHandle` for one whose address
3519            // memo it cannot reuse. The default stands for the root's own empty
3520            // path, which is the only empty one a handle holds.
3521            return Err(Error::NotAGroup(self.path.clone().unwrap_or_default()));
3522        }
3523        let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
3524        // Two threads can re-resolve at once. The older answer must not land on
3525        // top of the newer one, or the newer handle would go on serving an
3526        // address the file has already moved past. A handle that had no memo
3527        // takes this one: any address beats naming nothing.
3528        if state.is_none_or(|memo| at.address_revision >= memo.address_revision) {
3529            *state = Some(at);
3530        }
3531        Ok(at.address)
3532    }
3533
3534    /// List the names of datasets in this group.
3535    ///
3536    /// To read from the datasets themselves, prefer
3537    /// [`iter_datasets`](Self::iter_datasets): it hands back opened handles for
3538    /// the cost of this call, where opening each name separately re-walks the
3539    /// group once per member.
3540    ///
3541    /// A dataset this session has staged and not yet committed is listed too,
3542    /// once — a name that is both on disk and staged is the replacement of issue
3543    /// #305, and the staged object is what that name already resolves to.
3544    pub fn datasets(&self) -> Result<Vec<String>, Error> {
3545        let (entries, staged) = self.children_and_staged()?;
3546        let members = staged_members(&staged, StagedKind::Dataset, &entries);
3547        let mut names = Vec::new();
3548        for entry in &entries {
3549            if superseded(&staged, &entry.name) {
3550                continue;
3551            }
3552            let hdr = self.file.parse_header(entry.object_header_address)?;
3553            if has_message(&hdr, MessageType::DataLayout) {
3554                names.push(entry.name.clone());
3555            }
3556        }
3557        names.extend(members);
3558        Ok(names)
3559    }
3560
3561    /// Open every dataset in this group, each paired with its name.
3562    ///
3563    /// This is the walk to reach for when the members themselves are what you
3564    /// want — their attributes, shapes or data — rather than a list of names.
3565    /// [`datasets`](Self::datasets) already parses every child's object header to
3566    /// tell a dataset from a group, and then keeps only the name, so following it
3567    /// with a [`dataset`](Self::dataset) call per entry re-walks the group's link
3568    /// structure and re-parses that same header. This keeps what it read, and
3569    /// costs one enumeration of the group rather than one per member.
3570    ///
3571    /// **This walk is for taking every member, or nearly every one.** Telling a
3572    /// dataset from a group means parsing its header, so the whole group is
3573    /// enumerated and every child's header parsed before the iterator is
3574    /// returned — breaking out early saves nothing, and reaching one known member
3575    /// this way costs far more than [`dataset`](Self::dataset) does. Only the
3576    /// handle construction is deferred to each step, and that is not where the
3577    /// cost is.
3578    ///
3579    /// The headers of the members are held for the length of the walk, since each
3580    /// one is what its handle is built from. That is bounded by the group being
3581    /// walked rather than by the file, but it is proportional to the group: a
3582    /// header carries a compact dataset's data and its compact attributes inline,
3583    /// so a large group of such datasets is a large allocation.
3584    ///
3585    /// Each dataset gets the file-wide chunk-cache default; to override the cache
3586    /// for one, open it by name with
3587    /// [`dataset_with_options`](Self::dataset_with_options).
3588    ///
3589    /// Members arrive in the order the group's link structure yields them — the
3590    /// same order [`datasets`](Self::datasets) reports, which is not necessarily
3591    /// sorted, with this session's staged datasets after them. Each handle is a
3592    /// snapshot taken when the iterator was built, so a [`File::commit`] that
3593    /// runs mid-walk is not reflected in the members still to come; re-open the
3594    /// group to see past it.
3595    ///
3596    /// ```no_run
3597    /// # use hdf5_pure::File;
3598    /// # fn main() -> Result<(), hdf5_pure::Error> {
3599    /// let file = File::open("runs.h5")?;
3600    /// for (name, dataset) in file.root().iter_datasets()? {
3601    ///     println!("{name}: {:?}", dataset.shape());
3602    /// }
3603    /// # Ok(())
3604    /// # }
3605    /// ```
3606    pub fn iter_datasets(
3607        &self,
3608    ) -> Result<impl ExactSizeIterator<Item = (String, Dataset)> + use<>, Error> {
3609        let revisions = self.file.revisions();
3610        let (entries, staged) = self.children_and_staged()?;
3611        // Taken before the entries are consumed below, since which staged
3612        // members survive depends on the names the file already holds.
3613        let staged_members = staged_members(&staged, StagedKind::Dataset, &entries);
3614        // A member is either an on-disk header this walk read, or a staged
3615        // creation with no header to read yet.
3616        let mut members: Vec<(String, Option<(u64, ObjectHeader)>)> = Vec::new();
3617        for entry in entries {
3618            if superseded(&staged, &entry.name) {
3619                continue;
3620            }
3621            let hdr = self.file.parse_header(entry.object_header_address)?;
3622            if has_message(&hdr, MessageType::DataLayout) {
3623                members.push((entry.name, Some((entry.object_header_address, hdr))));
3624            }
3625        }
3626        members.extend(staged_members.into_iter().map(|name| (name, None)));
3627        let file = Arc::clone(&self.file);
3628        let parent = self.path.clone();
3629        let chunk_cache = DatasetAccessProperties::new()
3630            .resolved_chunk_cache(self.file.access_properties.chunk_cache);
3631        Ok(members.into_iter().map(move |(name, on_disk)| {
3632            let path = child_path_of(parent.as_deref(), &name);
3633            let dataset = match on_disk {
3634                Some((address, header)) => Dataset::new(
3635                    Arc::clone(&file),
3636                    revisions.at(address),
3637                    header,
3638                    chunk_cache,
3639                    path,
3640                ),
3641                // Only a group with a path of its own reports staged children,
3642                // so a staged member always has one to be named by.
3643                None => Dataset::pending(
3644                    Arc::clone(&file),
3645                    chunk_cache,
3646                    path.expect("a staged member's parent has a path"),
3647                ),
3648            };
3649            (name, dataset)
3650        }))
3651    }
3652
3653    /// The names of children that are committed (`H5Tcommit`) datatype objects:
3654    /// an object header carrying a datatype and neither data nor links.
3655    ///
3656    /// Such an object is the third kind HDF5 links into a group, and it appears
3657    /// in neither [`datasets`](Self::datasets) nor [`groups`](Self::groups) — so a
3658    /// walk that asks only for those two passes over one without noticing. Read
3659    /// the type itself with [`named_datatype`](Self::named_datatype).
3660    pub fn named_datatypes(&self) -> Result<Vec<String>, Error> {
3661        let entries = self.children()?;
3662        let mut names = Vec::new();
3663        for entry in &entries {
3664            let hdr = self.file.parse_header(entry.object_header_address)?;
3665            if is_named_datatype(&hdr) {
3666                names.push(entry.name.clone());
3667            }
3668        }
3669        Ok(names)
3670    }
3671
3672    /// The datatype a committed (`H5Tcommit`) child object holds.
3673    ///
3674    /// `name` must be one [`named_datatypes`](Self::named_datatypes) returned: a
3675    /// name that reaches nothing fails with [`FormatError::PathNotFound`], and
3676    /// one that reaches an object of another kind fails with
3677    /// [`Error::NotANamedDatatype`], the way `H5Topen` does.
3678    pub fn named_datatype(&self, name: &str) -> Result<Datatype, Error> {
3679        Ok(self.named_datatype_at(name)?.0)
3680    }
3681
3682    /// How many things reference the committed (`H5Tcommit`) datatype `name`:
3683    /// its hard links, plus every dataset and attribute that names it.
3684    ///
3685    /// This is HDF5's own object reference count (`H5Oget_info`'s `rc`), and what
3686    /// says whether unlinking the name would destroy the type or merely stop it
3687    /// being reachable through the link. A version 1 object header keeps the count
3688    /// in its prefix, a version 2 header in an Object Reference Count message,
3689    /// and a version 2 header without that message has exactly one reference,
3690    /// which is what the format means by omitting it.
3691    ///
3692    /// A name reaching anything but a committed datatype is
3693    /// [`Error::NotANamedDatatype`], as for
3694    /// [`named_datatype`](Self::named_datatype).
3695    pub fn named_datatype_references(&self, name: &str) -> Result<u32, Error> {
3696        let (_, hdr) = self.named_datatype_header(name)?;
3697        if let Some(count) = hdr.reference_count {
3698            return Ok(count);
3699        }
3700        let Ok(msg) = find_message(&hdr, MessageType::ObjectReferenceCount) else {
3701            return Ok(1);
3702        };
3703        // version(1) + count(4).
3704        let body = self.file.message_body(msg)?;
3705        if body.len() < 5 {
3706            return Err(Error::Format(FormatError::UnexpectedEof {
3707                expected: 5,
3708                available: body.len(),
3709            }));
3710        }
3711        Ok(u32::from_le_bytes([body[1], body[2], body[3], body[4]]))
3712    }
3713
3714    /// The object header of a child that is a committed datatype, and its
3715    /// address.
3716    ///
3717    /// The one place the by-name datatype lookups classify what they reached, so
3718    /// that a child this refuses cannot be one
3719    /// [`named_datatypes`](Self::named_datatypes) would list. Reached the way
3720    /// [`group`](Self::group) and [`dataset`](Self::dataset) reach theirs, which
3721    /// looks the one name up rather than enumerating the group to find it.
3722    fn named_datatype_header(&self, name: &str) -> Result<(u64, ObjectHeader), Error> {
3723        let address = self
3724            .child_address(name)?
3725            .ok_or_else(|| Error::Format(FormatError::PathNotFound(name.to_string())))?;
3726        let hdr = self.file.parse_header(address)?;
3727        if !is_named_datatype(&hdr) {
3728            return Err(Error::NotANamedDatatype(name.to_string()));
3729        }
3730        Ok((address, hdr))
3731    }
3732
3733    /// The datatype a committed child object holds, and the address of the object
3734    /// header holding it.
3735    ///
3736    /// The address is the identity every user of the type shares: two datasets
3737    /// naming the same address name one type, and reproducing that requires
3738    /// matching them up by address rather than by what the type decodes to.
3739    pub(crate) fn named_datatype_at(&self, name: &str) -> Result<(Datatype, u64), Error> {
3740        let (address, hdr) = self.named_datatype_header(name)?;
3741        let msg = find_message(&hdr, MessageType::Datatype)?;
3742        let (dt, _) = Datatype::parse(&self.file.message_body(msg)?)?;
3743        Ok((dt, address))
3744    }
3745
3746    /// List the names of subgroups in this group.
3747    ///
3748    /// To descend into the subgroups themselves, prefer
3749    /// [`iter_groups`](Self::iter_groups), which hands back opened handles for
3750    /// the cost of this call.
3751    ///
3752    /// A group this session has staged and not yet committed is listed too, on
3753    /// the terms [`datasets`](Self::datasets) sets out.
3754    pub fn groups(&self) -> Result<Vec<String>, Error> {
3755        let (entries, staged) = self.children_and_staged()?;
3756        let members = staged_members(&staged, StagedKind::Group, &entries);
3757        let mut names = Vec::new();
3758        for entry in &entries {
3759            if superseded(&staged, &entry.name) {
3760                continue;
3761            }
3762            let hdr = self.file.parse_header(entry.object_header_address)?;
3763            if is_group(&hdr) {
3764                names.push(entry.name.clone());
3765            }
3766        }
3767        names.extend(members);
3768        Ok(names)
3769    }
3770
3771    /// Open every subgroup of this group, each paired with its name.
3772    ///
3773    /// The counterpart to [`iter_datasets`](Self::iter_datasets), and the way to
3774    /// recurse without paying a [`group`](Self::group) lookup per child: that
3775    /// lookup re-walks this group's link structure, which a walk of the whole
3776    /// tree would otherwise repeat once per subgroup.
3777    ///
3778    /// As with [`iter_datasets`](Self::iter_datasets), the whole group is
3779    /// enumerated and classified before the iterator is returned, so this is the
3780    /// walk for taking every subgroup rather than for reaching one — breaking out
3781    /// early saves nothing. A [`Group`] handle carries no parsed header, so
3782    /// unlike `iter_datasets` this holds none of them.
3783    ///
3784    /// Members arrive in the order the group's link structure yields them — the
3785    /// same order [`groups`](Self::groups) reports, which is not necessarily
3786    /// sorted, with this session's staged groups after them.
3787    ///
3788    /// ```no_run
3789    /// # use hdf5_pure::{Error, Group};
3790    /// fn total_datasets(group: &Group) -> Result<usize, Error> {
3791    ///     let mut n = group.datasets()?.len();
3792    ///     for (_, child) in group.iter_groups()? {
3793    ///         n += total_datasets(&child)?;
3794    ///     }
3795    ///     Ok(n)
3796    /// }
3797    /// ```
3798    pub fn iter_groups(
3799        &self,
3800    ) -> Result<impl ExactSizeIterator<Item = (String, Group)> + use<>, Error> {
3801        let revisions = self.file.revisions();
3802        let (entries, staged) = self.children_and_staged()?;
3803        // Taken before the entries are consumed, as in `iter_datasets`.
3804        let staged_members = staged_members(&staged, StagedKind::Group, &entries);
3805        // `None` for a staged member, which has no address to resolve until the
3806        // commit places its header.
3807        let mut members: Vec<(String, Option<u64>)> = Vec::new();
3808        for entry in entries {
3809            if superseded(&staged, &entry.name) {
3810                continue;
3811            }
3812            // A `Group` handle carries no parsed header, so the header that
3813            // classified this child is dropped here rather than held for the
3814            // length of the walk.
3815            if is_group(&self.file.parse_header(entry.object_header_address)?) {
3816                members.push((entry.name, Some(entry.object_header_address)));
3817            }
3818        }
3819        members.extend(staged_members.into_iter().map(|name| (name, None)));
3820        let file = Arc::clone(&self.file);
3821        let parent = self.path.clone();
3822        Ok(members.into_iter().map(move |(name, address)| {
3823            let path = child_path_of(parent.as_deref(), &name);
3824            let group = match address {
3825                Some(address) => Group::new(Arc::clone(&file), revisions.at(address), path),
3826                // As in `iter_datasets`: staged members exist only under a
3827                // group that has a path.
3828                None => Group::pending(
3829                    Arc::clone(&file),
3830                    path.expect("a staged member's parent has a path"),
3831                ),
3832            };
3833            (name, group)
3834        }))
3835    }
3836
3837    /// Read all attributes of this group.
3838    ///
3839    /// Each value takes the [`AttrValue`] variant that describes its on-disk
3840    /// encoding, so the variant reflects the charset, width and dataspace its
3841    /// writer chose rather than the shape of the data alone: a one-element array
3842    /// stays an array, an ASCII string does not arrive as a UTF-8
3843    /// [`String`](AttrValue::String), and a 16-bit integer arrives as
3844    /// [`I16`](AttrValue::I16) rather than widened, a 32-bit float as
3845    /// [`F32`](AttrValue::F32). Prefer the accessors —
3846    /// [`AttrValue::as_str`], [`as_strings`](AttrValue::as_strings),
3847    /// [`as_i64`](AttrValue::as_i64) and the rest — over matching on the variant,
3848    /// unless the encoding is the thing you care about. **The variant may become
3849    /// more specific in a future release** as `AttrValue` grows further ones
3850    /// (variable-length strings, say), and a `_` arm is required regardless
3851    /// because the enum is `#[non_exhaustive]`.
3852    ///
3853    /// An attribute whose datatype has no `AttrValue` representation is omitted
3854    /// from the map rather than reported as an error. Read
3855    /// [`attr_datatypes`](Self::attr_datatypes) to see it.
3856    pub fn attrs(&self) -> Result<HashMap<String, AttrValue>, Error> {
3857        let hdr = self.file.parse_header(self.header_address()?)?;
3858        self.file.attrs_of(&hdr)
3859    }
3860
3861    /// The exact on-disk [`Datatype`] of every attribute on this group, keyed by
3862    /// name — including compound field offsets, integer widths and enumeration
3863    /// members.
3864    ///
3865    /// This is the type channel to [`attrs`](Self::attrs)'s value channel, the
3866    /// pair a dataset already has in [`Dataset::datatype`] and its `read_*`
3867    /// methods. An [`AttrValue`] is a deliberately lossy view of the value, so an
3868    /// attribute's byte order, sub-width precision, string padding and
3869    /// enumeration members are recoverable only from here — its width is not,
3870    /// since [`attrs`](Self::attrs) keeps that. Its *rank* is not either: that
3871    /// lives in the
3872    /// dataspace, which nothing public exposes, so a rank-2 attribute still
3873    /// reads as a flat `AttrValue` array with no way to recover its shape.
3874    ///
3875    /// **Every attribute message is reported, including the ones `attrs` omits**
3876    /// because no `AttrValue` can carry them, so a name missing from that map can
3877    /// be told from one the object does not have.
3878    ///
3879    /// A **committed** datatype — one created with `H5Tcommit`, what netCDF-4
3880    /// writes for a user-defined type and what h5py writes for
3881    /// `f["t"] = np.dtype(...)` — is stored as a reference to the type's own
3882    /// object header rather than inline, and is resolved to the type it names.
3883    /// What it does *not* carry is the name: two attributes sharing `/mytype`
3884    /// report the same [`Datatype`] as one that spells it out inline.
3885    ///
3886    /// A boolean attribute is the case that needs both channels. The C library
3887    /// gives `H5T_NATIVE_HBOOL` — what h5py writes for every `np.bool_` — a
3888    /// [`Datatype::Enumeration`] of `FALSE` and `TRUE` over an 8-bit base, and
3889    /// `attrs` decodes it through that base, so the value arrives as `0` or `1`
3890    /// and only the datatype records that it was a bool.
3891    pub fn attr_datatypes(&self) -> Result<HashMap<String, Datatype>, Error> {
3892        Ok(self
3893            .attr_messages()?
3894            .into_iter()
3895            .map(|a| (a.name, a.datatype))
3896            .collect())
3897    }
3898
3899    /// Every attribute message on this group as it is encoded on disk, in the
3900    /// order the header holds them.
3901    ///
3902    /// [`attrs`](Self::attrs) decodes each into an [`AttrValue`], which loses the
3903    /// encoding; this keeps it. Repack copies from here so an attribute survives
3904    /// a rewrite unchanged, and falls back to the decoded map only where the
3905    /// bytes are not position-independent.
3906    pub(crate) fn attr_messages(&self) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
3907        let hdr = self.file.parse_header(self.header_address()?)?;
3908        self.file.attr_messages_of(&hdr)
3909    }
3910
3911    /// Get a dataset within this group by name.
3912    ///
3913    /// The dataset uses the file-wide chunk-cache default. To override the cache
3914    /// for this one dataset, use
3915    /// [`dataset_with_options`](Self::dataset_with_options).
3916    pub fn dataset(&self, name: &str) -> Result<Dataset, Error> {
3917        self.dataset_with_options(name, DatasetAccessProperties::new())
3918    }
3919
3920    /// Get a dataset within this group by name, applying per-dataset
3921    /// [`DatasetAccessProperties`] that override file-wide access defaults (HDF5's
3922    /// `dapl`; see `H5Pset_chunk_cache`).
3923    pub fn dataset_with_options(
3924        &self,
3925        name: &str,
3926        properties: DatasetAccessProperties,
3927    ) -> Result<Dataset, Error> {
3928        let chunk_cache = properties.resolved_chunk_cache(self.file.access_properties.chunk_cache);
3929        if let Some(child) = self.child_path(name) {
3930            match self.file.staged_object(&child).map(|o| o.kind) {
3931                Some(StagedKind::Dataset) => {
3932                    return Ok(Dataset::pending(self.file.clone(), chunk_cache, child));
3933                }
3934                Some(StagedKind::Group) => return Err(Error::NotADataset(name.to_string())),
3935                None => {}
3936            }
3937        }
3938        let revisions = self.file.revisions();
3939        let address = self
3940            .child_address(name)?
3941            .ok_or_else(|| Error::Format(FormatError::PathNotFound(name.to_string())))?;
3942        let hdr = self.file.parse_header(address)?;
3943        if !has_message(&hdr, MessageType::DataLayout) {
3944            return Err(Error::NotADataset(name.to_string()));
3945        }
3946        Ok(Dataset::new(
3947            self.file.clone(),
3948            revisions.at(address),
3949            hdr,
3950            chunk_cache,
3951            self.child_path(name),
3952        ))
3953    }
3954
3955    /// Get a subgroup within this group by name.
3956    ///
3957    /// Returns [`Error::NotAGroup`] if the child is not a group, the way
3958    /// [`dataset`](Self::dataset) returns [`Error::NotADataset`] for the mirror
3959    /// case, and [`FormatError::PathNotFound`] if there is no such child.
3960    pub fn group(&self, name: &str) -> Result<Group, Error> {
3961        if let Some(child) = self.child_path(name) {
3962            match self.file.staged_object(&child).map(|o| o.kind) {
3963                Some(StagedKind::Group) => {
3964                    return Ok(Group::pending(self.file.clone(), child));
3965                }
3966                Some(StagedKind::Dataset) => return Err(Error::NotAGroup(name.to_string())),
3967                None => {}
3968            }
3969        }
3970        let revisions = self.file.revisions();
3971        let address = self
3972            .child_address(name)?
3973            .ok_or_else(|| Error::Format(FormatError::PathNotFound(name.to_string())))?;
3974        if !is_group(&self.file.parse_header(address)?) {
3975            return Err(Error::NotAGroup(name.to_string()));
3976        }
3977        Ok(Group::new(
3978            self.file.clone(),
3979            revisions.at(address),
3980            self.child_path(name),
3981        ))
3982    }
3983
3984    /// The object-header address of this group's child named `name`.
3985    ///
3986    /// The by-name form of [`children`](Self::children): it reads the group's
3987    /// links without building one entry per child, which is what makes opening
3988    /// each member of a large group in turn cost the group once rather than once
3989    /// per member (issue #228).
3990    fn child_address(&self, name: &str) -> Result<Option<u64>, Error> {
3991        match self.file.group_child(self.header_address()?, name)? {
3992            ChildLookup::Found(address) => Ok(Some(address)),
3993            ChildLookup::Absent => Ok(None),
3994            // Reached by the one handle whose object is never classified: the
3995            // root. Every other `Group` comes from a lookup that classified it
3996            // (`File::group`, `Group::group`, `iter_groups`, `object_at_relative`)
3997            // or re-resolves through `header_address`, which classifies again;
3998            // `File::root` takes the superblock's word for it, and nothing checks
3999            // that the root address names a group. The empty path is the root's
4000            // own name here, so the refusal names it correctly.
4001            ChildLookup::NotAGroup => Err(Error::NotAGroup(self.path.clone().unwrap_or_default())),
4002        }
4003    }
4004
4005    /// The root-relative path of a child named `name`, or `None` if this group
4006    /// itself has no resolvable path (reached by object reference).
4007    fn child_path(&self, name: &str) -> Option<String> {
4008        child_path_of(self.path.as_deref(), name)
4009    }
4010
4011    /// Create an empty subgroup `name` within this group, staged until
4012    /// [`File::commit`], and return a handle to it.
4013    ///
4014    /// The handle addresses the new group straight away: further groups,
4015    /// datasets, deletions and attributes can be staged through it, and
4016    /// [`group`](Self::group) finds it by name from the same session — as does
4017    /// any group named in this call, but not an intermediate one the commit
4018    /// fills in (`create_group("a/b")` leaves `a` unaddressable until then).
4019    /// Reading it
4020    /// — its attributes, or a member's data — reports
4021    /// [`Error::NotCommitted`](crate::Error::NotCommitted) until the commit,
4022    /// after which the same handle answers for the group in the file. Deleting
4023    /// it before the commit withdraws the staging, and the handle then reports
4024    /// [`Error::StagingWithdrawn`](crate::Error::StagingWithdrawn) rather than
4025    /// answering for whatever else the path may name.
4026    ///
4027    /// **The handle keeps the file's exclusive OS lock alive**, as every
4028    /// [`Group`] and [`Dataset`] handle does, so `let g = root.create_group(..)?`
4029    /// holds what `root.create_group(..)?;` used to drop and a reopen of the
4030    /// file fails until it goes. [`File::close`] states the rule.
4031    ///
4032    /// [`create_group_with`](Self::create_group_with) builds a whole subtree in
4033    /// one call instead.
4034    ///
4035    /// Requires a read-write file ([`File::open_rw`]), else
4036    /// [`Error::ReadOnly`](crate::Error::ReadOnly). A name the file already
4037    /// links to is refused here with
4038    /// [`Error::EditUnsupported`](crate::Error::EditUnsupported) unless this
4039    /// session also deletes it — a [replacement](Self::delete) — since there
4040    /// would otherwise be no new object for the handle to address. A name this
4041    /// session already staged a *dataset* at is refused for the same reason —
4042    /// one name cannot mean two objects, and the handle would answer for
4043    /// whichever was staged first — while staging the same group twice is
4044    /// allowed and hands back another handle onto that one group, which is how
4045    /// attributes and children are added to a group already staged.
4046    /// [`delete`](Self::delete) withdraws a staged creation, which frees the
4047    /// name for another.
4048    ///
4049    /// ```no_run
4050    /// # use hdf5_pure::File;
4051    /// # fn main() -> Result<(), hdf5_pure::Error> {
4052    /// let file = File::open_rw("runs.h5")?;
4053    /// let run = file.root().create_group("run2")?;
4054    /// run.create_dataset("signal", |b| {
4055    ///     b.with_f64_data(&[1.0, 2.0, 3.0]);
4056    /// })?;
4057    /// file.commit()?;
4058    /// # Ok(())
4059    /// # }
4060    /// ```
4061    pub fn create_group(&self, name: &str) -> Result<Group, Error> {
4062        self.create_group_with(name, |_| {})
4063    }
4064
4065    /// Create a subgroup `name` within this group, configuring it through
4066    /// `build` (attributes, nested groups and datasets), staged until
4067    /// [`File::commit`], and return a handle to it.
4068    ///
4069    /// The closure describes a whole subtree in one call, which is what makes it
4070    /// worth having over [`create_group`](Self::create_group) plus calls on the
4071    /// handle that returns; either way the new group is addressable by name
4072    /// before the commit, and the handle this returns is the same one
4073    /// [`group`](Self::group) would give back.
4074    ///
4075    /// The closure records into a buffer rather than into the file itself, and
4076    /// what it stages is applied together when it returns — so reading the same
4077    /// [`File`] from inside it sees the file as it was before this call, and
4078    /// everything it staged is addressable by name from the moment it returns.
4079    ///
4080    /// The handle this returns keeps the file's exclusive OS lock alive, as
4081    /// every [`Group`] and [`Dataset`] handle does; see [`File::close`].
4082    ///
4083    /// Requires a read-write file ([`File::open_rw`]), else
4084    /// [`Error::ReadOnly`](crate::Error::ReadOnly). Name collisions are refused
4085    /// on [`create_group`](Self::create_group)'s terms, for the group this
4086    /// creates and for everything the closure stages under it.
4087    ///
4088    /// ```no_run
4089    /// # use hdf5_pure::{AttrValue, File};
4090    /// # fn main() -> Result<(), hdf5_pure::Error> {
4091    /// let file = File::open_rw("runs.h5")?;
4092    /// file.root().create_group_with("run2", |g| {
4093    ///     g.set_attr("count", AttrValue::I64(7));
4094    ///     g.set_attr("label", AttrValue::String("second".into()));
4095    /// })?;
4096    /// file.commit()?;
4097    /// # Ok(())
4098    /// # }
4099    /// ```
4100    pub fn create_group_with(
4101        &self,
4102        name: &str,
4103        build: impl FnOnce(&mut StagedGroup<'_>),
4104    ) -> Result<Group, Error> {
4105        let child = self.child_edit_path(name)?;
4106        let mut ops = vec![StagedOp::CreateGroup(child.clone())];
4107        build(&mut StagedGroup {
4108            ops: &mut ops,
4109            path: child.clone(),
4110        });
4111        self.apply_staged(ops)?;
4112        Ok(Group::pending(self.file.clone(), child))
4113    }
4114
4115    /// Create a dataset `name` within this group, configuring it through `build`
4116    /// (shape, data, chunks, filters, …), staged until [`File::commit`], and
4117    /// return a handle to it.
4118    ///
4119    /// The handle addresses the new dataset straight away, which is what lets a
4120    /// writer cache one per column while it is still building the schema. It
4121    /// answers [`shape`](Dataset::shape), [`maxshape`](Dataset::maxshape),
4122    /// [`dtype`](Dataset::dtype), [`datatype`](Dataset::datatype),
4123    /// [`is_chunked`](Dataset::is_chunked) and [`filters`](Dataset::filters)
4124    /// from what was staged, and [`append_staged`](Dataset::append_staged) folds
4125    /// more elements into the pending creation. Anything that reads the
4126    /// dataset's bytes — a `read_*`, its attributes, an immediate
4127    /// [`append`](Dataset::append) — reports
4128    /// [`Error::NotCommitted`](crate::Error::NotCommitted) until the commit,
4129    /// after which the same handle reads the dataset in the file. Deleting it
4130    /// before the commit withdraws the staging, and the handle then reports
4131    /// [`Error::StagingWithdrawn`](crate::Error::StagingWithdrawn) rather than
4132    /// answering for whatever else the path may name.
4133    ///
4134    /// **The handle keeps the file's exclusive OS lock alive**, as every
4135    /// [`Group`] and [`Dataset`] handle does, so `let ds = root.create_dataset(..)?`
4136    /// holds what `root.create_dataset(..)?;` used to drop and a reopen of the
4137    /// file fails until it goes. [`File::close`] states the rule.
4138    ///
4139    /// As with [`create_group_with`](Self::create_group_with), the closure
4140    /// configures a builder rather than the file, so it may read the same
4141    /// [`File`] — it will see the file as it was before this call.
4142    ///
4143    /// Requires a read-write file ([`File::open_rw`]), else
4144    /// [`Error::ReadOnly`](crate::Error::ReadOnly). A name the file already
4145    /// links to is refused here with
4146    /// [`Error::EditUnsupported`](crate::Error::EditUnsupported) unless this
4147    /// session also deletes it — a [replacement](Self::delete) — since there
4148    /// would otherwise be no new dataset for the handle to address. A name this
4149    /// session already staged a creation at is refused for the same reason:
4150    /// two creations at one path are one name for two objects, and the handle
4151    /// would answer for whichever was staged first. [`delete`](Self::delete)
4152    /// withdraws a staged creation, which frees the name for another.
4153    ///
4154    /// ```no_run
4155    /// # use hdf5_pure::File;
4156    /// # fn main() -> Result<(), hdf5_pure::Error> {
4157    /// let file = File::open_rw("runs.h5")?;
4158    /// let mut col = file.root().create_dataset("col", |b| {
4159    ///     b.with_f64_data(&[])
4160    ///         .with_shape(&[0])
4161    ///         .with_maxshape(&[u64::MAX])
4162    ///         .with_chunks(&[512]);
4163    /// })?;
4164    /// col.append_staged(|a| {
4165    ///     a.append_f64(&[1.0, 2.0, 3.0]);
4166    /// })?;
4167    /// file.commit()?;
4168    /// assert_eq!(col.read_f64()?, vec![1.0, 2.0, 3.0]);
4169    /// # Ok(())
4170    /// # }
4171    /// ```
4172    pub fn create_dataset(
4173        &self,
4174        name: &str,
4175        build: impl FnOnce(&mut DatasetBuilder),
4176    ) -> Result<Dataset, Error> {
4177        let child = self.child_edit_path(name)?;
4178        let mut builder = DatasetBuilder::new(name);
4179        build(&mut builder);
4180        self.apply_staged(vec![StagedOp::CreateDataset {
4181            path: child.clone(),
4182            builder: Box::new(builder),
4183        }])?;
4184        Ok(Dataset::pending(
4185            self.file.clone(),
4186            DatasetAccessProperties::new()
4187                .resolved_chunk_cache(self.file.access_properties.chunk_cache),
4188            child,
4189        ))
4190    }
4191
4192    /// Delete the object named `name` from this group, staged until
4193    /// [`File::commit`]. See [`create_group`](Self::create_group) for the
4194    /// file-mode rules.
4195    ///
4196    /// Creating a new object at the same path in the same commit *replaces* it:
4197    /// the removal is applied before the addition and one superblock write
4198    /// publishes both, so a rotation costs one commit and the path is never
4199    /// momentarily absent.
4200    ///
4201    /// Deleting an object this session **staged** and has not committed
4202    /// withdraws that staging instead — its attributes, appends and staged
4203    /// children go with it — since there is no link in the file to unlink. Where
4204    /// the deletion was part of a replacement, the plain deletion of the file's
4205    /// own object is what remains. A handle onto the withdrawn creation reports
4206    /// [`Error::StagingWithdrawn`](crate::Error::StagingWithdrawn) from then on:
4207    /// it names nothing, and the object the file holds at that path is the one
4208    /// this session is removing.
4209    ///
4210    /// Deleting a group carries its whole subtree away, but only a commit that
4211    /// builds that group *again* can put anything back under it: staging a
4212    /// creation below a deleted path that nothing recreates is a batch `commit`
4213    /// refuses, and until it does the file's own children still own their names.
4214    /// The root itself cannot be deleted.
4215    ///
4216    /// ```no_run
4217    /// # use hdf5_pure::File;
4218    /// # fn main() -> Result<(), hdf5_pure::Error> {
4219    /// let file = File::open_rw("ring.h5")?;
4220    /// file.root().delete("t0")?;
4221    /// file.root().create_dataset("t0", |b| { b.with_i32_data(&[1, 2, 3]); })?;
4222    /// file.commit()?;
4223    /// # Ok(())
4224    /// # }
4225    /// ```
4226    pub fn delete(&self, name: &str) -> Result<(), Error> {
4227        self.with_child_session(name, |session, child| session.delete(child))
4228    }
4229
4230    /// Add or update an attribute on this group, staged until [`File::commit`].
4231    /// Use [`remove_attr`](Self::remove_attr) to remove one. The
4232    /// [`root`](File::root) group's attributes are edited the same way.
4233    ///
4234    /// Requires a read-write file ([`File::open_rw`]), else
4235    /// [`Error::ReadOnly`](crate::Error::ReadOnly). An attribute set too large
4236    /// for the object header — more than eight attributes, or one whose message
4237    /// the header's 2-byte size field cannot describe — is written to a fractal
4238    /// heap on `commit`, as it is when the whole file is written, and a group
4239    /// already storing its attributes in one is rebuilt.
4240    pub fn set_attr(&self, name: &str, value: AttrValue) -> Result<(), Error> {
4241        self.with_own_session(|session, path| session.set_group_attr(path, name, value))
4242    }
4243
4244    /// Remove an attribute from this group, staged until [`File::commit`].
4245    /// See [`set_attr`](Self::set_attr) for the file-mode rules.
4246    pub fn remove_attr(&self, name: &str) -> Result<(), Error> {
4247        self.with_own_session(|session, path| session.remove_group_attr(path, name))
4248    }
4249
4250    /// Run `f` with the writable session and the root-relative path of child
4251    /// `name`. Returns [`Error::ReadOnly`](crate::Error::ReadOnly) if the file is
4252    /// read-only or this group has no resolvable path.
4253    fn with_child_session<R>(
4254        &self,
4255        name: &str,
4256        f: impl FnOnce(&mut WriteEngine, &str) -> Result<R, Error>,
4257    ) -> Result<R, Error> {
4258        self.refuse_if_withdrawn()?;
4259        let child = self.child_path(name).ok_or(Error::ReadOnly)?;
4260        self.file
4261            .with_engine_mut(Change::Relocating, |session| f(session, &child))
4262    }
4263
4264    /// Refuse an edit staged *through* a handle whose own staged creation has
4265    /// been withdrawn.
4266    ///
4267    /// Every read through such a handle already reports it: they resolve the
4268    /// path, and [`FileInner::locate_staged`] is where that is decided. The
4269    /// staging calls resolve nothing — they address the file by path — so this
4270    /// is where they ask the same question, and without it an edit staged
4271    /// through a withdrawn group would land under whatever the file holds at its
4272    /// path, which is the object the session is deleting.
4273    ///
4274    /// Costs nothing for a handle opened onto an object in the file: those carry
4275    /// no birth generation, so the session is never locked to answer.
4276    fn refuse_if_withdrawn(&self) -> Result<(), Error> {
4277        let Some(path) = self.path.as_deref().filter(|_| self.staged_birth.is_some()) else {
4278            return Ok(());
4279        };
4280        match self.file.staged_standing(path, self.staged_birth) {
4281            Standing::Withdrawn => Err(Error::StagingWithdrawn(path.to_string())),
4282            Standing::Live | Standing::Pending => Ok(()),
4283        }
4284    }
4285
4286    /// Validate that this group can stage an edit to child `name` and return the
4287    /// child's root-relative path, *without* taking the session lock.
4288    ///
4289    /// Paired with [`apply_staged`](Self::apply_staged): the checks run first so
4290    /// a read-only or sealed file is reported before any user closure runs, the
4291    /// closure then runs unlocked, and the lock is taken only to record what it
4292    /// built (issue #200).
4293    fn child_edit_path(&self, name: &str) -> Result<String, Error> {
4294        self.file.check_staged_writable()?;
4295        self.refuse_if_withdrawn()?;
4296        self.child_path(name).ok_or(Error::ReadOnly)
4297    }
4298
4299    /// Record already-built edits on the writable session, holding the lock only
4300    /// for the duration of the replay.
4301    ///
4302    /// The file is re-checked here because the closure that produced `ops` ran
4303    /// unlocked and could have closed the file in the meantime; staging into a
4304    /// sealed file would otherwise be silently accepted and then dropped.
4305    fn apply_staged(&self, ops: Vec<StagedOp>) -> Result<(), Error> {
4306        self.file.with_engine_mut(Change::Relocating, |session| {
4307            // All or nothing: one call can carry a whole subtree, and each op is
4308            // validated as it is staged, so a refusal partway must not leave the
4309            // ops before it recorded.
4310            session.stage_atomically(|s| {
4311                for op in ops {
4312                    op.apply(s)?;
4313                }
4314                Ok(())
4315            })
4316        })
4317    }
4318
4319    /// Run `f` with the writable session and this group's *own* root-relative
4320    /// path (for attribute edits, which act on the group itself rather than a
4321    /// child). Returns [`Error::ReadOnly`](crate::Error::ReadOnly) if the file is
4322    /// read-only or this group has no resolvable path, and
4323    /// [`Error::FileClosed`](crate::Error::FileClosed) once the file is sealed.
4324    fn with_own_session<R>(
4325        &self,
4326        f: impl FnOnce(&mut WriteEngine, &str) -> Result<R, Error>,
4327    ) -> Result<R, Error> {
4328        self.refuse_if_withdrawn()?;
4329        let path = self.path.clone().ok_or(Error::ReadOnly)?;
4330        self.file
4331            .with_engine_mut(Change::Relocating, |session| f(session, &path))
4332    }
4333
4334    fn children(&self) -> Result<Vec<GroupEntry>, Error> {
4335        let hdr = self.file.parse_header(self.header_address()?)?;
4336        self.file.group_children(&hdr)
4337    }
4338
4339    /// This group's on-disk children, paired with the children the session has
4340    /// staged under it.
4341    ///
4342    /// A staged name supersedes an on-disk link of the same name: the two can
4343    /// coexist only as a replacement (issue #305), where the commit removes the
4344    /// link before adding the new object, and every by-name lookup already
4345    /// answers with the staged one. A group that is *itself* staged has no links
4346    /// on disk to enumerate, and its members are exactly what is staged under
4347    /// it.
4348    fn children_and_staged(&self) -> Result<(Vec<GroupEntry>, StagedChildren), Error> {
4349        let staged = match self.path.as_deref() {
4350            Some(path) => self.file.staged_children(path),
4351            // A group reached by object reference cannot name itself, so nothing
4352            // can have been staged under it by name either.
4353            None => Vec::new(),
4354        };
4355        match self.children() {
4356            Ok(entries) => Ok((entries, staged)),
4357            Err(Error::NotCommitted(_)) => Ok((Vec::new(), staged)),
4358            Err(e) => Err(e),
4359        }
4360    }
4361}
4362
4363/// The children of one group this session has staged.
4364type StagedChildren = Vec<StagedChild>;
4365
4366/// Whether a staged creation takes `name` over from the link the file holds
4367/// there — which it does only when the same commit removes that link.
4368///
4369/// A creation that merely collides with a surviving link is refused where it is
4370/// staged, so the file's own object is what the name lists as.
4371fn superseded(staged: &[StagedChild], name: &str) -> bool {
4372    staged.iter().any(|c| c.name == name && c.replaces_link)
4373}
4374
4375/// The staged children of `kind` that own their names, given the on-disk links
4376/// they sit beside, in the order they were staged.
4377fn staged_members(staged: &[StagedChild], kind: StagedKind, entries: &[GroupEntry]) -> Vec<String> {
4378    staged
4379        .iter()
4380        .filter(|c| c.kind == kind)
4381        // Either the file has no link of this name, or the commit removes it.
4382        .filter(|c| c.replaces_link || !entries.iter().any(|e| e.name == c.name))
4383        .map(|c| c.name.clone())
4384        .collect()
4385}
4386
4387// ---------------------------------------------------------------------------
4388// Dataset handle
4389// ---------------------------------------------------------------------------
4390
4391/// A [`Dataset`] handle's memo: where its object header sits, what that header
4392/// says, and what both hold as of.
4393///
4394/// The handle names the dataset; this is a memo of what that name resolved to.
4395/// See [`Dataset::resolved`].
4396struct DatasetState {
4397    /// Where the header sits — [`Resolution::address`] is base-adjusted and
4398    /// file-absolute — and the revisions the address and the parse hold as of.
4399    at: Resolution,
4400    header: ObjectHeader,
4401}
4402
4403/// An owned handle to an HDF5 dataset.
4404///
4405/// The handle names the dataset by its root-relative path and remembers where
4406/// that path resolved to and what the header there said. A [`File::commit`]
4407/// rewrites and relocates object headers, and an immediate [`append`](Self::append)
4408/// rewrites one where it stands, so the memo is worked out again on the first
4409/// use after either and the handle goes on answering for the same dataset — see
4410/// [`File::commit`] for the two cases that report instead. That covers an edit
4411/// made through *another* handle to the same dataset as well as through this
4412/// one. Cloning gives a second handle to the same dataset, sharing its chunk
4413/// cache.
4414///
4415/// Three accessors cannot report a handle that no longer resolves, because they
4416/// return no `Result`: [`filters`](Self::filters) and
4417/// [`filter_pipeline`](Self::filter_pipeline) answer empty, the same answer an
4418/// unfiltered dataset gives, and [`is_chunked`](Self::is_chunked) answers
4419/// `false`. Every other reader of the header says what went wrong.
4420pub struct Dataset {
4421    file: Arc<FileInner>,
4422    /// Where this dataset's object header sits and what it says, as of the file
4423    /// revision they were read at. Re-taken on first use after the file changes;
4424    /// see [`Dataset::resolved`].
4425    ///
4426    /// `None` for a dataset this session has *staged* and not yet committed:
4427    /// there is no header to read. Such a handle answers the metadata questions
4428    /// from what was staged and resolves its path on every other use, until a
4429    /// commit gives it a header to memoize.
4430    state: RwLock<Option<Arc<DatasetState>>>,
4431    // Held per-dataset: the chunk index is keyed only by chunk coordinate, so
4432    // a file-level cache would alias chunk addresses across datasets. Shared
4433    // between clones, which are the same dataset: a chunk read through one is
4434    // warm for the other, and an edit through either drops it for both.
4435    chunk_cache: Arc<ChunkCache>,
4436    // The effective chunk-cache config for this dataset: the file-wide default
4437    // or a per-dataset DAPL override. Reported by `chunk_cache_config`.
4438    chunk_cache_config: ChunkCacheConfig,
4439    /// Root-relative path of this dataset, used to address it for write
4440    /// operations on a read-write file, and to find it again after an edit moved
4441    /// it. `None` for a dataset reached by object reference
4442    /// ([`Dataset::dereference`]), which has no resolvable path.
4443    path: Option<String>,
4444    /// The session's staged generation when this handle was made onto a staged
4445    /// creation, and `None` for a handle opened onto a dataset in the file.
4446    ///
4447    /// It is what keeps such a handle from being retargeted at a different
4448    /// object: see [`Standing`] and [`FileInner::staged_standing`].
4449    staged_birth: Option<u64>,
4450}
4451
4452impl Clone for Dataset {
4453    /// The clone is a second handle to the same dataset, sharing its chunk cache
4454    /// and resolved as of the same revision.
4455    fn clone(&self) -> Self {
4456        Self {
4457            file: Arc::clone(&self.file),
4458            state: RwLock::new(
4459                self.state
4460                    .read()
4461                    .unwrap_or_else(PoisonError::into_inner)
4462                    .clone(),
4463            ),
4464            chunk_cache: Arc::clone(&self.chunk_cache),
4465            chunk_cache_config: self.chunk_cache_config,
4466            path: self.path.clone(),
4467            staged_birth: self.staged_birth,
4468        }
4469    }
4470}
4471
4472impl std::fmt::Debug for Dataset {
4473    /// Reports the memo as it stands, without re-resolving: a `Debug` that read
4474    /// the file could fail, and one that failed would have nothing to print.
4475    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4476        let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
4477        match state.as_ref() {
4478            Some(state) => f
4479                .debug_struct("Dataset")
4480                .field("messages", &state.header.messages.len())
4481                .finish(),
4482            None => f.debug_struct("Dataset").field("staged", &true).finish(),
4483        }
4484    }
4485}
4486
4487/// How many stored bytes a typed whole-dataset read holds beside its output,
4488/// before rounding the window up to whole chunk bands.
4489///
4490/// The decoded values are the caller's and there is no bound to put on them;
4491/// what this bounds is the *stored* copy standing next to them, which used to be
4492/// the whole dataset over again (issue #289). A mebibyte is small against any
4493/// dataset large enough for that to matter, and large enough that a sweep costs
4494/// reads in the tens rather than the thousands. A dataset that fits inside one
4495/// window is read whole, exactly as before.
4496const TYPED_READ_WINDOW_BYTES: u64 = 1 << 20;
4497
4498/// How many values of the requested type a whole-dataset read produces per
4499/// stored element, which is what its output buffer is reserved at.
4500#[derive(Clone, Copy)]
4501enum OutputSize {
4502    /// One value per stored element — every numeric decoder.
4503    PerElement,
4504    /// One value per stored *byte* — [`Dataset::read_i8`], which reinterprets
4505    /// bytes rather than decoding elements, and so yields one per byte of a
4506    /// dataset whose elements are wider than one.
4507    PerByte,
4508}
4509
4510/// How many leading-dimension rows a typed whole-dataset read decodes at a time.
4511///
4512/// [`TYPED_READ_WINDOW_BYTES`] of stored bytes, rounded *down* to whole chunk
4513/// bands so that no chunk is ever decoded for two windows, and never fewer than
4514/// one row — or one band, when a single band is already over budget, since a
4515/// window narrower than that would decode the same chunks again.
4516///
4517/// A dataset whose rows have no bytes (a zero inner dimension) has no elements
4518/// to read at all: it answers `NonZeroU64::MAX`, which the caller reads as "one
4519/// window covers it".
4520///
4521/// The answer is a `NonZeroU64` because the sweep advances by it: a window of no
4522/// rows would leave that loop running forever rather than returning something
4523/// wrong, and a test cannot report the difference.
4524fn typed_window_rows(
4525    dl: &DataLayout,
4526    ds: &Dataspace,
4527    elem_size: NonZeroUsize,
4528) -> Result<NonZeroU64, FormatError> {
4529    let mut row_bytes = elem_size.get() as u64;
4530    for &d in ds.dimensions.iter().skip(1) {
4531        row_bytes = row_bytes
4532            .checked_mul(d)
4533            .ok_or(FormatError::OffsetOverflow {
4534                offset: row_bytes,
4535                length: d,
4536            })?;
4537    }
4538    if row_bytes == 0 {
4539        return Ok(NonZeroU64::MAX);
4540    }
4541
4542    let mut rows = (TYPED_READ_WINDOW_BYTES / row_bytes).max(1);
4543    if let DataLayout::Chunked {
4544        chunk_dimensions, ..
4545    } = dl
4546    {
4547        // A chunked layout message carries rank + 1 dimensions, the last being
4548        // the element size, so the first is the leading dimension's chunk extent
4549        // for every layout version this crate parses.
4550        if let Some(band) = chunk_dimensions
4551            .first()
4552            .map(|&d| u64::from(d))
4553            .filter(|&d| d > 0)
4554        {
4555            rows = if rows < band {
4556                band
4557            } else {
4558                rows - rows % band
4559            };
4560        }
4561    }
4562    // At least one row always, and at least one whole band when a band applied:
4563    // that arm runs only when `rows >= band`, so the remainder it subtracts
4564    // leaves a band standing.
4565    Ok(NonZeroU64::new(rows).unwrap_or(NonZeroU64::MIN))
4566}
4567
4568impl Dataset {
4569    /// Build a handle for a dataset whose header was found, and read, at `at`.
4570    fn new(
4571        file: Arc<FileInner>,
4572        at: Resolution,
4573        header: ObjectHeader,
4574        chunk_cache_config: ChunkCacheConfig,
4575        path: Option<String>,
4576    ) -> Self {
4577        Self {
4578            file,
4579            state: RwLock::new(Some(Arc::new(DatasetState { at, header }))),
4580            chunk_cache: Arc::new(ChunkCache::with_config(chunk_cache_config)),
4581            chunk_cache_config,
4582            path,
4583            staged_birth: None,
4584        }
4585    }
4586
4587    /// Build a handle for a dataset this session has staged and not yet
4588    /// committed, which has no object header to read.
4589    ///
4590    /// It answers the questions the staged builder already settles — shape,
4591    /// maximum shape, datatype, whether the storage is chunked and which filters
4592    /// it carries — and stages further edits on the pending creation. Everything
4593    /// that needs bytes reports [`Error::NotCommitted`](crate::Error::NotCommitted)
4594    /// until [`File::commit`], after which the first use resolves the path and
4595    /// the handle behaves as if it had been opened by name.
4596    fn pending(file: Arc<FileInner>, chunk_cache_config: ChunkCacheConfig, path: String) -> Self {
4597        Self {
4598            staged_birth: file.staged_generation(),
4599            file,
4600            state: RwLock::new(None),
4601            chunk_cache: Arc::new(ChunkCache::with_config(chunk_cache_config)),
4602            chunk_cache_config,
4603            path: Some(path),
4604        }
4605    }
4606
4607    /// What this dataset's staged creation says about itself, `Ok(None)` once it
4608    /// is committed (or when it never was staged), and
4609    /// [`Error::StagingWithdrawn`] when the creation this handle was made onto
4610    /// has been withdrawn — the one answer that is neither the staged record nor
4611    /// the file, because the object at that path is one the session is deleting.
4612    ///
4613    /// Only a handle with no memo can be pending: one that has resolved a header
4614    /// names an object the file already holds, so this costs a lock read rather
4615    /// than a session lock on the common path.
4616    fn staged_meta(&self) -> Result<Option<StagedMeta>, Error> {
4617        if self
4618            .state
4619            .read()
4620            .unwrap_or_else(PoisonError::into_inner)
4621            .is_some()
4622        {
4623            return Ok(None);
4624        }
4625        let Some(path) = self.path.as_deref() else {
4626            return Ok(None);
4627        };
4628        self.file.staged_dataset_view(path, self.staged_birth)
4629    }
4630
4631    /// Refuse an operation that needs this dataset's bytes while it is still
4632    /// staged.
4633    fn refuse_if_pending(&self) -> Result<(), Error> {
4634        match self.staged_meta()? {
4635            // `staged_meta` answers only for a handle with a path.
4636            Some(_) => Err(Error::NotCommitted(self.path.clone().unwrap_or_default())),
4637            None => Ok(()),
4638        }
4639    }
4640
4641    /// This dataset's address and parsed header, worked out again if the file
4642    /// has changed since the memo was taken.
4643    ///
4644    /// Returns [`Error::StaleHandle`](crate::Error::StaleHandle) for a handle
4645    /// that has no path to re-resolve — one an object reference produced — once
4646    /// a commit has run under it, and the resolution's own error (a
4647    /// `PathNotFound`, say, for a dataset a commit deleted) when the path no
4648    /// longer names anything. A dataset this session has staged and not
4649    /// committed has no header at all, and that is
4650    /// [`Error::NotCommitted`](crate::Error::NotCommitted). A path that now
4651    /// names something other than a dataset is
4652    /// [`Error::NotADataset`](crate::Error::NotADataset), the same answer
4653    /// opening it afresh would give.
4654    fn resolved(&self) -> Result<Arc<DatasetState>, Error> {
4655        let live = self.file.content_revision();
4656        let memo = {
4657            let state = self.state.read().unwrap_or_else(PoisonError::into_inner);
4658            match state.as_ref() {
4659                Some(state) if state.at.content_revision == live => {
4660                    return Ok(Arc::clone(state));
4661                }
4662                Some(state) => Some(state.at),
4663                None => None,
4664            }
4665        };
4666        let at = self
4667            .file
4668            .locate_staged(self.path.as_deref(), memo, self.staged_birth)?;
4669        let header = self.file.parse_header(at.address)?;
4670        // Checked *before* it is memoized. A header installed and then refused is
4671        // the answer every later call short-circuits on, so this handle would
4672        // report `NotADataset` once and then serve the other object's header —
4673        // which a commit replacing a dataset with a group at the same path
4674        // (issue #305) makes reachable.
4675        if !has_message(&header, MessageType::DataLayout) {
4676            // Only a path can reach this: an address memo that survived is the
4677            // address of the dataset this handle already read there.
4678            return Err(Error::NotADataset(self.path.clone().unwrap_or_default()));
4679        }
4680        Ok(self.install(at, header))
4681    }
4682
4683    /// Memoize `header`, read at `at`, as this handle's resolution.
4684    ///
4685    /// Drops the chunk cache: an edit that rewrote this header can have moved
4686    /// the chunk index and the chunks it names, so what the cache holds belongs
4687    /// to the copy the edit replaced.
4688    fn install(&self, at: Resolution, header: ObjectHeader) -> Arc<DatasetState> {
4689        let fresh = Arc::new(DatasetState { at, header });
4690        self.chunk_cache.clear();
4691        let mut state = self.state.write().unwrap_or_else(PoisonError::into_inner);
4692        // Two threads can re-resolve at once. The older answer must not land on
4693        // top of the newer one, or the newer handle would go on serving a header
4694        // the file has already moved past. A handle that had no memo takes this
4695        // one: any header beats naming nothing.
4696        if state
4697            .as_ref()
4698            .is_none_or(|memo| at.content_revision >= memo.at.content_revision)
4699        {
4700            *state = Some(Arc::clone(&fresh));
4701        }
4702        fresh
4703    }
4704
4705    /// Address of this dataset's object header (base-adjusted, file-absolute).
4706    /// Used to resolve object references that point at this dataset.
4707    pub(crate) fn header_address(&self) -> Result<u64, Error> {
4708        Ok(self.resolved()?.at.address)
4709    }
4710
4711    /// Append `data` to this dataset in place, growing it along its first
4712    /// (unlimited) dimension. Every handle onto the dataset reads the new length
4713    /// afterwards, this one included.
4714    ///
4715    /// The file must have been opened for writing with [`File::open_rw`];
4716    /// a read-only file returns
4717    /// [`Error::ReadOnly`](crate::Error::ReadOnly). The target must be a chunked,
4718    /// rank-1, unlimited, Extensible-Array-indexed dataset; anything else returns
4719    /// [`Error::AppendInPlaceUnsupported`](crate::Error::AppendInPlaceUnsupported).
4720    /// Both the dataset's current length and the appended length are
4721    /// unconstrained, on a filtered dataset as much as an unfiltered one: a
4722    /// partial trailing chunk is rewritten into a fresh allocation — decoded,
4723    /// extended and re-encoded when there is a filter pipeline — and its index
4724    /// element is repointed once those bytes are on the disk. The bytes the old
4725    /// chunk occupied are left for [`repack`](crate::repack).
4726    ///
4727    /// Two things still require a chunk-aligned starting length. A **lossy**
4728    /// pipeline (ZFP, or float D-scale scale-offset) is refused, because
4729    /// re-encoding the trailing chunk would change values that are already
4730    /// committed rather than reproduce them. And a trailing chunk the chunk index
4731    /// does not name — one a writer allocated lazily, which the reference C
4732    /// library does for a chunk it has not written — cannot be read to be grown,
4733    /// so it is refused too; append whole chunks, or use
4734    /// [`append_staged`](Self::append_staged).
4735    ///
4736    /// The append is immediate and crash-atomic (no `commit` needed) — under the
4737    /// default [`SyncPolicy::Always`]. Under
4738    /// [`SyncPolicy::OnClose`](crate::SyncPolicy::OnClose) the same writes are made
4739    /// in the same order without the `fsync` barriers between them, so the
4740    /// append is still immediate and still crash-atomic against *this process*
4741    /// failing, but ordering it against power loss is the caller's, through
4742    /// [`File::sync`].
4743    ///
4744    /// A **SWMR** writer ([`File::open_swmr_writer`]) keeps the narrower rule it
4745    /// always had — unfiltered, and chunk-aligned at both ends — because its
4746    /// readers are concurrent by contract and a rewritten trailing chunk is one
4747    /// they could be crossing. It allocates at end-of-file for that same reason:
4748    /// a region this session freed is one a reader may still be inside.
4749    ///
4750    /// Where the new chunks land otherwise depends on how the file records its
4751    /// free space. A default-strategy file spends a hole an earlier commit in
4752    /// this session left; a file that **persists** its free-space managers
4753    /// (including every paged file) spends only space the session has first taken
4754    /// *out* of them, in a rewrite of its own, so no byte an append writes is one
4755    /// a durable manager advertises — a crash can strand the unspent remainder of
4756    /// such a batch, but nothing can be handed out twice. A batch gathers every
4757    /// hole the appended chunk fits in, up to a megabyte of them, so the file
4758    /// grows only when no hole can hold the chunk.
4759    ///
4760    /// A handle reached by object reference ([`dereference`](Self::dereference))
4761    /// has no resolvable path, so it names its dataset by the object-header
4762    /// address it was reached through and can append like any other — until the
4763    /// session stages or commits an edit. A commit can move that header, and the
4764    /// bytes it vacates still parse as the dataset they were, so an append
4765    /// against the old address would land in a header nothing points at. Rather
4766    /// than do that silently, such an append is refused once edits are staged or
4767    /// a commit has run; re-open the dataset by path to keep appending. A
4768    /// path-named handle is unaffected, because the path is resolved afresh every
4769    /// time.
4770    pub fn append<T: H5Element>(&mut self, data: &[T]) -> Result<(), Error> {
4771        let g = self.append_geometry()?;
4772        self.append_batches(g, data.len() as u64, |b, r| {
4773            b.append(&data[r]);
4774        })
4775    }
4776
4777    /// Append raw little-endian element bytes to this dataset in place. Prefer
4778    /// [`append`](Self::append) when the element type is known; see it for the
4779    /// file-mode and eligibility rules.
4780    pub fn append_raw(&mut self, bytes: &[u8]) -> Result<(), Error> {
4781        let g = self.append_geometry()?;
4782        let es = g.element_size;
4783        // Whole-element length is checked before any batch applies, so the
4784        // refusal is atomic (the per-batch validation would only reject the
4785        // final, short batch after earlier ones had durably committed).
4786        if bytes.len() % es != 0 {
4787            return Err(Error::AppendInPlaceUnsupported(
4788                "appended byte length is not a whole number of elements",
4789            ));
4790        }
4791        let total = (bytes.len() / es) as u64;
4792        self.append_batches(g, total, |b, r| {
4793            b.append_raw(&bytes[r.start * es.get()..r.end * es.get()]);
4794        })
4795    }
4796
4797    /// How an append names this dataset to the session: by path when the handle
4798    /// has one, so the session can check the target against its own staged
4799    /// edits, and otherwise by the object-header address the handle was reached
4800    /// through — which is what lets a handle obtained by object reference append
4801    /// at all.
4802    fn append_target(&self) -> Result<AppendTarget<'_>, Error> {
4803        Ok(match &self.path {
4804            Some(path) => AppendTarget::Path(path),
4805            None => AppendTarget::Header(self.resolved()?.at.address),
4806        })
4807    }
4808
4809    /// A [`BufferedAppender`] over this dataset: appended elements are held in
4810    /// memory and written a whole chunk at a time, so a caller appending less
4811    /// than a chunk per call writes to the file once per chunk instead of once
4812    /// per call, and a filtered dataset is never left mid-chunk for the next
4813    /// write to re-encode.
4814    ///
4815    /// Every eligibility rule [`append`](Self::append) applies is applied here,
4816    /// so an ineligible dataset is reported now rather than on the first write.
4817    /// Buffered elements are not in the file until the appender flushes; see
4818    /// [`BufferedAppender`] for the full bargain.
4819    pub fn buffered_appender(&mut self) -> Result<BufferedAppender<'_>, Error> {
4820        BufferedAppender::new(self)
4821    }
4822
4823    /// Register a live `BufferedAppender` on this dataset with the session, so a
4824    /// staged edit that would stop it from flushing is refused at the call that
4825    /// creates the conflict rather than in the appender's `Drop`.
4826    pub(crate) fn claim_for_appender(&self) -> Result<u64, Error> {
4827        let Backend::Edit(m) = &self.file.backend else {
4828            return Err(Error::ReadOnly);
4829        };
4830        m.lock()
4831            .unwrap_or_else(std::sync::PoisonError::into_inner)
4832            .claim_for_appender(self.path.as_deref())
4833    }
4834
4835    /// Release the claim taken by [`claim_for_appender`](Self::claim_for_appender).
4836    pub(crate) fn release_appender_claim(&self, token: u64) {
4837        if let Backend::Edit(m) = &self.file.backend {
4838            m.lock()
4839                .unwrap_or_else(std::sync::PoisonError::into_inner)
4840                .release_appender_claim(token);
4841        }
4842    }
4843
4844    /// Whether this dataset's session is the SWMR writer, whose append rules are
4845    /// a strict subset of the ordinary ones. `false` for a read-only file, which
4846    /// has no session to ask.
4847    pub(crate) fn session_is_swmr(&self) -> bool {
4848        match &self.file.backend {
4849            Backend::Edit(m) => m
4850                .lock()
4851                .unwrap_or_else(std::sync::PoisonError::into_inner)
4852                .is_swmr(),
4853            _ => false,
4854        }
4855    }
4856
4857    /// Immediate in-place append of an already-gathered builder, used by
4858    /// [`BufferedAppender`], whose bytes are materialized in its buffer before
4859    /// it decides how many of them to write. `append_batches` exists for the
4860    /// opposite case — a caller whose bytes are cheaper to build per batch — so
4861    /// this hands the engine one builder and lets it batch the plan.
4862    pub(crate) fn append_prebuilt(&mut self, b: &AppendBuilder) -> Result<(), Error> {
4863        let target = self.append_target()?;
4864        self.file.with_engine_mut(Change::InPlace, |engine| {
4865            engine.append_inplace_gathered(target, b, 4)
4866        })
4867    }
4868
4869    /// Fetch (locating on first use) this dataset's append geometry from the
4870    /// write session, which also applies every refusal that does not depend on
4871    /// the bytes being appended.
4872    pub(crate) fn append_geometry(&self) -> Result<AppendGeometry, Error> {
4873        // An in-place append rewrites bytes where they stand, and a dataset this
4874        // session has only staged has none. Refused here rather than deep in the
4875        // engine, so `append`, `append_raw` and `buffered_appender` all say the
4876        // same thing.
4877        self.refuse_if_pending()?;
4878        let target = self.append_target()?;
4879        self.file
4880            .with_engine_mut(Change::Nothing, |engine| engine.append_geometry(target))
4881    }
4882
4883    /// Immediate in-place append, driven batch by batch. The call is split into
4884    /// aligned batches — the trailing partial chunk is filled first, then
4885    /// whole-chunk batches under the session's byte budget — and `fill` builds
4886    /// each batch's bytes on demand, so a bounded session's peak memory holds
4887    /// one batch rather than the whole call. A session that keeps the whole file
4888    /// resident reports one unbounded batch, so the call stays a single
4889    /// crash-atomic apply there.
4890    ///
4891    /// Every predictable refusal (wrong datatype, ineligible dataset) is raised
4892    /// before the first batch is applied. The cached header and chunk cache are
4893    /// then refreshed so later reads on this handle observe the new length. A
4894    /// filtered dataset sitting on a partial trailing chunk is grown by the
4895    /// first batch, which re-encodes that chunk into a fresh allocation and
4896    /// leaves every later batch starting on a boundary.
4897    fn append_batches(
4898        &mut self,
4899        g: AppendGeometry,
4900        total_elems: u64,
4901        fill: impl Fn(&mut AppendBuilder, std::ops::Range<usize>),
4902    ) -> Result<(), Error> {
4903        // Worked out once for the whole call: every batch names the same
4904        // dataset, and an in-place append does not move it.
4905        let target = self.append_target()?;
4906        let mut dim = g.current_dim;
4907        let mut done = 0u64;
4908        loop {
4909            // An empty append still runs one (empty) engine call, so datatype
4910            // validation happens whether or not there are elements.
4911            let to_boundary = (g.chunk_elems - dim % g.chunk_elems) % g.chunk_elems;
4912            let take = (total_elems - done).min(to_boundary.saturating_add(g.full_batch_elems));
4913            let mut b = AppendBuilder::new();
4914            fill(&mut b, done.to_usize()?..(done + take).to_usize()?);
4915            self.file.with_engine_mut(Change::InPlace, |engine| {
4916                engine.append_inplace_gathered(target, &b, 4)
4917            })?;
4918            dim += take;
4919            done += take;
4920            if done >= total_elems {
4921                break;
4922            }
4923        }
4924        Ok(())
4925    }
4926
4927    /// Overwrite this dataset's values, staged until [`File::commit`]. The new
4928    /// data must match the dataset's existing shape and datatype.
4929    ///
4930    /// The file must have been opened with [`File::open_rw`], else
4931    /// [`Error::ReadOnly`](crate::Error::ReadOnly). Unlike [`append`](Self::append)
4932    /// (immediate), this is a staged edit applied on [`File::commit`].
4933    pub fn write<T: H5Element>(&mut self, data: &[T]) -> Result<(), Error> {
4934        // Build off the lock, as `write_staged` does: `write_into` is trait
4935        // code reached with no lock held, keeping both paths identical.
4936        self.check_staged_edit()?;
4937        let mut builder = DatasetBuilder::new("");
4938        T::write_into(&mut builder, data);
4939        self.with_session_mut(|session, path| session.stage_dataset_write(path, builder))
4940    }
4941
4942    /// Overwrite this dataset's values through its full [`DatasetBuilder`],
4943    /// staged until [`File::commit`] — the builder-level counterpart of
4944    /// [`write`](Self::write), and the only one of the two that can carry a
4945    /// **shape**. [`write`](Self::write) sends a flat `&[T]`, so it can overwrite
4946    /// a one-dimensional dataset only; a multi-dimensional one needs
4947    /// [`with_shape`](DatasetBuilder::with_shape) and so comes through here, as
4948    /// do compound, complex, and raw bytes under an explicit datatype.
4949    ///
4950    /// The replacement must match the on-disk datatype and shape exactly; a
4951    /// reshape or retype is refused on [`File::commit`].
4952    ///
4953    /// This overwrites element bytes and nothing else, so a builder asking for
4954    /// more than that is refused by **this call**, before anything is staged:
4955    /// chunking, filters or an extensible shape; an attribute; a fill value; and
4956    /// [`with_path_references`](DatasetBuilder::with_path_references), whose
4957    /// element bytes are placeholder addresses only a newly created dataset can
4958    /// resolve. Set those when the dataset is created.
4959    ///
4960    /// [`with_vlen_strings`](DatasetBuilder::with_vlen_strings) is **not**
4961    /// refused: overwriting a variable-length-string dataset places a fresh
4962    /// global heap collection for the new strings and resolves the staged
4963    /// element references against it. Overwriting the same dataset again
4964    /// reclaims the collection the previous overwrite placed, so rotating its
4965    /// strings in a session does not grow the file without bound. The
4966    /// collections it held when the session *opened* are not reclaimed — a
4967    /// collection can be shared between objects, and only this session's own
4968    /// placements are known not to be — so [`repack`](crate::repack) is what
4969    /// recovers those.
4970    ///
4971    /// The reference refusal is on the builder, not on the datatype it produces:
4972    /// such a dataset can still be overwritten by supplying element bytes that
4973    /// need no resolving, with
4974    /// [`with_reference_data`](DatasetBuilder::with_reference_data) or
4975    /// [`with_raw_data`](DatasetBuilder::with_raw_data). An object reference
4976    /// supplied that way is screened at `commit` by *address*, against both what
4977    /// the same commit deletes and what it rewrites elsewhere, so it cannot be
4978    /// left naming storage the commit is vacating — the answer a target named as
4979    /// a path already got by name.
4980    ///
4981    /// The file must have been opened with [`File::open_rw`], else
4982    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
4983    ///
4984    /// ```no_run
4985    /// # use hdf5_pure::File;
4986    /// # fn main() -> Result<(), hdf5_pure::Error> {
4987    /// let file = File::open_rw("counters.h5")?;
4988    /// let mut ds = file.dataset("ticks")?;
4989    /// // Keep the dataset's own datatype and supply the replacement bytes it
4990    /// // describes — three little-endian 16-bit elements here.
4991    /// let dt = ds.datatype()?;
4992    /// ds.write_staged(|b| {
4993    ///     b.with_raw_data(dt, vec![1, 0, 2, 0, 3, 0], 3);
4994    /// })?;
4995    /// file.commit()?;
4996    /// # Ok(())
4997    /// # }
4998    /// ```
4999    /// The closure configures a standalone builder, not the file, so it may read
5000    /// the same [`File`]; nothing it stages resolves until [`File::commit`].
5001    pub fn write_staged(&mut self, build: impl FnOnce(&mut DatasetBuilder)) -> Result<(), Error> {
5002        // Report a read-only, sealed, or unaddressable dataset before running the
5003        // closure, then run it with no lock held; `stage_dataset_write` names the
5004        // builder from the dataset's path (issue #200).
5005        self.check_staged_edit()?;
5006        let mut builder = DatasetBuilder::new("");
5007        build(&mut builder);
5008        self.with_session_mut(|session, path| session.stage_dataset_write(path, builder))
5009    }
5010
5011    /// Stage an append to this dataset applied on [`File::commit`] — the staged,
5012    /// index-rebuilding counterpart of the immediate [`append`](Self::append).
5013    ///
5014    /// Unlike [`append`](Self::append) (immediate, amortized `O(1)`,
5015    /// Extensible-Array only), this rebuilds the chunk index on commit and so
5016    /// also grows datasets whose Extensible-Array index is not yet allocated.
5017    /// Like it, a dataset under a **lossy** pipeline (ZFP, or float D-scale
5018    /// scale-offset) whose length is not a whole multiple of its chunk length
5019    /// is refused: growing that trailing chunk would decode and re-encode
5020    /// values that are already committed, changing them. That one is refused
5021    /// *here*, by this call, rather than at the commit — nothing is staged, and
5022    /// edits staged beside it are unaffected.
5023    /// Configure the appended elements through `build` on the
5024    /// [`AppendBuilder`]; repeated calls within the builder concatenate in
5025    /// order. The dataset must be chunked, unlimited
5026    /// along axis 0, Extensible-Array indexed, rank 1, use a re-encodable filter
5027    /// pipeline, and have a single hard link, otherwise
5028    /// [`Error::AppendUnsupported`](crate::Error::AppendUnsupported) is returned
5029    /// on [`File::commit`].
5030    ///
5031    /// The file must have been opened with [`File::open_rw`], else
5032    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
5033    /// The closure configures a standalone builder, not the file, so it may read
5034    /// the same [`File`]; nothing it stages resolves until [`File::commit`].
5035    pub fn append_staged(&mut self, build: impl FnOnce(&mut AppendBuilder)) -> Result<(), Error> {
5036        // A dataset *this handle* names and this session has not written has no
5037        // bytes to grow, so the elements are folded into the pending creation
5038        // instead (see the note above). `check_staged_edit` refuses that dataset,
5039        // so ask it only about one the file already holds; the file-mode and path
5040        // gates apply either way.
5041        //
5042        // The distinction is the handle's, not the path's: a handle onto the
5043        // object a staged creation *replaces* is not pending, and the session
5044        // refuses its append rather than growing the replacement under it.
5045        let pending = self.staged_meta()?.is_some();
5046        if pending {
5047            self.file.check_staged_writable()?;
5048        } else {
5049            self.check_staged_edit()?;
5050        }
5051        let mut builder = AppendBuilder::new();
5052        build(&mut builder);
5053        self.with_session_mut(|session, path| {
5054            if pending {
5055                session.stage_dataset_append_pending(path, builder)
5056            } else {
5057                session.stage_dataset_append(path, builder)
5058            }
5059        })
5060    }
5061
5062    /// Add or update an attribute on this dataset, staged until
5063    /// [`File::commit`]. Use [`remove_attr`](Self::remove_attr) to remove one.
5064    /// An attribute set too large for the object header is written to a fractal
5065    /// heap, exactly as [`Group::set_attr`] does.
5066    ///
5067    /// The file must have been opened with [`File::open_rw`], else
5068    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
5069    pub fn set_attr(&mut self, name: &str, value: AttrValue) -> Result<(), Error> {
5070        self.refuse_if_pending()?;
5071        self.with_session_mut(|session, path| session.set_dataset_attr(path, name, value))
5072    }
5073
5074    /// Remove an attribute from this dataset, staged until [`File::commit`].
5075    /// See [`set_attr`](Self::set_attr) for the file-mode rules.
5076    pub fn remove_attr(&mut self, name: &str) -> Result<(), Error> {
5077        self.refuse_if_pending()?;
5078        self.with_session_mut(|session, path| session.remove_dataset_attr(path, name))
5079    }
5080
5081    /// Gate a staged edit on this dataset *without* taking the session lock:
5082    /// the file must accept staged edits, this handle must have a resolvable
5083    /// path, and this dataset's elements must be ones the engine owns.
5084    ///
5085    /// The path check belongs here rather than only in
5086    /// [`with_session_mut`](Self::with_session_mut) so that every reason to
5087    /// refuse is reported *before* a user closure runs, not after. A handle
5088    /// reached by object reference ([`dereference`](Self::dereference)) has no
5089    /// path, and would otherwise have its closure run and its result discarded.
5090    ///
5091    /// This is the one gate every data-writing entry point shares — `write`,
5092    /// `write_staged`, `append_staged`, and the staged rewrite behind
5093    /// [`BufferedAppender`] — which is why the external-storage refusal is here
5094    /// and not on any one of them. Attribute edits do not come through here, and
5095    /// are unaffected: they change the object header, not the elements.
5096    fn check_staged_edit(&self) -> Result<(), Error> {
5097        self.file.check_staged_writable()?;
5098        if self.path.is_none() {
5099            return Err(Error::ReadOnly);
5100        }
5101        // Ahead of the external-storage question, which reads a header this
5102        // dataset does not have yet. `append_staged` is the one staged edit that
5103        // *is* supported on a pending dataset, and it re-admits itself below.
5104        self.refuse_if_pending()?;
5105        // The elements of an externally stored dataset are not in this file, and
5106        // the engine writes only this file. Its contiguous layout message records
5107        // no address, so a write took it for never-allocated storage, appended the
5108        // new bytes and pointed the layout at them — leaving the file with two
5109        // contradictory records of where the data lives, and the reference
5110        // library still reading the external files it had not touched. See
5111        // [`has_external_storage`](Self::has_external_storage).
5112        if self.has_external_storage()? {
5113            return Err(Error::EditUnsupported(
5114                "this dataset's elements live in external files (H5Pset_external), which this \
5115engine does not write; writing them into the HDF5 file would leave it disagreeing with \
5116the external files about where the data is. Delete the dataset and create it again in \
5117the same commit to replace it",
5118            ));
5119        }
5120        Ok(())
5121    }
5122
5123    /// Run `f` with the writable session and this dataset's path, then refresh
5124    /// the cached header so a later read on this handle reflects any immediate
5125    /// change (e.g. an append's new dimension). Returns
5126    /// [`Error::ReadOnly`](crate::Error::ReadOnly) if the file is read-only or the
5127    /// handle has no resolvable path (reached by object reference).
5128    fn with_session_mut<R>(
5129        &mut self,
5130        f: impl FnOnce(&mut WriteEngine, &str) -> Result<R, Error>,
5131    ) -> Result<R, Error> {
5132        let path = self.path.clone().ok_or(Error::ReadOnly)?;
5133        self.file
5134            .with_engine_mut(Change::Relocating, |session| f(session, &path))
5135    }
5136
5137    /// The effective raw chunk-cache configuration for this dataset.
5138    ///
5139    /// This reflects the per-dataset [`DatasetAccessProperties`] override when one
5140    /// was supplied to [`File::dataset_with_options`] /
5141    /// [`Group::dataset_with_options`], otherwise the file-wide default. It is
5142    /// the read-side analogue of HDF5's `H5Pget_chunk_cache`.
5143    pub const fn chunk_cache_config(&self) -> ChunkCacheConfig {
5144        self.chunk_cache_config
5145    }
5146
5147    /// A point-in-time snapshot of this dataset handle's chunk-cache occupancy.
5148    ///
5149    /// Lets callers confirm a chunk-cache configuration (set with
5150    /// [`FileAccessProperties::with_chunk_cache`]) is taking effect: after a
5151    /// chunked read, an enabled cache reports a loaded index and retained
5152    /// chunks; a disabled one (or one over its budget) reports fewer or none.
5153    /// The cache is per-handle — though clones of one handle share it — so a
5154    /// freshly opened [`Dataset`] reports an empty snapshot until its first read.
5155    pub fn chunk_cache_stats(&self) -> ChunkCacheStats {
5156        // Notice any edit first, which is what drops the chunks it invalidated.
5157        // A snapshot taken before that would count chunks no read will ever be
5158        // served, and this exists to say what the cache is holding *for* a read.
5159        // An unresolvable handle has no live cache to report, and an empty
5160        // snapshot is the truthful answer for one.
5161        let _ = self.resolved();
5162        self.chunk_cache.stats()
5163    }
5164
5165    /// Zero this handle's cumulative chunk-cache counters, leaving the retained
5166    /// index and chunks in place.
5167    ///
5168    /// [`ChunkCacheStats`]'s occupancy figures are unaffected — this resets what
5169    /// the cache has *done*, not what it is *holding* — so a caller can measure
5170    /// one read on a cache an earlier read already warmed. The cache is
5171    /// per-handle, though clones of one handle share it, so this resets the
5172    /// counters those clones report too.
5173    pub fn reset_chunk_cache_stats(&self) {
5174        // Resolve first for the same reason [`Self::chunk_cache_stats`] does: an
5175        // edit drops the chunks it invalidated, and those belong in the
5176        // invalidation count of the window being reset, not the next one.
5177        let _ = self.resolved();
5178        self.chunk_cache.reset_stats();
5179    }
5180
5181    /// Returns the shape (dimensions) of the dataset.
5182    ///
5183    /// A dataset this session has staged and not yet committed answers from what
5184    /// was staged, which includes the elements
5185    /// [`append_staged`](Self::append_staged) has folded into it.
5186    pub fn shape(&self) -> Result<Vec<u64>, Error> {
5187        if let Some(meta) = self.staged_meta()? {
5188            return Ok(meta.dimensions);
5189        }
5190        let ds = self.dataspace()?;
5191        Ok(ds.dimensions.clone())
5192    }
5193
5194    /// The dataset's maximum dimensions, when it is extensible. An unlimited
5195    /// dimension is reported as `u64::MAX`. Returns `Ok(None)` for a fixed-shape
5196    /// dataset (no maximum-dimensions record, or one equal to the current shape).
5197    ///
5198    /// Together with [`is_chunked`](Self::is_chunked) and
5199    /// [`chunk_shape`](Self::chunk_shape), this lets a caller check up front
5200    /// whether a dataset is eligible for
5201    /// [`Dataset::append_staged`](crate::Dataset::append_staged)
5202    /// (which requires a chunked dataset whose first maximum dimension is
5203    /// `u64::MAX`) instead of relying on the append's refusal error.
5204    pub fn maxshape(&self) -> Result<Option<Vec<u64>>, Error> {
5205        if let Some(meta) = self.staged_meta()? {
5206            return Ok(meta.maxshape);
5207        }
5208        let ds = self.dataspace()?;
5209        match &ds.max_dimensions {
5210            Some(md) if *md != ds.dimensions => Ok(Some(md.clone())),
5211            _ => Ok(None),
5212        }
5213    }
5214
5215    /// Whether the dataset uses chunked storage (as opposed to contiguous or
5216    /// compact). Filtered datasets are always chunked. Returns `false` for a
5217    /// dataset with no data-layout message, for a non-chunked layout, and — like
5218    /// the other accessors that return no `Result` — for a handle that can no
5219    /// longer be resolved. A dataset this session has staged and not yet
5220    /// committed answers with the storage its builder selected.
5221    pub fn is_chunked(&self) -> bool {
5222        if let Ok(Some(meta)) = self.staged_meta() {
5223            return meta.chunked;
5224        }
5225        matches!(self.data_layout(), Ok(DataLayout::Chunked { .. }))
5226    }
5227
5228    /// The dataset's chunk dimensions (one per dataset rank), or `Ok(None)` when
5229    /// the dataset is not chunked. The element-size dimension the on-disk layout
5230    /// appends is stripped, so the result lines up with
5231    /// [`shape`](Self::shape) / [`maxshape`](Self::maxshape).
5232    pub fn chunk_shape(&self) -> Result<Option<Vec<u64>>, Error> {
5233        let DataLayout::Chunked {
5234            chunk_dimensions, ..
5235        } = self.data_layout()?
5236        else {
5237            return Ok(None);
5238        };
5239        let rank = self.dataspace()?.dimensions.len();
5240        if chunk_dimensions.len() <= rank {
5241            return Ok(None);
5242        }
5243        Ok(Some(
5244            chunk_dimensions[..rank]
5245                .iter()
5246                .map(|&c| u64::from(c))
5247                .collect(),
5248        ))
5249    }
5250
5251    /// The HDF5 filter IDs applied to this dataset's chunks, in pipeline
5252    /// (application) order, or an empty vector when the dataset is unfiltered.
5253    /// The IDs are the registered HDF5 filter numbers — e.g. 1 = deflate,
5254    /// 2 = shuffle, 3 = fletcher32, 6 = scale-offset — so a caller can inspect
5255    /// the pipeline without decoding a chunk. A dataset this session has staged
5256    /// and not yet committed reports the pipeline its builder asked for.
5257    pub fn filters(&self) -> Vec<u16> {
5258        if let Ok(Some(meta)) = self.staged_meta() {
5259            return meta.filters.into_iter().map(|(id, _)| id).collect();
5260        }
5261        self.filter_pipeline_parsed()
5262            .map(|p| p.filters.iter().map(|f| f.filter_id).collect())
5263            .unwrap_or_default()
5264    }
5265
5266    /// How and where this dataset's raw data is stored: compact, contiguous,
5267    /// chunked, or virtual.
5268    ///
5269    /// The structured companion to [`is_chunked`](Self::is_chunked) and
5270    /// [`chunk_shape`](Self::chunk_shape), which it subsumes: one call that
5271    /// classifies the layout and, for a [`Layout::Contiguous`] dataset, gives the
5272    /// absolute address and byte size to seek to, or for a [`Layout::Chunked`]
5273    /// dataset the chunk shape and [`ChunkIndex`] kind. This parses only the
5274    /// data-layout message; it never walks the chunk index or reads any data —
5275    /// use [`chunks`](Self::chunks) for per-chunk locations. The curated analogue
5276    /// of `H5Pget_layout`.
5277    ///
5278    /// Returns `Err` if the dataset has no data-layout message, if it cannot be
5279    /// parsed, or if a chunked dataset uses an index kind this crate does not
5280    /// recognize.
5281    pub fn layout(&self) -> Result<Layout, Error> {
5282        Ok(match self.data_layout()? {
5283            DataLayout::Compact { data } => Layout::Compact {
5284                size: data.len() as u64,
5285            },
5286            DataLayout::Contiguous { address, size } => Layout::Contiguous {
5287                address: self.absolute_address(address)?,
5288                size,
5289            },
5290            DataLayout::Chunked {
5291                version,
5292                chunk_index_type,
5293                ..
5294            } => Layout::Chunked {
5295                // Reuse `chunk_shape` so the two accessors can never disagree on
5296                // how the element-size dimension is stripped.
5297                chunk_shape: self.chunk_shape()?.unwrap_or_default(),
5298                index: ChunkIndex::from_layout(version, chunk_index_type)?,
5299            },
5300            DataLayout::Virtual { .. } => Layout::Virtual,
5301        })
5302    }
5303
5304    /// The [`ChunkIndex`] kind of this chunked dataset, or `Ok(None)` when the
5305    /// dataset is not chunked.
5306    ///
5307    /// A convenience shortcut for the `index` of [`Layout::Chunked`], for the
5308    /// common up-front append-eligibility check
5309    /// ([`ChunkIndex::supports_inplace_append`]). Complements
5310    /// [`maxshape`](Self::maxshape) and [`chunk_shape`](Self::chunk_shape).
5311    ///
5312    /// Returns `Err` if the data-layout message is missing or cannot be parsed,
5313    /// or if a chunked dataset uses an index kind this crate does not recognize.
5314    pub fn chunk_index(&self) -> Result<Option<ChunkIndex>, Error> {
5315        match self.data_layout()? {
5316            DataLayout::Chunked {
5317                version,
5318                chunk_index_type,
5319                ..
5320            } => Ok(Some(ChunkIndex::from_layout(version, chunk_index_type)?)),
5321            _ => Ok(None),
5322        }
5323    }
5324
5325    /// Enumerate every allocated chunk of this chunked dataset — one [`Chunk`]
5326    /// (logical offset, absolute file address, on-disk stored size, filter mask)
5327    /// per chunk, in index order.
5328    ///
5329    /// This reads only the chunk index, not the chunk data, so a caller can seek
5330    /// to and decode chunks one at a time without materializing the whole
5331    /// dataset. The curated analogue of `H5Dget_num_chunks` + `H5Dget_chunk_info`
5332    /// (`chunks()?.len()` is the chunk count).
5333    ///
5334    /// Returns `Ok(vec![])` for a chunked dataset whose storage has not been
5335    /// allocated yet (including a not-yet-written dataset that will use a
5336    /// [`ChunkIndex::BTreeV2`] index). Returns `Err` if the dataset is not chunked
5337    /// (check [`layout`](Self::layout) or [`is_chunked`](Self::is_chunked) first),
5338    /// or if its allocated storage is indexed by a [`ChunkIndex::BTreeV2`] index,
5339    /// which has no enumerator yet.
5340    pub fn chunks(&self) -> Result<Vec<Chunk>, Error> {
5341        let rank = self.dataspace()?.dimensions.len();
5342        Ok(self
5343            .raw_chunks()?
5344            .into_iter()
5345            .map(|c| Chunk {
5346                offset: c.offsets.into_iter().take(rank).collect(),
5347                address: c.address,
5348                storage_size: u64::from(c.chunk_size),
5349                filter_mask: c.filter_mask,
5350            })
5351            .collect())
5352    }
5353
5354    /// This dataset's filter pipeline as an ordered list of [`Filter`]s — each
5355    /// with its identifier, optional name, optional/mandatory flag, and client
5356    /// data — or an empty vector when the dataset is unfiltered.
5357    ///
5358    /// The detailed companion to [`filters`](Self::filters), which returns just
5359    /// the identifiers. Filters are listed in application (write) order — the
5360    /// on-disk pipeline order, matching [`filters`](Self::filters); a reader
5361    /// inverts them in the *reverse* of this order to decode a chunk. The curated
5362    /// analogue of `H5Pget_nfilters` + `H5Pget_filter2`.
5363    ///
5364    /// A dataset this session has staged and not yet committed reports each
5365    /// filter's identifier and optional flag, and no name or client data: those
5366    /// are derived from the dataset being written (element size, chunk geometry,
5367    /// fill value) when [`File::commit`] writes it.
5368    pub fn filter_pipeline(&self) -> Vec<Filter> {
5369        if let Ok(Some(meta)) = self.staged_meta() {
5370            return meta
5371                .filters
5372                .into_iter()
5373                .map(|(id, is_optional)| Filter {
5374                    id,
5375                    is_optional,
5376                    name: None,
5377                    client_data: Vec::new(),
5378                })
5379                .collect();
5380        }
5381        self.filter_pipeline_parsed()
5382            .map(|p| {
5383                p.filters
5384                    .into_iter()
5385                    .map(|f| Filter {
5386                        id: f.filter_id,
5387                        is_optional: f.is_optional(),
5388                        name: f.name,
5389                        client_data: f.client_data,
5390                    })
5391                    .collect()
5392            })
5393            .unwrap_or_default()
5394    }
5395
5396    /// Shift a base-relative on-disk address to an absolute file offset using the
5397    /// superblock base address (`addr_offset`). A no-op for the common
5398    /// base-zero file. Returns `Ok(None)` for an unallocated (undefined) address.
5399    fn absolute_address(&self, address: Option<u64>) -> Result<Option<u64>, Error> {
5400        match address {
5401            Some(rel) => Ok(Some(self.file.addr_offset.absolute(rel)?)),
5402            None => Ok(None),
5403        }
5404    }
5405
5406    /// Returns the simplified datatype of the dataset.
5407    ///
5408    /// A dataset this session has staged and not yet committed answers with the
5409    /// datatype its builder settled on.
5410    pub fn dtype(&self) -> Result<DType, Error> {
5411        let dt = self.datatype()?;
5412        Ok(classify_datatype(&dt))
5413    }
5414
5415    /// The size in bytes of one on-disk element of this dataset's datatype —
5416    /// HDF5's datatype storage size (`H5Tget_size`).
5417    ///
5418    /// This is the byte width of a single stored element: 8 for `f64`, the
5419    /// declared length for a fixed-length string, the record size for a compound
5420    /// type, or the reference/descriptor size for a variable-length type (whose
5421    /// payload lives separately in the file's global heaps).
5422    ///
5423    /// Multiplied by the element count from [`shape`](Self::shape), it is the
5424    /// exact number of raw bytes a full [`read_raw`](Self::read_raw)
5425    /// materializes. A caller reading an untrusted file can use it to bound that
5426    /// allocation up front rather than trusting the file's declared extent: a
5427    /// dataset can name a small element count yet a per-element size of billions
5428    /// of bytes, so the product — not the count alone — is what a read allocates.
5429    pub fn element_size(&self) -> Result<u64, Error> {
5430        Ok(u64::from(self.datatype()?.type_size()))
5431    }
5432
5433    /// The raw bytes of this dataset's user-defined fill value, encoded in its
5434    /// datatype, or `None` when no user-defined fill value is set (the library
5435    /// default or an explicitly undefined fill). Reads whichever Fill Value
5436    /// message the header carries — the current `0x0005` (versions 1/2/3) or the
5437    /// legacy `0x0004` — so files from this crate, the reference C library, and
5438    /// h5py are all handled.
5439    pub(crate) fn defined_fill_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
5440        let state = self.resolved()?;
5441        let msg = state
5442            .header
5443            .messages
5444            .iter()
5445            .find(|m| m.msg_type == MessageType::FillValue)
5446            .or_else(|| {
5447                state
5448                    .header
5449                    .messages
5450                    .iter()
5451                    .find(|m| m.msg_type == MessageType::FillValueOld)
5452            });
5453        match msg {
5454            Some(m) => Ok(crate::fill_value::parse_defined_fill_value(
5455                m.msg_type,
5456                &self.file.message_body(m)?,
5457            )?),
5458            None => Ok(None),
5459        }
5460    }
5461
5462    /// The fill bytes that *unallocated storage reads as* — which is not the
5463    /// same question [`defined_fill_bytes`](Self::defined_fill_bytes) answers.
5464    ///
5465    /// A dataset may declare a fill value and also declare, through the Fill
5466    /// Value Write Time, that the library never writes it
5467    /// (`H5D_FILL_TIME_NEVER`). Its unallocated storage then has no defined
5468    /// contents — the C library leaves the read buffer untouched — so this
5469    /// returns `None` and the region reads as deterministic zeros rather than as
5470    /// a value nothing ever put there. `fill_value` still reports the declared
5471    /// value, because it *is* declared; see [`fill_value_is_written`].
5472    fn fill_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
5473        let state = self.resolved()?;
5474        let msg = state
5475            .header
5476            .messages
5477            .iter()
5478            .find(|m| m.msg_type == MessageType::FillValue)
5479            .or_else(|| {
5480                state
5481                    .header
5482                    .messages
5483                    .iter()
5484                    .find(|m| m.msg_type == MessageType::FillValueOld)
5485            });
5486        let Some(m) = msg else {
5487            return Ok(None);
5488        };
5489        let body = self.file.message_body(m)?;
5490        if !crate::fill_value::fill_value_is_written(m.msg_type, &body)? {
5491            return Ok(None);
5492        }
5493        Ok(crate::fill_value::parse_defined_fill_value(
5494            m.msg_type, &body,
5495        )?)
5496    }
5497
5498    /// Read the whole dataset with `decode`, sweeping it a row window at a time.
5499    ///
5500    /// A typed whole-dataset read used to be [`read_raw`](Self::read_raw)
5501    /// followed by a decode of the entire buffer, which held the stored bytes
5502    /// and the decoded values at the same time and so peaked at twice the
5503    /// dataset — a caller reading a 4 GiB array needed 8 GiB (issue #289).
5504    /// Decoding a window at a time leaves one window of stored bytes beside the
5505    /// output instead of a whole second copy of it, and the bytes are identical
5506    /// either way: a window returns exactly the rows [`read_raw`](Self::read_raw)
5507    /// would have put there.
5508    ///
5509    /// The output buffer is reserved once, at the size the whole dataset decodes
5510    /// to, so no window reallocates it — a growth step would put a second copy of
5511    /// the output alongside the first and give back what the windowing saved.
5512    ///
5513    /// A dataset that fits in one window — including one with no rows at all — is
5514    /// read whole. The empty case still runs `decode`, because a decoder is also
5515    /// what reports a datatype it cannot read, and a zero-element string dataset
5516    /// must go on failing a numeric read rather than answering with an empty
5517    /// vector.
5518    fn read_whole_typed<T, F>(&self, out_size: OutputSize, decode: F) -> Result<Vec<T>, Error>
5519    where
5520        F: Fn(&[u8], &Datatype, &mut Vec<T>) -> Result<(), FormatError>,
5521    {
5522        let dt = self.datatype()?;
5523        let ds = self.dataspace()?;
5524        let dl = self.read_layout()?;
5525        let pipeline = self.filter_pipeline_parsed();
5526        // See `read_raw`: an unparseable fill value message is carried into the
5527        // read rather than failing it up front.
5528        let fill_bytes = self.fill_bytes();
5529        let elem_size = dt.element_size_usize()?;
5530        let fill = match &fill_bytes {
5531            Ok(b) => FillPattern::new(b.as_deref(), elem_size),
5532            Err(_) => FillPattern::UNKNOWN,
5533        };
5534        let spec = RawReadSpec {
5535            layout: &dl,
5536            dataspace: &ds,
5537            datatype: &dt,
5538            pipeline: pipeline.as_ref(),
5539            fill,
5540        };
5541
5542        // What a whole read checks before it reads a byte, and what a sweep would
5543        // otherwise skip: a compact or contiguous layout whose declared size
5544        // disagrees with the dataspace is refused. Reading in windows must not
5545        // turn that into a check that fires only on datasets small enough to be
5546        // read whole.
5547        let stored = spec.stored_byte_len()?;
5548
5549        // A window is cut by the *stored* element width, while a decoder slices
5550        // what it is handed by the width of the type it decodes — the base type,
5551        // for an enumeration. Those are the same width for every valid file, an
5552        // enumeration's size being its base's. A crafted file where they differ
5553        // must not get one verdict from a sweep and another from a whole read, so
5554        // it is read whole.
5555        let decoded_width = data_read::effective_numeric(&dt).type_size();
5556
5557        let mut out = Vec::new();
5558        let n0 = ds.dimensions.first().copied().unwrap_or(1);
5559        let rows = typed_window_rows(&dl, &ds, elem_size)?.get();
5560        if n0 <= rows || decoded_width != dt.type_size() {
5561            // No reservation here: `decode` sizes the output from the bytes it
5562            // was handed, which is exact.
5563            let raw = self.file.read_dataset_raw(spec, &self.chunk_cache)?;
5564            decode(&raw, &dt, &mut out)?;
5565            return Ok(out);
5566        }
5567
5568        let values = match out_size {
5569            OutputSize::PerElement => ds.num_elements().to_usize()?,
5570            OutputSize::PerByte => stored,
5571        };
5572
5573        // One pass for the whole sweep, not one per window: the sweep visits each
5574        // chunk exactly once, so a window that offered its chunks to a cache
5575        // already full would copy and evict with no later reader for either. The
5576        // cache ends up holding what a whole read would have left it — the
5577        // chunks reached first. See [`CachePass`].
5578        let pass = self.chunk_cache.begin_pass();
5579        let mut start = 0;
5580        while start < n0 {
5581            let count = rows.min(n0 - start);
5582            let raw =
5583                self.file
5584                    .read_dataset_raw_rows(spec, &self.chunk_cache, pass, start, count)?;
5585            if start == 0 {
5586                // Reserved once, and only after a window has come back. This size
5587                // comes from the file: sizing an allocation from it before
5588                // reading anything lets a dataspace claiming a terabyte ask for a
5589                // terabyte over a file that cannot serve one row. `try_reserve`
5590                // for the same reason — a file-derived capacity that cannot be
5591                // had is an answer this reader owes its caller, not a panic.
5592                out.try_reserve(values)
5593                    .map_err(|_| FormatError::ValueTooLargeForPlatform {
5594                        value: values as u64,
5595                        target: "one allocation",
5596                    })?;
5597            }
5598            decode(&raw, &dt, &mut out)?;
5599            start += count;
5600        }
5601        Ok(out)
5602    }
5603
5604    /// Read all data as `f64` values.
5605    ///
5606    /// This and the other typed whole-dataset readers decode a row window at a
5607    /// time, so the memory standing beside the returned `Vec` is one window of
5608    /// stored bytes — on the order of a mebibyte — rather than a second copy of
5609    /// the dataset. Reading a 4 GiB array costs about 4 GiB, not 8.
5610    ///
5611    /// The values are what [`read_raw`](Self::read_raw) returns, decoded: a
5612    /// dataset stored as a narrower or wider type is converted, so pick the
5613    /// reader that matches the stored type for a lossless read.
5614    pub fn read_f64(&self) -> Result<Vec<f64>, Error> {
5615        self.read_whole_typed(OutputSize::PerElement, data_read::read_as_f64_into)
5616    }
5617
5618    /// Read all data as `f32` values.
5619    pub fn read_f32(&self) -> Result<Vec<f32>, Error> {
5620        self.read_whole_typed(OutputSize::PerElement, data_read::read_as_f32_into)
5621    }
5622
5623    /// Read all data as `i32` values.
5624    pub fn read_i32(&self) -> Result<Vec<i32>, Error> {
5625        self.read_whole_typed(OutputSize::PerElement, data_read::read_as_i32_into)
5626    }
5627
5628    /// Read all data as `i64` values.
5629    pub fn read_i64(&self) -> Result<Vec<i64>, Error> {
5630        self.read_whole_typed(OutputSize::PerElement, data_read::read_as_i64_into)
5631    }
5632
5633    /// Read all data as `u64` values.
5634    pub fn read_u64(&self) -> Result<Vec<u64>, Error> {
5635        self.read_whole_typed(OutputSize::PerElement, data_read::read_as_u64_into)
5636    }
5637
5638    /// Read all data as `u8` values.
5639    pub fn read_u8(&self) -> Result<Vec<u8>, Error> {
5640        self.read_raw()
5641    }
5642
5643    /// Read all data as `i8` values.
5644    pub fn read_i8(&self) -> Result<Vec<i8>, Error> {
5645        self.read_whole_typed(OutputSize::PerByte, |raw, _dt, out| {
5646            #[expect(
5647                clippy::cast_possible_wrap,
5648                reason = "read_i8 reinterprets each stored byte as the signed i8 the caller requested"
5649            )]
5650            out.extend(raw.iter().map(|&b| b as i8));
5651            Ok(())
5652        })
5653    }
5654
5655    /// Read all data as `i16` values.
5656    pub fn read_i16(&self) -> Result<Vec<i16>, Error> {
5657        self.read_whole_typed(OutputSize::PerElement, data_read::read_as_i16_into)
5658    }
5659
5660    /// Read all data as `u16` values.
5661    pub fn read_u16(&self) -> Result<Vec<u16>, Error> {
5662        self.read_whole_typed(OutputSize::PerElement, data_read::read_as_u16_into)
5663    }
5664
5665    /// Read all data as `u32` values.
5666    pub fn read_u32(&self) -> Result<Vec<u32>, Error> {
5667        self.read_whole_typed(OutputSize::PerElement, data_read::read_as_u32_into)
5668    }
5669
5670    /// Read all data as `String` values.
5671    ///
5672    /// Fixed-length and variable-length HDF5 string datasets are both
5673    /// supported. Use [`read_vlen_strings`](Self::read_vlen_strings) when
5674    /// variable-length allocation limits are required.
5675    pub fn read_string(&self) -> Result<Vec<String>, Error> {
5676        let dt = self.datatype()?;
5677        if vl_data::is_vlen_string_datatype(&dt) {
5678            self.read_vlen_strings(VlenStringReadOptions::default())
5679        } else {
5680            let raw = self.read_raw()?;
5681            Ok(data_read::read_as_strings(&raw, &dt)?)
5682        }
5683    }
5684
5685    /// Return the total bytes referenced by this VL string dataset.
5686    ///
5687    /// This is the payload equivalent of HDF5's `H5Dvlen_get_buf_size`: it
5688    /// excludes `Vec<String>` and `String` allocation metadata.
5689    pub fn vlen_string_payload_size(&self) -> Result<u64, Error> {
5690        let datatype = self.datatype()?;
5691        if !vl_data::is_vlen_string_datatype(&datatype) {
5692            return Err(FormatError::TypeMismatch {
5693                expected: "VariableLength string",
5694                actual: "non-VariableLength string",
5695            }
5696            .into());
5697        }
5698        let dataspace = self.dataspace()?;
5699        let raw = self.read_raw()?;
5700        Ok(vl_data::vlen_string_payload_size(
5701            &raw,
5702            dataspace.num_elements(),
5703            self.file.offset_size(),
5704        )?)
5705    }
5706
5707    /// Read a VL string dataset with explicit allocation limits.
5708    ///
5709    /// Both limits are checked before any string payload is materialized.
5710    pub fn read_vlen_strings(&self, options: VlenStringReadOptions) -> Result<Vec<String>, Error> {
5711        let mut strings = Vec::new();
5712        self.visit_vlen_strings(options, |string| strings.push(string.to_owned()))?;
5713        Ok(strings)
5714    }
5715
5716    /// Visit a VL string dataset one element at a time.
5717    ///
5718    /// The string slice passed to `visitor` is valid only for the duration of
5719    /// that callback. This avoids retaining all decoded string payloads at once.
5720    ///
5721    /// On a read-write file ([`File::open_rw`]) the
5722    /// visitor runs while the file's engine lock is held, so it must not read
5723    /// or write through this file (or a clone / handle of it) — doing so
5724    /// deadlocks. Collect values and act on them after the call instead.
5725    pub fn visit_vlen_strings<F>(
5726        &self,
5727        options: VlenStringReadOptions,
5728        visitor: F,
5729    ) -> Result<(), Error>
5730    where
5731        F: FnMut(&str),
5732    {
5733        let datatype = self.datatype()?;
5734        if !vl_data::is_vlen_string_datatype(&datatype) {
5735            return Err(FormatError::TypeMismatch {
5736                expected: "VariableLength string",
5737                actual: "non-VariableLength string",
5738            }
5739            .into());
5740        }
5741        let dataspace = self.dataspace()?;
5742        if let Some(limit) = options.max_elements()
5743            && dataspace.num_elements() > limit as u64
5744        {
5745            return Err(FormatError::VariableLengthElementLimitExceeded {
5746                limit,
5747                actual: dataspace.num_elements(),
5748            }
5749            .into());
5750        }
5751        let raw = self.read_raw()?;
5752        self.file.with_source(|source| {
5753            Ok(vl_data::visit_vl_strings_from_source(
5754                source,
5755                &raw,
5756                dataspace.num_elements(),
5757                self.file.offset_size(),
5758                self.file.length_size(),
5759                self.file.addr_offset,
5760                options,
5761                visitor,
5762            )?)
5763        })
5764    }
5765
5766    /// Read a VL string dataset's exact heap bytes, preserving the
5767    /// null-vs-empty distinction and never lossily decoding.
5768    ///
5769    /// Unlike [`read_vlen_strings`](Self::read_vlen_strings), which returns
5770    /// `String`s via `from_utf8_lossy` and so cannot reproduce embedded NULs or
5771    /// non-UTF-8 payloads, this yields each element's raw bytes (or a null
5772    /// marker). It underpins faithful rewriting (e.g. repack) of VL strings.
5773    pub(crate) fn read_vlen_string_bytes(
5774        &self,
5775        options: VlenStringReadOptions,
5776    ) -> Result<Vec<vl_data::VlByteObject>, Error> {
5777        let datatype = self.datatype()?;
5778        if !vl_data::is_vlen_string_datatype(&datatype) {
5779            return Err(FormatError::TypeMismatch {
5780                expected: "VariableLength string",
5781                actual: "non-VariableLength string",
5782            }
5783            .into());
5784        }
5785        let dataspace = self.dataspace()?;
5786        if let Some(limit) = options.max_elements()
5787            && dataspace.num_elements() > limit as u64
5788        {
5789            return Err(FormatError::VariableLengthElementLimitExceeded {
5790                limit,
5791                actual: dataspace.num_elements(),
5792            }
5793            .into());
5794        }
5795        let raw = self.read_raw()?;
5796        self.file.with_source(|source| {
5797            Ok(vl_data::read_vl_byte_objects_from_source(
5798                source,
5799                &raw,
5800                dataspace.num_elements(),
5801                self.file.offset_size(),
5802                self.file.length_size(),
5803                self.file.addr_offset,
5804                1, // a VL string's base type is a single byte
5805                options,
5806            )?)
5807        })
5808    }
5809
5810    /// Read every element of a *non-string* variable-length (sequence) dataset as
5811    /// its exact heap bytes, alongside the base-type element size in bytes.
5812    ///
5813    /// Each element's heap object holds `length * element_size` bytes, where
5814    /// `length` is the stored element count and `element_size` is the byte width
5815    /// of the sequence's base type. Returning the raw bytes (not decoded values)
5816    /// keeps a faithful rewrite (repack) byte-exact for any base type whose bytes
5817    /// carry no embedded heap or file addresses. Errors with a
5818    /// [`TypeMismatch`](crate::FormatError::TypeMismatch) if the datatype is not a
5819    /// non-string VL datatype.
5820    pub(crate) fn read_vlen_sequence_bytes(
5821        &self,
5822        options: VlenStringReadOptions,
5823    ) -> Result<(Vec<vl_data::VlByteObject>, usize), Error> {
5824        let datatype = self.datatype()?;
5825        let Datatype::VariableLength { base_type, .. } = &datatype else {
5826            return Err(FormatError::TypeMismatch {
5827                expected: "non-string VariableLength",
5828                actual: "non-VariableLength",
5829            }
5830            .into());
5831        };
5832        if vl_data::is_vlen_string_datatype(&datatype) {
5833            return Err(FormatError::TypeMismatch {
5834                expected: "non-string VariableLength",
5835                actual: "VariableLength string",
5836            }
5837            .into());
5838        }
5839        let element_size = base_type.type_size() as usize;
5840        if element_size == 0 {
5841            return Err(
5842                FormatError::VlDataError("non-string VL base type has zero size".into()).into(),
5843            );
5844        }
5845        let dataspace = self.dataspace()?;
5846        if let Some(limit) = options.max_elements()
5847            && dataspace.num_elements() > limit as u64
5848        {
5849            return Err(FormatError::VariableLengthElementLimitExceeded {
5850                limit,
5851                actual: dataspace.num_elements(),
5852            }
5853            .into());
5854        }
5855        let raw = self.read_raw()?;
5856        let objects = self.file.with_source(|source| {
5857            vl_data::read_vl_byte_objects_from_source(
5858                source,
5859                &raw,
5860                dataspace.num_elements(),
5861                self.file.offset_size(),
5862                self.file.length_size(),
5863                self.file.addr_offset,
5864                element_size,
5865                options,
5866            )
5867        })?;
5868        Ok((objects, element_size))
5869    }
5870
5871    /// Read a dataset whose datatype *contains* variable-length references
5872    /// without being variable-length itself — a compound with a VL member, or an
5873    /// array of them (issue #201).
5874    ///
5875    /// Returns everything a rewrite needs: the element bytes, where each embedded
5876    /// reference sits within them, and the heap payload each one names. That lets
5877    /// the writer re-stage the payloads into a new file's global heap and rewrite
5878    /// the references in place, which is what keeps a rewrite from carrying the
5879    /// source file's heap addresses into the destination.
5880    ///
5881    /// The references are resolved one slot at a time, so `options`' limits apply
5882    /// per slot rather than across the whole dataset.
5883    pub(crate) fn read_embedded_vlen_bytes(
5884        &self,
5885        slots: &[vl_data::EmbeddedVlSlot],
5886        options: VlenStringReadOptions,
5887    ) -> Result<vl_data::EmbeddedVlData, Error> {
5888        let stride = self.datatype()?.type_size() as usize;
5889        let dataspace = self.dataspace()?;
5890        let n = dataspace.num_elements();
5891        if let Some(limit) = options.max_elements()
5892            && n > limit as u64
5893        {
5894            return Err(
5895                FormatError::VariableLengthElementLimitExceeded { limit, actual: n }.into(),
5896            );
5897        }
5898
5899        // A zero-element dataset owns no element bytes, so there is no storage
5900        // to visit: skip the read rather than open a dataset the C library
5901        // left unallocated only to receive the same empty buffer back.
5902        let raw = if n == 0 { Vec::new() } else { self.read_raw()? };
5903        let n_usize = n.to_usize()?;
5904        let needed = n_usize
5905            .checked_mul(stride)
5906            .ok_or(FormatError::OffsetOverflow {
5907                offset: n,
5908                length: stride as u64,
5909            })?;
5910        if raw.len() < needed {
5911            return Err(FormatError::UnexpectedEof {
5912                expected: needed,
5913                available: raw.len(),
5914            }
5915            .into());
5916        }
5917
5918        let mut offsets = Vec::with_capacity(n_usize * slots.len());
5919        let mut objects = Vec::with_capacity(n_usize * slots.len());
5920        for slot in slots {
5921            // Gather this slot's reference from every element into a dense buffer,
5922            // which is the shape the shared VL reader consumes. Each slot has its
5923            // own base-type width, so they are resolved a slot at a time rather
5924            // than in one pass.
5925            let mut dense = Vec::with_capacity(n_usize * VL_REF_SIZE);
5926            for e in 0..n_usize {
5927                let at = e * stride + slot.byte_offset;
5928                dense.extend_from_slice(&raw[at..at + VL_REF_SIZE]);
5929                offsets.push(at);
5930            }
5931            let resolved = self.file.with_source(|source| {
5932                vl_data::read_vl_byte_objects_from_source(
5933                    source,
5934                    &dense,
5935                    n,
5936                    self.file.offset_size(),
5937                    self.file.length_size(),
5938                    self.file.addr_offset,
5939                    slot.element_size,
5940                    options,
5941                )
5942            })?;
5943            objects.extend(resolved);
5944        }
5945        Ok(vl_data::EmbeddedVlData {
5946            raw,
5947            offsets,
5948            objects,
5949        })
5950    }
5951
5952    /// Read all attributes of this dataset.
5953    ///
5954    /// The variant of each value describes its on-disk encoding; see
5955    /// [`Group::attrs`] for what that means for matching on it, and prefer the
5956    /// [`AttrValue`] accessors.
5957    pub fn attrs(&self) -> Result<HashMap<String, AttrValue>, Error> {
5958        self.file.attrs_of(&self.resolved()?.header)
5959    }
5960
5961    /// The exact on-disk [`Datatype`] of every attribute on this dataset, keyed
5962    /// by name.
5963    ///
5964    /// See [`Group::attr_datatypes`] for what this channel carries that
5965    /// [`attrs`](Self::attrs) cannot, including how a boolean attribute is
5966    /// recognized. Note that this describes the *attributes*, not the dataset's
5967    /// own element type — that is [`datatype`](Self::datatype).
5968    pub fn attr_datatypes(&self) -> Result<HashMap<String, Datatype>, Error> {
5969        Ok(self
5970            .attr_messages()?
5971            .into_iter()
5972            .map(|a| (a.name, a.datatype))
5973            .collect())
5974    }
5975
5976    /// Every attribute message on this dataset as it is encoded on disk, in the
5977    /// order the header holds them.
5978    ///
5979    /// See [`Group::attr_messages`] for why repack reads these rather than the
5980    /// decoded map.
5981    pub(crate) fn attr_messages(&self) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
5982        self.file.attr_messages_of(&self.resolved()?.header)
5983    }
5984
5985    /// Returns the exact HDF5 datatype, including compound field offsets and
5986    /// total record size.
5987    ///
5988    /// A committed (`H5Tcommit`) element type — what netCDF-4 writes for a
5989    /// user-defined type, and what h5py writes for
5990    /// `create_dataset(..., dtype=f["t"])` — is stored as a reference to the
5991    /// datatype's own object header and is resolved to the type it names.
5992    pub fn datatype(&self) -> Result<Datatype, Error> {
5993        if let Some(meta) = self.staged_meta()? {
5994            return Ok(meta.datatype);
5995        }
5996        let state = self.resolved()?;
5997        let msg = find_message(&state.header, MessageType::Datatype)?;
5998        let (dt, _) = Datatype::parse(&self.file.message_body(msg)?)?;
5999        Ok(dt)
6000    }
6001
6002    /// The object-header address of this dataset's committed (shared) element
6003    /// type, or `None` when the type is written in the dataset's own header.
6004    ///
6005    /// [`datatype`](Self::datatype) resolves it either way, so this is for
6006    /// callers that must *reproduce* the dataset: writing the resolved type back
6007    /// inline loses the link every C-library reader reports by name, and the
6008    /// address is what says which committed object to name instead.
6009    pub(crate) fn committed_datatype_address(&self) -> Result<Option<u64>, Error> {
6010        let state = self.resolved()?;
6011        let msg = find_message(&state.header, MessageType::Datatype)?;
6012        self.file.shared_target_address(msg)
6013    }
6014
6015    pub(crate) fn dataspace(&self) -> Result<Dataspace, Error> {
6016        let state = self.resolved()?;
6017        let msg = find_message(&state.header, MessageType::Dataspace)?;
6018        Ok(Dataspace::parse(
6019            &self.file.message_body(msg)?,
6020            self.file.length_size(),
6021        )?)
6022    }
6023
6024    pub(crate) fn data_layout(&self) -> Result<DataLayout, Error> {
6025        let state = self.resolved()?;
6026        let msg = find_message(&state.header, MessageType::DataLayout)?;
6027        Ok(DataLayout::parse(
6028            &msg.data,
6029            self.file.offset_size(),
6030            self.file.length_size(),
6031        )?)
6032    }
6033
6034    /// A handle that can no longer be resolved reports no pipeline, the same
6035    /// answer an unfiltered dataset gives: this feeds the two infallible
6036    /// accessors ([`filters`](Self::filters) and
6037    /// [`filter_pipeline`](Self::filter_pipeline)), which have no way to say
6038    /// why. Every caller that *can* say uses a `Result` reader instead.
6039    pub(crate) fn filter_pipeline_parsed(&self) -> Option<FilterPipeline> {
6040        let state = self.resolved().ok()?;
6041        let msg = state
6042            .header
6043            .messages
6044            .iter()
6045            .find(|m| m.msg_type == MessageType::FilterPipeline)?;
6046        let body = self.file.message_body(msg).ok()?;
6047        FilterPipeline::parse(&body).ok()
6048    }
6049
6050    /// Whether this dataset's element bytes live in files outside this one
6051    /// (`H5Pset_external`, the External Data Files header message, type 7).
6052    ///
6053    /// Such a dataset carries a *contiguous* layout message whose data address
6054    /// is undefined — the same encoding a never-written dataset uses — so a
6055    /// caller that reads "no address" as "no storage" would call a dataset full
6056    /// of data empty. This crate does not follow the external files, so the only
6057    /// safe answer is to refuse: [`read_layout`](Self::read_layout) refuses a
6058    /// read of one rather than answering its fill value, and `repack` refuses to
6059    /// reproduce it without its data.
6060    pub(crate) fn has_external_storage(&self) -> Result<bool, Error> {
6061        Ok(self
6062            .resolved()?
6063            .header
6064            .messages
6065            .iter()
6066            .any(|m| m.msg_type == MessageType::ExternalDataFiles))
6067    }
6068
6069    /// The data layout to read element bytes through, as opposed to the one
6070    /// [`layout`](Self::layout) reports.
6071    ///
6072    /// [`data_layout`](Self::data_layout) answers what the message records;
6073    /// this answers whether those bytes are reachable at all. The two differ for
6074    /// exactly one kind of dataset: an externally stored one, whose contiguous layout
6075    /// with no address is also what a never-written dataset carries, so reading
6076    /// it would answer the fill value for every element it holds. Introspection
6077    /// keeps answering — the address-less layout is the evidence a caller needs
6078    /// — while every path that turns a layout into bytes comes through here and
6079    /// refuses.
6080    fn read_layout(&self) -> Result<DataLayout, Error> {
6081        if self.has_external_storage()? {
6082            return Err(FormatError::UnsupportedExternalStorage.into());
6083        }
6084        self.data_layout()
6085    }
6086
6087    /// The raw, still-compressed on-disk bytes of every allocated chunk of this
6088    /// chunked dataset, with each chunk's `(address, on-disk size, filter mask,
6089    /// logical offset)` — the same `ChunkInfo`s the chunked reader walks before
6090    /// decompressing. Used by repack to copy compressed chunks verbatim without
6091    /// ever decoding them.
6092    ///
6093    /// Returns `Err` if the layout is not chunked. Returns `Ok(vec![])` for an
6094    /// empty / never-allocated chunked dataset (no index address). Covers every
6095    /// index type the reader supports (v3 B-tree and v4 single-chunk, implicit,
6096    /// fixed-array, and extensible-array).
6097    pub(crate) fn raw_chunks(&self) -> Result<Vec<crate::chunked_read::ChunkInfo>, Error> {
6098        let DataLayout::Chunked {
6099            chunk_dimensions,
6100            btree_address,
6101            version,
6102            chunk_index_type,
6103            single_chunk_filtered_size,
6104            single_chunk_filter_mask,
6105        } = self.data_layout()?
6106        else {
6107            return Err(Error::Format(crate::error::FormatError::ChunkedReadError(
6108                "chunk enumeration requires a chunked dataset".into(),
6109            )));
6110        };
6111        // An undefined index address means no storage is allocated yet.
6112        let Some(addr) = btree_address else {
6113            return Ok(Vec::new());
6114        };
6115        let dataspace = self.dataspace()?;
6116        let elem_size = self.datatype()?.element_size_usize()?;
6117        let base = self.file.addr_offset;
6118        // The chunk index — its root at `addr` and every internal node — stores
6119        // addresses relative to the base address. Walk it through a base-relative
6120        // view so those resolve, then shift each returned chunk address back to an
6121        // absolute file offset, since callers (repack) read the chunk bytes from
6122        // the full file source.
6123        self.file.with_source(|source| {
6124            if base.is_zero() {
6125                return Ok(crate::chunked_read::collect_chunks_for_layout_from_source(
6126                    source,
6127                    version,
6128                    chunk_index_type,
6129                    addr,
6130                    single_chunk_filtered_size,
6131                    single_chunk_filter_mask,
6132                    &chunk_dimensions,
6133                    &dataspace,
6134                    elem_size,
6135                    self.file.offset_size(),
6136                    self.file.length_size(),
6137                )?);
6138            }
6139            let framed = BaseOffsetSource {
6140                inner: source,
6141                base,
6142            };
6143            let mut chunks = crate::chunked_read::collect_chunks_for_layout_from_source(
6144                &framed,
6145                version,
6146                chunk_index_type,
6147                addr,
6148                single_chunk_filtered_size,
6149                single_chunk_filter_mask,
6150                &chunk_dimensions,
6151                &dataspace,
6152                elem_size,
6153                self.file.offset_size(),
6154                self.file.length_size(),
6155            )?;
6156            for c in &mut chunks {
6157                c.address = base.absolute(c.address)?;
6158            }
6159            Ok(chunks)
6160        })
6161    }
6162
6163    /// The raw `FilterPipeline` message bytes from this dataset's object header,
6164    /// if it has one. Repack reuses this verbatim so that every filter — including
6165    /// ones this crate cannot itself apply (ZFP, SZIP, unknown) — is reproduced
6166    /// byte-for-byte in the repacked file's pipeline message.
6167    pub(crate) fn filter_pipeline_message_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
6168        let state = self.resolved()?;
6169        let Some(msg) = state
6170            .header
6171            .messages
6172            .iter()
6173            .find(|m| m.msg_type == MessageType::FilterPipeline)
6174        else {
6175            return Ok(None);
6176        };
6177        // A shared pipeline message's record body is an address, not a pipeline;
6178        // its resolved content is what a copy must carry. The content itself is
6179        // position-independent, so copying it verbatim stays faithful.
6180        Ok(Some(self.file.message_body(msg)?.into_owned()))
6181    }
6182
6183    /// Read the dataset's exact unfiltered element bytes.
6184    ///
6185    /// For compound datasets this preserves all file padding and uses the
6186    /// offsets reported by [`datatype`](Self::datatype).
6187    ///
6188    /// A dataset whose elements live in external files (`H5Pset_external`) is
6189    /// refused with `FormatError::UnsupportedExternalStorage` rather than read as
6190    /// unallocated storage, which is what its layout message alone says. This
6191    /// applies to every read here; its shape, datatype, and
6192    /// [`layout`](Self::layout) still read.
6193    pub fn read_raw(&self) -> Result<Vec<u8>, Error> {
6194        let dt = self.datatype()?;
6195        let ds = self.dataspace()?;
6196        let dl = self.read_layout()?;
6197        // The data layout's on-disk addresses are left base-relative here;
6198        // `read_dataset_raw` applies the base address centrally (for both
6199        // contiguous and chunked layouts) by reading from a base-relative view of
6200        // the file.
6201        let pipeline = self.filter_pipeline_parsed();
6202        // A fill value message this parser cannot read does not, by itself,
6203        // make the dataset unreadable: it only decides what *unallocated*
6204        // storage looks like. Carry the uncertainty into the read and let it
6205        // fail there, and only there. `Dataset::fill_value` still reports the
6206        // parse error to a caller asking about the value.
6207        let fill_bytes = self.fill_bytes();
6208        let fill = match &fill_bytes {
6209            Ok(b) => FillPattern::new(b.as_deref(), dt.element_size_usize()?),
6210            Err(_) => FillPattern::UNKNOWN,
6211        };
6212        let spec = RawReadSpec {
6213            layout: &dl,
6214            dataspace: &ds,
6215            datatype: &dt,
6216            pipeline: pipeline.as_ref(),
6217            fill,
6218        };
6219        Ok(self.file.read_dataset_raw(spec, &self.chunk_cache)?)
6220    }
6221
6222    /// Read the raw element bytes of the row window `[start_row, start_row + num_rows)`
6223    /// — a range along the first dimension.
6224    ///
6225    /// The windowed companion to [`read_raw`](Self::read_raw): only the storage the
6226    /// window overlaps is read — a bounded sub-read for compact and contiguous
6227    /// layouts, just the overlapping chunks for chunked layouts — so peak memory
6228    /// scales with the window, not the dataset. Use it to stream a large dataset a
6229    /// fixed number of rows at a time.
6230    ///
6231    /// Each row keeps its full inner shape, and the bytes match what
6232    /// [`read_raw`](Self::read_raw) produces for those rows, so the typed
6233    /// `read_*_rows` helpers decode a window like their whole-dataset forms. The
6234    /// window is clamped to the first dimension: a read past the end returns only
6235    /// the rows that exist, and a 0-D scalar is one row. A window covering every
6236    /// row delegates to [`read_raw`](Self::read_raw), so a full-range window never
6237    /// costs more than a whole read. Variable-length string
6238    /// bytes are heap references, not text — use
6239    /// [`read_string_rows`](Self::read_string_rows).
6240    pub fn read_raw_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u8>, Error> {
6241        let dt = self.datatype()?;
6242        let ds = self.dataspace()?;
6243        let dl = self.read_layout()?;
6244
6245        let n0 = ds.dimensions.first().copied().unwrap_or(1);
6246        let start = start_row.min(n0);
6247        let count = num_rows.min(n0 - start);
6248
6249        // A window covering every row is exactly a whole read: delegate, so it
6250        // never costs a window-shaped copy on top of one.
6251        // See `read_raw`: an unparseable fill value message is carried into the
6252        // read rather than failing it up front.
6253        let parsed_fill = self.fill_bytes();
6254        let fill = match &parsed_fill {
6255            Ok(b) => FillPattern::new(b.as_deref(), dt.element_size_usize()?),
6256            Err(_) => FillPattern::UNKNOWN,
6257        };
6258
6259        let pipeline = self.filter_pipeline_parsed();
6260        let spec = RawReadSpec {
6261            layout: &dl,
6262            dataspace: &ds,
6263            datatype: &dt,
6264            pipeline: pipeline.as_ref(),
6265            fill,
6266        };
6267
6268        if start == 0 && count == n0 {
6269            return Ok(self.file.read_dataset_raw(spec, &self.chunk_cache)?);
6270        }
6271
6272        // A lone window's successor is the adjacent one, and the chunk they share
6273        // is the one this read finishes on; `CachePass::LRU` is what retains it.
6274        Ok(self.file.read_dataset_raw_rows(
6275            spec,
6276            &self.chunk_cache,
6277            CachePass::LRU,
6278            start,
6279            count,
6280        )?)
6281    }
6282
6283    /// Windowed [`read_f64`](Self::read_f64) — decodes only the row window.
6284    pub fn read_f64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<f64>, Error> {
6285        let raw = self.read_raw_rows(start_row, num_rows)?;
6286        Ok(data_read::read_as_f64(&raw, &self.datatype()?)?)
6287    }
6288
6289    /// Windowed [`read_f32`](Self::read_f32) — decodes only the row window.
6290    pub fn read_f32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<f32>, Error> {
6291        let raw = self.read_raw_rows(start_row, num_rows)?;
6292        Ok(data_read::read_as_f32(&raw, &self.datatype()?)?)
6293    }
6294
6295    /// Windowed [`read_i8`](Self::read_i8) — decodes only the row window.
6296    #[expect(
6297        clippy::cast_possible_wrap,
6298        reason = "read_i8 reinterprets each stored byte as the signed i8 the caller requested"
6299    )]
6300    pub fn read_i8_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i8>, Error> {
6301        let raw = self.read_raw_rows(start_row, num_rows)?;
6302        Ok(raw.iter().map(|&b| b as i8).collect())
6303    }
6304
6305    /// Windowed [`read_i16`](Self::read_i16) — decodes only the row window.
6306    pub fn read_i16_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i16>, Error> {
6307        let raw = self.read_raw_rows(start_row, num_rows)?;
6308        Ok(data_read::read_as_i16(&raw, &self.datatype()?)?)
6309    }
6310
6311    /// Windowed [`read_i32`](Self::read_i32) — decodes only the row window.
6312    pub fn read_i32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i32>, Error> {
6313        let raw = self.read_raw_rows(start_row, num_rows)?;
6314        Ok(data_read::read_as_i32(&raw, &self.datatype()?)?)
6315    }
6316
6317    /// Windowed [`read_i64`](Self::read_i64) — decodes only the row window.
6318    pub fn read_i64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i64>, Error> {
6319        let raw = self.read_raw_rows(start_row, num_rows)?;
6320        Ok(data_read::read_as_i64(&raw, &self.datatype()?)?)
6321    }
6322
6323    /// Windowed [`read_u8`](Self::read_u8) — reads only the row window.
6324    pub fn read_u8_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u8>, Error> {
6325        self.read_raw_rows(start_row, num_rows)
6326    }
6327
6328    /// Windowed [`read_u16`](Self::read_u16) — decodes only the row window.
6329    pub fn read_u16_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u16>, Error> {
6330        let raw = self.read_raw_rows(start_row, num_rows)?;
6331        Ok(data_read::read_as_u16(&raw, &self.datatype()?)?)
6332    }
6333
6334    /// Windowed [`read_u32`](Self::read_u32) — decodes only the row window.
6335    pub fn read_u32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u32>, Error> {
6336        let raw = self.read_raw_rows(start_row, num_rows)?;
6337        Ok(data_read::read_as_u32(&raw, &self.datatype()?)?)
6338    }
6339
6340    /// Windowed [`read_u64`](Self::read_u64) — decodes only the row window.
6341    pub fn read_u64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u64>, Error> {
6342        let raw = self.read_raw_rows(start_row, num_rows)?;
6343        Ok(data_read::read_as_u64(&raw, &self.datatype()?)?)
6344    }
6345
6346    /// Windowed [`read_string`](Self::read_string).
6347    ///
6348    /// Fixed-length strings decode straight from the window. Variable-length
6349    /// strings resolve only the window's heap references, so the window memory
6350    /// bound holds for them too: peak allocation is the window's references,
6351    /// its text, and the metadata of the heap collections it touches.
6352    pub fn read_string_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<String>, Error> {
6353        let dt = self.datatype()?;
6354        if vl_data::is_vlen_string_datatype(&dt) {
6355            // The window's heap references, read memory-bounded like any other
6356            // fixed-size element (4-byte length + collection address + 4-byte
6357            // object index), one row spanning its inner dimensions. Resolving
6358            // only those against the global heap keeps the bound — the same
6359            // resolution `read_string` runs over the whole dataset's references.
6360            let raw = self.read_raw_rows(start_row, num_rows)?;
6361            let ref_size = 4 + self.file.offset_size() as usize + 4;
6362            let num_elements = (raw.len() / ref_size) as u64;
6363            let mut strings = Vec::new();
6364            self.file.with_source(|source| -> Result<(), Error> {
6365                Ok(vl_data::visit_vl_strings_from_source(
6366                    source,
6367                    &raw,
6368                    num_elements,
6369                    self.file.offset_size(),
6370                    self.file.length_size(),
6371                    self.file.addr_offset,
6372                    VlenStringReadOptions::default(),
6373                    |string| strings.push(String::from(string)),
6374                )?)
6375            })?;
6376            return Ok(strings);
6377        }
6378        let raw = self.read_raw_rows(start_row, num_rows)?;
6379        Ok(data_read::read_as_strings(&raw, &dt)?)
6380    }
6381
6382    /// Interpret this dataset as an array of HDF5 object references
6383    /// (`H5R_OBJECT`) and resolve each, in storage order, to the [`Object`] it
6384    /// points at.
6385    ///
6386    /// MATLAB cell arrays and the `#subsystem#` machinery store their members
6387    /// this way: the dataset holds one object-header address per element, each
6388    /// naming an object elsewhere in the file (conventionally under the hidden
6389    /// `#refs#` group).
6390    ///
6391    /// # Errors
6392    ///
6393    /// - [`FormatError::TypeMismatch`] if this dataset's datatype is not an
6394    ///   object reference.
6395    /// - [`FormatError::InvalidObjectReference`] if an element is a null or
6396    ///   undefined reference, or does not point at a group or dataset.
6397    pub fn dereference(&self) -> Result<Vec<Object>, Error> {
6398        let dt = self.datatype()?;
6399        if !matches!(
6400            dt,
6401            Datatype::Reference {
6402                ref_type: ReferenceType::Object,
6403                ..
6404            }
6405        ) {
6406            return Err(FormatError::TypeMismatch {
6407                expected: "object reference",
6408                actual: "non-reference datatype",
6409            }
6410            .into());
6411        }
6412        // An object reference stores an 8-byte object-header address. Refuse a
6413        // sub-address-width element rather than read a truncated address.
6414        let elem_size = dt.type_size().to_usize()?;
6415        if elem_size < 8 {
6416            return Err(FormatError::TypeMismatch {
6417                expected: "8-byte object reference",
6418                actual: "object reference narrower than 8 bytes",
6419            }
6420            .into());
6421        }
6422        let raw = self.read_raw()?;
6423        if raw.is_empty() {
6424            return Ok(Vec::new());
6425        }
6426        if !raw.len().is_multiple_of(elem_size) {
6427            return Err(FormatError::DataSizeMismatch {
6428                expected: elem_size,
6429                actual: raw.len(),
6430            }
6431            .into());
6432        }
6433        // Read after the element bytes the addresses came out of, which is the
6434        // one place these could be taken too late: a commit landing between that
6435        // read and this one would move the headers the addresses name, and a
6436        // handle labelled with the later revisions would call them current.
6437        let revisions = self.file.revisions();
6438        let mut out = Vec::with_capacity(raw.len() / elem_size);
6439        for chunk in raw.chunks_exact(elem_size) {
6440            let addr = u64::from_le_bytes(chunk[..8].try_into().expect("chunk has >= 8 bytes"));
6441            out.push(FileInner::object_at_relative(&self.file, revisions, addr)?);
6442        }
6443        Ok(out)
6444    }
6445
6446    /// Decode all elements of a compound dataset field by field.
6447    ///
6448    /// Built-in implementations support numeric tuples with one through twelve
6449    /// fields. Decoding uses the file's field offsets rather than Rust's tuple
6450    /// memory layout, so padded compound records are supported safely.
6451    pub fn read_compound<T: CompoundType>(&self) -> Result<Vec<T>, Error> {
6452        let datatype = self.datatype()?;
6453        let element_size = datatype.element_size_usize()?;
6454        if !matches!(datatype, Datatype::Compound { .. }) {
6455            return Err(FormatError::TypeMismatch {
6456                expected: "Compound",
6457                actual: "non-Compound",
6458            }
6459            .into());
6460        }
6461        let raw = self.read_raw()?;
6462        if !raw.len().is_multiple_of(element_size.get()) {
6463            return Err(FormatError::DataSizeMismatch {
6464                expected: element_size.get(),
6465                actual: raw.len(),
6466            }
6467            .into());
6468        }
6469        raw.chunks_exact(element_size.get())
6470            .map(|bytes| T::decode(&datatype, bytes).map_err(Error::Format))
6471            .collect()
6472    }
6473
6474    /// Verify this dataset against its stored provenance hash.
6475    ///
6476    /// Recomputes the SHA-256 of the dataset's raw bytes and compares it with
6477    /// the `_provenance_sha256` attribute written by
6478    /// [`DatasetBuilder::with_provenance`](crate::DatasetBuilder::with_provenance).
6479    /// Returns [`VerifyResult::NoHash`](crate::VerifyResult::NoHash) when the
6480    /// dataset carries no provenance hash, so a missing hash is distinguishable
6481    /// from an actual mismatch.
6482    #[cfg(feature = "provenance")]
6483    pub fn verify_provenance(&self) -> Result<crate::provenance::VerifyResult, Error> {
6484        use crate::provenance::{ATTR_SHA256, VerifyResult, sha256_hex};
6485
6486        let attrs = self.attrs()?;
6487        let stored = match attrs.get(ATTR_SHA256).and_then(AttrValue::as_str) {
6488            Some(s) => s.trim_end_matches('\0').to_string(),
6489            None => return Ok(VerifyResult::NoHash),
6490        };
6491
6492        let computed = sha256_hex(&self.read_raw()?);
6493        if computed == stored {
6494            Ok(VerifyResult::Ok)
6495        } else {
6496            Ok(VerifyResult::Mismatch { stored, computed })
6497        }
6498    }
6499}
6500
6501// ---------------------------------------------------------------------------
6502// Helpers
6503// ---------------------------------------------------------------------------
6504
6505fn find_message(
6506    header: &ObjectHeader,
6507    msg_type: MessageType,
6508) -> Result<&crate::object_header::HeaderMessage, Error> {
6509    header
6510        .messages
6511        .iter()
6512        .find(|m| m.msg_type == msg_type)
6513        .ok_or(Error::MissingMessage(msg_type))
6514}
6515
6516/// Normalize a user-supplied object path to the root-relative form the write
6517/// session addresses by: strip any leading/trailing `/` so `"/a/b"` and `"a/b"`
6518/// name the same object.
6519fn normalize_path(path: &str) -> String {
6520    path.trim_matches('/').to_string()
6521}
6522
6523fn has_message(header: &ObjectHeader, msg_type: MessageType) -> bool {
6524    header.messages.iter().any(|m| m.msg_type == msg_type)
6525}
6526
6527/// Whether an object header describes a committed (`H5Tcommit`) datatype: it
6528/// carries a datatype and is neither a dataset nor a group.
6529///
6530/// A dataset's header carries a datatype message too — its element type — so
6531/// "has a datatype message" is not the question, and a lookup that asked only
6532/// that answered a dataset's element type where it owed a refusal (issue #364).
6533/// The listing and the by-name lookups share this one predicate so they cannot
6534/// disagree about the same child.
6535///
6536/// The conjunction encodes the precedence the reference library gets from its
6537/// ordering: `H5O__obj_class_real` walks `H5O_obj_class_g` in reverse, so it
6538/// asks group, then dataset, then datatype, and that is what `H5Topen` gates on.
6539/// Two terms are read differently here. It calls a header a dataset for a
6540/// datatype beside a *dataspace* where this reads a datatype beside a data
6541/// layout, and a group for a symbol table or link info where this counts a bare
6542/// link message as well. Every object either library writes carries the messages
6543/// that make those agree, so the rules part only on a malformed header.
6544fn is_named_datatype(header: &ObjectHeader) -> bool {
6545    has_message(header, MessageType::Datatype)
6546        && !has_message(header, MessageType::DataLayout)
6547        && !is_group(header)
6548}
6549
6550/// The root-relative path of a child named `name` under `parent`, or `None` if
6551/// the parent has no resolvable path (reached by object reference).
6552///
6553/// Free-standing rather than a method on [`Group`] so the member iterators can
6554/// build child paths from a closure that outlives the borrow of the group they
6555/// came from.
6556fn child_path_of(parent: Option<&str>, name: &str) -> Option<String> {
6557    parent.map(|p| {
6558        if p.is_empty() {
6559            name.to_string()
6560        } else {
6561            format!("{p}/{name}")
6562        }
6563    })
6564}
6565
6566#[cfg(test)]
6567mod tests {
6568    use super::*;
6569    use crate::FileBuilder;
6570    use std::sync::atomic::AtomicUsize;
6571
6572    // -----------------------------------------------------------------------
6573    // Reporting the metadata cache (issue #353)
6574    // -----------------------------------------------------------------------
6575
6576    /// `SourceView` serves its metadata reads from the streaming backend's
6577    /// cache, so it has to forward the account of them as well. It is the one
6578    /// wrapper `File::metadata_cache_stats` does not itself go through (that
6579    /// dispatch uses `with_source`, which reaches the read-write backend too), so
6580    /// nothing else would notice the forward going missing.
6581    #[test]
6582    fn the_source_view_reports_the_cache_it_reads_through() {
6583        let backend = MetadataCachingSource::new(
6584            BytesSource::new((0..=255u8).collect::<Vec<u8>>()),
6585            MetadataCacheConfig::new(4096),
6586        );
6587        let view = SourceView::Stream(&backend);
6588
6589        assert_eq!(view.metadata_cache_stats().unwrap().reads(), 0);
6590        view.read_metadata_at(0, 64).unwrap();
6591        view.read_metadata_at(0, 64).unwrap();
6592        let stats = view
6593            .metadata_cache_stats()
6594            .expect("the backend has a cache, so the view reports it");
6595        assert_eq!((stats.hits(), stats.misses()), (1, 1));
6596
6597        view.reset_metadata_cache_stats();
6598        let cleared = view.metadata_cache_stats().unwrap();
6599        assert_eq!(cleared.hits(), 0);
6600        assert_eq!(cleared.entries(), 1, "a reset evicts nothing");
6601
6602        // A whole-file buffer is the cache; there is no second one to report.
6603        assert_eq!(SourceView::Mem(&[0u8; 16]).metadata_cache_stats(), None);
6604    }
6605
6606    // -----------------------------------------------------------------------
6607    // Handle re-validation across an edit (issue #351)
6608    // -----------------------------------------------------------------------
6609
6610    /// A file with two chunked datasets whose trailing chunk is partial, one
6611    /// contiguous dataset, and one subgroup.
6612    fn revalidation_fixture(path: &std::path::Path) {
6613        let mut b = FileBuilder::new();
6614        for ds in ["log", "other"] {
6615            b.create_dataset(ds)
6616                .with_i32_data(&[0, 1])
6617                .with_shape(&[2])
6618                .with_maxshape(&[u64::MAX])
6619                .with_chunks(&[4]);
6620        }
6621        b.create_dataset("plain").with_i32_data(&[7, 8, 9]);
6622        let g = b.create_group("g");
6623        b.add_group(g.finish());
6624        b.write(path).unwrap();
6625    }
6626
6627    /// The issue itself: a handle taken before a commit goes on answering for
6628    /// the object after it, rather than for the copy the commit left behind.
6629    #[test]
6630    fn a_handle_follows_its_object_across_a_commit() {
6631        let dir = tempfile::tempdir().unwrap();
6632        let path = dir.path().join("follow.h5");
6633        revalidation_fixture(&path);
6634
6635        let file = File::open_rw(&path).unwrap();
6636        let mut ds = file.dataset("plain").unwrap();
6637        let group = file.group("g").unwrap();
6638
6639        // Both edits relocate an object header: the dataset's own, and — since
6640        // the new child is linked into it — the group's.
6641        ds.set_attr("units", AttrValue::AsciiString("m".into()))
6642            .unwrap();
6643        group
6644            .create_dataset("child", |b| {
6645                b.with_i32_data(&[4, 5]);
6646            })
6647            .unwrap();
6648        file.commit().unwrap();
6649
6650        assert_eq!(
6651            sorted(ds.attrs()),
6652            vec![r#"units=AsciiString("m")"#.to_string()],
6653            "the dataset handle must report the attribute the commit added"
6654        );
6655        assert_eq!(
6656            group.datasets().unwrap(),
6657            vec!["child".to_string()],
6658            "the group handle must report the child the commit added"
6659        );
6660        assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
6661        assert_eq!(
6662            group.dataset("child").unwrap().read_i32().unwrap(),
6663            vec![4, 5]
6664        );
6665        file.close().unwrap();
6666    }
6667
6668    /// Two handles on one dataset are two views of one object, not two objects:
6669    /// an append through either is what the other reads next, chunk cache and
6670    /// all. The appended elements land in a chunk the reader already holds, so a
6671    /// retained one would answer with what stood there before.
6672    #[test]
6673    fn an_append_through_one_handle_is_what_another_reads() {
6674        let dir = tempfile::tempdir().unwrap();
6675        let path = dir.path().join("two_handles.h5");
6676        revalidation_fixture(&path);
6677
6678        let file = File::open_rw(&path).unwrap();
6679        let reader = file.dataset("log").unwrap();
6680        let mut writer = file.dataset("log").unwrap();
6681
6682        assert_eq!(reader.read_i32().unwrap(), vec![0, 1]);
6683        assert!(
6684            reader.chunk_cache_stats().cached_chunks() > 0,
6685            "the read must leave the partial trailing chunk cached, or this \
6686             test cannot tell a retained chunk from a re-read one"
6687        );
6688
6689        writer.append(&[2i32, 3]).unwrap();
6690
6691        assert_eq!(
6692            reader.chunk_cache_stats().cached_chunks(),
6693            0,
6694            "the snapshot must report what the cache holds for a read, and the \
6695             append left it holding nothing a read will be served"
6696        );
6697        assert_eq!(reader.shape().unwrap(), vec![4]);
6698        assert_eq!(reader.read_i32().unwrap(), vec![0, 1, 2, 3]);
6699        file.close().unwrap();
6700    }
6701
6702    /// A clone is a second handle to the same object, and follows it the same
6703    /// way. It shares the chunk cache, so the edit that drops one drops both.
6704    #[test]
6705    fn a_clone_is_a_second_handle_to_the_same_object() {
6706        let dir = tempfile::tempdir().unwrap();
6707        let path = dir.path().join("clone.h5");
6708        revalidation_fixture(&path);
6709
6710        let file = File::open_rw(&path).unwrap();
6711        let mut ds = file.dataset("log").unwrap();
6712        let copy = ds.clone();
6713        let root = file.root();
6714        let root_copy = root.clone();
6715
6716        assert_eq!(copy.read_i32().unwrap(), vec![0, 1]);
6717        assert!(copy.chunk_cache_stats().cached_chunks() > 0);
6718        assert_eq!(
6719            ds.chunk_cache_stats().cached_chunks(),
6720            copy.chunk_cache_stats().cached_chunks(),
6721            "clones share one cache: a chunk read through either is warm for both"
6722        );
6723
6724        ds.append(&[2i32, 3]).unwrap();
6725        assert_eq!(copy.read_i32().unwrap(), vec![0, 1, 2, 3]);
6726
6727        root.create_group("later").unwrap();
6728        file.commit().unwrap();
6729        assert!(
6730            root_copy.groups().unwrap().contains(&"later".to_string()),
6731            "a cloned group handle follows its group across a commit too"
6732        );
6733        file.close().unwrap();
6734    }
6735
6736    /// A handle to an object a commit deleted has nothing to answer for. The
6737    /// bytes it vacated still parse as the dataset that left them, so reading
6738    /// them would answer with data no longer in the file.
6739    #[test]
6740    fn a_handle_to_a_deleted_object_refuses_rather_than_reading_what_it_left() {
6741        let dir = tempfile::tempdir().unwrap();
6742        let path = dir.path().join("deleted.h5");
6743        revalidation_fixture(&path);
6744
6745        let file = File::open_rw(&path).unwrap();
6746        let ds = file.dataset("plain").unwrap();
6747        assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
6748
6749        file.root().delete("plain").unwrap();
6750        file.commit().unwrap();
6751
6752        assert!(
6753            matches!(
6754                ds.read_i32(),
6755                Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "plain"
6756            ),
6757            "reading a deleted dataset must fail the way opening it does, got {:?}",
6758            ds.read_i32()
6759        );
6760        file.close().unwrap();
6761    }
6762
6763    /// A handle reached by object reference has no name to look itself up by, so
6764    /// it pins to the address the reference gave it. An immediate append leaves
6765    /// that address alone and it keeps reading; a commit can move the header and
6766    /// it stops.
6767    #[test]
6768    fn a_reference_handle_reads_until_a_commit_could_have_moved_its_object() {
6769        let dir = tempfile::tempdir().unwrap();
6770        let path = dir.path().join("by_ref.h5");
6771        let mut b = FileBuilder::new();
6772        b.create_dataset("log")
6773            .with_i32_data(&[0, 1])
6774            .with_shape(&[2])
6775            .with_maxshape(&[u64::MAX])
6776            .with_chunks(&[4]);
6777        b.create_dataset("refs").with_path_references(&["log"]);
6778        b.write(&path).unwrap();
6779
6780        let file = File::open_rw(&path).unwrap();
6781        let mut by_ref = match file
6782            .dataset("refs")
6783            .unwrap()
6784            .dereference()
6785            .unwrap()
6786            .remove(0)
6787        {
6788            Object::Dataset(ds) => *ds,
6789            other => panic!("expected a dataset, got {other:?}"),
6790        };
6791        assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1]);
6792
6793        // An in-place append rewrites the header where it stands, so the address
6794        // is still the object's and the handle reads its own new elements.
6795        by_ref.append(&[2i32, 3]).unwrap();
6796        assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1, 2, 3]);
6797
6798        // A commit can put the header somewhere else, and nothing on disk marks
6799        // the bytes it vacated as dead.
6800        file.root().create_group("g").unwrap();
6801        file.commit().unwrap();
6802        assert!(
6803            matches!(by_ref.read_i32(), Err(Error::StaleHandle)),
6804            "unexpected: {:?}",
6805            by_ref.read_i32()
6806        );
6807
6808        // And the recovery the error names: dereference again, against the file
6809        // the commit left.
6810        let fresh = match file
6811            .dataset("refs")
6812            .unwrap()
6813            .dereference()
6814            .unwrap()
6815            .remove(0)
6816        {
6817            Object::Dataset(ds) => *ds,
6818            other => panic!("expected a dataset, got {other:?}"),
6819        };
6820        assert_eq!(fresh.read_i32().unwrap(), vec![0, 1, 2, 3]);
6821        file.close().unwrap();
6822    }
6823
6824    /// The counters are what every handle trusts, so every entry point that
6825    /// reaches the write engine has to declare what it does to them. This is
6826    /// that declaration, written out entry point by entry point and checked
6827    /// against the code: a [`Change::Relocating`] advances both counters, an
6828    /// [`Change::InPlace`] only the content one, and a [`Change::Nothing`]
6829    /// neither.
6830    ///
6831    /// The table is the point. An entry point classified too weakly leaves a
6832    /// handle memoizing a header it moved; one classified too strongly ends
6833    /// every by-reference handle in the session for nothing — which is what
6834    /// `File::sync` did until its line was written here.
6835    #[test]
6836    fn every_write_entry_point_declares_what_it_changes() {
6837        let dir = tempfile::tempdir().unwrap();
6838
6839        type Step = (&'static str, Change, fn(&File));
6840        let steps: Vec<Step> = vec![
6841            ("File::sync", Change::Nothing, |f| {
6842                f.sync().unwrap();
6843            }),
6844            ("Dataset::chunk_cache_stats", Change::Nothing, |f| {
6845                let _ = f.dataset("log").unwrap().chunk_cache_stats();
6846            }),
6847            ("Dataset::reset_chunk_cache_stats", Change::Nothing, |f| {
6848                f.dataset("log").unwrap().reset_chunk_cache_stats();
6849            }),
6850            ("BufferedAppender::new", Change::Nothing, |f| {
6851                let mut ds = f.dataset("log").unwrap();
6852                let app = ds.buffered_appender().unwrap();
6853                app.discard();
6854            }),
6855            ("Dataset::append", Change::InPlace, |f| {
6856                f.dataset("log").unwrap().append(&[9i32]).unwrap();
6857            }),
6858            ("Dataset::append_raw", Change::InPlace, |f| {
6859                f.dataset("log")
6860                    .unwrap()
6861                    .append_raw(&7i32.to_le_bytes())
6862                    .unwrap();
6863            }),
6864            ("BufferedAppender::flush", Change::InPlace, |f| {
6865                let mut ds = f.dataset("log").unwrap();
6866                let mut app = ds.buffered_appender().unwrap();
6867                app.append(&[5i32, 6, 7, 8]).unwrap();
6868                app.flush().unwrap();
6869            }),
6870            ("Dataset::write", Change::Relocating, |f| {
6871                f.dataset("plain").unwrap().write(&[1i32, 2, 3]).unwrap();
6872            }),
6873            ("Dataset::set_attr", Change::Relocating, |f| {
6874                f.dataset("plain")
6875                    .unwrap()
6876                    .set_attr("a", AttrValue::I32(1))
6877                    .unwrap();
6878            }),
6879            ("Dataset::remove_attr", Change::Relocating, |f| {
6880                let mut ds = f.dataset("plain").unwrap();
6881                ds.set_attr("gone", AttrValue::I32(1)).unwrap();
6882                f.commit().unwrap();
6883                ds.remove_attr("gone").unwrap();
6884            }),
6885            ("Dataset::write_staged", Change::Relocating, |f| {
6886                f.dataset("plain")
6887                    .unwrap()
6888                    .write_staged(|b| {
6889                        b.with_i32_data(&[4, 5, 6]);
6890                    })
6891                    .unwrap();
6892            }),
6893            ("Dataset::append_staged", Change::Relocating, |f| {
6894                f.dataset("log")
6895                    .unwrap()
6896                    .append_staged(|b| {
6897                        b.append_i32(&[3]);
6898                    })
6899                    .unwrap();
6900            }),
6901            ("Group::create_group", Change::Relocating, |f| {
6902                f.root().create_group("fresh").unwrap();
6903            }),
6904            ("Group::create_dataset", Change::Relocating, |f| {
6905                f.root()
6906                    .create_dataset("made", |b| {
6907                        b.with_i32_data(&[1]);
6908                    })
6909                    .unwrap();
6910            }),
6911            ("Group::delete", Change::Relocating, |f| {
6912                f.root().delete("plain").unwrap();
6913            }),
6914            ("Group::set_attr", Change::Relocating, |f| {
6915                f.root().set_attr("a", AttrValue::I32(1)).unwrap();
6916            }),
6917            ("Group::remove_attr", Change::Relocating, |f| {
6918                f.root().set_attr("gone", AttrValue::I32(1)).unwrap();
6919                f.commit().unwrap();
6920                f.root().remove_attr("gone").unwrap();
6921            }),
6922            ("Group::create_group_with", Change::Relocating, |f| {
6923                f.root()
6924                    .create_group_with("built", |g| {
6925                        g.set_attr("a", AttrValue::I32(1));
6926                    })
6927                    .unwrap();
6928            }),
6929            ("File::copy", Change::Relocating, |f| {
6930                f.copy("plain", "copied").unwrap();
6931            }),
6932            ("File::commit", Change::Relocating, |f| {
6933                f.root().create_group("committed").unwrap();
6934                f.commit().unwrap();
6935            }),
6936        ];
6937
6938        let revisions = |f: &File| (f.inner.content_revision(), f.inner.address_revision());
6939        let declared = |c: Change| match c {
6940            Change::Relocating => "Relocating",
6941            Change::InPlace => "InPlace",
6942            Change::Nothing => "Nothing",
6943        };
6944        for (name, change, step) in steps {
6945            let path = dir.path().join(format!("{}.h5", name.replace("::", "_")));
6946            revalidation_fixture(&path);
6947            let file = File::open_rw(&path).unwrap();
6948            let before = revisions(&file);
6949            step(&file);
6950            let after = revisions(&file);
6951            let observed = match (after.0 > before.0, after.1 > before.1) {
6952                (true, true) => "Relocating",
6953                (true, false) => "InPlace",
6954                (false, false) => "Nothing",
6955                (false, true) => "an address move with no content change",
6956            };
6957            assert_eq!(
6958                observed,
6959                declared(change),
6960                "{name} is declared here as one thing and behaves as another \
6961                 ({before:?} -> {after:?})"
6962            );
6963            file.close().unwrap();
6964        }
6965
6966        // `close` consumes the file, so it cannot be a row above. It is two
6967        // operations: the commit it makes, which relocates like any other, and
6968        // the teardown, which re-homes free space and releases status flags
6969        // where they stand. Both counters therefore move, and the content one
6970        // moves twice.
6971        let path = dir.path().join("File_close.h5");
6972        revalidation_fixture(&path);
6973        let file = File::open_rw(&path).unwrap();
6974        let before = revisions(&file);
6975        let by_path = file.dataset("plain").unwrap();
6976        let by_ref = {
6977            let mut b = FileBuilder::new();
6978            b.create_dataset("d").with_i32_data(&[1, 2]);
6979            b.create_dataset("refs").with_path_references(&["d"]);
6980            let refs_path = dir.path().join("File_close_refs.h5");
6981            b.write(&refs_path).unwrap();
6982            let refs = File::open_rw(&refs_path).unwrap();
6983            let handle = match refs
6984                .dataset("refs")
6985                .unwrap()
6986                .dereference()
6987                .unwrap()
6988                .remove(0)
6989            {
6990                Object::Dataset(ds) => *ds,
6991                other => panic!("expected a dataset, got {other:?}"),
6992            };
6993            refs.close().unwrap();
6994            handle
6995        };
6996        // `close` consumes its `File`; the counters live on the shared inner
6997        // state every handle holds, so read them back through one of those.
6998        let inner = Arc::clone(&file.inner);
6999        file.close().unwrap();
7000        let after = (inner.content_revision(), inner.address_revision());
7001        assert_eq!(
7002            (after.0 - before.0, after.1 - before.1),
7003            (2, 1),
7004            "File::close is a Relocating commit and an InPlace teardown"
7005        );
7006        assert_eq!(
7007            by_path.read_i32().unwrap(),
7008            vec![7, 8, 9],
7009            "`close` promises reads through surviving handles still work"
7010        );
7011        assert!(
7012            matches!(by_ref.read_i32(), Err(Error::StaleHandle)),
7013            "the one handle that cannot follow the commit `close` makes: {:?}",
7014            by_ref.read_i32()
7015        );
7016    }
7017
7018    /// What the *second* counter buys, which nothing else pins: an edit that
7019    /// moves no object header leaves a by-reference handle — the one kind that
7020    /// cannot look itself up again — still able to read. Collapse the two
7021    /// counters into one and every case here becomes `StaleHandle`.
7022    #[test]
7023    fn an_edit_that_moves_no_header_leaves_a_reference_handle_reading() {
7024        let dir = tempfile::tempdir().unwrap();
7025
7026        // Named for the same reason `Step` above is: a bare tuple of a name and
7027        // a function pointer reads as noise at the call site.
7028        type Case = (&'static str, fn(&File));
7029        let cases: Vec<Case> = vec![
7030            (
7031                "an append through another handle to the same dataset",
7032                |f| {
7033                    f.dataset("log").unwrap().append(&[9i32]).unwrap();
7034                },
7035            ),
7036            ("an append to a different dataset", |f| {
7037                f.dataset("other").unwrap().append(&[9i32]).unwrap();
7038            }),
7039            ("a durability barrier", |f| {
7040                f.sync().unwrap();
7041            }),
7042        ];
7043
7044        for (name, edit) in cases {
7045            let path = dir.path().join(format!("{}.h5", name.replace(' ', "_")));
7046            let mut b = FileBuilder::new();
7047            for ds in ["log", "other"] {
7048                b.create_dataset(ds)
7049                    .with_i32_data(&[0, 1])
7050                    .with_shape(&[2])
7051                    .with_maxshape(&[u64::MAX])
7052                    .with_chunks(&[4]);
7053            }
7054            b.create_dataset("refs").with_path_references(&["log"]);
7055            b.write(&path).unwrap();
7056
7057            let file = File::open_rw(&path).unwrap();
7058            let by_ref = match file
7059                .dataset("refs")
7060                .unwrap()
7061                .dereference()
7062                .unwrap()
7063                .remove(0)
7064            {
7065                Object::Dataset(ds) => *ds,
7066                other => panic!("expected a dataset, got {other:?}"),
7067            };
7068            assert_eq!(by_ref.read_i32().unwrap(), vec![0, 1], "{name}: before");
7069            edit(&file);
7070            assert!(
7071                by_ref.read_i32().is_ok(),
7072                "{name} moves no object header, so it must not end a handle that \
7073                 names its dataset by address: {:?}",
7074                by_ref.read_i32()
7075            );
7076            file.close().unwrap();
7077        }
7078    }
7079
7080    /// A commit can put something else at a handle's path — issue #305 makes a
7081    /// dataset and a group interchangeable in one commit. The refusal has to
7082    /// hold on *every* call: a header memoized and then refused is the answer
7083    /// each later call short-circuits on, and this handle would go on serving
7084    /// the other object's header without an error.
7085    #[test]
7086    fn a_handle_whose_path_becomes_a_group_keeps_refusing() {
7087        let dir = tempfile::tempdir().unwrap();
7088        let path = dir.path().join("replaced.h5");
7089        revalidation_fixture(&path);
7090
7091        let file = File::open_rw(&path).unwrap();
7092        let ds = file.dataset("plain").unwrap();
7093        assert_eq!(ds.read_i32().unwrap(), vec![7, 8, 9]);
7094
7095        file.root().delete("plain").unwrap();
7096        file.root()
7097            .create_group_with("plain", |g| {
7098                g.set_attr("i_am_a_group", AttrValue::I32(42));
7099            })
7100            .unwrap();
7101        file.commit().unwrap();
7102
7103        for call in 1..=3 {
7104            assert!(
7105                matches!(ds.attrs(), Err(Error::NotADataset(ref p)) if p == "plain"),
7106                "call {call} answered {:?}",
7107                ds.attrs()
7108            );
7109            assert!(matches!(ds.read_i32(), Err(Error::NotADataset(_))));
7110            assert!(matches!(ds.shape(), Err(Error::NotADataset(_))));
7111        }
7112        file.close().unwrap();
7113    }
7114
7115    /// A read-only file cannot change under a handle, so nothing a reader does
7116    /// may move the counters — and no handle on one ever pays to re-resolve.
7117    #[test]
7118    fn reading_never_moves_the_file_on() {
7119        let dir = tempfile::tempdir().unwrap();
7120        let path = dir.path().join("readonly.h5");
7121        revalidation_fixture(&path);
7122
7123        let file = File::open(&path).unwrap();
7124        let _ = read_everything(&file);
7125        let ds = file.dataset("log").unwrap();
7126        let _ = ds.read_i32().unwrap();
7127        let _ = ds.attrs().unwrap();
7128        let _ = ds.layout().unwrap();
7129        let _ = file.root().groups().unwrap();
7130        assert_eq!(
7131            (file.inner.content_revision(), file.inner.address_revision()),
7132            (0, 0),
7133            "a read must not tell every handle its memo has expired"
7134        );
7135    }
7136
7137    // -----------------------------------------------------------------------
7138    // Opening an object as the wrong kind (issue #352)
7139    // -----------------------------------------------------------------------
7140
7141    /// A dataset at the root and a dataset one level down inside a group.
7142    ///
7143    /// Shared by the two sections below: opening one of those datasets *as* a
7144    /// group is issue #352, and resolving a path *through* one is issue #365, so
7145    /// the sibling refusals are provably about the same file.
7146    fn nested_dataset_bytes() -> Vec<u8> {
7147        let mut b = FileBuilder::new();
7148        b.create_dataset("plain").with_i32_data(&[1]);
7149        let mut g = b.create_group("g");
7150        g.create_dataset("inner").with_i32_data(&[2]);
7151        b.add_group(g.finish());
7152        b.finish().unwrap()
7153    }
7154
7155    /// The issue: a by-name group lookup took whatever the name resolved to.
7156    /// `H5Gopen` fails on a non-group, and so must this — at the lookup, where
7157    /// the caller can act on it, rather than at some later call on a handle that
7158    /// was never a group.
7159    ///
7160    /// The refusal matters most on the calls that did *not* fail: `attrs()`
7161    /// through such a handle answered with the dataset's attributes, which is a
7162    /// wrong answer rather than an error.
7163    #[test]
7164    fn opening_a_dataset_as_a_group_is_refused() {
7165        let file = File::from_bytes(nested_dataset_bytes()).unwrap();
7166        let nested = file.group("g").unwrap();
7167
7168        // Both by-name forms — from the file by path, and from a group by child
7169        // name — at the root and one level down. Each is its own lookup, and
7170        // each was missing the check. The refusal names the object, which is
7171        // what the old `PathNotFound("object header is not a group")` could not.
7172        for (label, named, got) in [
7173            ("File::group", "plain", file.group("plain")),
7174            ("Group::group", "plain", file.root().group("plain")),
7175            ("File::group nested", "g/inner", file.group("g/inner")),
7176            (
7177                "Group::group from a subgroup",
7178                "inner",
7179                nested.group("inner"),
7180            ),
7181        ] {
7182            assert!(
7183                matches!(&got, Err(Error::NotAGroup(p)) if p == named),
7184                "{label} answered {:?}",
7185                got.map(|_| "a group")
7186            );
7187        }
7188
7189        // The name it reports is the normalized one, so the same object refused
7190        // at a lookup and refused through a live handle names itself the same
7191        // way — a handle holds only the normalized path.
7192        assert!(matches!(file.group("/plain/"), Err(Error::NotAGroup(ref p)) if p == "plain"));
7193
7194        // A name that resolves to nothing stays distinct from one that resolves
7195        // to the wrong kind: the second reports what is there.
7196        assert!(matches!(
7197            file.group("absent"),
7198            Err(Error::Format(FormatError::PathNotFound(_)))
7199        ));
7200        assert!(matches!(
7201            file.root().group("absent"),
7202            Err(Error::Format(FormatError::PathNotFound(_)))
7203        ));
7204
7205        // And a real group still opens, by either form.
7206        assert!(file.group("g").is_ok());
7207        assert!(file.root().group("g").is_ok());
7208    }
7209
7210    /// A v1 symbol-table group must keep opening by name.
7211    ///
7212    /// The predicate that decides a lookup was, until this change, only a filter
7213    /// over a listing, where failing to recognise a form merely left a group out.
7214    /// Gating the lookup on it makes each form it names load-bearing, and the v1
7215    /// form is the one with no writer here to produce it — the bytes are a
7216    /// fixture, and a classifier that forgot the symbol table would refuse every
7217    /// group in every file written before the 1.8 format.
7218    #[test]
7219    fn a_symbol_table_group_still_opens_by_name() {
7220        let file =
7221            File::from_bytes(include_bytes!("../tests/data/unattributed/two_groups.h5").to_vec())
7222                .unwrap();
7223
7224        // Names, not a count: this file holds two one-child groups, so a lookup
7225        // that classified correctly and then took its sibling's address would
7226        // pass any count worth asserting.
7227        for lookup in [file.group("group1"), file.root().group("group1")] {
7228            assert_eq!(lookup.unwrap().datasets().unwrap(), ["values"]);
7229        }
7230    }
7231
7232    /// A committed datatype is neither a dataset nor a group, so it separates
7233    /// "is a group" from "is not a dataset" — a check written as the latter
7234    /// would let this one through.
7235    #[test]
7236    fn opening_a_named_datatype_as_a_group_is_refused() {
7237        let mut b = FileBuilder::new();
7238        b.commit_datatype("mytype", crate::make_i32_type());
7239        let file = File::from_bytes(b.finish().unwrap()).unwrap();
7240
7241        assert_eq!(file.root().named_datatypes().unwrap(), vec!["mytype"]);
7242        assert!(matches!(file.group("mytype"), Err(Error::NotAGroup(_))));
7243        assert!(matches!(
7244            file.root().group("mytype"),
7245            Err(Error::NotAGroup(_))
7246        ));
7247    }
7248
7249    // -----------------------------------------------------------------------
7250    // Opening something else as a named datatype (issue #364)
7251    // -----------------------------------------------------------------------
7252
7253    /// An object header carrying exactly `types`, and nothing that would make it
7254    /// parse: the predicate below reads message types and no message body.
7255    fn header_of(types: &[MessageType]) -> ObjectHeader {
7256        ObjectHeader {
7257            version: 2,
7258            messages: types
7259                .iter()
7260                .map(|&msg_type| crate::object_header::HeaderMessage {
7261                    msg_type,
7262                    size: 0,
7263                    flags: 0,
7264                    creation_order: None,
7265                    data: Vec::new(),
7266                })
7267                .collect(),
7268            reference_count: None,
7269            flags: 0,
7270            access_time: None,
7271            modification_time: None,
7272            change_time: None,
7273            birth_time: None,
7274        }
7275    }
7276
7277    /// The rule the listing and both by-name lookups now share, stated over the
7278    /// message combinations rather than over one file's children.
7279    ///
7280    /// Two of these cannot be produced by any writer, here or in the reference
7281    /// library, which is why this is a predicate test and not another fixture. A
7282    /// header carrying links *and* a datatype is a group to the C library, which
7283    /// asks whether it is a group before asking whether it is a datatype; a
7284    /// header carrying neither is no object class at all, and must not become a
7285    /// datatype by default.
7286    #[test]
7287    fn a_committed_datatype_is_a_datatype_that_is_neither_dataset_nor_group() {
7288        for (label, types, expected) in [
7289            ("a committed datatype", &[MessageType::Datatype][..], true),
7290            (
7291                "a dataset, whose element type is a datatype message too",
7292                &[MessageType::Datatype, MessageType::DataLayout],
7293                false,
7294            ),
7295            ("a group with a link table", &[MessageType::LinkInfo], false),
7296            (
7297                "a group with a symbol table",
7298                &[MessageType::SymbolTable],
7299                false,
7300            ),
7301            (
7302                "a group carrying a datatype",
7303                &[MessageType::LinkInfo, MessageType::Datatype],
7304                false,
7305            ),
7306            (
7307                "a header with no datatype at all",
7308                &[MessageType::Dataspace],
7309                false,
7310            ),
7311        ] {
7312            assert_eq!(is_named_datatype(&header_of(types)), expected, "{label}");
7313        }
7314    }
7315
7316    /// The issue: the by-name datatype lookups asked only whether the child had
7317    /// a datatype message. Every dataset does — its element type — so a dataset
7318    /// answered, and the two entry points disagreed with the
7319    /// `named_datatypes()` listing about the same child.
7320    #[test]
7321    fn a_child_that_is_not_a_committed_datatype_is_refused_by_name() {
7322        let mut b = FileBuilder::new();
7323        b.commit_datatype("mytype", crate::make_i32_type());
7324        b.create_dataset("typed")
7325            .with_i32_data(&[1, 2, 3])
7326            .with_committed_datatype("mytype");
7327        b.create_dataset("plain").with_f64_data(&[1.0]);
7328        let g = b.create_group("g").finish();
7329        b.add_group(g);
7330        let file = File::from_bytes(b.finish().unwrap()).unwrap();
7331        let root = file.root();
7332
7333        // The listing is the contract both lookups now share, so it is what the
7334        // refusals below have to agree with.
7335        assert_eq!(root.named_datatypes().unwrap(), ["mytype"]);
7336
7337        // A dataset, a dataset carrying that very type, and a group: three kinds
7338        // that are not a committed datatype, against both entry points. Neither
7339        // had the check, so each needs its own assertion.
7340        for name in ["typed", "plain", "g"] {
7341            let got = root.named_datatype(name);
7342            assert!(
7343                matches!(&got, Err(Error::NotANamedDatatype(p)) if p == name),
7344                "named_datatype({name:?}) answered {got:?}"
7345            );
7346            let got = root.named_datatype_references(name);
7347            assert!(
7348                matches!(&got, Err(Error::NotANamedDatatype(p)) if p == name),
7349                "named_datatype_references({name:?}) answered {got:?}"
7350            );
7351        }
7352
7353        // A name that reaches nothing stays distinct from one that reaches the
7354        // wrong kind, as it is for `group` and `dataset`.
7355        assert!(matches!(
7356            root.named_datatype("absent"),
7357            Err(Error::Format(FormatError::PathNotFound(_)))
7358        ));
7359        assert!(matches!(
7360            root.named_datatype_references("absent"),
7361            Err(Error::Format(FormatError::PathNotFound(_)))
7362        ));
7363
7364        // And the committed type still reads, by both entry points — the value,
7365        // not merely `is_ok`, since a lookup that refused everything would pass
7366        // every assertion above.
7367        assert_eq!(
7368            root.named_datatype("mytype").unwrap(),
7369            crate::make_i32_type()
7370        );
7371        assert_eq!(root.named_datatype_references("mytype").unwrap(), 2);
7372    }
7373
7374    /// The mirror of [`a_handle_whose_path_becomes_a_group_keeps_refusing`]: a
7375    /// commit can leave a live group handle's path naming a dataset (issue
7376    /// #305), which is the one way past the lookup check above. The handle
7377    /// re-resolves, finds the wrong kind, and reports it on every call rather
7378    /// than serving the dataset's header as a group's.
7379    #[test]
7380    fn a_group_handle_whose_path_becomes_a_dataset_keeps_refusing() {
7381        let dir = tempfile::tempdir().unwrap();
7382        let path = dir.path().join("replaced_group.h5");
7383        revalidation_fixture(&path);
7384
7385        let file = File::open_rw(&path).unwrap();
7386        let group = file.group("g").unwrap();
7387        assert!(group.datasets().unwrap().is_empty());
7388
7389        file.root().delete("g").unwrap();
7390        file.root()
7391            .create_dataset("g", |b| {
7392                b.with_i32_data(&[7]);
7393            })
7394            .unwrap();
7395        file.commit().unwrap();
7396
7397        // `attrs` every time round: it is the call that answered with the
7398        // dataset's attributes instead of failing, and a check installed after
7399        // the memo rather than before it would let the second call through.
7400        for call in 1..=3 {
7401            assert!(
7402                matches!(group.attrs(), Err(Error::NotAGroup(ref p)) if p == "g"),
7403                "call {call} answered {:?}",
7404                group.attrs()
7405            );
7406        }
7407        // The rest of the read surface funnels through the same re-resolve, so
7408        // once each is enough to say the refusal is the group's, not `attrs`'s.
7409        assert!(matches!(group.datasets(), Err(Error::NotAGroup(_))));
7410        assert!(matches!(group.groups(), Err(Error::NotAGroup(_))));
7411        assert!(matches!(
7412            group.dataset("anything"),
7413            Err(Error::NotAGroup(_))
7414        ));
7415
7416        // The path still names something, and that something still opens as
7417        // what it now is.
7418        assert_eq!(file.dataset("g").unwrap().read_i32().unwrap(), vec![7]);
7419        file.close().unwrap();
7420    }
7421
7422    // -----------------------------------------------------------------------
7423    // Resolving a path *through* something that is not a group (issue #365)
7424    // -----------------------------------------------------------------------
7425
7426    /// The issue: resolution opens each component in turn to look the next one
7427    /// up inside it, and reported a component that is not a group as
7428    /// `PathNotFound("object header is not a group")` — one string for every
7429    /// such path, naming no component at all. It read as "this path does not
7430    /// exist" where the truth was "`plain` is a dataset".
7431    ///
7432    /// It is now the same [`Error::NotAGroup`] a *final* component that is not a
7433    /// group returns (issue #352), so one match covers a path that goes wrong
7434    /// anywhere along it, and it names the object that stopped the walk rather
7435    /// than the path that was asked for.
7436    #[test]
7437    fn a_path_through_a_non_group_names_the_object_that_stopped_it() {
7438        let file = File::from_bytes(nested_dataset_bytes()).unwrap();
7439
7440        // Two entry points, because each resolves the path for itself, and two
7441        // depths, because the name is the whole prefix walked rather than the
7442        // one component: `g/inner` is a path the caller can go and open, where a
7443        // bare `inner` would not say where to find it.
7444        for (asked, stopper) in [("plain/sub", "plain"), ("g/inner/deeper", "g/inner")] {
7445            for (entry, got) in [
7446                ("File::group", file.group(asked).map(|_| ())),
7447                ("File::dataset", file.dataset(asked).map(|_| ())),
7448            ] {
7449                assert!(
7450                    matches!(&got, Err(Error::NotAGroup(p)) if p == stopper),
7451                    "{entry}({asked:?}) answered {got:?}, expected the stop at {stopper:?}"
7452                );
7453            }
7454        }
7455
7456        // A component that names nothing at all stays a `PathNotFound` naming
7457        // it. The two are different facts, and reading differently is the whole
7458        // point of the change.
7459        assert!(matches!(
7460            file.group("absent/sub"),
7461            Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "absent"
7462        ));
7463
7464        // Empty components are dropped before the walk, so the object is named
7465        // the same way however the path was spelled.
7466        assert!(matches!(
7467            file.group("/plain//sub/"),
7468            Err(Error::NotAGroup(ref p)) if p == "plain"
7469        ));
7470
7471        // The name reaches a caller that only prints the error, too.
7472        let printed = file
7473            .group("g/inner/deeper")
7474            .map(|_| ())
7475            .unwrap_err()
7476            .to_string();
7477        assert!(printed.contains("g/inner"), "the message read {printed:?}");
7478
7479        // And a path that really does run through groups still resolves, so the
7480        // classification has not turned the walk itself into a refusal.
7481        assert_eq!(file.dataset("g/inner").unwrap().read_i32().unwrap(), [2]);
7482
7483        // A committed datatype is neither a dataset nor a group, so it separates
7484        // "is not a group" from "is a dataset" for an intermediate component the
7485        // way it does for a final one.
7486        let mut b = FileBuilder::new();
7487        b.commit_datatype("mytype", crate::make_i32_type());
7488        let typed = File::from_bytes(b.finish().unwrap()).unwrap();
7489        assert!(matches!(
7490            typed.group("mytype/sub"),
7491            Err(Error::NotAGroup(ref p)) if p == "mytype"
7492        ));
7493    }
7494
7495    /// The streaming walk is a second copy of the same loop, reading each header
7496    /// from a `Source`. A fix applied to one and not the other would leave the
7497    /// backend that exists for files too large to buffer reporting the old
7498    /// string.
7499    #[test]
7500    fn the_streaming_walk_names_the_object_that_stopped_it_too() {
7501        let dir = tempfile::tempdir().unwrap();
7502        let path = dir.path().join("nested.h5");
7503        std::fs::write(&path, nested_dataset_bytes()).unwrap();
7504
7505        let file = File::open_streaming(&path).unwrap();
7506        for (asked, stopper) in [("plain/sub", "plain"), ("g/inner/deeper", "g/inner")] {
7507            let got = file.group(asked).map(|_| ());
7508            assert!(
7509                matches!(&got, Err(Error::NotAGroup(p)) if p == stopper),
7510                "group({asked:?}) answered {got:?}"
7511            );
7512        }
7513        assert!(matches!(
7514            file.group("absent/sub"),
7515            Err(Error::Format(FormatError::PathNotFound(ref p))) if p == "absent"
7516        ));
7517        assert_eq!(file.dataset("g/inner").unwrap().read_i32().unwrap(), [2]);
7518        // Before the directory goes: an open file blocks its removal on Windows.
7519        drop(file);
7520    }
7521
7522    /// The root is the one group handle nothing classifies: `File::root` takes
7523    /// the address from the superblock, and no open validates that it names a
7524    /// group. So a file whose superblock points the root at a dataset reaches
7525    /// the refusal in `Group::child_address` that every other handle is kept
7526    /// away from — and names the root by the empty path it carries.
7527    #[test]
7528    fn a_root_that_is_not_a_group_refuses_rather_than_being_searched() {
7529        let mut bytes = nested_dataset_bytes();
7530        let sig = crate::signature::find_signature(&bytes).unwrap();
7531        let mut sb = crate::superblock::Superblock::parse(&bytes, sig).unwrap();
7532        // The fixture has no userblock, so its base address is zero and the
7533        // absolute address a walk returns is also the stored one the superblock
7534        // field wants.
7535        assert_eq!(sb.base_address, BaseAddress::ZERO);
7536        sb.root_group_address = group_v2::resolve_path_any(&bytes, &sb, "plain").unwrap();
7537        let rewritten = sb.serialize();
7538        bytes[sig..sig + rewritten.len()].copy_from_slice(&rewritten);
7539
7540        let file = File::from_bytes(bytes).unwrap();
7541        let got = file.root().dataset("anything").map(|_| ());
7542        assert!(
7543            matches!(&got, Err(Error::NotAGroup(p)) if p.is_empty()),
7544            "a root that is not a group must refuse, got {got:?}"
7545        );
7546    }
7547
7548    /// Read everything a read-write file can serve through the paired read
7549    /// paths, as comparable text.
7550    ///
7551    /// Each entry exercises a different `with_engine` call site: path
7552    /// resolution, object-header parsing, group listing, attribute reads (both
7553    /// the compact and the dense form), a whole-dataset read, and a row-range
7554    /// read. Errors are formatted rather than unwrapped so that a *divergence in
7555    /// which error* is reported also fails the comparison.
7556    fn read_everything(file: &File) -> Vec<String> {
7557        let mut out = Vec::new();
7558        out.push(format!("root groups: {:?}", file.root().groups()));
7559        out.push(format!("root datasets: {:?}", file.root().datasets()));
7560        out.push(format!("root attrs: {:?}", sorted(file.root().attrs())));
7561
7562        // `plain/nope` runs the walk through a dataset (issue #365): the two
7563        // backends must refuse it with the same error as well as agree on the
7564        // reads that succeed.
7565        for path in [
7566            "plain",
7567            "g/nested",
7568            "many_attrs",
7569            "missing",
7570            "g/missing",
7571            "plain/nope",
7572        ] {
7573            match file.dataset(path) {
7574                Ok(ds) => {
7575                    out.push(format!("{path}: shape {:?}", ds.shape()));
7576                    out.push(format!("{path}: attrs {:?}", sorted(ds.attrs())));
7577                    out.push(format!("{path}: all {:?}", ds.read_i32()));
7578                    out.push(format!("{path}: rows {:?}", ds.read_i32_rows(1, 2)));
7579                    out.push(format!("{path}: raw rows {:?}", ds.read_raw_rows(0, 1)));
7580                }
7581                Err(e) => out.push(format!("{path}: error {e}")),
7582            }
7583        }
7584        out
7585    }
7586
7587    /// Attribute maps compare only after ordering; `HashMap`'s `Debug` is not
7588    /// deterministic, and an ordering difference here would be noise rather
7589    /// than the divergence this is looking for.
7590    fn sorted(attrs: Result<HashMap<String, AttrValue>, Error>) -> Vec<String> {
7591        match attrs {
7592            Ok(map) => {
7593                let mut v: Vec<String> =
7594                    map.iter().map(|(k, val)| format!("{k}={val:?}")).collect();
7595                v.sort();
7596                v
7597            }
7598            Err(e) => vec![format!("error {e}")],
7599        }
7600    }
7601
7602    /// Drive `bytes` down both forms of every read a read-write file serves and
7603    /// require identical answers.
7604    ///
7605    /// Every read has a slice form (walking the whole-file mirror) and a
7606    /// `Source` form, and until a mirrorless backing lands (issue #198) only the
7607    /// slice form ever runs. This makes the other form reachable now, so it
7608    /// cannot quietly drift as its twin is edited.
7609    fn assert_both_read_paths_agree(bytes: &[u8], what: &str) {
7610        let dir = tempfile::tempdir().unwrap();
7611        let path = dir.path().join("both.h5");
7612        std::fs::write(&path, bytes).unwrap();
7613
7614        // One session at a time: `open_rw` takes an exclusive lock, and holding
7615        // two over one path fails outright where OS locks are mandatory.
7616        let via_mirror = {
7617            let f = File::open_rw(&path).unwrap();
7618            read_everything(&f)
7619        };
7620        let via_source = {
7621            let f = File::open_rw_source_only(&path).unwrap();
7622            read_everything(&f)
7623        };
7624
7625        assert_eq!(
7626            via_mirror.len(),
7627            via_source.len(),
7628            "{what}: the two read paths produced different numbers of results"
7629        );
7630        for (m, s) in via_mirror.iter().zip(&via_source) {
7631            assert_eq!(m, s, "{what}: slice and Source read paths disagree");
7632        }
7633        // Guard the guard: a helper that read nothing would make the comparison
7634        // vacuous, and a file whose datasets all failed to open would too.
7635        assert!(
7636            via_mirror.iter().any(|r| r.contains("all Ok(")),
7637            "{what}: no dataset read succeeded, so this compared nothing"
7638        );
7639    }
7640
7641    /// A file exercising each paired read: a plain dataset, a nested one behind
7642    /// a group (path resolution), and one carrying enough attributes to force
7643    /// the dense (fractal-heap) attribute layout rather than compact messages.
7644    fn both_paths_file_bytes(userblock: Option<u64>) -> Vec<u8> {
7645        let mut b = FileBuilder::new();
7646        if let Some(ub) = userblock {
7647            b.with_userblock(ub);
7648        }
7649        b.create_dataset("plain")
7650            .with_i32_data(&(0..24).collect::<Vec<i32>>())
7651            .with_shape(&[6, 4])
7652            .set_attr("units", AttrValue::String("m".into()));
7653        // Well past the eight-attribute compact limit, so the header converts to
7654        // the dense layout and the dense extraction path is the one that runs.
7655        {
7656            let ds = b
7657                .create_dataset("many_attrs")
7658                .with_i32_data(&(0..8).collect::<Vec<i32>>());
7659            for i in 0..24 {
7660                ds.set_attr(&format!("attr_{i:02}"), AttrValue::I64(i));
7661            }
7662        }
7663        let mut g = b.create_group("g");
7664        g.create_dataset("nested")
7665            .with_i32_data(&(100..112).collect::<Vec<i32>>())
7666            .with_shape(&[3, 4]);
7667        b.add_group(g.finish());
7668        b.finish().unwrap()
7669    }
7670
7671    #[test]
7672    fn both_read_paths_agree() {
7673        assert_both_read_paths_agree(&both_paths_file_bytes(None), "no userblock");
7674    }
7675
7676    /// The userblock case is the one where the two forms are built differently:
7677    /// the slice form reframes by slicing at the base address, the `Source` form
7678    /// wraps in a `BaseOffsetSource`. A file with a nonzero base is the only way
7679    /// to compare them.
7680    #[test]
7681    fn both_read_paths_agree_with_a_userblock() {
7682        assert_both_read_paths_agree(&both_paths_file_bytes(Some(512)), "512-byte userblock");
7683    }
7684
7685    /// One 256-element i32 dataset, chunked into 32-element chunks, in memory.
7686    fn chunked_file_bytes() -> Vec<u8> {
7687        let data: Vec<i32> = (0..256).collect();
7688        let mut b = FileBuilder::new();
7689        b.create_dataset("chunked")
7690            .with_i32_data(&data)
7691            .with_shape(&[256])
7692            .with_chunks(&[32]);
7693        b.finish().unwrap()
7694    }
7695
7696    // The DAPL override must drive the *live* `ChunkCache`, not merely the value
7697    // reported by `chunk_cache_config()`. These assertions reach the crate's
7698    // `#[cfg(test)]` cache introspection (unavailable to integration tests), so
7699    // they fail if the resolved config ever stops flowing into the real cache.
7700
7701    #[test]
7702    fn enabled_override_populates_live_cache_over_disabled_file_default() {
7703        let file = File::from_bytes_with_options(
7704            chunked_file_bytes(),
7705            FileAccessProperties::new().with_chunk_cache(ChunkCacheConfig::disabled()),
7706        )
7707        .unwrap();
7708
7709        let ds = file
7710            .dataset_with_options(
7711                "chunked",
7712                DatasetAccessProperties::new().with_chunk_cache(ChunkCacheConfig::new()),
7713            )
7714            .unwrap();
7715        assert_eq!(ds.read_i32().unwrap(), (0..256).collect::<Vec<i32>>());
7716
7717        // The enabled override built the chunk index and retained chunks; the
7718        // disabled file default would have left both empty.
7719        assert!(ds.chunk_cache_stats().index_loaded());
7720        assert!(ds.chunk_cache_stats().cached_chunks() > 0);
7721    }
7722
7723    #[test]
7724    fn disabled_override_suppresses_live_cache_over_enabled_file_default() {
7725        let file = File::from_bytes_with_options(
7726            chunked_file_bytes(),
7727            FileAccessProperties::new().with_chunk_cache(ChunkCacheConfig::new()),
7728        )
7729        .unwrap();
7730
7731        let ds = file
7732            .dataset_with_options(
7733                "chunked",
7734                DatasetAccessProperties::new().with_chunk_cache(ChunkCacheConfig::disabled()),
7735            )
7736            .unwrap();
7737        assert_eq!(ds.read_i32().unwrap(), (0..256).collect::<Vec<i32>>());
7738
7739        // The disabled override suppressed the index and chunk retention; the
7740        // enabled file default would have populated both.
7741        assert!(!ds.chunk_cache_stats().index_loaded());
7742        assert_eq!(ds.chunk_cache_stats().cached_chunks(), 0);
7743    }
7744
7745    /// A group child whose stored (base-relative) object-header address overflows
7746    /// `u64` once the base address is added must be rejected, not wrapped or
7747    /// panicked on. Reaching this needs a nonzero base address, so the file
7748    /// carries a userblock; the child link's stored address is then rewritten to
7749    /// `HADDR_UNDEF` (all ones) so `group_children`'s normalization overflows.
7750    #[test]
7751    fn group_child_address_base_overflow_is_rejected() {
7752        const UB: u64 = 512;
7753        let mut b = FileBuilder::new();
7754        b.with_userblock(UB);
7755        let mut child = b.create_group("child");
7756        child.create_dataset("inner").with_i32_data(&[1, 2, 3]);
7757        b.add_group(child.finish());
7758        let mut bytes = b.finish().unwrap();
7759
7760        // Baseline: the file reads and the subgroup is listed.
7761        let file = File::from_bytes(bytes.clone()).unwrap();
7762        assert_eq!(file.root().groups().unwrap(), vec!["child".to_string()]);
7763
7764        // Rewrite the child's stored object-header address to HADDR_UNDEF. It is
7765        // stored base-relative (absolute minus the userblock base) and, for this
7766        // single-child file, appears exactly once in the bytes. The link lives in
7767        // the root object header's chunk-0.
7768        let stored = file
7769            .root()
7770            .group("child")
7771            .unwrap()
7772            .header_address()
7773            .unwrap()
7774            - UB;
7775        let needle = stored.to_le_bytes();
7776        let matches: Vec<usize> = bytes
7777            .windows(8)
7778            .enumerate()
7779            .filter(|(_, w)| *w == needle)
7780            .map(|(i, _)| i)
7781            .collect();
7782        assert_eq!(
7783            matches.len(),
7784            1,
7785            "stored child address {stored:#x} was not uniquely locatable: {matches:?}"
7786        );
7787        bytes[matches[0]..matches[0] + 8].copy_from_slice(&u64::MAX.to_le_bytes());
7788
7789        // The v2 object header is checksum-protected, so a real crafted file would
7790        // carry a matching checksum; recompute the root header's over the edited
7791        // bytes so parsing reaches the address normalization rather than failing on
7792        // the checksum first. Mirrors the chunk-0 extent from `parse_v2`.
7793        #[cfg(feature = "checksum")]
7794        {
7795            let root_addr = file.root().header_address().unwrap() as usize;
7796            assert_eq!(&bytes[root_addr..root_addr + 4], b"OHDR");
7797            let flags = bytes[root_addr + 5];
7798            let mut pos = root_addr + 6;
7799            if flags & 0x20 != 0 {
7800                pos += 16;
7801            }
7802            if flags & 0x10 != 0 {
7803                pos += 4;
7804            }
7805            let width = 1usize << (flags & 0x03);
7806            let chunk0 = (0..width).fold(0usize, |acc, i| {
7807                acc | ((bytes[pos + i] as usize) << (8 * i))
7808            });
7809            pos += width;
7810            let chunk0_end = pos + chunk0;
7811            assert!(
7812                matches[0] < chunk0_end,
7813                "patched link address is outside the root header's chunk-0"
7814            );
7815            let cs = crate::checksum::jenkins_lookup3(&bytes[root_addr..chunk0_end]);
7816            bytes[chunk0_end..chunk0_end + 4].copy_from_slice(&cs.to_le_bytes());
7817        }
7818
7819        // Iterating the root now normalizes `u64::MAX + base` and must surface the
7820        // overflow as a format error rather than panicking or wrapping.
7821        let file = File::from_bytes(bytes).unwrap();
7822        match file.root().groups() {
7823            Err(Error::Format(FormatError::OffsetOverflow { offset, length })) => {
7824                assert_eq!(offset, u64::MAX);
7825                assert_eq!(length, UB);
7826            }
7827            other => panic!("expected group-child address overflow, got {other:?}"),
7828        }
7829    }
7830
7831    /// Recompute a version-2 object header's checksum over its chunk 0, after a
7832    /// test has edited a message inside it.
7833    ///
7834    /// A crafted file a reader must survive carries a *valid* checksum — an
7835    /// attacker recomputes it — so a test that edits a header and leaves the old
7836    /// one measures the checksum rather than the thing it meant to.
7837    #[cfg(feature = "checksum")]
7838    fn refresh_v2_header_checksum(bytes: &mut [u8], header_addr: usize) -> std::ops::Range<usize> {
7839        assert_eq!(&bytes[header_addr..header_addr + 4], b"OHDR");
7840        let flags = bytes[header_addr + 5];
7841        let mut pos = header_addr + 6;
7842        if flags & 0x20 != 0 {
7843            pos += 16;
7844        }
7845        if flags & 0x10 != 0 {
7846            pos += 4;
7847        }
7848        let width = 1usize << (flags & 0x03);
7849        let chunk0 = (0..width).fold(0usize, |acc, i| {
7850            acc | ((bytes[pos + i] as usize) << (8 * i))
7851        });
7852        pos += width;
7853        let chunk0_end = pos + chunk0;
7854        let cs = crate::checksum::jenkins_lookup3(&bytes[header_addr..chunk0_end]);
7855        bytes[chunk0_end..chunk0_end + 4].copy_from_slice(&cs.to_le_bytes());
7856        header_addr..chunk0_end
7857    }
7858
7859    /// A contiguous layout whose declared size disagrees with its dataspace is
7860    /// refused, whatever the dataset's size and whichever read asks.
7861    ///
7862    /// The whole-dataset readers have always refused it. A typed read now takes a
7863    /// large dataset a row window at a time, and a window only ever checks that
7864    /// its *own* rows are inside the declared storage — so without the shared
7865    /// check this refusal would have applied to small datasets, which are still
7866    /// read whole, and not to large ones. A validation that fires depending on
7867    /// the size of the input is the kind that surfaces years later as an
7868    /// inconsistent bug report, which is why both sizes are here.
7869    #[cfg(feature = "checksum")]
7870    #[test]
7871    fn a_layout_size_disagreeing_with_the_dataspace_is_refused_at_every_dataset_size() {
7872        // One dataset below the typed read's window budget and read whole; one
7873        // above it and swept.
7874        for n in [1000usize, 200_000] {
7875            let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
7876            let mut b = crate::writer::FileBuilder::new();
7877            b.create_dataset("t")
7878                .with_f64_data(&data)
7879                .with_shape(&[n as u64]);
7880            let mut bytes = b.finish().unwrap();
7881            // Taken before the edit: an edited header fails its checksum, and the
7882            // address is needed to recompute it.
7883            let header_addr = {
7884                let file = File::from_bytes(bytes.clone()).unwrap();
7885                file.dataset("t").unwrap().header_address().unwrap() as usize
7886            };
7887
7888            // The layout message's size field: the dataset's byte length, which
7889            // appears once in the file. The assertion is the fixture's own guard —
7890            // patching some other field would test nothing in particular.
7891            let declared = (n * 8) as u64;
7892            let needle = declared.to_le_bytes();
7893            let at: Vec<usize> = bytes
7894                .windows(8)
7895                .enumerate()
7896                .filter(|(_, w)| *w == needle)
7897                .map(|(i, _)| i)
7898                .collect();
7899            assert_eq!(
7900                at.len(),
7901                1,
7902                "the stored size {declared} was not uniquely locatable in a {n}-element file: {at:?}"
7903            );
7904            bytes[at[0]..at[0] + 8].copy_from_slice(&(declared * 2).to_le_bytes());
7905
7906            let chunk0 = refresh_v2_header_checksum(&mut bytes, header_addr);
7907            assert!(
7908                chunk0.contains(&at[0]),
7909                "the patched size is outside the header chunk whose checksum was refreshed"
7910            );
7911
7912            let file = File::from_bytes(bytes).unwrap();
7913            let ds = file.dataset("t").unwrap();
7914            let expected = FormatError::DataSizeMismatch {
7915                expected: n * 8,
7916                actual: n * 16,
7917            };
7918            for (what, err) in [
7919                ("read_raw", ds.read_raw().unwrap_err()),
7920                ("read_f64", ds.read_f64().unwrap_err()),
7921                ("read_i32", ds.read_i32().unwrap_err()),
7922            ] {
7923                match err {
7924                    Error::Format(got) => assert_eq!(
7925                        format!("{got:?}"),
7926                        format!("{expected:?}"),
7927                        "{what} over {n} elements reported the wrong mismatch"
7928                    ),
7929                    other => {
7930                        panic!("{what} over {n} elements: expected a format error, got {other:?}")
7931                    }
7932                }
7933            }
7934        }
7935    }
7936
7937    /// A zero-row window returns `Ok(empty)` uniformly across layouts, including
7938    /// over unallocated storage. Unallocated storage now reads as the fill value
7939    /// rather than erroring, so this no longer guards a cross-layout divergence
7940    /// in the error; what it still pins is that a window of no rows is an *empty*
7941    /// buffer and not a zero-length fill, which is what a caller iterating past
7942    /// the end of a dataset sees.
7943    #[test]
7944    fn read_rows_framed_zero_row_window_is_ok_even_when_unallocated() {
7945        let dl = DataLayout::Contiguous {
7946            address: None,
7947            size: 0,
7948        };
7949        let ds = Dataspace {
7950            space_type: crate::dataspace::DataspaceType::Simple,
7951            rank: 1,
7952            dimensions: vec![0],
7953            max_dimensions: None,
7954        };
7955        let dt = Datatype::FixedPoint {
7956            size: 8,
7957            byte_order: crate::datatype::DatatypeByteOrder::LittleEndian,
7958            signed: false,
7959            bit_offset: 0,
7960            bit_precision: 64,
7961        };
7962        let cache = ChunkCache::new();
7963        let out = read_rows_framed(
7964            &BytesSource::new(b""),
7965            RawReadSpec::plain(&dl, &ds, &dt),
7966            8,
7967            8,
7968            &cache,
7969            CachePass::LRU,
7970            0,
7971            0,
7972            8,
7973        )
7974        .expect("a zero-row window must be Ok(empty)");
7975        assert!(out.is_empty());
7976
7977        // A Virtual layout is unsupported and must still error for a zero-row
7978        // window, matching `read_raw`, rather than being swallowed by the early
7979        // return.
7980        let virtual_dl = DataLayout::Virtual { version: 4 };
7981        let err = read_rows_framed(
7982            &BytesSource::new(b""),
7983            RawReadSpec::plain(&virtual_dl, &ds, &dt),
7984            8,
7985            8,
7986            &cache,
7987            CachePass::LRU,
7988            0,
7989            0,
7990            8,
7991        )
7992        .expect_err("a virtual layout must error even for a zero-row window");
7993        assert!(
7994            matches!(err, FormatError::UnsupportedVirtualLayout),
7995            "expected UnsupportedVirtualLayout, got {err:?}"
7996        );
7997    }
7998
7999    /// The window a typed whole-dataset read sweeps in is a budget in *stored
8000    /// bytes* resolved against the dataset's own geometry, and a chunked dataset
8001    /// adds a second rule on top: whole chunk bands.
8002    ///
8003    /// A window that ended mid-band would make the next window decode the band
8004    /// again — the cost windowing exists to avoid — so the budget is rounded down
8005    /// to a multiple of the band, and *up* to one whole band when even one band
8006    /// is over budget. The three ways a window can come out are one rule with
8007    /// different inputs, which is why they are asserted together.
8008    #[test]
8009    fn a_typed_read_windows_in_whole_chunk_bands() {
8010        const BUDGET: u64 = TYPED_READ_WINDOW_BYTES;
8011        let ds1 = |dims: &[u64]| Dataspace {
8012            space_type: crate::dataspace::DataspaceType::Simple,
8013            rank: dims.len() as u8,
8014            dimensions: dims.to_vec(),
8015            max_dimensions: None,
8016        };
8017        let contiguous = DataLayout::Contiguous {
8018            address: Some(0),
8019            size: 0,
8020        };
8021        let chunked = |band: u32| DataLayout::Chunked {
8022            chunk_dimensions: vec![band, 8],
8023            btree_address: Some(0),
8024            version: 3,
8025            chunk_index_type: None,
8026            single_chunk_filtered_size: None,
8027            single_chunk_filter_mask: None,
8028        };
8029        let elem = NonZeroUsize::new(8).unwrap();
8030
8031        // Unchunked: the budget, divided by the row width, exactly.
8032        assert_eq!(
8033            typed_window_rows(&contiguous, &ds1(&[1 << 20]), elem)
8034                .unwrap()
8035                .get(),
8036            BUDGET / 8
8037        );
8038
8039        // A band that divides the budget takes it unchanged; one that does not
8040        // is rounded down to a whole number of bands, never up.
8041        assert_eq!(
8042            typed_window_rows(&chunked(512), &ds1(&[1 << 20]), elem)
8043                .unwrap()
8044                .get(),
8045            BUDGET / 8
8046        );
8047        let rows = typed_window_rows(&chunked(300), &ds1(&[1 << 20]), elem)
8048            .unwrap()
8049            .get();
8050        assert_eq!(rows % 300, 0, "a window must end on a chunk band");
8051        assert!(
8052            rows <= BUDGET / 8 && rows > BUDGET / 8 - 300,
8053            "a window must be the largest whole number of bands within the \
8054             budget, not a smaller one: {rows} rows against {} in budget",
8055            BUDGET / 8
8056        );
8057
8058        // One band over budget: the window is that band, since a narrower one
8059        // would decode it twice.
8060        assert_eq!(
8061            typed_window_rows(&chunked(1 << 20), &ds1(&[1 << 21]), elem)
8062                .unwrap()
8063                .get(),
8064            1 << 20
8065        );
8066
8067        // Rows wider than the whole budget: one row, which is the least a window
8068        // can be and still make progress.
8069        assert_eq!(
8070            typed_window_rows(&contiguous, &ds1(&[4, 1 << 20]), elem)
8071                .unwrap()
8072                .get(),
8073            1
8074        );
8075
8076        // Rank 0. A chunked layout message carries rank + 1 dimensions, so a
8077        // scalar's only entry is the element-size trailer and the "band" read
8078        // out of it is not a band at all. Nothing rests on it: a scalar has one
8079        // row, so `n0 <= rows` sends it to the whole read whatever this says.
8080        // Asserted so that a later reading of `first()` as the leading extent
8081        // has to account for this case rather than discover it.
8082        let scalar = Dataspace {
8083            space_type: crate::dataspace::DataspaceType::Scalar,
8084            rank: 0,
8085            dimensions: Vec::new(),
8086            max_dimensions: None,
8087        };
8088        assert!(typed_window_rows(&chunked(8), &scalar, elem).unwrap().get() >= 1);
8089
8090        // A zero inner dimension makes a row zero bytes wide, and the dataset
8091        // has no elements at all: one window covers it, and nothing divides by
8092        // zero on the way there.
8093        assert_eq!(
8094            typed_window_rows(&contiguous, &ds1(&[4, 0]), elem)
8095                .unwrap()
8096                .get(),
8097            u64::MAX
8098        );
8099    }
8100
8101    /// The two channels a caller has for one object's attributes.
8102    struct AttrChannels {
8103        owner: &'static str,
8104        /// What `attrs` decoded — the values, lossily.
8105        values: HashMap<String, AttrValue>,
8106        /// What `attr_datatypes` reported — the encodings, exactly.
8107        datatypes: HashMap<String, Datatype>,
8108    }
8109
8110    /// One written file: its bytes, and both channels read back from each owner.
8111    struct AttrFile {
8112        /// The file as written, so a test can assert which storage form it got.
8113        bytes: Vec<u8>,
8114        owners: Vec<AttrChannels>,
8115    }
8116
8117    /// Put the same attributes on the root group and on a dataset, write the
8118    /// file, and read both channels back from each owner.
8119    ///
8120    /// Both owners, because `Group` and `Dataset` reach their attribute messages
8121    /// by different routes: one parses an object header by address, the other
8122    /// already holds one.
8123    fn attr_channels(
8124        values: &[(&str, AttrValue)],
8125        verbatim: &[crate::attribute::AttributeMessage],
8126    ) -> AttrFile {
8127        let mut b = FileBuilder::new();
8128        for (name, value) in values {
8129            b.set_attr(name, value.clone());
8130        }
8131        for message in verbatim {
8132            b.set_attr_verbatim(message.clone());
8133        }
8134        {
8135            let ds = b.create_dataset("data").with_f64_data(&[1.0]);
8136            for (name, value) in values {
8137                ds.set_attr(name, value.clone());
8138            }
8139            for message in verbatim {
8140                ds.set_attr_verbatim(message.clone());
8141            }
8142        }
8143        let bytes = b.finish().unwrap();
8144        let file = File::from_bytes(bytes.clone()).unwrap();
8145        let root = file.root();
8146        let dataset = file.dataset("data").unwrap();
8147        AttrFile {
8148            bytes,
8149            owners: vec![
8150                AttrChannels {
8151                    owner: "root group",
8152                    values: root.attrs().unwrap(),
8153                    datatypes: root.attr_datatypes().unwrap(),
8154                },
8155                AttrChannels {
8156                    owner: "dataset",
8157                    values: dataset.attrs().unwrap(),
8158                    datatypes: dataset.attr_datatypes().unwrap(),
8159                },
8160            ],
8161        }
8162    }
8163
8164    /// The two channels on the axes each one carries.
8165    ///
8166    /// Both now report an integer's width: the value channel keeps it (#350),
8167    /// so `count` is `I32` and its datatype is the 4-byte signed type it is
8168    /// stored as. What the value channel still cannot say is how those bytes are
8169    /// laid out — `be` holds the same number big-endian and decodes to the same
8170    /// `I32`, so the datatype channel is the only record that re-encoding from
8171    /// the value would flip its byte order. `AttrValue` is documented as lossy;
8172    /// this is where the loss is (#248).
8173    #[test]
8174    fn attr_datatypes_reports_the_byte_order_attrs_normalizes() {
8175        let be = crate::attribute::AttributeMessage {
8176            name: "be".into(),
8177            datatype: Datatype::FixedPoint {
8178                size: 4,
8179                byte_order: crate::datatype::DatatypeByteOrder::BigEndian,
8180                signed: true,
8181                bit_offset: 0,
8182                bit_precision: 32,
8183            },
8184            dataspace: Dataspace {
8185                space_type: crate::dataspace::DataspaceType::Scalar,
8186                rank: 0,
8187                dimensions: vec![],
8188                max_dimensions: None,
8189            },
8190            raw_data: (-7i32).to_be_bytes().to_vec(),
8191            datatype_location: crate::shared_message::DatatypeLocation::Inline,
8192        };
8193
8194        for c in attr_channels(&[("count", AttrValue::I32(-7))], std::slice::from_ref(&be)).owners {
8195            assert_eq!(
8196                c.values.get("count"),
8197                Some(&AttrValue::I32(-7)),
8198                "{}: the value channel keeps the width the attribute was written at",
8199                c.owner
8200            );
8201            let Some(Datatype::FixedPoint { size, signed, .. }) = c.datatypes.get("count") else {
8202                panic!(
8203                    "{}: expected a fixed-point datatype, got {:?}",
8204                    c.owner,
8205                    c.datatypes.get("count")
8206                );
8207            };
8208            assert_eq!(
8209                (*size, *signed),
8210                (4, true),
8211                "{}: the datatype channel must report the width on disk",
8212                c.owner
8213            );
8214
8215            assert_eq!(
8216                c.values.get("be"),
8217                Some(&AttrValue::I32(-7)),
8218                "{}: a big-endian attribute decodes to the value it holds",
8219                c.owner
8220            );
8221            let Some(Datatype::FixedPoint { byte_order, .. }) = c.datatypes.get("be") else {
8222                panic!(
8223                    "{}: expected a fixed-point datatype, got {:?}",
8224                    c.owner,
8225                    c.datatypes.get("be")
8226                );
8227            };
8228            assert_eq!(
8229                *byte_order,
8230                crate::datatype::DatatypeByteOrder::BigEndian,
8231                "{}: the datatype channel is the only record of the byte order",
8232                c.owner
8233            );
8234        }
8235    }
8236
8237    /// Every attribute message is reported, including one `attrs` drops because
8238    /// no `AttrValue` can carry it.
8239    ///
8240    /// That is what lets a caller tell a dropped attribute from an absent one. An
8241    /// omission with nothing to compare against is invisible, which is how every
8242    /// `np.bool_` attribute in an h5py file went missing without a trace (#248).
8243    #[test]
8244    fn attr_datatypes_reports_an_attribute_attrs_omits() {
8245        let opaque = Datatype::Opaque {
8246            size: 3,
8247            tag: b"rgb".to_vec(),
8248        };
8249        let raw = crate::attribute::AttributeMessage {
8250            name: "raw".into(),
8251            datatype: opaque.clone(),
8252            dataspace: Dataspace {
8253                space_type: crate::dataspace::DataspaceType::Scalar,
8254                rank: 0,
8255                dimensions: vec![],
8256                max_dimensions: None,
8257            },
8258            raw_data: vec![1, 2, 3],
8259            datatype_location: crate::shared_message::DatatypeLocation::Inline,
8260        };
8261
8262        for c in attr_channels(&[("count", AttrValue::I32(1))], std::slice::from_ref(&raw)).owners {
8263            assert!(
8264                !c.values.contains_key("raw"),
8265                "{}: an opaque attribute has no `AttrValue`, so `attrs` omits it — \
8266                 if that changes, this test is measuring the wrong thing",
8267                c.owner
8268            );
8269            assert_eq!(
8270                c.datatypes.get("raw"),
8271                Some(&opaque),
8272                "{}: the datatype channel must report an attribute `attrs` omits",
8273                c.owner
8274            );
8275            // Specific to the one attribute: a channel that reported only the
8276            // undecodable one, or dropped its neighbour, would pass the above.
8277            assert!(
8278                c.values.contains_key("count") && c.datatypes.contains_key("count"),
8279                "{}: the attribute beside it must appear in both channels",
8280                c.owner
8281            );
8282        }
8283    }
8284
8285    /// The refusal reaches a dataset too, not only the attribute the issue was
8286    /// reported through: the same decoders back `Dataset::read_*`, by both the
8287    /// whole-dataset and the windowed route.
8288    #[test]
8289    fn a_wide_dataset_is_refused_by_the_typed_readers() {
8290        let dt = Datatype::FixedPoint {
8291            size: 16,
8292            byte_order: crate::datatype::DatatypeByteOrder::LittleEndian,
8293            signed: false,
8294            bit_offset: 0,
8295            bit_precision: 128,
8296        };
8297        let mut b = FileBuilder::new();
8298        b.create_dataset("wide")
8299            .with_raw_data(dt.clone(), vec![0xFF; 32], 2);
8300        let file = File::from_bytes(b.finish().unwrap()).unwrap();
8301        let ds = file.dataset("wide").unwrap();
8302
8303        assert_eq!(
8304            ds.datatype().unwrap(),
8305            dt,
8306            "the datatype still reads: it is the values that have no answer"
8307        );
8308        assert!(matches!(
8309            ds.read_u64(),
8310            Err(Error::Format(FormatError::NumericElementTooWide {
8311                size: 16
8312            }))
8313        ));
8314        assert!(matches!(
8315            ds.read_u64_rows(0, 1),
8316            Err(Error::Format(FormatError::NumericElementTooWide {
8317                size: 16
8318            }))
8319        ));
8320        // What is refused is the decode, not the data: the bytes are still
8321        // there for a caller willing to read the width itself.
8322        assert_eq!(ds.read_raw().unwrap().len(), 32);
8323    }
8324
8325    /// A fixed-point attribute wider than the 64-bit value the readers decode
8326    /// into joins the attributes `attrs` omits, rather than appearing there
8327    /// holding part of its value.
8328    ///
8329    /// This one used to be *present* in the values channel: nine bytes holding
8330    /// 2^64 read back as `U64(0)`, a value indistinguishable from an attribute
8331    /// that really holds zero. Omitting it puts it where the opaque attribute
8332    /// above already sits — absent from the values, reported in full by the
8333    /// datatypes — so a caller can see that something was dropped (#361).
8334    #[test]
8335    fn an_attribute_too_wide_to_decode_is_omitted_rather_than_truncated() {
8336        let wide = Datatype::FixedPoint {
8337            size: 9,
8338            byte_order: crate::datatype::DatatypeByteOrder::LittleEndian,
8339            signed: false,
8340            bit_offset: 0,
8341            bit_precision: 72,
8342        };
8343        // 2^64 exactly, so every one of the low 64 bits is zero.
8344        let mut raw_data = vec![0u8; 9];
8345        raw_data[8] = 1;
8346        let huge = crate::attribute::AttributeMessage {
8347            name: "huge".into(),
8348            datatype: wide.clone(),
8349            dataspace: Dataspace {
8350                space_type: crate::dataspace::DataspaceType::Scalar,
8351                rank: 0,
8352                dimensions: vec![],
8353                max_dimensions: None,
8354            },
8355            raw_data,
8356            datatype_location: crate::shared_message::DatatypeLocation::Inline,
8357        };
8358
8359        for c in attr_channels(&[("count", AttrValue::I32(1))], std::slice::from_ref(&huge)).owners
8360        {
8361            assert!(
8362                !c.values.contains_key("huge"),
8363                "{}: decoding this attribute would report 0 for a value of 2^64",
8364                c.owner
8365            );
8366            assert_eq!(
8367                c.datatypes.get("huge"),
8368                Some(&wide),
8369                "{}: the datatype channel must still report the width on disk",
8370                c.owner
8371            );
8372            assert!(
8373                c.values.contains_key("count") && c.datatypes.contains_key("count"),
8374                "{}: the attribute beside it must still decode",
8375                c.owner
8376            );
8377        }
8378    }
8379
8380    /// The channel must cover dense (fractal-heap) attribute storage, not only
8381    /// the compact form that lives in the object header.
8382    ///
8383    /// The two tests above use three attributes, which is well inside the
8384    /// writer's compact threshold, so on their own they leave every dense path
8385    /// unpinned: an implementation that walked `hdr.messages` directly and never
8386    /// touched the heap would pass both and report *nothing* for a real file with
8387    /// many attributes. Twelve is past the threshold, and the heap signature is
8388    /// asserted so the fixture cannot quietly revert to compact storage and take
8389    /// the coverage with it.
8390    #[test]
8391    fn attr_datatypes_covers_dense_attribute_storage() {
8392        let names: Vec<String> = (0..12).map(|i| format!("a{i:02}")).collect();
8393        let values: Vec<(&str, AttrValue)> = names
8394            .iter()
8395            .enumerate()
8396            .map(|(i, n)| {
8397                (
8398                    n.as_str(),
8399                    #[expect(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
8400                    AttrValue::I32(i as i32),
8401                )
8402            })
8403            .collect();
8404
8405        let f = attr_channels(&values, &[]);
8406        assert!(
8407            f.bytes.windows(4).any(|w| w == b"FRHP"),
8408            "the fixture must really use dense storage, or this test proves \
8409             nothing beyond the compact path the other tests already cover"
8410        );
8411
8412        for c in f.owners {
8413            assert_eq!(
8414                c.datatypes.len(),
8415                names.len(),
8416                "{}: every attribute in the heap must be reported, got {:?}",
8417                c.owner,
8418                c.datatypes.keys().collect::<Vec<_>>()
8419            );
8420            // The two channels must agree on which attributes exist: all of these
8421            // decode, so neither one has anything to omit here.
8422            let mut from_values: Vec<&String> = c.values.keys().collect();
8423            let mut from_types: Vec<&String> = c.datatypes.keys().collect();
8424            from_values.sort();
8425            from_types.sort();
8426            assert_eq!(
8427                from_values, from_types,
8428                "{}: the channels disagree",
8429                c.owner
8430            );
8431            for name in &names {
8432                assert!(
8433                    matches!(
8434                        c.datatypes.get(name),
8435                        Some(Datatype::FixedPoint { size: 4, .. })
8436                    ),
8437                    "{}: {name} must keep its 4-byte width through the heap, got {:?}",
8438                    c.owner,
8439                    c.datatypes.get(name)
8440                );
8441            }
8442        }
8443    }
8444
8445    // -----------------------------------------------------------------------
8446    // Coalesced chunk reads (see `crate::chunk_span`)
8447    // -----------------------------------------------------------------------
8448
8449    /// A [`Source`] over a file image that counts what the reader asks the file
8450    /// for, so a test can assert read *volume* and not only the values returned.
8451    struct CountingSource {
8452        bytes: Vec<u8>,
8453        reads: Arc<AtomicUsize>,
8454        bytes_read: Arc<AtomicUsize>,
8455    }
8456
8457    impl Source for CountingSource {
8458        fn len(&self) -> u64 {
8459            self.bytes.len() as u64
8460        }
8461
8462        fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
8463            self.reads.fetch_add(1, Ordering::Relaxed);
8464            self.bytes_read.fetch_add(buf.len(), Ordering::Relaxed);
8465            BytesSource::new(&self.bytes).read_at(offset, buf)
8466        }
8467    }
8468
8469    /// Counters for one measured read.
8470    #[derive(Default)]
8471    struct ReadCounts {
8472        reads: Arc<AtomicUsize>,
8473        bytes: Arc<AtomicUsize>,
8474    }
8475
8476    impl ReadCounts {
8477        /// Run `f` and report `(reads, bytes)` it cost.
8478        fn measure<R>(&self, f: impl FnOnce() -> R) -> (usize, usize) {
8479            let r0 = self.reads.load(Ordering::Relaxed);
8480            let b0 = self.bytes.load(Ordering::Relaxed);
8481            f();
8482            (
8483                self.reads.load(Ordering::Relaxed) - r0,
8484                self.bytes.load(Ordering::Relaxed) - b0,
8485            )
8486        }
8487    }
8488
8489    /// A streaming [`File`] over `bytes` whose reads are counted, built the way
8490    /// [`File::open_streaming_with_options`] builds one over a file handle.
8491    fn counting_streaming_file(
8492        bytes: Vec<u8>,
8493        counts: &ReadCounts,
8494        access: FileAccessProperties,
8495    ) -> File {
8496        File::from_source_with_options(
8497            CountingSource {
8498                bytes,
8499                reads: Arc::clone(&counts.reads),
8500                bytes_read: Arc::clone(&counts.bytes),
8501            },
8502            access,
8503        )
8504        .expect("open from source")
8505    }
8506
8507    /// An `n`-element f64 dataset in chunks of `chunk` elements.
8508    fn chunked_f64_file(n: usize, chunk: u64) -> (Vec<u8>, Vec<f64>) {
8509        let data: Vec<f64> = (0..n).map(|i| i as f64).collect();
8510        let mut builder = FileBuilder::new();
8511        builder
8512            .create_dataset("d")
8513            .with_f64_data(&data)
8514            .with_shape(&[n as u64])
8515            .with_chunks(&[chunk]);
8516        (builder.finish().expect("write file"), data)
8517    }
8518
8519    /// One chunk per row is what a writer that appends as data arrives
8520    /// produces; the rows land next to each other, so the streaming reader must
8521    /// fetch them in a few spans rather than one read each.
8522    #[test]
8523    fn a_streaming_read_coalesces_a_run_of_small_chunks() {
8524        let n = 1024usize;
8525        let (bytes, data) = chunked_f64_file(n, 1);
8526        let counts = ReadCounts::default();
8527        let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8528
8529        let mut got = Vec::new();
8530        let (reads, _) = counts.measure(|| got = file.dataset("d").unwrap().read_f64().unwrap());
8531        assert_eq!(got, data, "the coalesced read must return the same values");
8532
8533        // Reading each of the 1024 chunks on its own would cost at least that
8534        // many reads; the whole run plus its metadata fits in far fewer.
8535        assert!(
8536            reads < n / 8,
8537            "expected the {n} chunks to be coalesced into few reads, got {reads}"
8538        );
8539    }
8540
8541    /// A windowed read must coalesce only the chunks its window overlaps: a
8542    /// plan built over the dataset's whole chunk list would put the rows on
8543    /// either side of the window inside a span and read them for nothing.
8544    #[test]
8545    fn a_windowed_streaming_read_fetches_only_its_own_window() {
8546        let rows = 4096usize;
8547        let (bytes, data) = chunked_f64_file(rows, 4);
8548        let counts = ReadCounts::default();
8549        let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8550        let ds = file.dataset("d").unwrap();
8551
8552        // The first window walks (and caches on this handle) the chunk index,
8553        // so measure the second: what it reads is the window's own chunks.
8554        assert_eq!(ds.read_f64_rows(0, 8).unwrap(), data[0..8]);
8555        let (_, window_bytes) =
8556            counts.measure(|| assert_eq!(ds.read_f64_rows(2048, 8).unwrap(), data[2048..2056]));
8557
8558        // Eight rows are two 32-byte chunks.
8559        assert!(
8560            window_bytes < 256,
8561            "an 8-row window read {window_bytes} bytes; it needs 64"
8562        );
8563    }
8564
8565    /// The read volume of a whole-dataset read must not depend on which read it
8566    /// is. The chunk list comes from the handle's cached index on every read
8567    /// after the first, and that index is a map: it yields no address order at
8568    /// all. A reader holding one coalesced span re-reads a whole span each time
8569    /// an unordered walk crosses back, so the second read of a dataset spanning
8570    /// more than one span cost two orders of magnitude more than the first.
8571    ///
8572    /// A regression here is probabilistic rather than certain — the map's order
8573    /// is seeded per process, and a run that happened to be sorted would pass —
8574    /// but with hundreds of chunks over two spans the chance of that is nil.
8575    /// Correct code passes deterministically.
8576    #[test]
8577    fn a_second_whole_read_costs_what_the_first_did() {
8578        // 512 KiB of f64 in 1 KiB chunks: more than one 256 KiB span, so an
8579        // unordered walk has somewhere to thrash between.
8580        let n = 64 * 1024usize;
8581        let dataset_bytes = n * 8;
8582        let (bytes, data) = chunked_f64_file(n, 128);
8583        let counts = ReadCounts::default();
8584        let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8585        let ds = file.dataset("d").unwrap();
8586
8587        counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
8588        let (_, second) = counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
8589
8590        assert!(
8591            second <= dataset_bytes,
8592            "the second read fetched {second} bytes of a {dataset_bytes}-byte dataset"
8593        );
8594    }
8595
8596    /// The same, for row windows: `docs/guide/streaming.md` walks a dataset in
8597    /// windows on one handle, so every window but the first takes its chunk
8598    /// list from the cached index.
8599    ///
8600    /// Each window here covers more than one span, which is what it takes to
8601    /// see the defect: a window whose chunks all fit a single span is served
8602    /// out of that one buffer whatever order it walks them in.
8603    #[test]
8604    fn a_window_loop_costs_the_dataset_once() {
8605        // 2 MiB of f64 in 1 KiB chunks, read in 512 KiB windows — two spans
8606        // each.
8607        let rows = 256 * 1024usize;
8608        let dataset_bytes = rows * 8;
8609        let (bytes, data) = chunked_f64_file(rows, 128);
8610        let counts = ReadCounts::default();
8611        let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8612        let ds = file.dataset("d").unwrap();
8613
8614        let window = 64 * 1024;
8615        let (_, total) = counts.measure(|| {
8616            for lo in (0..rows).step_by(window) {
8617                let got = ds.read_f64_rows(lo as u64, window as u64).unwrap();
8618                assert_eq!(got, data[lo..lo + window]);
8619            }
8620        });
8621
8622        assert!(
8623            total <= 2 * dataset_bytes,
8624            "a window loop over a {dataset_bytes}-byte dataset read {total} bytes"
8625        );
8626    }
8627
8628    /// A span must not cover a chunk the chunk cache already holds: the read
8629    /// skips that chunk, so those bytes would be fetched for nothing.
8630    ///
8631    /// The cache here is given room for the whole dataset. At the default
8632    /// sixteen slots a dataset this size evicts its own warm chunks as it
8633    /// walks, so every chunk misses on the second read and the plan has nothing
8634    /// to leave out — a config that cannot show the difference either way.
8635    #[test]
8636    fn a_warm_chunk_cache_is_not_re_fetched() {
8637        // 32 KiB of f64 in 32 chunks of 1 KiB, laid end to end.
8638        let n = 4096usize;
8639        let chunk_bytes = 1024usize;
8640        let (bytes, data) = chunked_f64_file(n, 128);
8641        let counts = ReadCounts::default();
8642        let file = counting_streaming_file(
8643            bytes,
8644            &counts,
8645            FileAccessProperties::new()
8646                .with_chunk_cache(ChunkCacheConfig::new().with_max_slots(64)),
8647        );
8648        let ds = file.dataset("d").unwrap();
8649
8650        // Warm the second half of the dataset: chunks 16..31.
8651        assert_eq!(ds.read_f64_rows(2048, 2048).unwrap(), data[2048..]);
8652        assert_eq!(
8653            ds.chunk_cache_stats().cached_chunks(),
8654            16,
8655            "the window's own chunks stay cached"
8656        );
8657
8658        let (_, second) = counts.measure(|| assert_eq!(ds.read_f64().unwrap(), data));
8659        assert_eq!(
8660            second,
8661            16 * chunk_bytes,
8662            "only the cold half was needed; a span over the whole dataset would \
8663             have fetched {} bytes",
8664            32 * chunk_bytes
8665        );
8666    }
8667
8668    // -----------------------------------------------------------------------
8669    // Group member iterators (`iter_datasets` / `iter_groups`)
8670    // -----------------------------------------------------------------------
8671
8672    /// A root holding `datasets` datasets, `groups` subgroups (each with one
8673    /// dataset of its own) and one committed datatype, so a member walk has all
8674    /// three child kinds to sort apart.
8675    ///
8676    /// Names are not zero-padded, so lexical order and insertion order disagree
8677    /// past the tenth member: a walk that silently sorted would show up here.
8678    fn mixed_member_file(datasets: usize, groups: usize) -> Vec<u8> {
8679        let mut b = FileBuilder::new();
8680        b.commit_datatype("a_type", crate::make_i32_type());
8681        for i in 0..datasets {
8682            b.create_dataset(&format!("ds{i}"))
8683                .with_i32_data(&[i as i32, -(i as i32)]);
8684        }
8685        for i in 0..groups {
8686            let mut g = b.create_group(&format!("g{i}"));
8687            g.create_dataset("inner").with_i32_data(&[i as i32]);
8688            g.create_dataset("other").with_i32_data(&[-1]);
8689            b.add_group(g.finish());
8690        }
8691        b.finish().expect("write the fixture")
8692    }
8693
8694    /// The iterator must report exactly what opening each name reports: the same
8695    /// members, in the same order, resolving to the same objects. Anything the
8696    /// two disagree on is a member a caller would see differently for having
8697    /// chosen the cheaper walk.
8698    #[test]
8699    fn iter_datasets_agrees_with_opening_each_name() {
8700        let file = File::from_bytes(mixed_member_file(12, 3)).unwrap();
8701
8702        for group in [file.root(), file.group("g1").unwrap()] {
8703            let names = group.datasets().unwrap();
8704            assert!(
8705                !names.is_empty(),
8706                "the fixture must have members to compare"
8707            );
8708
8709            let iterated: Vec<(String, Dataset)> = group.iter_datasets().unwrap().collect();
8710            assert_eq!(
8711                iterated.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
8712                names,
8713                "the iterator must yield the members `datasets` lists, in that order"
8714            );
8715
8716            for (name, ds) in &iterated {
8717                let opened = group.dataset(name).unwrap();
8718                assert_eq!(
8719                    ds.header_address().unwrap(),
8720                    opened.header_address().unwrap(),
8721                    "{name}"
8722                );
8723                assert_eq!(ds.shape().unwrap(), opened.shape().unwrap(), "{name}");
8724                assert_eq!(ds.read_i32().unwrap(), opened.read_i32().unwrap(), "{name}");
8725            }
8726        }
8727    }
8728
8729    /// The subgroup counterpart, including that a handle it yields can be walked
8730    /// again — recursion through `iter_groups` is the shape it exists for.
8731    #[test]
8732    fn iter_groups_agrees_with_opening_each_name() {
8733        let file = File::from_bytes(mixed_member_file(4, 5)).unwrap();
8734        let root = file.root();
8735
8736        let names = root.groups().unwrap();
8737        let iterated: Vec<(String, Group)> = root.iter_groups().unwrap().collect();
8738        assert_eq!(
8739            iterated.iter().map(|(n, _)| n.clone()).collect::<Vec<_>>(),
8740            names,
8741            "the iterator must yield the subgroups `groups` lists, in that order"
8742        );
8743        assert_eq!(names.len(), 5);
8744
8745        for (name, group) in &iterated {
8746            assert_eq!(
8747                group.header_address().unwrap(),
8748                root.group(name).unwrap().header_address().unwrap(),
8749                "{name}"
8750            );
8751            let mut inner = group
8752                .iter_datasets()
8753                .unwrap()
8754                .map(|(n, _)| n)
8755                .collect::<Vec<_>>();
8756            inner.sort();
8757            assert_eq!(inner, ["inner", "other"], "{name} must be walkable in turn");
8758        }
8759    }
8760
8761    /// Each iterator must claim only its own kind of child. A committed datatype
8762    /// is the child that belongs to neither, and the one a walk asking only for
8763    /// datasets and groups would otherwise be free to mis-sort into either.
8764    #[test]
8765    fn the_member_iterators_sort_the_child_kinds_apart() {
8766        let file = File::from_bytes(mixed_member_file(3, 2)).unwrap();
8767        let root = file.root();
8768
8769        let datasets: Vec<String> = root.iter_datasets().unwrap().map(|(n, _)| n).collect();
8770        let groups: Vec<String> = root.iter_groups().unwrap().map(|(n, _)| n).collect();
8771
8772        assert_eq!(datasets, ["ds0", "ds1", "ds2"]);
8773        assert_eq!(groups, ["g0", "g1"]);
8774        assert_eq!(root.named_datatypes().unwrap(), ["a_type"]);
8775        assert!(
8776            !datasets.contains(&"a_type".to_string()) && !groups.contains(&"a_type".to_string()),
8777            "a committed datatype is neither a dataset nor a group"
8778        );
8779    }
8780
8781    /// The bytes a walk of `n` members reads, by each route: opening every name,
8782    /// and iterating handles.
8783    fn member_walk_bytes(n: usize) -> (usize, usize) {
8784        let mut b = FileBuilder::new();
8785        for i in 0..n {
8786            b.create_dataset(&format!("ds{i}"))
8787                .with_i32_data(&[i as i32]);
8788        }
8789        let bytes = b.finish().expect("write the fixture");
8790
8791        let counts = ReadCounts::default();
8792        let file = counting_streaming_file(bytes.clone(), &counts, FileAccessProperties::new());
8793        let (_, by_name) = counts.measure(|| {
8794            let root = file.root();
8795            for name in root.datasets().unwrap() {
8796                root.dataset(&name).unwrap();
8797            }
8798        });
8799
8800        let counts = ReadCounts::default();
8801        let file = counting_streaming_file(bytes, &counts, FileAccessProperties::new());
8802        let (_, iterated) =
8803            counts.measure(|| for (_, _ds) in file.root().iter_datasets().unwrap() {});
8804
8805        (by_name, iterated)
8806    }
8807
8808    /// Opening members by name re-walks the group's link structure once per
8809    /// member; iterating handles walks it once. Bytes is the metric that
8810    /// separates them: a group this size keeps its links inline in the object
8811    /// header, so that header grows with the member count and re-reading it per
8812    /// member is quadratic, while the *number* of reads stays linear either way
8813    /// and would show almost nothing.
8814    ///
8815    /// Asserted as how the cost scales rather than as one fixture's byte count,
8816    /// since a fixed number would pass just as well on a walk that stayed
8817    /// quadratic with a smaller constant.
8818    #[test]
8819    fn iterating_members_enumerates_the_group_once() {
8820        let (_, iterated_16) = member_walk_bytes(16);
8821        let (by_name_64, iterated_64) = member_walk_bytes(64);
8822
8823        // The rule this test exists for, and the only assertion here that is
8824        // about `iter_datasets` itself.
8825        assert!(
8826            iterated_64 <= 5 * iterated_16,
8827            "one enumeration plus one header per member is linear, so four times \
8828             the members must cost about four times the bytes: {iterated_16} -> \
8829             {iterated_64}"
8830        );
8831
8832        // Contrast, not a property of this code: it holds because `Group::dataset`
8833        // re-enumerates. If a future change makes that route cheap enough to turn
8834        // this red, nothing here has regressed — confirm the scaling assertion
8835        // above still holds and then drop this one.
8836        assert!(
8837            iterated_64 < by_name_64,
8838            "at 64 members the one-enumeration walk should still be the cheaper: \
8839             {iterated_64} bytes against {by_name_64}"
8840        );
8841    }
8842
8843    /// A yielded handle must carry the chunk-cache configuration the file was
8844    /// opened with, the same one `dataset` resolves for it.
8845    ///
8846    /// Nothing about the values read would show a handle that quietly ignored
8847    /// it: the cache decides how often the chunk index is re-parsed and how much
8848    /// decompressed data is retained, not what comes back. So the configuration
8849    /// has to be asserted directly, or dropping it here is a silent regression.
8850    #[test]
8851    fn a_member_handle_carries_the_files_chunk_cache_config() {
8852        let configured = ChunkCacheConfig::new()
8853            .with_max_slots(17)
8854            .with_max_bytes(4096)
8855            .with_index_cache(false);
8856        let file = File::from_bytes_with_options(
8857            mixed_member_file(3, 0),
8858            FileAccessProperties::new().with_chunk_cache(configured),
8859        )
8860        .unwrap();
8861        let root = file.root();
8862
8863        let members: Vec<(String, Dataset)> = root.iter_datasets().unwrap().collect();
8864        assert_eq!(members.len(), 3);
8865        for (name, ds) in &members {
8866            assert_eq!(
8867                ds.chunk_cache_config(),
8868                root.dataset(name).unwrap().chunk_cache_config(),
8869                "{name} must resolve its cache the way `dataset` does"
8870            );
8871            assert_eq!(
8872                ds.chunk_cache_config(),
8873                configured,
8874                "{name} must carry the configuration the file was opened with"
8875            );
8876        }
8877    }
8878
8879    /// A file whose root holds `inner` and `sub`, and a group `g0` holding
8880    /// children of those same names, plus a `refs` dataset pointing at `g0`.
8881    ///
8882    /// The duplicated names are the trap: a member handle that wrongly took a
8883    /// root-relative path would address a real object rather than fail, so the
8884    /// mistake would look like a successful write.
8885    fn dereferenced_group_file() -> Vec<u8> {
8886        let mut b = FileBuilder::new();
8887        b.create_dataset("inner").with_i32_data(&[0]);
8888        let mut root_sub = b.create_group("sub");
8889        root_sub.create_dataset("x").with_i32_data(&[0]);
8890        b.add_group(root_sub.finish());
8891
8892        let mut g = b.create_group("g0");
8893        g.create_dataset("inner").with_i32_data(&[1]);
8894        let mut nested = g.create_group("sub");
8895        nested.create_dataset("x").with_i32_data(&[1]);
8896        g.add_group(nested.finish());
8897        b.add_group(g.finish());
8898
8899        b.create_dataset("refs").with_path_references(&["g0"]);
8900        b.finish().expect("write the fixture")
8901    }
8902
8903    /// A group reached by object reference has no resolvable path, so neither can
8904    /// its members: there is nothing for a write through one to address. The
8905    /// iterators must carry that `None` across rather than fall back to a
8906    /// root-relative path, which here would reach a different, real object.
8907    #[test]
8908    fn members_of_a_dereferenced_group_have_no_path() {
8909        let dir = tempfile::tempdir().unwrap();
8910        let path = dir.path().join("refs.h5");
8911        std::fs::write(&path, dereferenced_group_file()).unwrap();
8912
8913        let file = File::open_rw(&path).unwrap();
8914        let mut objects = file.dataset("refs").unwrap().dereference().unwrap();
8915        let group = match objects.remove(0) {
8916            Object::Group(g) => g,
8917            other => panic!("expected a group, got {other:?}"),
8918        };
8919
8920        // The file is writable and both names exist at the root, so a refusal
8921        // here can only come from the handle having no path to address.
8922        let (name, mut member) = group.iter_datasets().unwrap().next().unwrap();
8923        assert_eq!(name, "inner");
8924        assert!(
8925            matches!(
8926                member.set_attr("tag", AttrValue::I64(1)),
8927                Err(Error::ReadOnly)
8928            ),
8929            "a member of a path-less group must refuse a write, not address `/inner`"
8930        );
8931
8932        let (name, subgroup) = group.iter_groups().unwrap().next().unwrap();
8933        assert_eq!(name, "sub");
8934        assert!(
8935            matches!(
8936                subgroup.set_attr("tag", AttrValue::I64(1)),
8937                Err(Error::ReadOnly)
8938            ),
8939            "and so must a subgroup of one, not address `/sub`"
8940        );
8941    }
8942
8943    /// A yielded handle must carry the same root-relative path as one opened by
8944    /// name, or a write through it would address the wrong object — or no object
8945    /// at all. A member of a *subgroup* is the case that separates them, since
8946    /// its path has a prefix to get right.
8947    #[test]
8948    fn a_member_handle_resolves_to_its_own_path() {
8949        let dir = tempfile::tempdir().unwrap();
8950        let path = dir.path().join("members.h5");
8951        std::fs::write(&path, mixed_member_file(2, 2)).unwrap();
8952
8953        let file = File::open_rw(&path).unwrap();
8954        let group = file.group("g1").unwrap();
8955        let (name, mut ds) = group
8956            .iter_datasets()
8957            .unwrap()
8958            .find(|(n, _)| n == "inner")
8959            .expect("g1/inner");
8960        assert_eq!(name, "inner");
8961        ds.set_attr("tag", AttrValue::I64(7)).unwrap();
8962        file.commit().unwrap();
8963        // Windows holds the write lock until the session is dropped.
8964        drop(ds);
8965        drop(group);
8966        drop(file);
8967
8968        let file = File::open(&path).unwrap();
8969        assert_eq!(
8970            file.dataset("g1/inner")
8971                .unwrap()
8972                .attrs()
8973                .unwrap()
8974                .get("tag")
8975                .and_then(AttrValue::as_i64),
8976            Some(7),
8977            "the attribute must land on the member the handle came from"
8978        );
8979        assert!(
8980            !file
8981                .dataset("g0/inner")
8982                .unwrap()
8983                .attrs()
8984                .unwrap()
8985                .contains_key("tag"),
8986            "and on no other group's member of the same name"
8987        );
8988    }
8989
8990    /// Every read-write entry point has to hand the fapl's `fsync` cadence to
8991    /// the session it opens, and none of them can be checked from outside: a
8992    /// skipped barrier writes the same bytes as an issued one (issue #263).
8993    ///
8994    /// One entry point missing the funnel is the whole failure mode, so this
8995    /// asserts the property at each of them rather than at the funnel.
8996    #[test]
8997    fn every_read_write_open_carries_the_fapl_sync_policy() {
8998        use tempfile::tempdir;
8999
9000        let dir = tempdir().unwrap();
9001        let fixture = |name: &str| {
9002            let path = dir.path().join(name);
9003            let mut b = FileBuilder::new();
9004            b.create_dataset("d")
9005                .with_i32_data(&[1, 2, 3, 4])
9006                .with_shape(&[4])
9007                .with_maxshape(&[u64::MAX])
9008                .with_chunks(&[2]);
9009            b.write(&path).unwrap();
9010            path
9011        };
9012        let props = || FileAccessProperties::new().with_sync_policy(SyncPolicy::OnClose);
9013        let policy_of = |file: &File| match &file.inner.backend {
9014            Backend::Edit(m) => m
9015                .lock()
9016                .unwrap_or_else(std::sync::PoisonError::into_inner)
9017                .sync_policy(),
9018            _ => panic!("a read-write open must build an editing session"),
9019        };
9020
9021        let opened = File::open_rw_with_options(fixture("open_rw.h5"), props()).unwrap();
9022        assert_eq!(policy_of(&opened), SyncPolicy::OnClose, "File::open_rw");
9023        // Windows OS locks are mandatory: release each session before the next
9024        // open touches the same directory's files.
9025        drop(opened);
9026
9027        let created = File::create_with_options(
9028            dir.path().join("created.h5"),
9029            crate::FileCreateProperties::new(),
9030            props(),
9031        )
9032        .unwrap();
9033        assert_eq!(policy_of(&created), SyncPolicy::OnClose, "File::create");
9034        drop(created);
9035
9036        let swmr = File::open_swmr_writer_with_options(fixture("swmr.h5"), props()).unwrap();
9037        assert_eq!(
9038            policy_of(&swmr),
9039            SyncPolicy::OnClose,
9040            "File::open_swmr_writer"
9041        );
9042        drop(swmr);
9043
9044        let bounded = File::open_rw_with_options(
9045            fixture("bounded.h5"),
9046            props().with_memory_strategy(MemoryStrategy::Bounded),
9047        )
9048        .unwrap();
9049        assert_eq!(
9050            policy_of(&bounded),
9051            SyncPolicy::OnClose,
9052            "File::open_rw_with_options (bounded)"
9053        );
9054    }
9055
9056    /// The page-buffer property is refused where it cannot be honored, rather
9057    /// than accepted and ignored (issues #288 and #308).
9058    ///
9059    /// Each refusal has a different reason and none stands in for the others: a
9060    /// budget under one page is a buffer that drains on every page it touches, a
9061    /// *paged* file that persists no free space can neither commit nor append, a
9062    /// pre-version-3 superblock carries a status-flags byte no library reads back
9063    /// so the crash mark would announce nothing, and the SWMR writer's readers
9064    /// observe the order its writes become visible in. The first is where the C
9065    /// library refuses `H5Pset_page_buffer_size` too; the rest are this crate's.
9066    ///
9067    /// Two shapes are **not** among them, and the accepted cases here are what
9068    /// pin that. An unpaged file: `H5PB_create` requires the paged allocator
9069    /// because the C page buffer is a page cache with per-kind reservations, and
9070    /// this gatherer has neither (issue #357). And a budget below the 1 MiB a
9071    /// session already gathers under, which is a request for less resident
9072    /// memory paid for in writes rather than an unhonorable pair (issue #391).
9073    ///
9074    /// Every refusal also asserts the file is left byte-identical. Each one
9075    /// fires with a read-write session already open, and the version-3 one fires
9076    /// from the same function that raises the mark — so a refusal ordered after
9077    /// the raise would leave a file marked in use by a session that never
9078    /// existed, which is a file nothing can open until `clear_swmr_flag`.
9079    #[test]
9080    fn a_page_buffer_is_refused_where_it_cannot_be_honored() {
9081        use tempfile::tempdir;
9082
9083        let dir = tempdir().unwrap();
9084        let fixture = |name: &str, paged: bool| {
9085            let path = dir.path().join(name);
9086            let mut b = FileBuilder::new();
9087            if paged {
9088                b.with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 1)
9089                    .with_file_space_page_size(16 * 1024);
9090            }
9091            b.create_dataset("d")
9092                .with_i32_data(&[1, 2, 3, 4])
9093                .with_shape(&[4]);
9094            b.write(&path).unwrap();
9095            path
9096        };
9097        let buffered = |bytes| {
9098            FileAccessProperties::new()
9099                .with_sync_policy(SyncPolicy::OnClose)
9100                .with_page_buffer_size(bytes)
9101        };
9102
9103        // Each refusal fires after the session is already open read-write, so the
9104        // file it declined must be left exactly as it was — and still openable.
9105        let untouched = |label: &str, path: &std::path::Path, before: &[u8]| {
9106            assert_eq!(
9107                std::fs::read(path).unwrap(),
9108                before,
9109                "{label}: a refused open changed the file"
9110            );
9111            assert!(
9112                File::open(path).is_ok(),
9113                "{label}: a refused open left the file unopenable"
9114            );
9115        };
9116
9117        // The two that are *accepted*, kept here beside their former siblings so
9118        // reinstating either refusal fails this test rather than only the
9119        // behavior tests one module over.
9120        let unpaged = fixture("unpaged.h5", false);
9121        let accepted = File::open_rw_with_options(&unpaged, buffered(1 << 20));
9122        assert!(
9123            accepted.is_ok(),
9124            "a page buffer on an unpaged file must be accepted, got {accepted:?}"
9125        );
9126        accepted.unwrap().close().unwrap();
9127
9128        // 256 KiB on a 16 KiB-paged file: a budget the session would not have
9129        // gathered under, which is the point of asking for it.
9130        let small_budget = fixture("small_budget.h5", true);
9131        let accepted = File::open_rw_with_options(&small_budget, buffered(256 * 1024));
9132        assert!(
9133            accepted.is_ok(),
9134            "a page buffer below the byte budget a session gathers under must be \
9135             accepted, got {accepted:?}"
9136        );
9137        accepted.unwrap().close().unwrap();
9138
9139        let paged = fixture("paged.h5", true);
9140        let before = std::fs::read(&paged).unwrap();
9141        let refused = File::open_rw_with_options(&paged, buffered(8192));
9142        assert!(
9143            matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("page size")),
9144            "a budget below one page must be refused, got {refused:?}"
9145        );
9146        untouched("budget under one page", &paged, &before);
9147
9148        // And the pairing a caller reaches by doing nothing, since Always is the
9149        // default: every barrier there is an fsync that flushes the buffer.
9150        let refused = File::open_rw_with_options(
9151            &paged,
9152            FileAccessProperties::new().with_page_buffer_size(1 << 20),
9153        );
9154        assert!(
9155            matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("SyncPolicy::Always")),
9156            "a page buffer under SyncPolicy::Always must be refused, got {refused:?}"
9157        );
9158        untouched("page buffer under Always", &paged, &before);
9159
9160        // A version-2 superblock. This crate's writer refuses `Page` below the
9161        // 1.10 format, so the file is built at version 3 and its superblock
9162        // rewritten — the v2 and v3 layouts are identical apart from the version
9163        // byte and what the flags byte means, which is exactly the point.
9164        let old_format = dir.path().join("v2.h5");
9165        std::fs::copy(&paged, &old_format).unwrap();
9166        {
9167            let mut bytes = std::fs::read(&old_format).unwrap();
9168            let sig = crate::signature::find_signature(&bytes).unwrap();
9169            let mut sb = crate::superblock::Superblock::parse(&bytes, sig).unwrap();
9170            assert_eq!(sb.version, 3, "the fixture must start at the newer format");
9171            sb.version = 2;
9172            let rewritten = sb.serialize();
9173            bytes[sig..sig + rewritten.len()].copy_from_slice(&rewritten);
9174            std::fs::write(&old_format, &bytes).unwrap();
9175        }
9176        let before = std::fs::read(&old_format).unwrap();
9177        assert!(
9178            File::open(&old_format).is_ok(),
9179            "the version-2 fixture must be a readable file, or the refusal below \
9180             could be about anything"
9181        );
9182        let refused = File::open_rw_with_options(&old_format, buffered(1 << 20));
9183        assert!(
9184            matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("version-3 superblock")),
9185            "a page buffer on a pre-v3 superblock must be refused, got {refused:?}"
9186        );
9187        untouched("version-2 superblock", &old_format, &before);
9188
9189        // A paged file whose free space is not persisted, reached two ways: asked
9190        // for outright, and produced by a userblock, for which persistence is
9191        // declined however the creation properties were written. Such a session
9192        // can neither commit nor append, so the buffer would hold nothing while
9193        // its mark blocked every reader.
9194        for (label, name, userblock) in [
9195            ("paged, not persisting", "no_persist.h5", 0u64),
9196            ("paged with a userblock", "ub_paged.h5", 4096),
9197        ] {
9198            let path = dir.path().join(name);
9199            let mut b = FileBuilder::new();
9200            if userblock != 0 {
9201                b.with_userblock(userblock);
9202            }
9203            b.with_file_space_strategy(crate::FileSpaceStrategy::Page, userblock != 0, 1)
9204                .with_file_space_page_size(4096);
9205            b.create_dataset("d")
9206                .with_i32_data(&[1, 2, 3, 4])
9207                .with_shape(&[4]);
9208            b.write(&path).unwrap();
9209            let before = std::fs::read(&path).unwrap();
9210            let refused = File::open_rw_with_options(
9211                &path,
9212                buffered(1 << 20).with_memory_strategy(MemoryStrategy::Mirrored),
9213            );
9214            assert!(
9215                matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("persisted")),
9216                "{label}: a page buffer on a session that cannot write must be refused, \
9217                 got {refused:?}"
9218            );
9219            untouched(label, &path, &before);
9220        }
9221
9222        let swmr = fixture("swmr.h5", false);
9223        let before = std::fs::read(&swmr).unwrap();
9224        let refused = File::open_swmr_writer_with_options(&swmr, buffered(1 << 20));
9225        assert!(
9226            matches!(&refused, Err(Error::EditUnsupported(m)) if m.contains("SWMR")),
9227            "the SWMR writer must refuse a page buffer, got {refused:?}"
9228        );
9229        // The sharpest of the three: `open_swmr_writer` raises the on-disk
9230        // SWMR-write flag, and a refusal that fired after it would leave every
9231        // later open reporting `FileMarkedInUse`.
9232        untouched("swmr", &swmr, &before);
9233
9234        // And an unset page buffer refuses none of the three.
9235        for (name, paged) in [("ok_unpaged.h5", false), ("ok_paged.h5", true)] {
9236            let f = File::open_rw_with_options(fixture(name, paged), FileAccessProperties::new());
9237            assert!(f.is_ok(), "{name}: an unset page buffer refuses nothing");
9238        }
9239        let f = File::open_swmr_writer_with_options(
9240            fixture("ok_swmr.h5", false),
9241            FileAccessProperties::new(),
9242        );
9243        assert!(f.is_ok(), "swmr: an unset page buffer refuses nothing");
9244    }
9245
9246    /// `File::create_with_options` refuses a creation/access pair whose file it
9247    /// could write but not then open, rather than writing it and failing the open
9248    /// it promised (issue #288).
9249    ///
9250    /// A page buffer needs a budget of at least the file's page size and a
9251    /// version-3 superblock, and both of those are properties of the file being
9252    /// *created* — so the refusal belongs before the bytes are written, not in
9253    /// the reopen. The assertion that matters here is the `!path.exists()`: an
9254    /// error alone would pass with the file already on disk, which is the defect.
9255    ///
9256    /// The version-3 case is the one issue #357 made reachable. While a page
9257    /// buffer required a paged file it could not be: this crate's builder refuses
9258    /// `FileSpaceStrategy::Page` below the 1.10 format outright, so no pair got
9259    /// this far. An unpaged file at `LibVer::V18` is an ordinary buildable file,
9260    /// and without a check here it would be written and only then refused.
9261    #[test]
9262    fn create_with_options_refuses_a_page_buffer_it_could_not_reopen_with() {
9263        use tempfile::tempdir;
9264
9265        let dir = tempdir().unwrap();
9266        let paged = |page: u64| {
9267            crate::FileCreateProperties::new()
9268                .with_file_space_strategy(crate::FileSpaceStrategy::Page, true, 1)
9269                .with_file_space_page_size(page)
9270        };
9271        // 8192 clears the format's 4096 default and still falls short of this
9272        // file's 16 KiB page, so it fails only against the page size actually
9273        // read from the file.
9274        //
9275        // The last case is the one a caller reaches by doing nothing: `Always` is
9276        // the default policy, and every other property in that pair is honorable.
9277        // It is here because it was missing — the refusal used to sit at the fapl
9278        // rather than with its siblings, so this function did not restate it and
9279        // the file was written before the open failed.
9280        let cases: [(&str, crate::FileCreateProperties, usize, SyncPolicy); 5] = [
9281            // A page larger than the budget, on a file that does not exist yet:
9282            // the page size has to come from the creation properties, which is
9283            // what this pins.
9284            (
9285                "page larger than the budget",
9286                paged(2 << 20),
9287                1 << 20,
9288                SyncPolicy::OnClose,
9289            ),
9290            (
9291                "the 1.8 format",
9292                crate::FileCreateProperties::new()
9293                    .with_libver_bounds(crate::LibVer::Earliest, crate::LibVer::V18),
9294                1 << 20,
9295                SyncPolicy::OnClose,
9296            ),
9297            (
9298                "budget under one page",
9299                paged(16 * 1024),
9300                8192,
9301                SyncPolicy::OnClose,
9302            ),
9303            // The unpaged arm of the same check, which decides on its own now
9304            // that a budget below the session's gather budget is honored (issue
9305            // #391): an unpaged file has no page size, so it is held to the
9306            // format's 4 KiB default and 2 KiB falls short of it.
9307            (
9308                "unpaged budget under the default page",
9309                crate::FileCreateProperties::new(),
9310                2048,
9311                SyncPolicy::OnClose,
9312            ),
9313            (
9314                "the default sync policy",
9315                paged(16 * 1024),
9316                1 << 20,
9317                SyncPolicy::Always,
9318            ),
9319        ];
9320
9321        for (label, create, budget, policy) in cases {
9322            let path = dir
9323                .path()
9324                .join(std::format!("{}.h5", label.replace(' ', "_")));
9325            let result = File::create_with_options(
9326                &path,
9327                create,
9328                FileAccessProperties::new()
9329                    .with_sync_policy(policy)
9330                    .with_page_buffer_size(budget),
9331            );
9332            assert!(
9333                matches!(result, Err(Error::EditUnsupported(_))),
9334                "{label}: expected a refusal, got {result:?}"
9335            );
9336            assert!(
9337                !path.exists(),
9338                "{label}: the file was written and only then refused"
9339            );
9340        }
9341
9342        // The honorable pairs still create — including the unpaged one, which is
9343        // the default creation properties and so the pair a caller reaches by
9344        // asking for nothing but the buffer, and the 256 KiB one, which is below
9345        // the byte budget a session gathers under (issue #391).
9346        for (label, create, budget) in [
9347            ("paged", paged(16 * 1024), 1 << 20),
9348            ("unpaged", crate::FileCreateProperties::new(), 1 << 20),
9349            ("paged_small_budget", paged(16 * 1024), 256 * 1024),
9350        ] {
9351            let ok = File::create_with_options(
9352                dir.path().join(std::format!("ok_{label}.h5")),
9353                create,
9354                FileAccessProperties::new()
9355                    .with_sync_policy(SyncPolicy::OnClose)
9356                    .with_page_buffer_size(budget),
9357            );
9358            assert!(
9359                ok.is_ok(),
9360                "{label}: an honorable budget must create: {ok:?}"
9361            );
9362            ok.unwrap().close().unwrap();
9363        }
9364    }
9365
9366    /// `close` and `drop` issue their barrier under *every* policy, on both the
9367    /// ordinary and the SWMR branch — the four sites where this crate writes
9368    /// after the last point a caller could have ordered anything (issue #263).
9369    ///
9370    /// This is the half of the contract `SyncPolicy` cannot express: the two
9371    /// `drop` sites are unreachable by any caller discipline at all, since the
9372    /// handle that would have issued `File::sync` is gone by the time they run.
9373    /// Asserted through a counting image, because the difference between a
9374    /// forced barrier and a skipped one is invisible in the bytes.
9375    #[test]
9376    fn close_and_drop_force_their_barrier_under_every_policy() {
9377        use crate::edit::WriteEngine;
9378        use std::sync::Arc;
9379        use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
9380        use tempfile::tempdir;
9381
9382        let dir = tempdir().unwrap();
9383        // `swmr` picks the teardown branch; `explicit` picks `close` over `drop`.
9384        let teardown = |name: &str, swmr: bool, explicit: bool| -> u64 {
9385            let path = dir.path().join(name);
9386            let mut b = FileBuilder::new();
9387            b.create_dataset("d")
9388                .with_i32_data(&(0..8).collect::<Vec<_>>())
9389                .with_shape(&[8])
9390                .with_maxshape(&[u64::MAX])
9391                .with_chunks(&[4]);
9392            // Persisting, so the ordinary branch has manager re-homing to do:
9393            // an immediate append below leaves the on-disk managers mid-file,
9394            // and settling them is the write no earlier sync could cover.
9395            b.with_file_space_strategy(crate::FileSpaceStrategy::FsmAggr, true, 1);
9396            b.write(&path).unwrap();
9397
9398            let syncs = Arc::new(AtomicU64::new(0));
9399            let session =
9400                WriteEngine::open_sync_counting(&path, SyncPolicy::OnClose, Arc::clone(&syncs))
9401                    .unwrap();
9402            let mut inner = FileInner::from_rw_session(
9403                session,
9404                FileAccessProperties::new().with_sync_policy(SyncPolicy::OnClose),
9405            )
9406            .unwrap();
9407            inner.swmr_write = swmr;
9408            let file = File {
9409                inner: Arc::new(inner),
9410            };
9411            if !swmr {
9412                // The SWMR branch stages nothing and appends through its own
9413                // path; give the ordinary branch real work to settle.
9414                file.dataset("d")
9415                    .unwrap()
9416                    .append(&[8i32, 9, 10, 11])
9417                    .unwrap();
9418            }
9419            assert_eq!(
9420                syncs.load(AtomicOrdering::Relaxed),
9421                0,
9422                "nothing before teardown may sync under OnClose ({name})"
9423            );
9424
9425            if explicit {
9426                file.close().unwrap();
9427            } else {
9428                drop(file);
9429            }
9430            syncs.load(AtomicOrdering::Relaxed)
9431        };
9432
9433        for (name, swmr, explicit) in [
9434            ("close_plain.h5", false, true),
9435            ("close_swmr.h5", true, true),
9436            ("drop_plain.h5", false, false),
9437            ("drop_swmr.h5", true, false),
9438        ] {
9439            assert!(
9440                teardown(name, swmr, explicit) > 0,
9441                "{name} must force its barrier: the writes it makes are past the \
9442                 last point a caller could have ordered them"
9443            );
9444        }
9445    }
9446}