Skip to main content

hdf5_pure/
reader.rs

1//! Reading API: File, Dataset, and Group handles for reading HDF5 files.
2
3use std::collections::HashMap;
4use std::io::{Read, Seek, SeekFrom};
5use std::sync::atomic::{AtomicBool, Ordering};
6use std::sync::{Arc, Mutex};
7
8use crate::edit::{
9    AppendBuilder, AppendGeometry, AppendTarget, EditBacking, MemoryStrategy, SpaceAccounting,
10    WriteEngine,
11};
12use crate::element::H5Element;
13use crate::type_builders::{DatasetBuilder, VL_REF_SIZE};
14
15use crate::attribute::{extract_attributes_full, extract_attributes_full_from_source};
16use crate::chunk_cache::{ChunkCache, ChunkCacheConfig, ChunkCacheStats};
17use crate::compound::CompoundType;
18use crate::convert::TryToUsize;
19use crate::data_layout::DataLayout;
20use crate::data_read;
21use crate::dataspace::Dataspace;
22use crate::datatype::{Datatype, ReferenceType};
23use crate::error::{Error, FormatError};
24use crate::file_create_properties::FileCreateProperties;
25use crate::file_lock::{self, FileLocking, OpenIntent};
26use crate::file_space_info::{FileSpaceInfo, FileSpaceStrategy};
27use crate::filter_pipeline::FilterPipeline;
28use crate::free_space_manager;
29use crate::group_v1::GroupEntry;
30use crate::group_v2;
31use crate::layout_info::{Chunk, ChunkIndex, Filter, Layout};
32use crate::libver::LibVer;
33use crate::message_type::MessageType;
34use crate::object_header::ObjectHeader;
35use crate::signature;
36use crate::source::{
37    BaseOffsetSource, BytesSource, MetadataCacheConfig, MetadataCachingSource, ReadSeekSource,
38    Source,
39};
40use crate::superblock::Superblock;
41use crate::vl_data::{self, VlenStringReadOptions};
42
43use crate::types::{AttrValue, DType, attrs_to_map, classify_datatype};
44
45// ---------------------------------------------------------------------------
46// File
47// ---------------------------------------------------------------------------
48
49/// Backing store for a [`File`]: either the whole file buffered in memory, or a
50/// lazy [`Source`] that reads regions on demand (see [`File::open_streaming`]).
51enum Backend {
52    InMemory(Vec<u8>),
53    Streaming(Box<dyn Source + Send + Sync>),
54    /// A read-write file opened with [`File::open_rw`] or
55    /// [`File::open_rw_bounded`]: a [`WriteEngine`] (exclusive OS lock + staged
56    /// edit queues + append geometry cache) behind a lock, so owned handles can
57    /// both read and mutate in place. Handle write methods route to the engine,
58    /// and `File::commit` applies staged structural edits.
59    ///
60    /// Either backing — a whole-file mirror, or positioned I/O against the
61    /// handle — appears here as the same `WriteEngine`; which one an open
62    /// resolved to is the engine's own business rather than the backend's
63    /// (issue #198). Reads
64    /// borrow the mirror's slice when there is one and go through the image's
65    /// `Source` otherwise; see [`with_engine`](FileInner::with_engine). Boxed to
66    /// keep the `Backend` enum small (a `WriteEngine` is far larger than the
67    /// other variants).
68    Edit(Box<Mutex<WriteEngine>>),
69}
70
71/// A borrowed `Source` view over a [`File`]'s backend, used by the
72/// streaming-capable read paths so one call site serves both backends.
73pub(crate) enum SourceView<'a> {
74    Mem(&'a [u8]),
75    Stream(&'a (dyn Source + Send + Sync)),
76}
77
78impl Source for SourceView<'_> {
79    fn len(&self) -> u64 {
80        match self {
81            SourceView::Mem(b) => b.len() as u64,
82            SourceView::Stream(s) => s.len(),
83        }
84    }
85    fn read_at(&self, offset: u64, buf: &mut [u8]) -> Result<(), FormatError> {
86        match self {
87            SourceView::Mem(b) => BytesSource::new(*b).read_at(offset, buf),
88            SourceView::Stream(s) => s.read_at(offset, buf),
89        }
90    }
91
92    fn read_metadata_at(&self, offset: u64, len: usize) -> Result<Vec<u8>, FormatError> {
93        match self {
94            SourceView::Mem(b) => BytesSource::new(*b).read_metadata_at(offset, len),
95            SourceView::Stream(s) => s.read_metadata_at(offset, len),
96        }
97    }
98}
99
100/// A base-relative view of an in-memory file: `bytes` with its first `base` bytes
101/// (the userblock) cut off, so every address stored relative to the base address
102/// indexes it directly. The in-memory counterpart of [`BaseOffsetSource`], and the
103/// identity for a plain file.
104fn frame(bytes: &[u8], base: u64) -> Result<&[u8], FormatError> {
105    if base == 0 {
106        return Ok(bytes);
107    }
108    let start = base.to_usize()?;
109    bytes.get(start..).ok_or(FormatError::UnexpectedEof {
110        expected: start,
111        available: bytes.len(),
112    })
113}
114
115/// File-access properties applied when opening an HDF5 file.
116///
117/// This is the `hdf5-pure` analogue of an HDF5 **file access property list**
118/// (`fapl`): one value carrying every access-time setting, built once and passed
119/// to whichever open a caller reaches for, exactly as a `fapl` is handed to
120/// `H5Fopen`. Every `*_with_options` constructor on [`File`] accepts it, so a
121/// read path and a read-write path can share one configuration.
122///
123/// The `Properties` suffix means the type stands in for one whole HDF5 property
124/// list, so every setting on it has a C counterpart to look up. It is a stand-in
125/// and not a port: a plain `Copy` value, with no handle to create or close, no
126/// runtime property registry, and no setter that can fail. `fapl` and each
127/// `H5Pset_*` it models are doc aliases, so a search for either lands here.
128///
129/// - The metadata cache (`H5Pset_mdc_config`) applies to the streaming and
130///   bounded backends; an in-memory open already holds the whole file in one
131///   buffer.
132/// - The chunk cache (`H5Pset_cache`) is the file-wide default for datasets
133///   opened from any backend, overridable per dataset with
134///   [`DatasetAccessProperties`].
135/// - The locking policy (`H5Pset_file_locking`) applies to the read-write opens.
136///   Readers and the SWMR writer take no lock by design, so they ignore it.
137///
138/// See the [property-support reference] for the full property-by-property map.
139///
140/// [property-support reference]: https://github.com/stephenberry/hdf5-pure/blob/main/docs/reference/property-support.md
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142#[doc(alias = "fapl")]
143pub struct FileAccessProperties {
144    metadata_cache: MetadataCacheConfig,
145    chunk_cache: ChunkCacheConfig,
146    locking: FileLocking,
147    memory_strategy: Option<MemoryStrategy>,
148}
149
150/// Former name of [`FileAccessProperties`].
151#[deprecated(
152    since = "0.26.0",
153    note = "renamed to `FileAccessProperties`: a type standing in for a whole HDF5 property list now carries the `Properties` suffix"
154)]
155pub type FileAccessOptions = FileAccessProperties;
156
157impl FileAccessProperties {
158    /// A value carrying the crate's default access behavior.
159    pub const fn new() -> Self {
160        Self {
161            metadata_cache: MetadataCacheConfig::disabled(),
162            chunk_cache: ChunkCacheConfig::new(),
163            locking: FileLocking::Enabled,
164            memory_strategy: None,
165        }
166    }
167
168    /// Configure the bounded streaming metadata cache.
169    #[doc(alias = "H5Pset_mdc_config")]
170    pub const fn with_metadata_cache(mut self, metadata_cache: MetadataCacheConfig) -> Self {
171        self.metadata_cache = metadata_cache;
172        self
173    }
174
175    /// Configure the per-dataset raw chunk cache used by datasets opened from
176    /// this file. This is the `H5Pset_cache`-style file-wide default.
177    #[doc(alias = "H5Pset_cache")]
178    pub const fn with_chunk_cache(mut self, chunk_cache: ChunkCacheConfig) -> Self {
179        self.chunk_cache = chunk_cache;
180        self
181    }
182
183    /// Set the OS advisory file-locking policy for the read-write opens.
184    ///
185    /// Defaults to [`FileLocking::Enabled`]. Use [`FileLocking::Disabled`] only
186    /// when an external mechanism already guarantees single-writer access, or
187    /// [`FileLocking::BestEffort`] on a filesystem (such as some network mounts)
188    /// where the OS lock is unavailable. Setting `HDF5_USE_FILE_LOCKING` in the
189    /// environment overrides this, as in the C library.
190    ///
191    /// Readers and [`File::open_swmr_writer`] take no lock by design and ignore
192    /// this.
193    #[doc(alias = "H5Pset_file_locking")]
194    pub const fn with_locking(mut self, locking: FileLocking) -> Self {
195        self.locking = locking;
196        self
197    }
198
199    /// Set how much memory a read-write open may use to hold the file.
200    ///
201    /// Unset by default, which lets the entry point choose:
202    /// [`File::open_rw`] uses [`MemoryStrategy::Auto`], preferring the bounded
203    /// engine and falling back to the whole-file mirror for a file it cannot
204    /// edit, while the deprecated [`File::open_rw_bounded`] uses
205    /// [`MemoryStrategy::Bounded`], refusing such a file rather than quietly
206    /// spending `O(file size)` memory on a caller who asked not to. Setting this
207    /// overrides both, in either direction; [`MemoryStrategy::Mirrored`] takes
208    /// the whole-file mirror unconditionally, as `open_rw` did before it learned
209    /// to dispatch.
210    ///
211    /// The read-only opens ignore this: they build no editing session at all, and
212    /// their own names say what memory they spend. [`File::open_swmr_writer`]
213    /// does build one, and always mirrors: it accepts
214    /// [`MemoryStrategy::Auto`] and [`MemoryStrategy::Mirrored`], both of which
215    /// the mirror satisfies, and refuses an explicit [`MemoryStrategy::Bounded`]
216    /// with [`Error::EditUnsupported`] rather than quietly not honoring it. Ask a
217    /// `File` which backend it resolved to with [`File::edit_backing`].
218    pub const fn with_memory_strategy(mut self, memory_strategy: MemoryStrategy) -> Self {
219        self.memory_strategy = Some(memory_strategy);
220        self
221    }
222
223    /// Return the configured streaming metadata cache.
224    pub const fn metadata_cache(&self) -> MetadataCacheConfig {
225        self.metadata_cache
226    }
227
228    /// Return the configured per-dataset chunk cache.
229    pub const fn chunk_cache(&self) -> ChunkCacheConfig {
230        self.chunk_cache
231    }
232
233    /// Return the configured file-locking policy.
234    pub const fn locking(&self) -> FileLocking {
235        self.locking
236    }
237
238    /// Return the configured memory strategy, or `None` when none was asked for
239    /// and the entry point's own default applies. This is what was *requested*;
240    /// for which backend an open resolved to, see [`File::edit_backing`].
241    ///
242    /// The `Option` exists so the deprecated [`File::open_rw_bounded`] can
243    /// default to [`MemoryStrategy::Bounded`] while [`File::open_rw`] defaults to
244    /// [`MemoryStrategy::Auto`]; when that pair is removed this should collapse to
245    /// a plain `MemoryStrategy` with `Auto` as the default, in that same release
246    /// rather than as a second break on this accessor.
247    pub const fn memory_strategy(&self) -> Option<MemoryStrategy> {
248        self.memory_strategy
249    }
250}
251
252/// Dataset-access properties applied when opening a single dataset.
253///
254/// This is the `hdf5-pure` analogue of an HDF5 **dataset access property list**
255/// (`dapl`). Its chunk cache corresponds to `H5Pset_chunk_cache`: it overrides,
256/// for this one dataset, the file-wide chunk-cache default configured with
257/// [`FileAccessProperties::with_chunk_cache`] (the `H5Pset_cache` analogue). When
258/// left unset, the dataset inherits that file-wide default — matching the `dapl`
259/// default sentinels (`H5D_CHUNK_CACHE_*_DEFAULT`), which also mean "use the
260/// file's setting".
261///
262/// The `Properties` suffix means the type stands in for one whole HDF5 property
263/// list, so every setting on it has a C counterpart to look up. It is a stand-in
264/// and not a port: a plain `Copy` value, with no handle to create or close, no
265/// runtime property registry, and no setter that can fail. `dapl` and each
266/// `H5Pset_*` it models are doc aliases, so a search for either lands here.
267/// The chunk cache is the one `dapl` property modeled; see the
268/// [property-support reference] for the rest.
269///
270/// [`ChunkCacheConfig`] maps `H5Pset_chunk_cache`'s `rdcc_nslots` and
271/// `rdcc_nbytes`; its `rdcc_w0` preemption policy is not modeled, because this
272/// read cache uses strict LRU eviction (as noted on
273/// [`ChunkCacheConfig::from_h5p_cache`]).
274///
275/// Pass it to [`File::dataset_with_options`] or [`Group::dataset_with_options`].
276///
277/// [property-support reference]: https://github.com/stephenberry/hdf5-pure/blob/main/docs/reference/property-support.md
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
279#[doc(alias = "dapl")]
280pub struct DatasetAccessProperties {
281    chunk_cache: Option<ChunkCacheConfig>,
282}
283
284/// Former name of [`DatasetAccessProperties`].
285#[deprecated(
286    since = "0.26.0",
287    note = "renamed to `DatasetAccessProperties`: a type standing in for a whole HDF5 property list now carries the `Properties` suffix"
288)]
289pub type DatasetAccessOptions = DatasetAccessProperties;
290
291impl DatasetAccessProperties {
292    /// A value that inherits every file-wide access default.
293    pub const fn new() -> Self {
294        Self { chunk_cache: None }
295    }
296
297    /// Override the raw chunk cache for this one dataset, ignoring the file-wide
298    /// default. This is the `H5Pset_chunk_cache` analogue.
299    #[doc(alias = "H5Pset_chunk_cache")]
300    pub const fn with_chunk_cache(mut self, chunk_cache: ChunkCacheConfig) -> Self {
301        self.chunk_cache = Some(chunk_cache);
302        self
303    }
304
305    /// Return the chunk-cache override, or `None` when the dataset inherits the
306    /// file-wide default.
307    pub const fn chunk_cache(&self) -> Option<ChunkCacheConfig> {
308        self.chunk_cache
309    }
310
311    /// Resolve the effective chunk-cache config: the per-dataset override if one
312    /// was set, otherwise the file-wide `default`.
313    const fn resolved_chunk_cache(&self, default: ChunkCacheConfig) -> ChunkCacheConfig {
314        match self.chunk_cache {
315            Some(config) => config,
316            None => default,
317        }
318    }
319}
320
321/// Test whether a file looks like an HDF5 file, without reading it whole.
322///
323/// This is the spelling of the C library's `H5Fis_accessible` /
324/// `H5Fis_hdf5`: it opens the file and scans only the 8-byte candidate windows
325/// where the HDF5 signature is permitted (offsets 0, 512, 1024, 2048, …), so it
326/// never buffers the whole file. Returns:
327///
328/// - `Ok(true)` — the HDF5 signature was found,
329/// - `Ok(false)` — the file opened but has no HDF5 signature,
330/// - `Err(..)` — the file could not be opened (missing, permissions, …).
331///
332/// It validates only the signature, not the rest of the format; a truncated or
333/// corrupt file past the signature still reports `true`. Use [`File::open`] to
334/// fully parse and validate.
335pub fn is_hdf5<P: AsRef<std::path::Path>>(path: P) -> std::io::Result<bool> {
336    let handle = std::fs::File::open(path)?;
337    let source = ReadSeekSource::new(handle).map_err(std::io::Error::other)?;
338    match signature::find_signature_in(&source) {
339        Ok(_) => Ok(true),
340        Err(FormatError::SignatureNotFound) => Ok(false),
341        Err(e) => Err(std::io::Error::other(e)),
342    }
343}
344
345/// Test whether an in-memory buffer begins (at a permitted offset) with the
346/// HDF5 signature. The buffer-backed counterpart of [`is_hdf5`].
347pub fn is_hdf5_bytes(data: &[u8]) -> bool {
348    signature::find_signature(data).is_ok()
349}
350
351/// An open HDF5 file for reading.
352struct FileInner {
353    backend: Backend,
354    superblock: Superblock,
355    /// Byte offset to add to all relative addresses (= original base_address).
356    addr_offset: u64,
357    /// Live file handle, retained only when the file was opened with
358    /// [`File::open_swmr`] so [`File::refresh`] can re-read appended data.
359    handle: Option<std::fs::File>,
360    /// File Space Info parsed from the superblock extension, if the file records
361    /// one. Best-effort: a malformed or unreadable extension leaves this `None`
362    /// rather than failing the open.
363    file_space_info: Option<FileSpaceInfo>,
364    access_properties: FileAccessProperties,
365    /// Set by [`File::close`] to seal a read-write file: after it, a write
366    /// through any surviving [`Dataset`]/[`Group`] handle or [`File`] clone
367    /// returns [`Error::FileClosed`]. Reads still work. Only ever set on a
368    /// `Backend::Edit` file.
369    closed: AtomicBool,
370    /// True for a file opened with [`File::open_swmr_writer`]: no OS lock is held,
371    /// the superblock's SWMR-write flag is raised, only immediate
372    /// [`Dataset::append`] is permitted (the staged surface is refused), and the
373    /// flag is cleared on [`File::close`] / `Drop`. `false` for every other file.
374    swmr_write: bool,
375}
376
377impl Drop for FileInner {
378    /// Best-effort cleanup for a writer dropped without an explicit
379    /// [`File::close`], running only when the last `Arc<FileInner>` clone drops;
380    /// a clean `close` already did this work and set `closed`, so this is
381    /// idempotent and skipped in that case.
382    ///
383    /// - A SWMR writer clears the superblock's SWMR-write flag (mirroring
384    ///   `File::close`).
385    /// - A read-write file that persists its free space rewrites its on-disk
386    ///   free-space managers into canonical shape (issue #173), so a
387    ///   dropped-without-`close` handle leaves the same file a clean `close`
388    ///   would (a no-op unless an immediate append grew the file past them). A
389    ///   true crash (`SIGKILL`, power loss) skips `drop` entirely; the appended
390    ///   data is still durable.
391    ///
392    /// Staged edits are *not* committed here: dropping a handle discards them,
393    /// which is what `close` exists to distinguish.
394    fn drop(&mut self) {
395        if self.closed.load(Ordering::Acquire) {
396            return;
397        }
398        let Backend::Edit(m) = &self.backend else {
399            return;
400        };
401        let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
402        if self.swmr_write {
403            let _ = session.set_consistency_flags(0);
404            return;
405        }
406        let _ = session.finalize_persist();
407        let _ = session.sync();
408    }
409}
410
411impl FileInner {
412    /// Open an HDF5 file from a filesystem path.
413    ///
414    /// Reads the file into memory once. To follow a file that a concurrent
415    /// single writer is appending to (SWMR), use [`File::open_swmr`] instead.
416    /// To read a file larger than memory (e.g. on a 32-bit host) without
417    /// buffering it, use [`File::open_streaming`].
418    pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
419        Self::open_with_options(path, FileAccessProperties::new())
420    }
421
422    /// Open an HDF5 file from a filesystem path with explicit access properties.
423    ///
424    /// Like [`open`](Self::open), this buffers the whole file in memory. Use
425    /// [`open_streaming_with_options`](Self::open_streaming_with_options) when
426    /// the metadata cache budget should apply to lazy metadata reads.
427    pub fn open_with_options<P: AsRef<std::path::Path>>(
428        path: P,
429        properties: FileAccessProperties,
430    ) -> Result<Self, Error> {
431        let bytes = std::fs::read(path.as_ref()).map_err(Error::Io)?;
432        let inner = Self::from_bytes_with_options(bytes, properties)?;
433        // The status-flag check belongs to the *path* opens, not to
434        // `from_bytes_with_options` (issue #245). A caller who already holds the
435        // bytes has taken its own snapshot: there is no live file to coordinate
436        // over, and the recovery this refusal would name — `clear_swmr_flag`,
437        // which needs write access to a path — is not available to it either.
438        // This is a deliberate divergence from the C library, which checks under
439        // its in-memory core driver too.
440        file_lock::check_status_flags(&inner.superblock, OpenIntent::Read, path.as_ref())?;
441        Ok(inner)
442    }
443
444    /// Open an HDF5 file for **streaming** reads, fetching regions on demand from
445    /// the file instead of buffering it whole.
446    ///
447    /// This lets a host read a file larger than its address space — the original
448    /// motivation being 32-bit targets reading multi-gigabyte files (issue #27).
449    /// Metadata and dataset chunks are read through a `ReadSeekSource`, so peak
450    /// memory stays close to one chunk plus the metadata being parsed.
451    ///
452    /// Reads match the buffered [`File::open`]: every storage layout and chunk
453    /// index type, both group forms (v2 and v1 symbol-table), and compact,
454    /// dense, shared, and variable-length attributes. What differs:
455    /// [`as_bytes`](Self::as_bytes) returns an empty slice (there is no
456    /// whole-file buffer), [`persisted_free_space`](Self::persisted_free_space)
457    /// returns no regions, a streaming file cannot be the *source* of a
458    /// cross-file copy, and chunk decompression is sequential (the `parallel`
459    /// feature accelerates only buffered reads).
460    pub fn open_streaming<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
461        Self::open_streaming_with_options(path, FileAccessProperties::new())
462    }
463
464    /// Open an HDF5 file for streaming reads with explicit access properties.
465    pub fn open_streaming_with_options<P: AsRef<std::path::Path>>(
466        path: P,
467        properties: FileAccessProperties,
468    ) -> Result<Self, Error> {
469        let handle = std::fs::File::open(path.as_ref()).map_err(Error::Io)?;
470        let source = ReadSeekSource::new(handle).map_err(Error::Format)?;
471        let source: Box<dyn Source + Send + Sync> = if properties.metadata_cache.is_enabled() {
472            Box::new(MetadataCachingSource::new(
473                source,
474                properties.metadata_cache,
475            ))
476        } else {
477            Box::new(source)
478        };
479        let (superblock, addr_offset) = Self::parse_superblock_source(source.as_ref())?;
480        file_lock::check_status_flags(&superblock, OpenIntent::Read, path.as_ref())?;
481        Ok(Self::from_parts(
482            Backend::Streaming(source),
483            superblock,
484            addr_offset,
485            None,
486            properties,
487        ))
488    }
489
490    /// Open an HDF5 file for SWMR (single-writer/multiple-reader) reading.
491    ///
492    /// Like [`File::open`], but retains a live handle to the file so that
493    /// [`File::refresh`] can re-read data appended by a concurrent writer
494    /// (whether produced by this crate's append writer, the reference HDF5 C
495    /// library, or h5py in SWMR mode). The initial view is a consistent
496    /// snapshot; call [`File::refresh`] to advance to a newer one.
497    ///
498    /// Only the `std` build supports this (it requires a live filesystem
499    /// handle); the in-memory [`File::from_bytes`] path cannot refresh.
500    pub fn open_swmr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
501        Self::open_swmr_with_options(path, FileAccessProperties::new())
502    }
503
504    /// Open an HDF5 file for SWMR reading with explicit access properties.
505    ///
506    /// SWMR reads currently keep an in-memory mirror for refresh semantics, so
507    /// only the per-dataset chunk-cache settings affect this backend.
508    pub fn open_swmr_with_options<P: AsRef<std::path::Path>>(
509        path: P,
510        properties: FileAccessProperties,
511    ) -> Result<Self, Error> {
512        let mut handle = std::fs::File::open(path.as_ref()).map_err(Error::Io)?;
513        let mut data = Vec::new();
514        handle.read_to_end(&mut data).map_err(Error::Io)?;
515        let (superblock, addr_offset) = Self::parse_superblock(&data)?;
516        file_lock::check_status_flags(&superblock, OpenIntent::SwmrRead, path.as_ref())?;
517        Ok(Self::from_parts(
518            Backend::InMemory(data),
519            superblock,
520            addr_offset,
521            Some(handle),
522            properties,
523        ))
524    }
525
526    /// Open an HDF5 file from an in-memory byte vector.
527    pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
528        Self::from_bytes_with_options(data, FileAccessProperties::new())
529    }
530
531    /// Open an HDF5 file from an in-memory byte vector with explicit access properties.
532    pub fn from_bytes_with_options(
533        data: Vec<u8>,
534        properties: FileAccessProperties,
535    ) -> Result<Self, Error> {
536        let (superblock, addr_offset) = Self::parse_superblock(&data)?;
537        Ok(Self::from_parts(
538            Backend::InMemory(data),
539            superblock,
540            addr_offset,
541            None,
542            properties,
543        ))
544    }
545
546    /// Open an existing HDF5 file for reading **and** in-place editing, applying
547    /// `properties` (its [`FileLocking`] policy governs the OS file lock held for
548    /// the file's life, and its chunk cache is the file-wide default).
549    fn open_rw<P: AsRef<std::path::Path>>(
550        path: P,
551        properties: FileAccessProperties,
552    ) -> Result<Self, Error> {
553        Self::open_rw_with_default(path, properties, MemoryStrategy::Auto)
554    }
555
556    /// Open read-write under the properties' memory strategy, falling back to
557    /// `default` when the caller expressed none. The two public read-write entry
558    /// points differ only in that default: [`File::open_rw`] prefers the bounded
559    /// engine but takes the mirror for a file the bounded engine cannot edit,
560    /// while [`File::open_rw_bounded`] refuses that file instead (issue #198,
561    /// step 4).
562    fn open_rw_with_default<P: AsRef<std::path::Path>>(
563        path: P,
564        properties: FileAccessProperties,
565        default: MemoryStrategy,
566    ) -> Result<Self, Error> {
567        let session = WriteEngine::open_rw_with_strategy(
568            path.as_ref(),
569            properties.metadata_cache,
570            properties.locking,
571            properties.memory_strategy.unwrap_or(default),
572        )?;
573        Self::from_rw_session(session, properties)
574    }
575
576    /// Wrap an opened [`WriteEngine`] as a read-write [`Backend::Edit`] file.
577    fn from_rw_session(
578        session: WriteEngine,
579        properties: FileAccessProperties,
580    ) -> Result<Self, Error> {
581        // The engine parsed and normalized this at open; take it rather than
582        // re-parsing, so the image need not be able to hand out a slice.
583        let superblock = session.superblock().clone();
584        let addr_offset = superblock.base_address;
585        Ok(Self::from_parts(
586            Backend::Edit(Box::new(Mutex::new(session))),
587            superblock,
588            addr_offset,
589            None,
590            properties,
591        ))
592    }
593
594    /// Open for SWMR writing: no OS lock, superblock SWMR-write flag raised.
595    fn open_swmr_writer<P: AsRef<std::path::Path>>(
596        path: P,
597        properties: FileAccessProperties,
598    ) -> Result<Self, Error> {
599        // The SWMR writer always mirrors. `Auto` and unset are *satisfied* by
600        // that — they ask for the bounded engine where it applies and accept the
601        // mirror where it does not — but `Bounded` is a guarantee, and honoring a
602        // guarantee by ignoring it is how a caller ends up spending `O(file size)`
603        // memory it asked not to. Refusing is also the permissive direction to be
604        // wrong in: if this writer ever runs bounded, the refusal stops firing,
605        // which breaks nobody.
606        if properties.memory_strategy == Some(MemoryStrategy::Bounded) {
607            return Err(Error::EditUnsupported(
608                "the SWMR writer always holds the file in a whole-file mirror; leave \
609                 MemoryStrategy unset, or pass MemoryStrategy::Auto or MemoryStrategy::Mirrored, \
610                 to open it",
611            ));
612        }
613        let mut inner = Self::from_rw_session(WriteEngine::open_swmr_writer(path)?, properties)?;
614        inner.swmr_write = true;
615        Ok(inner)
616    }
617
618    /// Open for bounded-memory reading and appending (issue #147): no
619    /// whole-file mirror, and no fallback to one; see [`File::open_rw_bounded`].
620    fn open_rw_bounded<P: AsRef<std::path::Path>>(
621        path: P,
622        properties: FileAccessProperties,
623    ) -> Result<Self, Error> {
624        Self::open_rw_with_default(path, properties, MemoryStrategy::Bounded)
625    }
626
627    /// After the caller has confirmed a [`Backend::Edit`] backend, gate the
628    /// mutation: refuse a sealed file with [`Error::FileClosed`], and in
629    /// SWMR-writer mode refuse a staged edit (`staged = true`) with
630    /// [`Error::SwmrStagedUnsupported`] — only immediate appends are allowed.
631    fn check_mutable(&self, staged: bool) -> Result<(), Error> {
632        if self.closed.load(Ordering::Acquire) {
633            return Err(Error::FileClosed);
634        }
635        if staged && self.swmr_write {
636            return Err(Error::SwmrStagedUnsupported);
637        }
638        Ok(())
639    }
640
641    /// Gate a staged edit *without* taking the session lock: the backend must
642    /// offer the staged surface, and the file must still be mutable.
643    ///
644    /// This is the same gate the locking helpers apply before locking, split out
645    /// so a public method taking a user closure can report a read-only or sealed
646    /// file up front, run the closure with no lock held, and take the lock only
647    /// to record the result (issue #200).
648    fn check_staged_writable(&self) -> Result<(), Error> {
649        match &self.backend {
650            Backend::Edit(_) => self.check_mutable(true),
651            _ => Err(Error::ReadOnly),
652        }
653    }
654
655    /// A `Source` view over the backend, for the streaming-capable paths.
656    pub(crate) fn source(&self) -> SourceView<'_> {
657        match &self.backend {
658            Backend::InMemory(v) => SourceView::Mem(v),
659            Backend::Streaming(s) => SourceView::Stream(s.as_ref()),
660            // A mirror or bounded file's bytes live behind a lock and cannot be
661            // lent out as a borrowed view; the read paths that reach every
662            // backend go through [`with_source`](Self::with_source) instead.
663            Backend::Edit(_) => SourceView::Mem(&[]),
664        }
665    }
666
667    /// Run `f` with a random-access view of this file's bytes, taking the
668    /// write-engine lock when the backend requires one. Unlike
669    /// [`source`](Self::source) — which cannot lend a borrowed view out of a
670    /// lock and returns an empty view for the mirror and bounded backends —
671    /// this serves every backend, so it is the dispatch for read paths (heap
672    /// reads for variable-length data, chunk enumeration) that must also work
673    /// on a read-write file. `f` must not re-enter this file's backend (the
674    /// engine lock is held while it runs).
675    pub(crate) fn with_source<R>(&self, f: impl FnOnce(&dyn Source) -> R) -> R {
676        match &self.backend {
677            Backend::InMemory(v) => f(&BytesSource::new(v.as_slice())),
678            Backend::Streaming(s) => f(s.as_ref()),
679            Backend::Edit(m) => {
680                let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
681                f(core.image())
682            }
683        }
684    }
685
686    /// Run a read against a read-write session's file image, choosing the form
687    /// its backing can serve: `on_slice` when the session holds the whole file
688    /// in memory, so a slice-walking parser borrows the bytes instead of copying
689    /// them, and `on_source` otherwise.
690    ///
691    /// Both closures must compute the same thing. The pair exists because a
692    /// mirror can hand out a whole-file slice and a file-backed image cannot,
693    /// not because the two backings answer differently (issue #198).
694    fn with_engine<R>(
695        engine: &Mutex<WriteEngine>,
696        on_slice: impl FnOnce(&[u8]) -> R,
697        on_source: impl FnOnce(&dyn Source) -> R,
698    ) -> R {
699        let core = engine
700            .lock()
701            .unwrap_or_else(std::sync::PoisonError::into_inner);
702        match core.image_slice() {
703            Some(data) => on_slice(data),
704            None => on_source(core.image()),
705        }
706    }
707
708    /// Parse the superblock from `data`, returning it (with `root_group_address`
709    /// normalized to an absolute offset) and the base-address offset.
710    fn parse_superblock(data: &[u8]) -> Result<(Superblock, u64), Error> {
711        let sig_offset = signature::find_signature(data)?;
712        let mut superblock = Superblock::parse(data, sig_offset)?;
713        let addr_offset = superblock.base_address;
714        // Normalize root_group_address to absolute so resolve_path_any works.
715        superblock.root_group_address = superblock
716            .root_group_address
717            .checked_add(addr_offset)
718            .ok_or(FormatError::OffsetOverflow {
719                offset: superblock.root_group_address,
720                length: addr_offset,
721            })?;
722        debug_assert!(superblock.root_group_address >= addr_offset);
723        Ok((superblock, addr_offset))
724    }
725
726    /// Streaming counterpart of [`parse_superblock`]: locate and parse the
727    /// superblock by reading only small windows from the source.
728    fn parse_superblock_source<S: Source + ?Sized>(source: &S) -> Result<(Superblock, u64), Error> {
729        let sig_offset = signature::find_signature_in(source)?;
730        let mut superblock = Superblock::parse_from_source(source, sig_offset)?;
731        let addr_offset = superblock.base_address;
732        superblock.root_group_address = superblock
733            .root_group_address
734            .checked_add(addr_offset)
735            .ok_or(FormatError::OffsetOverflow {
736                offset: superblock.root_group_address,
737                length: addr_offset,
738            })?;
739        debug_assert!(superblock.root_group_address >= addr_offset);
740        Ok((superblock, addr_offset))
741    }
742
743    /// Assemble a [`File`] from parsed parts, then load the File Space Info from
744    /// the superblock extension (best-effort, so a bad extension never fails the
745    /// open).
746    fn from_parts(
747        backend: Backend,
748        superblock: Superblock,
749        addr_offset: u64,
750        handle: Option<std::fs::File>,
751        access_properties: FileAccessProperties,
752    ) -> Self {
753        let mut file = FileInner {
754            backend,
755            superblock,
756            addr_offset,
757            handle,
758            file_space_info: None,
759            access_properties,
760            closed: AtomicBool::new(false),
761            swmr_write: false,
762        };
763        file.file_space_info = file.read_file_space_info();
764        file
765    }
766
767    /// Parse the File Space Info message from the superblock extension, if the
768    /// file records one and it can be read. Best-effort: any failure (no
769    /// extension, unreadable object header, malformed message) yields `None`.
770    fn read_file_space_info(&self) -> Option<FileSpaceInfo> {
771        let rel = self.superblock.superblock_extension_address?;
772        if rel == u64::MAX {
773            return None;
774        }
775        let abs = self.addr_offset.checked_add(rel)?;
776        let header = self.parse_header(abs).ok()?;
777        let msg = header
778            .messages
779            .iter()
780            .find(|m| m.msg_type == MessageType::FileSpaceInfo)?;
781        FileSpaceInfo::parse(
782            &msg.data,
783            self.superblock.offset_size,
784            self.superblock.length_size,
785        )
786        .ok()
787    }
788
789    /// Re-read the file from disk to pick up data appended by a concurrent
790    /// writer, then re-parse the superblock.
791    ///
792    /// This is the SWMR reader's refresh primitive (analogous to the C library's
793    /// `H5Drefresh` / h5py's `Dataset.refresh()`): after it returns, newly
794    /// fetched [`Dataset`]/[`Group`] handles observe the writer's appended
795    /// chunks and extended dimensions, because they re-parse object headers at
796    /// their (stable) addresses against the refreshed bytes. Existing handles
797    /// borrow `&self`, so they must be dropped before calling this; re-fetch
798    /// them afterward.
799    ///
800    /// Returns [`Error::SwmrUnsupported`] if the file was not opened with
801    /// [`File::open_swmr`]. The superblock is checksum-validated on every
802    /// re-read; a transient parse failure (a writer caught mid-flush) is
803    /// retried a bounded number of times before being surfaced.
804    ///
805    /// Cost: each call re-reads the entire file from disk (`O(file size)`).
806    /// That keeps the implementation simple and correct, but when following a
807    /// large, steadily growing log it is the cost paid per refresh; budget
808    /// refresh frequency accordingly.
809    pub fn refresh(&mut self) -> Result<(), Error> {
810        let handle = self.handle.as_mut().ok_or(Error::SwmrUnsupported)?;
811
812        // A writer only appends (the file grows) and updates a few fixed-size,
813        // individually checksummed structures in place (superblock EOF, object
814        // header dimensions, array header counts). Re-reading the whole file and
815        // re-validating the superblock checksum yields a consistent view; if the
816        // superblock is caught mid-update, retry.
817        const MAX_ATTEMPTS: u32 = 100;
818        let mut last_err = None;
819        for attempt in 0..MAX_ATTEMPTS {
820            let mut data = Vec::new();
821            handle.seek(SeekFrom::Start(0)).map_err(Error::Io)?;
822            handle.read_to_end(&mut data).map_err(Error::Io)?;
823            match Self::parse_superblock(&data) {
824                Ok((superblock, addr_offset)) => {
825                    self.backend = Backend::InMemory(data);
826                    self.superblock = superblock;
827                    self.addr_offset = addr_offset;
828                    self.file_space_info = self.read_file_space_info();
829                    return Ok(());
830                }
831                Err(e) => {
832                    last_err = Some(e);
833                    // Brief backoff before re-reading; the writer's in-place
834                    // updates are tiny, so a short pause clears the window. Skip
835                    // it on the final attempt, where there is no re-read to come.
836                    if attempt + 1 < MAX_ATTEMPTS {
837                        std::thread::sleep(std::time::Duration::from_micros(
838                            50 * (attempt + 1) as u64,
839                        ));
840                    }
841                }
842            }
843        }
844        // The loop always runs at least once and only reaches here via the
845        // `Err` arm, so `last_err` is always `Some`; surface the real error.
846        Err(last_err.expect("refresh retried at least once before failing"))
847    }
848
849    /// Resolve a path to an object-header address, dispatching on the backend.
850    fn resolve_path(&self, path: &str) -> Result<u64, Error> {
851        Ok(match &self.backend {
852            Backend::InMemory(v) => group_v2::resolve_path_any(v, &self.superblock, path)?,
853            Backend::Streaming(s) => {
854                group_v2::resolve_path_any_from_source(s.as_ref(), &self.superblock, path)?
855            }
856            // A staged commit can relocate the object tree's root, so this
857            // file's cached superblock may name a stale one; resolve against the
858            // session's own superblock, which the commit updates.
859            Backend::Edit(m) => {
860                let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
861                let sb = core.superblock().clone();
862                match core.image_slice() {
863                    Some(data) => group_v2::resolve_path_any(data, &sb, path)?,
864                    None => group_v2::resolve_path_any_from_source(core.image(), &sb, path)?,
865                }
866            }
867        })
868    }
869
870    /// The current root-group address (base-adjusted, absolute). For a read-write
871    /// [`Backend::Edit`] file a prior relocating commit can have moved the
872    /// root, so take the session's own superblock, which the commit updates;
873    /// other backends use this file's cached one.
874    fn mirror_root_address(&self) -> u64 {
875        if let Backend::Edit(m) = &self.backend {
876            let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
877            return core.superblock().root_group_address;
878        }
879        self.superblock.root_group_address
880    }
881
882    /// Returns the raw file bytes for an in-memory file, or an empty slice for a
883    /// streaming file (which has no whole-file buffer).
884    pub fn as_bytes(&self) -> &[u8] {
885        match &self.backend {
886            Backend::InMemory(v) => v,
887            // A streaming, mirror, or bounded file has no borrowable whole-file
888            // buffer.
889            Backend::Streaming(_) | Backend::Edit(_) => &[],
890        }
891    }
892
893    /// Return the access properties used when opening this file.
894    pub const fn access_properties(&self) -> FileAccessProperties {
895        self.access_properties
896    }
897
898    /// The backend this file's editing session resolved to, or `None` when there
899    /// is no editing session to ask. Always [`EditBacking::Mirrored`] for the
900    /// SWMR writer, which builds a session but does not dispatch on the strategy.
901    fn edit_backing(&self) -> Option<EditBacking> {
902        match &self.backend {
903            Backend::Edit(m) => Some(m.lock().unwrap_or_else(|e| e.into_inner()).edit_backing()),
904            _ => None,
905        }
906    }
907
908    /// Returns a reference to the parsed superblock.
909    pub fn superblock(&self) -> &Superblock {
910        &self.superblock
911    }
912
913    /// The whole-file byte image when this file is buffered in memory
914    /// ([`open`](Self::open) / [`from_bytes`](Self::from_bytes)); `None` for a
915    /// streaming file ([`open_streaming`](Self::open_streaming)). Cross-file
916    /// object copy ([`File::copy_from`](crate::File::copy_from)) uses this to read
917    /// source objects by absolute address.
918    pub(crate) fn in_memory_image(&self) -> Option<&[u8]> {
919        match &self.backend {
920            Backend::InMemory(data) => Some(data),
921            Backend::Streaming(_) | Backend::Edit(_) => None,
922        }
923    }
924
925    /// The base address (`H5F` superblock base address), i.e. the byte offset
926    /// added to every stored relative address. Zero for a file with no
927    /// userblock.
928    pub(crate) fn base_address(&self) -> u64 {
929        self.addr_offset
930    }
931
932    /// The file-space management strategy this file records in its superblock
933    /// extension (set with `H5Pset_file_space_strategy`), or `None` if the file
934    /// records none — the default, which the C library also writes as "no
935    /// message". See [`file_space_info`](Self::file_space_info) for the full
936    /// record (persist flag, threshold, page size).
937    pub fn file_space_strategy(&self) -> Option<FileSpaceStrategy> {
938        self.file_space_info.as_ref().map(|info| info.strategy)
939    }
940
941    /// The full [`FileSpaceInfo`] recorded in this file's superblock extension,
942    /// if present and readable.
943    pub fn file_space_info(&self) -> Option<&FileSpaceInfo> {
944        self.file_space_info.as_ref()
945    }
946
947    /// The free regions a file persists on disk in its free-space managers (when
948    /// written with `H5Pset_file_space_strategy(..., persist = true)`), as
949    /// `(address, length)` pairs sorted by address.
950    ///
951    /// Empty when the file does not persist free space, or for the streaming
952    /// backend (which does not load the manager blocks). The addresses are file
953    /// offsets (relative to the base address); reading data is unaffected by the
954    /// presence or absence of these managers.
955    pub fn persisted_free_space(&self) -> Vec<(u64, u64)> {
956        let Some(info) = &self.file_space_info else {
957            return Vec::new();
958        };
959        if !info.persist {
960            return Vec::new();
961        }
962        let Backend::InMemory(data) = &self.backend else {
963            return Vec::new();
964        };
965        let mut sections = free_space_manager::read_persisted_sections(
966            data,
967            &info.manager_addrs,
968            self.addr_offset,
969            self.superblock.offset_size,
970        )
971        .unwrap_or_default();
972        sections.sort_by_key(|s| s.addr);
973        sections.into_iter().map(|s| (s.addr, s.size)).collect()
974    }
975
976    /// The size of the underlying file in bytes (the HDF5 `H5Fget_filesize`).
977    ///
978    /// This is the total byte length of the backing store — for a streaming
979    /// file the length reported by its source, for an in-memory file the length
980    /// of its buffer. It includes any userblock prefix and trailing bytes, so it
981    /// may exceed the superblock's logical end-of-file address; compare against
982    /// `Superblock::eof_address` (reachable via
983    /// [`File::superblock`]) to detect appended or unaccounted tail bytes.
984    pub fn file_size(&self) -> u64 {
985        match &self.backend {
986            Backend::Edit(m) => {
987                let core = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
988                core.image().len()
989            }
990            _ => self.source().len(),
991        }
992    }
993
994    /// The minimum library version required to read this file, derived from its
995    /// superblock version (the *low bound* of HDF5's `H5Fget_libver_bounds`).
996    ///
997    /// A version 3 superblock, for example, reports [`LibVer::V110`] because it
998    /// was introduced in HDF5 1.10.
999    pub fn libver_bound(&self) -> LibVer {
1000        LibVer::from_superblock_version(self.superblock.version)
1001    }
1002
1003    fn parse_header(&self, address: u64) -> Result<ObjectHeader, FormatError> {
1004        let os = self.superblock.offset_size;
1005        let ls = self.superblock.length_size;
1006        match &self.backend {
1007            Backend::InMemory(v) => {
1008                ObjectHeader::parse_with_base(v, address.to_usize()?, os, ls, self.addr_offset)
1009            }
1010            Backend::Streaming(s) => {
1011                ObjectHeader::parse_from_source(s.as_ref(), address, os, ls, self.addr_offset)
1012            }
1013            Backend::Edit(m) => Self::with_engine(
1014                m,
1015                |d| ObjectHeader::parse_with_base(d, address.to_usize()?, os, ls, self.addr_offset),
1016                |s| ObjectHeader::parse_from_source(s, address, os, ls, self.addr_offset),
1017            ),
1018        }
1019    }
1020
1021    /// Resolve a base-relative object-header address (the value stored in an
1022    /// HDF5 `H5R_OBJECT` reference element) to the [`Object`] it points at.
1023    ///
1024    /// The stored address is relative to the superblock base address, so any
1025    /// MAT-file userblock is accounted for here. A null (`0`) or undefined
1026    /// (`HADDR_UNDEF`) address, or one whose object header is neither a dataset
1027    /// nor a group, yields [`FormatError::InvalidObjectReference`].
1028    fn object_at_relative(file: &Arc<FileInner>, rel_addr: u64) -> Result<Object, Error> {
1029        // HADDR_UNDEF and the null address never name a real object. (Relative
1030        // address 0 is where the superblock sits, not an object header.)
1031        if rel_addr == u64::MAX || rel_addr == 0 {
1032            return Err(FormatError::InvalidObjectReference(rel_addr).into());
1033        }
1034        let abs = rel_addr
1035            .checked_add(file.addr_offset)
1036            .ok_or(FormatError::InvalidObjectReference(rel_addr))?;
1037        let hdr = file.parse_header(abs)?;
1038        if has_message(&hdr, MessageType::DataLayout) {
1039            let chunk_cache = DatasetAccessProperties::new()
1040                .resolved_chunk_cache(file.access_properties.chunk_cache);
1041            Ok(Object::Dataset(Box::new(Dataset {
1042                file: file.clone(),
1043                address: abs,
1044                header: hdr,
1045                chunk_cache: ChunkCache::with_config(chunk_cache),
1046                chunk_cache_config: chunk_cache,
1047                path: None,
1048            })))
1049        } else if is_group(&hdr) {
1050            Ok(Object::Group(Group {
1051                file: file.clone(),
1052                address: abs,
1053                path: None,
1054            }))
1055        } else {
1056            Err(FormatError::InvalidObjectReference(rel_addr).into())
1057        }
1058    }
1059
1060    fn offset_size(&self) -> u8 {
1061        self.superblock.offset_size
1062    }
1063
1064    fn length_size(&self) -> u8 {
1065        self.superblock.length_size
1066    }
1067
1068    /// Resolve the children of a group object header, dispatching on the backend
1069    /// and converting link addresses to absolute.
1070    fn group_children(&self, hdr: &ObjectHeader) -> Result<Vec<GroupEntry>, Error> {
1071        let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
1072        let mut entries = match &self.backend {
1073            Backend::InMemory(v) => group_v2::resolve_group_entries(v, hdr, os, ls, base),
1074            Backend::Streaming(s) => {
1075                group_v2::resolve_group_entries_from_source(s.as_ref(), hdr, os, ls, base)
1076            }
1077            Backend::Edit(m) => Self::with_engine(
1078                m,
1079                |d| group_v2::resolve_group_entries(d, hdr, os, ls, base),
1080                |s| group_v2::resolve_group_entries_from_source(s, hdr, os, ls, base),
1081            ),
1082        }
1083        .map_err(Error::Format)?;
1084        for entry in &mut entries {
1085            // The stored address is relative to the base address; normalize to an
1086            // absolute file offset. A crafted entry (e.g. the HADDR_UNDEF sentinel)
1087            // must not wrap or panic.
1088            entry.object_header_address = entry.object_header_address.checked_add(base).ok_or(
1089                FormatError::OffsetOverflow {
1090                    offset: entry.object_header_address,
1091                    length: base,
1092                },
1093            )?;
1094        }
1095        Ok(entries)
1096    }
1097
1098    /// Read all attributes attached to an object header, dispatching on the
1099    /// backend.
1100    fn attrs_of(&self, hdr: &ObjectHeader) -> Result<HashMap<String, AttrValue>, Error> {
1101        let (os, ls, base) = (self.offset_size(), self.length_size(), self.addr_offset);
1102        let attr_msgs = self.attr_messages_of(hdr)?;
1103        match &self.backend {
1104            Backend::Edit(m) => Ok(Self::with_engine(
1105                m,
1106                |d| attrs_to_map(&attr_msgs, &BytesSource::new(d), os, ls, base),
1107                |s| attrs_to_map(&attr_msgs, s, os, ls, base),
1108            )),
1109            _ => Ok(attrs_to_map(&attr_msgs, &self.source(), os, ls, base)),
1110        }
1111    }
1112
1113    /// Names of every attribute message on `hdr`, including ones whose datatype
1114    /// [`attrs_of`](Self::attrs_of) cannot decode into an [`AttrValue`] (and so
1115    /// silently omits from its map). Repack diffs this against the decoded map to
1116    /// refuse rather than drop an attribute it cannot reproduce.
1117    pub(crate) fn attr_message_names_of(&self, hdr: &ObjectHeader) -> Result<Vec<String>, Error> {
1118        Ok(self
1119            .attr_messages_of(hdr)?
1120            .into_iter()
1121            .map(|a| a.name)
1122            .collect())
1123    }
1124
1125    /// Extract every attribute message attached to an object header (compact,
1126    /// shared, and dense storage), dispatching on the backend.
1127    fn attr_messages_of(
1128        &self,
1129        hdr: &ObjectHeader,
1130    ) -> Result<Vec<crate::attribute::AttributeMessage>, Error> {
1131        let (os, ls) = (self.offset_size(), self.length_size());
1132        // Compact attributes come out of `hdr`, but the two addresses this walk
1133        // follows are read from message bodies and so are stored relative to the
1134        // base address: the Attribute Info message's fractal-heap address, and a
1135        // shared attribute's message address. Frame the file at `base` exactly as
1136        // [`Self::read_dataset_raw`] does, so both index it directly. For a plain
1137        // file (`base == 0`) this is the identity; without it, a userblock file's
1138        // dense attributes are looked for one userblock too early.
1139        let base = self.addr_offset;
1140        match &self.backend {
1141            Backend::InMemory(v) => Ok(extract_attributes_full(frame(v, base)?, hdr, os, ls)?),
1142            Backend::Streaming(s) if base == 0 => Ok(extract_attributes_full_from_source(
1143                s.as_ref(),
1144                hdr,
1145                os,
1146                ls,
1147            )?),
1148            Backend::Streaming(s) => {
1149                let framed = BaseOffsetSource {
1150                    inner: s.as_ref(),
1151                    base,
1152                };
1153                Ok(extract_attributes_full_from_source(&framed, hdr, os, ls)?)
1154            }
1155            Backend::Edit(m) => Self::with_engine(
1156                m,
1157                |d| Ok(extract_attributes_full(frame(d, base)?, hdr, os, ls)?),
1158                |s| {
1159                    if base == 0 {
1160                        Ok(extract_attributes_full_from_source(s, hdr, os, ls)?)
1161                    } else {
1162                        let framed = BaseOffsetSource { inner: s, base };
1163                        Ok(extract_attributes_full_from_source(&framed, hdr, os, ls)?)
1164                    }
1165                },
1166            ),
1167        }
1168    }
1169
1170    /// Read a dataset's raw bytes for the given layout, dispatching on the backend.
1171    fn read_dataset_raw(
1172        &self,
1173        dl: &DataLayout,
1174        ds: &Dataspace,
1175        dt: &Datatype,
1176        pipeline: Option<&FilterPipeline>,
1177        cache: &ChunkCache,
1178    ) -> Result<Vec<u8>, FormatError> {
1179        let (os, ls) = (self.offset_size(), self.length_size());
1180        // Every on-disk address in `dl` — the contiguous data address, the chunk
1181        // index root, and (followed deeper in the chunked reader) every B-tree /
1182        // fixed-array / extensible-array node and chunk-data address — is stored
1183        // relative to the base address. Present the payload reader a base-relative
1184        // view of the file so all of them index it directly: slice the in-memory
1185        // buffer at `base`, or wrap the streaming source to add `base` to each
1186        // read. For a plain file (`base == 0`) this is the identity.
1187        let base = self.addr_offset;
1188        match &self.backend {
1189            Backend::InMemory(v) => data_read::read_raw_data_cached(
1190                frame(v, base)?,
1191                dl,
1192                ds,
1193                dt,
1194                pipeline,
1195                os,
1196                ls,
1197                cache,
1198            ),
1199            Backend::Streaming(s) if base == 0 => data_read::read_raw_data_cached_from_source(
1200                s.as_ref(),
1201                dl,
1202                ds,
1203                dt,
1204                pipeline,
1205                os,
1206                ls,
1207                cache,
1208            ),
1209            Backend::Streaming(s) => {
1210                let framed = BaseOffsetSource {
1211                    inner: s.as_ref(),
1212                    base,
1213                };
1214                data_read::read_raw_data_cached_from_source(
1215                    &framed, dl, ds, dt, pipeline, os, ls, cache,
1216                )
1217            }
1218            Backend::Edit(m) => Self::with_engine(
1219                m,
1220                |data| {
1221                    let frame = if base == 0 {
1222                        data
1223                    } else {
1224                        let start = base.to_usize()?;
1225                        data.get(start..).ok_or(FormatError::UnexpectedEof {
1226                            expected: start,
1227                            available: data.len(),
1228                        })?
1229                    };
1230                    data_read::read_raw_data_cached(frame, dl, ds, dt, pipeline, os, ls, cache)
1231                },
1232                |s| {
1233                    let framed = BaseOffsetSource { inner: s, base };
1234                    data_read::read_raw_data_cached_from_source(
1235                        &framed, dl, ds, dt, pipeline, os, ls, cache,
1236                    )
1237                },
1238            ),
1239        }
1240    }
1241
1242    /// Windowed counterpart of [`read_dataset_raw`](Self::read_dataset_raw): read
1243    /// the raw element bytes of the row window `[start_row, start_row + num_rows)`,
1244    /// touching only the storage it overlaps. Reads through the same base-framed
1245    /// `Source`, so on-disk addresses resolve the same way. The caller clamps
1246    /// the window to the dataset.
1247    #[allow(clippy::too_many_arguments)]
1248    fn read_dataset_raw_rows(
1249        &self,
1250        dl: &DataLayout,
1251        ds: &Dataspace,
1252        dt: &Datatype,
1253        pipeline: Option<&FilterPipeline>,
1254        cache: &ChunkCache,
1255        start_row: u64,
1256        num_rows: u64,
1257    ) -> Result<Vec<u8>, FormatError> {
1258        let (os, ls) = (self.offset_size(), self.length_size());
1259        let elem_size = dt.type_size() as usize;
1260        // Elements per row (product of inner dims; 1 when 0-D or 1-D). Checked so
1261        // a crafted dataspace whose inner dims overflow `usize` errors instead of
1262        // panicking (debug) or wrapping (release).
1263        let row_elems: usize = ds.dimensions.iter().skip(1).try_fold(1usize, |acc, &d| {
1264            acc.checked_mul(d.to_usize()?)
1265                .ok_or(FormatError::OffsetOverflow {
1266                    offset: acc as u64,
1267                    length: d,
1268                })
1269        })?;
1270        let row_bytes = row_elems
1271            .checked_mul(elem_size)
1272            .ok_or(FormatError::OffsetOverflow {
1273                offset: row_elems as u64,
1274                length: elem_size as u64,
1275            })?;
1276
1277        // Compact data is inline in the layout message — no I/O, no framing.
1278        if let DataLayout::Compact { data } = dl {
1279            let start = start_row.to_usize()?.checked_mul(row_bytes);
1280            let len = num_rows.to_usize()?.checked_mul(row_bytes);
1281            let (Some(start), Some(len)) = (start, len) else {
1282                return Err(FormatError::OffsetOverflow {
1283                    offset: start_row,
1284                    length: row_bytes as u64,
1285                });
1286            };
1287            let end = start.checked_add(len).ok_or(FormatError::OffsetOverflow {
1288                offset: start as u64,
1289                length: len as u64,
1290            })?;
1291            return data
1292                .get(start..end)
1293                .map(<[u8]>::to_vec)
1294                .ok_or(FormatError::DataSizeMismatch {
1295                    expected: end,
1296                    actual: data.len(),
1297                });
1298        }
1299
1300        let base = self.addr_offset;
1301        match &self.backend {
1302            Backend::InMemory(v) => {
1303                let frame = if base == 0 {
1304                    v.as_slice()
1305                } else {
1306                    let start = base.to_usize()?;
1307                    v.get(start..).ok_or(FormatError::UnexpectedEof {
1308                        expected: start,
1309                        available: v.len(),
1310                    })?
1311                };
1312                read_rows_framed(
1313                    &BytesSource::new(frame),
1314                    dl,
1315                    ds,
1316                    dt,
1317                    pipeline,
1318                    os,
1319                    ls,
1320                    cache,
1321                    start_row,
1322                    num_rows,
1323                    row_bytes,
1324                )
1325            }
1326            Backend::Streaming(s) if base == 0 => read_rows_framed(
1327                s.as_ref(),
1328                dl,
1329                ds,
1330                dt,
1331                pipeline,
1332                os,
1333                ls,
1334                cache,
1335                start_row,
1336                num_rows,
1337                row_bytes,
1338            ),
1339            Backend::Streaming(s) => {
1340                let framed = BaseOffsetSource {
1341                    inner: s.as_ref(),
1342                    base,
1343                };
1344                read_rows_framed(
1345                    &framed, dl, ds, dt, pipeline, os, ls, cache, start_row, num_rows, row_bytes,
1346                )
1347            }
1348            Backend::Edit(m) => Self::with_engine(
1349                m,
1350                |data| {
1351                    let frame = if base == 0 {
1352                        data
1353                    } else {
1354                        let start = base.to_usize()?;
1355                        data.get(start..).ok_or(FormatError::UnexpectedEof {
1356                            expected: start,
1357                            available: data.len(),
1358                        })?
1359                    };
1360                    read_rows_framed(
1361                        &BytesSource::new(frame),
1362                        dl,
1363                        ds,
1364                        dt,
1365                        pipeline,
1366                        os,
1367                        ls,
1368                        cache,
1369                        start_row,
1370                        num_rows,
1371                        row_bytes,
1372                    )
1373                },
1374                |s| {
1375                    let framed = BaseOffsetSource { inner: s, base };
1376                    read_rows_framed(
1377                        &framed, dl, ds, dt, pipeline, os, ls, cache, start_row, num_rows,
1378                        row_bytes,
1379                    )
1380                },
1381            ),
1382        }
1383    }
1384}
1385
1386/// Read a row window through an already base-framed `Source`. Contiguous
1387/// layouts are one bounded sub-read; chunked layouts use the windowed chunk
1388/// reader (only the rank-0 crafted-file corner falls back to a whole read
1389/// plus slice).
1390#[allow(clippy::too_many_arguments)]
1391fn read_rows_framed<S: Source + ?Sized>(
1392    source: &S,
1393    dl: &DataLayout,
1394    ds: &Dataspace,
1395    dt: &Datatype,
1396    pipeline: Option<&FilterPipeline>,
1397    os: u8,
1398    ls: u8,
1399    cache: &ChunkCache,
1400    start_row: u64,
1401    num_rows: u64,
1402    row_bytes: usize,
1403) -> Result<Vec<u8>, FormatError> {
1404    // A zero-row window reads nothing, uniformly across the *supported* layouts.
1405    // Return early so that over unallocated storage — where the whole-dataset
1406    // readers differ (a contiguous None errors with `NoDataAllocated`, a chunked
1407    // None errors with "no address") — the contiguous and chunked arms agree
1408    // instead of one erroring and one succeeding. A `Virtual` layout is
1409    // unsupported and must still error like `read_raw` does, so it is excluded
1410    // here and falls through to the match.
1411    if num_rows == 0 && !matches!(dl, DataLayout::Virtual { .. }) {
1412        return Ok(Vec::new());
1413    }
1414    match dl {
1415        DataLayout::Compact { .. } => unreachable!("compact is handled before framing"),
1416        DataLayout::Contiguous { address, size } => {
1417            let addr = address.ok_or(FormatError::NoDataAllocated)?;
1418            let start =
1419                start_row
1420                    .checked_mul(row_bytes as u64)
1421                    .ok_or(FormatError::OffsetOverflow {
1422                        offset: start_row,
1423                        length: row_bytes as u64,
1424                    })?;
1425            let len =
1426                num_rows
1427                    .to_usize()?
1428                    .checked_mul(row_bytes)
1429                    .ok_or(FormatError::OffsetOverflow {
1430                        offset: num_rows,
1431                        length: row_bytes as u64,
1432                    })?;
1433            // Never read past the dataset's own contiguous storage.
1434            if start.saturating_add(len as u64) > *size {
1435                return Err(FormatError::DataSizeMismatch {
1436                    expected: start.to_usize()?.saturating_add(len),
1437                    actual: (*size).to_usize()?,
1438                });
1439            }
1440            let off = addr.checked_add(start).ok_or(FormatError::OffsetOverflow {
1441                offset: addr,
1442                length: start,
1443            })?;
1444            source.read_exact_at(off, len)
1445        }
1446        DataLayout::Chunked { .. } => {
1447            match crate::chunked_read::read_chunked_rows_from_source(
1448                source, dl, ds, dt, pipeline, os, ls, cache, start_row, num_rows,
1449            )? {
1450                Some(bytes) => Ok(bytes),
1451                // Rank-0 chunked (a crafted-file corner): fall back to a whole
1452                // read, then slice.
1453                None => {
1454                    let full = data_read::read_raw_data_cached_from_source(
1455                        source, dl, ds, dt, pipeline, os, ls, cache,
1456                    )?;
1457                    let start = start_row.to_usize()? * row_bytes;
1458                    let len = num_rows.to_usize()? * row_bytes;
1459                    full.get(start..start + len).map(<[u8]>::to_vec).ok_or(
1460                        FormatError::DataSizeMismatch {
1461                            expected: start + len,
1462                            actual: full.len(),
1463                        },
1464                    )
1465                }
1466            }
1467        }
1468        DataLayout::Virtual { .. } => Err(FormatError::UnsupportedVirtualLayout),
1469    }
1470}
1471
1472impl std::fmt::Debug for FileInner {
1473    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1474        f.debug_struct("File")
1475            .field("size", &self.file_size())
1476            .field("superblock_version", &self.superblock.version)
1477            .finish()
1478    }
1479}
1480
1481/// An open HDF5 file.
1482///
1483/// A `File` is an owned, cheaply cloneable handle to an open file: cloning it (or
1484/// deriving a [`Dataset`]/[`Group`] from it) shares one underlying open file
1485/// rather than re-reading it. Object handles returned by [`dataset`](Self::dataset),
1486/// [`group`](Self::group), and [`root`](Self::root) are **owned** — they keep the
1487/// file open for as long as they live and carry no borrow of the `File`, so they
1488/// can be stored in a struct, cached, and moved across threads.
1489#[derive(Clone)]
1490pub struct File {
1491    inner: Arc<FileInner>,
1492}
1493
1494impl std::fmt::Debug for File {
1495    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1496        std::fmt::Debug::fmt(&*self.inner, f)
1497    }
1498}
1499
1500impl File {
1501    /// Open an HDF5 file from a filesystem path.
1502    ///
1503    /// Reads the file into memory once. To follow a file that a concurrent
1504    /// single writer is appending to (SWMR), use [`File::open_swmr`] instead.
1505    /// To read a file larger than memory (e.g. on a 32-bit host) without
1506    /// buffering it, use [`File::open_streaming`].
1507    ///
1508    /// A file whose superblock marks it as held by a writer is refused with
1509    /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse) — the check
1510    /// `H5Fopen` makes of the same byte. That means a live writer or one that
1511    /// exited without closing the file; clear a stale flag with
1512    /// [`clear_swmr_flag`](Self::clear_swmr_flag), and follow a live SWMR writer
1513    /// with [`open_swmr`](Self::open_swmr). [`from_bytes`](Self::from_bytes) does
1514    /// not check, since its caller already holds the bytes — which is also the
1515    /// way to read a flagged file on a read-only mount, where clearing the flag
1516    /// would need write access.
1517    pub fn open<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
1518        Ok(File {
1519            inner: Arc::new(FileInner::open(path)?),
1520        })
1521    }
1522
1523    /// Open an HDF5 file from a filesystem path with explicit access properties.
1524    pub fn open_with_options<P: AsRef<std::path::Path>>(
1525        path: P,
1526        properties: FileAccessProperties,
1527    ) -> Result<Self, Error> {
1528        Ok(File {
1529            inner: Arc::new(FileInner::open_with_options(path, properties)?),
1530        })
1531    }
1532
1533    /// Open an HDF5 file for **streaming** reads, fetching regions on demand from
1534    /// the file instead of buffering it whole.
1535    ///
1536    /// This lets a host read a file larger than its address space. Metadata and
1537    /// dataset chunks are read through a `ReadSeekSource`, so peak memory stays
1538    /// close to one chunk plus the metadata being parsed. Attribute reading and
1539    /// v1 symbol-table groups on the resolved path are not yet supported on this
1540    /// backend.
1541    ///
1542    /// Like [`open`](Self::open), this refuses a file whose superblock marks it
1543    /// as held by a writer.
1544    pub fn open_streaming<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
1545        Ok(File {
1546            inner: Arc::new(FileInner::open_streaming(path)?),
1547        })
1548    }
1549
1550    /// Open an HDF5 file for streaming reads with explicit access properties.
1551    pub fn open_streaming_with_options<P: AsRef<std::path::Path>>(
1552        path: P,
1553        properties: FileAccessProperties,
1554    ) -> Result<Self, Error> {
1555        Ok(File {
1556            inner: Arc::new(FileInner::open_streaming_with_options(path, properties)?),
1557        })
1558    }
1559
1560    /// Open an HDF5 file for SWMR (single-writer/multiple-reader) reading.
1561    ///
1562    /// Like [`File::open`], but retains a live handle to the file so that
1563    /// [`File::refresh`] can re-read data appended by a concurrent writer.
1564    ///
1565    /// This is the open that *follows* a file marked as held by a SWMR writer,
1566    /// where [`open`](Self::open) refuses one. Only a half-set mark is refused
1567    /// here, with [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse):
1568    /// either bit without the other. Write access alone is what a plain
1569    /// (non-SWMR) writer leaves, and there is no protocol for following a writer
1570    /// that is not publishing consistent prefixes; the SWMR bit alone is a state
1571    /// no writer produces. Both bits is the live SWMR writer this exists to
1572    /// follow, and neither is a quiescent file.
1573    #[doc(alias = "H5F_ACC_SWMR_READ")]
1574    pub fn open_swmr<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
1575        Ok(File {
1576            inner: Arc::new(FileInner::open_swmr(path)?),
1577        })
1578    }
1579
1580    /// Open an HDF5 file for SWMR reading with explicit access properties.
1581    pub fn open_swmr_with_options<P: AsRef<std::path::Path>>(
1582        path: P,
1583        properties: FileAccessProperties,
1584    ) -> Result<Self, Error> {
1585        Ok(File {
1586            inner: Arc::new(FileInner::open_swmr_with_options(path, properties)?),
1587        })
1588    }
1589
1590    /// Open an HDF5 file from an in-memory byte vector.
1591    pub fn from_bytes(data: Vec<u8>) -> Result<Self, Error> {
1592        Ok(File {
1593            inner: Arc::new(FileInner::from_bytes(data)?),
1594        })
1595    }
1596
1597    /// Open an HDF5 file from an in-memory byte vector with explicit access properties.
1598    pub fn from_bytes_with_options(
1599        data: Vec<u8>,
1600        properties: FileAccessProperties,
1601    ) -> Result<Self, Error> {
1602        Ok(File {
1603            inner: Arc::new(FileInner::from_bytes_with_options(data, properties)?),
1604        })
1605    }
1606
1607    /// Open an existing HDF5 file for reading **and** in-place editing.
1608    ///
1609    /// Unlike [`open`](Self::open) (read-only, buffered), this takes an exclusive
1610    /// OS file lock held for the file's life and lets owned handles modify the
1611    /// file — immediate [`Dataset::append`]s, plus [`Dataset::write`]/`set_attr`,
1612    /// [`Group::create_dataset`]/`create_group`/`delete`/`set_attr`, and
1613    /// [`copy`](Self::copy)/[`copy_from`](Self::copy_from) staged until
1614    /// [`commit`](Self::commit). The file must use 8-byte offsets and lengths and
1615    /// keep its superblock at its base address (a canonical userblock, as in a
1616    /// MATLAB `.mat` file, is supported); anything else is refused with
1617    /// [`Error::EditUnsupported`](crate::Error::EditUnsupported).
1618    ///
1619    /// The fast immediate [`Dataset::append`] additionally requires a
1620    /// latest-format (version-2/3) file with no userblock and an
1621    /// Extensible-Array-indexed dataset; [`Dataset::append_staged`] covers the
1622    /// general case.
1623    ///
1624    /// Two things can turn this open away because another writer holds the file:
1625    /// the exclusive OS lock, reported as
1626    /// [`Error::FileLocked`](crate::Error::FileLocked), and the superblock's
1627    /// status-flags byte, reported as
1628    /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse). The second
1629    /// covers what the first cannot — a SWMR writer takes no lock, and a writer
1630    /// that exited without closing the file leaves the flag behind; recover a
1631    /// stale one with [`clear_swmr_flag`](Self::clear_swmr_flag).
1632    ///
1633    /// # Memory
1634    ///
1635    /// This picks its backing from the file rather than making the caller pick a
1636    /// function (issue #198): a latest-format file with no userblock is edited
1637    /// **bounded**, holding only the metadata being parsed plus the configured
1638    /// caches plus what an edit is building, so resident memory does not scale
1639    /// with the file; anything else falls back to a whole-file in-memory mirror,
1640    /// which is what makes a pre-v2 or userblock file editable at all. The two
1641    /// backings are the same engine over different storage and offer the same
1642    /// edit surface, differing in one trade: the bounded one applies a large
1643    /// immediate append in whole-chunk batches, each crash-atomic on its own, so
1644    /// a crash mid-call leaves a valid shorter dataset rather than none of the
1645    /// append. Ask a file which it got with
1646    /// [`edit_backing`](Self::edit_backing), and demand one with
1647    /// [`FileAccessProperties::with_memory_strategy`] —
1648    /// [`MemoryStrategy::Mirrored`] restores the unconditional mirror this
1649    /// entry point used before it learned to dispatch.
1650    #[doc(alias = "H5Fopen")]
1651    pub fn open_rw<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
1652        Self::open_rw_with_options(path, FileAccessProperties::new())
1653    }
1654
1655    /// Open an existing file for reading and in-place editing with explicit
1656    /// access properties — see [`open_rw`](Self::open_rw).
1657    ///
1658    /// The properties carry the locking policy (the `H5Pset_file_locking` analogue,
1659    /// [`FileAccessProperties::with_locking`]), the memory strategy
1660    /// ([`FileAccessProperties::with_memory_strategy`], which overrides the
1661    /// dispatch described on [`open_rw`](Self::open_rw)), the metadata cache used
1662    /// by the bounded backing, and the file-wide chunk-cache default applied to
1663    /// datasets opened from this file. Because one [`FileAccessProperties`] value
1664    /// serves every open, the same configuration can be shared with a read path.
1665    pub fn open_rw_with_options<P: AsRef<std::path::Path>>(
1666        path: P,
1667        properties: FileAccessProperties,
1668    ) -> Result<Self, Error> {
1669        Ok(File {
1670            inner: Arc::new(FileInner::open_rw(path, properties)?),
1671        })
1672    }
1673
1674    /// Open exactly as [`open_rw`](Self::open_rw) does, but behind an image that
1675    /// withholds its whole-file slice, so every read takes the `Source` path
1676    /// rather than the slice fast path.
1677    ///
1678    /// Each read this file serves has two forms (see `with_engine`), and only
1679    /// the slice form runs in production until a mirrorless backing lands
1680    /// (issue #198). Opening the same file both ways and comparing is what
1681    /// holds the other form to the same answers in the meantime.
1682    #[cfg(test)]
1683    pub(crate) fn open_rw_source_only(path: &std::path::Path) -> Result<Self, Error> {
1684        Ok(File {
1685            inner: Arc::new(FileInner::from_rw_session(
1686                WriteEngine::open_source_only(path)?,
1687                FileAccessProperties::new(),
1688            )?),
1689        })
1690    }
1691
1692    /// Open an existing file for **SWMR** (single-writer/multiple-reader)
1693    /// appending: take **no** OS lock (so concurrent readers, and Windows'
1694    /// mandatory locks, are never blocked) and raise the superblock's SWMR-write
1695    /// flag so a reader may attach with [`File::open_swmr`], the C library's
1696    /// `H5F_ACC_SWMR_READ`, or h5py `swmr=True`.
1697    ///
1698    /// Only immediate [`Dataset::append`] is permitted, and only over the SWMR
1699    /// subset — an **unfiltered**, chunk-aligned append, so a concurrent reader
1700    /// only ever observes a consistent prefix; a filtered or non-chunk-aligned
1701    /// append returns [`Error::SwmrAppendUnsupported`](crate::Error::SwmrAppendUnsupported).
1702    /// The staged edit surface (`write`/`set_attr`/`create_*`/`delete`/`copy`/
1703    /// `commit`) returns
1704    /// [`Error::SwmrStagedUnsupported`](crate::Error::SwmrStagedUnsupported).
1705    /// [`close`](Self::close) clears the SWMR-write flag; a writer that exits
1706    /// without a clean close leaves it set — recover with
1707    /// [`clear_swmr_flag`](Self::clear_swmr_flag). While the flag stands, this
1708    /// open is refused with
1709    /// [`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse), which is what
1710    /// keeps a second writer off a file SWMR gives only one (no OS lock is held
1711    /// to do it).
1712    ///
1713    /// Requires a latest-format (version-3 superblock) file with no userblock
1714    /// and no persisted free-space; other files are refused with
1715    /// [`Error::SwmrAppendUnsupported`](crate::Error::SwmrAppendUnsupported).
1716    /// The version-3 requirement is the C library's: neither library reads the
1717    /// SWMR-write flag back on an older superblock, so raising one there would
1718    /// announce the writer to nobody.
1719    #[doc(alias = "H5F_ACC_SWMR_WRITE")]
1720    pub fn open_swmr_writer<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
1721        Self::open_swmr_writer_with_options(path, FileAccessProperties::new())
1722    }
1723
1724    /// Open for SWMR appending with explicit access properties — see
1725    /// [`open_swmr_writer`](Self::open_swmr_writer).
1726    ///
1727    /// The properties' chunk cache is the file-wide default for datasets opened
1728    /// from this file. Its locking policy is ignored, which costs the caller
1729    /// nothing: SWMR takes no OS lock by design, which is stronger than any
1730    /// locking a caller could ask for. Its memory strategy is *not* ignored the
1731    /// same way — this writer always mirrors, so an explicit
1732    /// [`MemoryStrategy::Bounded`] is a guarantee it cannot meet and is refused
1733    /// with [`Error::EditUnsupported`]; [`MemoryStrategy::Auto`] and
1734    /// [`MemoryStrategy::Mirrored`] are both satisfied by the mirror.
1735    pub fn open_swmr_writer_with_options<P: AsRef<std::path::Path>>(
1736        path: P,
1737        properties: FileAccessProperties,
1738    ) -> Result<Self, Error> {
1739        Ok(File {
1740            inner: Arc::new(FileInner::open_swmr_writer(path, properties)?),
1741        })
1742    }
1743
1744    /// Open an existing HDF5 file for reading and editing with **bounded
1745    /// memory** (issue #147): no whole-file mirror is ever built, so peak
1746    /// memory stays at the metadata being parsed plus the configured caches
1747    /// plus a few chunks of append working set — independent of the file size
1748    /// and of the size of each append call.
1749    ///
1750    /// # Deprecated
1751    ///
1752    /// [`open_rw`](Self::open_rw) now edits such a file bounded on its own, so
1753    /// this is no longer a different capability set — only a different default
1754    /// for a file the bounded engine cannot edit (a pre-v2 superblock, or a
1755    /// userblock). This refuses that file with
1756    /// [`Error::EditUnsupported`](crate::Error::EditUnsupported); `open_rw`
1757    /// mirrors it instead. To keep the refusal, say so:
1758    ///
1759    /// ```no_run
1760    /// use hdf5_pure::{File, FileAccessProperties, MemoryStrategy};
1761    /// # fn main() -> Result<(), hdf5_pure::Error> {
1762    /// let file = File::open_rw_with_options(
1763    ///     "data.h5",
1764    ///     FileAccessProperties::new().with_memory_strategy(MemoryStrategy::Bounded),
1765    /// )?;
1766    /// # Ok(()) }
1767    /// ```
1768    ///
1769    /// # Behavior
1770    ///
1771    /// This is the read-write sibling of [`open_streaming`](Self::open_streaming):
1772    /// reads are served by positioned I/O with the same capabilities as the
1773    /// streaming backend, while immediate [`Dataset::append`] runs the same
1774    /// crash-atomic engine as [`open_rw`](Self::open_rw) — filtered whole-chunk
1775    /// / unfiltered any-length, durable before it returns, no `commit` needed.
1776    /// A large append is applied in whole-chunk batches, each crash-atomic, so
1777    /// a crash mid-call leaves a valid shorter dataset. An exclusive OS file
1778    /// lock is held for the file's life.
1779    ///
1780    /// The staged edit surface ([`Dataset::write`]/`set_attr`/`append_staged`,
1781    /// [`Group::create_dataset`]/`create_group`/`delete`/`set_attr`,
1782    /// [`commit`](Self::commit)/[`copy`](Self::copy)/[`copy_from`](Self::copy_from),
1783    /// and [`space_accounting`](Self::space_accounting)) is the same as
1784    /// [`open_rw`](Self::open_rw)'s: both open the same engine, differing only in
1785    /// how it holds the file's bytes (issue #198). A commit here holds only what
1786    /// it is building, so its resident memory is bounded by the edit rather than
1787    /// by the file — with [`copy`](Self::copy) the exception, since copying an
1788    /// object reads the whole of it into memory first.
1789    ///
1790    /// A file that persists its free space
1791    /// (`H5Pset_file_space_strategy(persist = true)`, non-paged) is supported:
1792    /// its on-disk free-space managers are seeded at open and rewritten into
1793    /// canonical shape when the file is closed — by an explicit
1794    /// [`close`](Self::close) or, best-effort, when the last handle drops (issue
1795    /// #173). Only a true crash (`SIGKILL`, power loss) skips that rewrite; the
1796    /// appended data is still durable and reopens correctly, the managers merely
1797    /// stay non-canonical until the next clean rewrite. A genuine **paged** file
1798    /// (`H5F_FSPACE_STRATEGY_PAGE` with `persist = true`) is also supported:
1799    /// appends stay page-homogeneous (raw and metadata in separate pages) and
1800    /// the per-page-type managers are rewritten at close. A paged file that
1801    /// does *not* persist its free space is refused at open — recreate it with
1802    /// `persist = true` to grow it in place. That refusal is shared with
1803    /// `open_rw`, which cannot commit such a file either.
1804    ///
1805    /// Requires a latest-format (v2/v3 superblock) file with 8-byte offsets and
1806    /// lengths and no userblock; other files are refused at open with
1807    /// [`Error::EditUnsupported`](crate::Error::EditUnsupported).
1808    #[deprecated(
1809        since = "0.28.0",
1810        note = "use `File::open_rw`, which now edits such a file bounded on its own; for the strict refusal, pass `FileAccessProperties::new().with_memory_strategy(MemoryStrategy::Bounded)` to `File::open_rw_with_options`"
1811    )]
1812    pub fn open_rw_bounded<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
1813        Self::open_rw_bounded_inner(path, FileAccessProperties::new())
1814    }
1815
1816    /// Open a file for bounded-memory reading and appending with explicit
1817    /// access properties — see [`open_rw_bounded`](Self::open_rw_bounded), which
1818    /// this is deprecated alongside.
1819    ///
1820    /// Both configured caches apply to this backend: the metadata cache bounds
1821    /// bytes retained for metadata reads (entries touched by an in-place write
1822    /// are invalidated, so reads never observe stale bytes), and the chunk
1823    /// cache bounds decompressed chunks retained by each [`Dataset`] handle. An
1824    /// explicit [`FileAccessProperties::with_memory_strategy`] wins over this
1825    /// entry point's bounded default, in either direction.
1826    #[deprecated(
1827        since = "0.28.0",
1828        note = "use `File::open_rw_with_options`; it honors the same `with_memory_strategy`, and defaults to falling back to the mirror rather than refusing"
1829    )]
1830    pub fn open_rw_bounded_with_options<P: AsRef<std::path::Path>>(
1831        path: P,
1832        properties: FileAccessProperties,
1833    ) -> Result<Self, Error> {
1834        Self::open_rw_bounded_inner(path, properties)
1835    }
1836
1837    /// The body both deprecated bounded entry points share, so that neither has
1838    /// to call the other and trip its own deprecation warning.
1839    fn open_rw_bounded_inner<P: AsRef<std::path::Path>>(
1840        path: P,
1841        properties: FileAccessProperties,
1842    ) -> Result<Self, Error> {
1843        Ok(File {
1844            inner: Arc::new(FileInner::open_rw_bounded(path, properties)?),
1845        })
1846    }
1847
1848    /// Clear a stale SWMR-write flag left in `path` by a writer that exited
1849    /// without a clean [`close`](Self::close) — the `h5clear -s` equivalent, for
1850    /// recovering a file that both this crate and the reference C library
1851    /// otherwise refuse to open ([`Error::FileMarkedInUse`](crate::Error::FileMarkedInUse)).
1852    /// A no-op if the flag is already clear.
1853    ///
1854    /// It takes the exclusive OS lock first, so it cannot clear the flag out
1855    /// from under a *live* [`open_rw`](Self::open_rw) writer. A live SWMR writer
1856    /// holds no lock, so make sure it is really gone: clearing the flag under
1857    /// one leaves its readers with no record that it is publishing.
1858    pub fn clear_swmr_flag<P: AsRef<std::path::Path>>(path: P) -> Result<(), Error> {
1859        crate::file_lock::clear_swmr_flag_at(path.as_ref())
1860    }
1861
1862    /// Create a new, empty HDF5 file at `path` and open it for reading and
1863    /// writing, so its contents can be built entirely through owned handles
1864    /// ([`Group::create_dataset`]/[`create_group`](Group::create_group), then
1865    /// [`commit`](Self::commit)).
1866    ///
1867    /// Overwrites any existing file at `path`. For an all-at-once write, use
1868    /// [`FileBuilder`](crate::FileBuilder) instead.
1869    #[doc(alias = "H5Fcreate")]
1870    pub fn create<P: AsRef<std::path::Path>>(path: P) -> Result<Self, Error> {
1871        Self::create_with_options(
1872            path,
1873            FileCreateProperties::new(),
1874            FileAccessProperties::new(),
1875        )
1876    }
1877
1878    /// Create a new, empty HDF5 file with explicit creation and access properties,
1879    /// then open it for reading and writing — see [`create`](Self::create).
1880    ///
1881    /// Mirrors `H5Fcreate(name, flags, fcpl_id, fapl_id)`: `create` carries the
1882    /// creation properties recorded in the new file (userblock, file-space
1883    /// strategy, library-version bounds), and `access` the properties governing
1884    /// the handle returned (locking policy, chunk cache). Both are values, so a
1885    /// layout defined once can be reused across every file an application writes.
1886    ///
1887    /// A creation property is validated as the file is written, so an invalid
1888    /// userblock or page size surfaces here rather than when the properties were
1889    /// built. A file created with [`FileSpaceStrategy::Page`] can be grown
1890    /// through either editor, by an immediate [`Dataset::append`] or a staged
1891    /// commit, provided it also persists its free space (issue #198).
1892    pub fn create_with_options<P: AsRef<std::path::Path>>(
1893        path: P,
1894        create: FileCreateProperties,
1895        access: FileAccessProperties,
1896    ) -> Result<Self, Error> {
1897        // Refuse a pair the reopen below would refuse, before anything is
1898        // written: this call promises a file *and* an open handle, and half of
1899        // that is worse than neither.
1900        if let Some(reason) = crate::edit::create_would_refuse_reopen(&create, &access) {
1901            return Err(Error::EditUnsupported(reason));
1902        }
1903        let mut builder = crate::writer::FileBuilder::new();
1904        builder.with_create_properties(create);
1905        let bytes = builder.finish()?;
1906        std::fs::write(path.as_ref(), bytes).map_err(Error::Io)?;
1907        Self::open_rw_with_options(path, access)
1908    }
1909
1910    /// Apply all staged structural edits made through this file's handles —
1911    /// [`Dataset::write`]/`set_attr`/`remove_attr` and
1912    /// [`Group::create_group`]/`delete` — as one transaction. Immediate
1913    /// [`Dataset::append`]s need no commit.
1914    ///
1915    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
1916    /// [`Error::ReadOnly`](crate::Error::ReadOnly). A commit that relocates
1917    /// objects invalidates outstanding handles — re-fetch any you keep using.
1918    pub fn commit(&self) -> Result<(), Error> {
1919        self.with_mirror_session(true, |session| session.commit())
1920    }
1921
1922    /// Copy the object at `src` to `dst` within this file (the in-file
1923    /// `H5Ocopy`), staged until [`commit`](Self::commit).
1924    ///
1925    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
1926    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
1927    pub fn copy(&self, src: &str, dst: &str) -> Result<(), Error> {
1928        self.with_mirror_session(true, |session| {
1929            session.copy(&normalize_path(src), &normalize_path(dst));
1930            Ok(())
1931        })
1932    }
1933
1934    /// Copy the object at `src` in `source` — a separate, buffered read-only
1935    /// file — into this file at `dst`: the cross-file `H5Ocopy`, staged until
1936    /// [`commit`](Self::commit).
1937    ///
1938    /// `source` must be a buffered file ([`File::open`] or [`File::from_bytes`],
1939    /// not [`File::open_streaming`]) that uses 8-byte offsets and has no
1940    /// userblock; anything else is refused with
1941    /// [`Error::EditUnsupported`](crate::Error::EditUnsupported). The source
1942    /// subtree is read and validated eagerly, so `source` need not outlive this
1943    /// call. Requires a read-write destination ([`File::open_rw`]); a read-only
1944    /// one returns [`Error::ReadOnly`](crate::Error::ReadOnly).
1945    pub fn copy_from(&self, source: &File, src: &str, dst: &str) -> Result<(), Error> {
1946        self.with_mirror_session(true, |session| session.copy_from(source, src, dst))
1947    }
1948
1949    /// Report whether this file has structural edits staged but not yet applied
1950    /// by [`commit`](Self::commit) — [`Dataset::write`]/`set_attr`/`remove_attr`,
1951    /// [`Dataset::append_staged`], [`Group::create_group`]/`create_dataset`/
1952    /// `delete`/`set_attr`/`remove_attr`, and [`copy`](Self::copy)/
1953    /// [`copy_from`](Self::copy_from). Immediate [`Dataset::append`]s are never
1954    /// staged and do not count. Always `false` for a read-only file.
1955    pub fn has_staged_edits(&self) -> bool {
1956        match &self.inner.backend {
1957            Backend::Edit(m) => {
1958                let session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1959                session.has_staged_edits()
1960            }
1961            _ => false,
1962        }
1963    }
1964
1965    /// Report this read-write file's live space usage as a [`SpaceAccounting`] —
1966    /// the current logical size, total reusable free bytes, and reusable free
1967    /// regions. It reflects committed state plus immediate in-place appends, not
1968    /// edits still staged for [`commit`](Self::commit).
1969    ///
1970    /// Requires a read-write file ([`File::open_rw`]); a read-only file returns
1971    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
1972    pub fn space_accounting(&self) -> Result<SpaceAccounting, Error> {
1973        match &self.inner.backend {
1974            Backend::Edit(m) => {
1975                let session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1976                Ok(session.space_accounting())
1977            }
1978            _ => Err(Error::ReadOnly),
1979        }
1980    }
1981
1982    /// Commit any staged edits and seal this file. The exclusive OS lock is
1983    /// released once the last handle derived from this file is also dropped.
1984    ///
1985    /// After `close`, a write through any surviving [`Dataset`]/[`Group`] handle
1986    /// or [`File`] clone returns [`Error::FileClosed`](crate::Error::FileClosed);
1987    /// reads still work.
1988    pub fn close(self) -> Result<(), Error> {
1989        if matches!(self.inner.backend, Backend::Edit(_)) {
1990            if self.inner.swmr_write {
1991                // SWMR mode stages nothing (the staged surface is refused), so do
1992                // not commit — clear the SWMR-write flag and flush, marking the
1993                // file cleanly closed for any concurrent reader.
1994                if let Backend::Edit(m) = &self.inner.backend {
1995                    let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
1996                    session.set_consistency_flags(0)?;
1997                }
1998            } else {
1999                self.commit()?;
2000                // Immediate appends grow the file past any persisted free-space
2001                // managers without running a commit tail, so re-home them here.
2002                // A no-op unless this session left them stale.
2003                self.with_mirror_session(false, |session| {
2004                    session.finalize_persist()?;
2005                    session.sync()
2006                })?;
2007            }
2008            self.inner.closed.store(true, Ordering::Release);
2009        }
2010        Ok(())
2011    }
2012
2013    /// Run `f` with the locked write session of a read-write file. `staged`
2014    /// distinguishes an edit applied by [`commit`](Self::commit) from an immediate
2015    /// one. Returns [`Error::ReadOnly`](crate::Error::ReadOnly) for a read-only
2016    /// file, [`Error::FileClosed`](crate::Error::FileClosed) once the file is
2017    /// sealed by [`close`](Self::close), and
2018    /// [`Error::SwmrStagedUnsupported`](crate::Error::SwmrStagedUnsupported) for a
2019    /// staged edit on a SWMR-writer file.
2020    fn with_mirror_session<R>(
2021        &self,
2022        staged: bool,
2023        f: impl FnOnce(&mut WriteEngine) -> Result<R, Error>,
2024    ) -> Result<R, Error> {
2025        let Backend::Edit(m) = &self.inner.backend else {
2026            return Err(Error::ReadOnly);
2027        };
2028        self.inner.check_mutable(staged)?;
2029        let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2030        f(&mut session)
2031    }
2032
2033    /// Returns an owned handle to the root group.
2034    pub fn root(&self) -> Group {
2035        Group {
2036            // A relocating commit on a read-write file can move the root, so
2037            // resolve it from the live mirror rather than the cached superblock.
2038            address: self.inner.mirror_root_address(),
2039            file: self.inner.clone(),
2040            path: Some(String::new()),
2041        }
2042    }
2043
2044    /// Resolve a path and return an owned [`Dataset`] handle.
2045    ///
2046    /// The dataset uses the file-wide chunk-cache default (configured with
2047    /// [`FileAccessProperties::with_chunk_cache`]). To override the cache for this
2048    /// one dataset, use [`dataset_with_options`](Self::dataset_with_options).
2049    pub fn dataset(&self, path: &str) -> Result<Dataset, Error> {
2050        self.dataset_with_options(path, DatasetAccessProperties::new())
2051    }
2052
2053    /// Resolve a path and return an owned [`Dataset`] handle, applying per-dataset
2054    /// [`DatasetAccessProperties`] that override file-wide access defaults.
2055    ///
2056    /// This is the dataset-open-with-access-property-list path (HDF5's `dapl`):
2057    /// the properties' chunk cache corresponds to `H5Pset_chunk_cache` and takes
2058    /// precedence, for this dataset only, over the `H5Pset_cache`-style
2059    /// file-wide default.
2060    pub fn dataset_with_options(
2061        &self,
2062        path: &str,
2063        properties: DatasetAccessProperties,
2064    ) -> Result<Dataset, Error> {
2065        let addr = self.inner.resolve_path(path)?;
2066        let hdr = self.inner.parse_header(addr)?;
2067        if !has_message(&hdr, MessageType::DataLayout) {
2068            return Err(Error::NotADataset(path.to_string()));
2069        }
2070        let chunk_cache = properties.resolved_chunk_cache(self.inner.access_properties.chunk_cache);
2071        Ok(Dataset {
2072            file: self.inner.clone(),
2073            address: addr,
2074            header: hdr,
2075            chunk_cache: ChunkCache::with_config(chunk_cache),
2076            chunk_cache_config: chunk_cache,
2077            path: Some(normalize_path(path)),
2078        })
2079    }
2080
2081    /// Resolve a path and return an owned [`Group`] handle.
2082    pub fn group(&self, path: &str) -> Result<Group, Error> {
2083        let addr = self.inner.resolve_path(path)?;
2084        Ok(Group {
2085            file: self.inner.clone(),
2086            address: addr,
2087            path: Some(normalize_path(path)),
2088        })
2089    }
2090
2091    /// Re-read the file from disk to pick up data appended by a concurrent
2092    /// writer, then re-parse the superblock.
2093    ///
2094    /// This is the SWMR reader's refresh primitive. Returns
2095    /// [`Error::SwmrUnsupported`] if the file was not opened with
2096    /// [`File::open_swmr`], and [`Error::HandlesOutstanding`] if any owned
2097    /// [`Dataset`]/[`Group`] handle (or a clone of this `File`) is still alive —
2098    /// drop them before refreshing, then re-fetch them afterward, since they
2099    /// observe the new bytes only when re-derived from the refreshed file.
2100    pub fn refresh(&mut self) -> Result<(), Error> {
2101        let inner = Arc::get_mut(&mut self.inner).ok_or(Error::HandlesOutstanding)?;
2102        inner.refresh()
2103    }
2104
2105    // --- delegating value getters (forward to the shared inner state) ---
2106
2107    /// Returns the raw file bytes for an in-memory file, or an empty slice for a
2108    /// streaming file (which has no whole-file buffer).
2109    pub fn as_bytes(&self) -> &[u8] {
2110        self.inner.as_bytes()
2111    }
2112
2113    /// Return the access properties used when opening this file.
2114    pub fn access_properties(&self) -> FileAccessProperties {
2115        self.inner.access_properties()
2116    }
2117
2118    /// Former name of [`access_properties`](Self::access_properties).
2119    #[deprecated(since = "0.26.0", note = "renamed to `access_properties`")]
2120    pub fn access_options(&self) -> FileAccessProperties {
2121        self.access_properties()
2122    }
2123
2124    /// Which backend this file's read-write session resolved to:
2125    /// [`EditBacking::Bounded`] when it reads through a handle, or
2126    /// [`EditBacking::Mirrored`] when it holds a whole-file image.
2127    ///
2128    /// This is how a caller who opened with [`MemoryStrategy::Auto`] finds out
2129    /// whether the fallback was taken, and so whether memory scales with the
2130    /// file. A file with no editing session — a read-only open, a streaming open
2131    /// — reports `None`.
2132    ///
2133    /// The answer is an [`EditBacking`] rather than the [`MemoryStrategy`] that
2134    /// was asked for, because `Auto` is a preference between the two backends and
2135    /// not an outcome either can report; `.into()` converts back when a later
2136    /// reopen should be pinned to what this one got.
2137    pub fn edit_backing(&self) -> Option<EditBacking> {
2138        self.inner.edit_backing()
2139    }
2140
2141    /// Returns a reference to the parsed superblock.
2142    pub fn superblock(&self) -> &Superblock {
2143        self.inner.superblock()
2144    }
2145
2146    /// The file-space management strategy this file records in its superblock
2147    /// extension, or `None` if it records none.
2148    pub fn file_space_strategy(&self) -> Option<FileSpaceStrategy> {
2149        self.inner.file_space_strategy()
2150    }
2151
2152    /// The full [`FileSpaceInfo`] recorded in this file's superblock extension,
2153    /// if present and readable.
2154    pub fn file_space_info(&self) -> Option<&FileSpaceInfo> {
2155        self.inner.file_space_info()
2156    }
2157
2158    /// The free regions a file persists on disk in its free-space managers, as
2159    /// `(address, length)` pairs sorted by address.
2160    pub fn persisted_free_space(&self) -> Vec<(u64, u64)> {
2161        self.inner.persisted_free_space()
2162    }
2163
2164    /// The size of the underlying file in bytes (the HDF5 `H5Fget_filesize`).
2165    pub fn file_size(&self) -> u64 {
2166        self.inner.file_size()
2167    }
2168
2169    /// The minimum library version required to read this file, derived from its
2170    /// superblock version (the *low bound* of HDF5's `H5Fget_libver_bounds`).
2171    pub fn libver_bound(&self) -> LibVer {
2172        self.inner.libver_bound()
2173    }
2174
2175    /// A `Source` view over the backend, for the streaming-capable paths.
2176    pub(crate) fn source(&self) -> SourceView<'_> {
2177        self.inner.source()
2178    }
2179
2180    /// The whole-file byte image when this file is buffered in memory; `None`
2181    /// for a streaming file. Used by cross-file object copy.
2182    pub(crate) fn in_memory_image(&self) -> Option<&[u8]> {
2183        self.inner.in_memory_image()
2184    }
2185
2186    /// The base address (superblock base address) added to every stored relative
2187    /// address. Zero for a file with no userblock.
2188    pub(crate) fn base_address(&self) -> u64 {
2189        self.inner.base_address()
2190    }
2191}
2192
2193// ---------------------------------------------------------------------------
2194// Object reference target
2195// ---------------------------------------------------------------------------
2196
2197/// The resolved target of an HDF5 object reference (`H5R_OBJECT`): either a
2198/// group or a dataset.
2199///
2200/// Produced by [`Dataset::dereference`]. MATLAB `.mat` files use object
2201/// references pervasively — a cell array stores one reference per element, and
2202/// the `#subsystem#` machinery references its payloads — so resolving a
2203/// reference to the group or dataset it names is the foundation for reading
2204/// those structures.
2205///
2206/// The [`Dataset`](Object::Dataset) handle is boxed: it carries a parsed object
2207/// header and is much larger than a [`Group`](Object::Group) handle, so boxing
2208/// keeps `Object` (and a `Vec<Object>`) compact without a size disparity. The
2209/// `Box` derefs transparently, so `&obj_dataset` is usable wherever a
2210/// `&Dataset` is expected.
2211///
2212/// Non-exhaustive: a reference can name an object kind this crate does not yet
2213/// resolve — a committed (named) datatype is refused with
2214/// [`FormatError::InvalidObjectReference`](crate::FormatError::InvalidObjectReference)
2215/// today — so match with a `_` arm.
2216#[non_exhaustive]
2217pub enum Object {
2218    /// The reference points at a group's object header.
2219    Group(Group),
2220    /// The reference points at a dataset's object header.
2221    Dataset(Box<Dataset>),
2222}
2223
2224impl std::fmt::Debug for Object {
2225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2226        match self {
2227            Object::Group(_) => f.write_str("Object::Group"),
2228            Object::Dataset(_) => f.write_str("Object::Dataset"),
2229        }
2230    }
2231}
2232
2233// ---------------------------------------------------------------------------
2234// Group handle
2235// ---------------------------------------------------------------------------
2236
2237/// A group that exists only as a staged edit, handed to
2238/// [`Group::create_group_with`]'s closure so attributes can be set on a group
2239/// that is not yet committed (and so has no resolvable header to hang a
2240/// [`Group`] handle off).
2241///
2242/// Every method stages; nothing is written until [`File::commit`], and a staged
2243/// object is not resolvable by name until then.
2244///
2245/// The closure holding this records into a buffer rather than into the file's
2246/// writable session, so nothing is locked while it runs. The recorded operations
2247/// are applied together when it returns, which is also why a staged object is not
2248/// resolvable until [`File::commit`].
2249pub struct StagedGroup<'a> {
2250    ops: &'a mut Vec<StagedOp>,
2251    path: String,
2252}
2253
2254impl StagedGroup<'_> {
2255    /// Stage an attribute on this group, applied with its creation on
2256    /// [`File::commit`].
2257    pub fn set_attr(&mut self, name: &str, value: AttrValue) -> &mut Self {
2258        self.ops.push(StagedOp::SetGroupAttr {
2259            path: self.path.clone(),
2260            name: name.to_string(),
2261            value,
2262        });
2263        self
2264    }
2265
2266    /// Stage an empty subgroup of this group.
2267    ///
2268    /// To configure it in the same commit, use
2269    /// [`create_group_with`](Self::create_group_with).
2270    pub fn create_group(&mut self, name: &str) -> &mut Self {
2271        self.create_group_with(name, |_| {})
2272    }
2273
2274    /// Stage a subgroup of this group, configured through `build`.
2275    pub fn create_group_with(
2276        &mut self,
2277        name: &str,
2278        build: impl FnOnce(&mut StagedGroup<'_>),
2279    ) -> &mut Self {
2280        let child = format!("{}/{}", self.path, name);
2281        self.ops.push(StagedOp::CreateGroup(child.clone()));
2282        let mut staged = StagedGroup {
2283            ops: &mut *self.ops,
2284            path: child,
2285        };
2286        build(&mut staged);
2287        self
2288    }
2289
2290    /// Stage a dataset in this group, configured through `build`.
2291    pub fn create_dataset(
2292        &mut self,
2293        name: &str,
2294        build: impl FnOnce(&mut DatasetBuilder),
2295    ) -> &mut Self {
2296        let mut builder = DatasetBuilder::new(name);
2297        build(&mut builder);
2298        self.ops.push(StagedOp::CreateDataset {
2299            path: format!("{}/{}", self.path, name),
2300            builder: Box::new(builder),
2301        });
2302        self
2303    }
2304}
2305
2306/// One edit recorded by a [`StagedGroup`] closure, replayed onto the writable
2307/// session after the closure returns.
2308///
2309/// The indirection is what keeps user code off the session lock: the closure
2310/// touches only this buffer, so calling back into the same [`File`] from inside
2311/// it is at worst wrongly ordered rather than a deadlock (issue #200).
2312enum StagedOp {
2313    CreateGroup(String),
2314    SetGroupAttr {
2315        path: String,
2316        name: String,
2317        value: AttrValue,
2318    },
2319    CreateDataset {
2320        path: String,
2321        /// Boxed because a `DatasetBuilder` dwarfs the other variants, and a
2322        /// closure staging many groups would otherwise pay its size per entry.
2323        builder: Box<DatasetBuilder>,
2324    },
2325}
2326
2327impl StagedOp {
2328    /// Record this edit on the session. Applied in the order the closure made
2329    /// the calls, so a group is always staged before its own attributes and
2330    /// children.
2331    fn apply(self, session: &mut WriteEngine) {
2332        match self {
2333            StagedOp::CreateGroup(path) => session.create_group(&path),
2334            StagedOp::SetGroupAttr { path, name, value } => {
2335                session.set_group_attr(&path, &name, value);
2336            }
2337            StagedOp::CreateDataset { path, builder } => {
2338                session.stage_created_dataset(&path, *builder);
2339            }
2340        }
2341    }
2342}
2343
2344/// An owned handle to an HDF5 group.
2345pub struct Group {
2346    file: Arc<FileInner>,
2347    address: u64,
2348    /// Root-relative path of this group (e.g. `""` for the root, `"a/b"`), used
2349    /// to address the group and its children for write operations on a
2350    /// read-write file. `None` for a group reached by object reference
2351    /// ([`Dataset::dereference`]), which has no resolvable path.
2352    path: Option<String>,
2353}
2354
2355impl Group {
2356    /// Address of this group's object header (base-adjusted, file-absolute).
2357    /// Used to resolve object references that point at this group.
2358    pub(crate) fn header_address(&self) -> u64 {
2359        self.address
2360    }
2361
2362    /// List the names of datasets in this group.
2363    pub fn datasets(&self) -> Result<Vec<String>, Error> {
2364        let entries = self.children()?;
2365        let mut names = Vec::new();
2366        for entry in &entries {
2367            let hdr = self.file.parse_header(entry.object_header_address)?;
2368            if has_message(&hdr, MessageType::DataLayout) {
2369                names.push(entry.name.clone());
2370            }
2371        }
2372        Ok(names)
2373    }
2374
2375    /// List the names of subgroups in this group.
2376    pub fn groups(&self) -> Result<Vec<String>, Error> {
2377        let entries = self.children()?;
2378        let mut names = Vec::new();
2379        for entry in &entries {
2380            let hdr = self.file.parse_header(entry.object_header_address)?;
2381            if is_group(&hdr) {
2382                names.push(entry.name.clone());
2383            }
2384        }
2385        Ok(names)
2386    }
2387
2388    /// Read all attributes of this group.
2389    ///
2390    /// Each value takes the [`AttrValue`] variant that describes its on-disk
2391    /// encoding, so the variant reflects the charset and dataspace its writer
2392    /// chose rather than the shape of the data alone: a one-element array stays
2393    /// an array, and an ASCII string does not arrive as a UTF-8
2394    /// [`String`](AttrValue::String). Prefer the accessors — [`AttrValue::as_str`],
2395    /// [`as_strings`](AttrValue::as_strings), [`as_i64`](AttrValue::as_i64) and
2396    /// the rest — over matching on the variant, unless the encoding is the thing
2397    /// you care about. **The variant may become more specific in a future
2398    /// release** as `AttrValue` grows narrower ones (fixed widths, variable-length
2399    /// strings), and a `_` arm is required regardless because the enum is
2400    /// `#[non_exhaustive]`.
2401    ///
2402    /// An attribute whose datatype has no `AttrValue` representation is omitted
2403    /// from the map rather than reported as an error.
2404    pub fn attrs(&self) -> Result<HashMap<String, AttrValue>, Error> {
2405        let hdr = self.file.parse_header(self.address)?;
2406        self.file.attrs_of(&hdr)
2407    }
2408
2409    /// Names of every attribute on this group, including any whose datatype
2410    /// [`attrs`](Self::attrs) cannot represent. Used by repack to detect an
2411    /// attribute it would otherwise drop.
2412    pub(crate) fn attr_names(&self) -> Result<Vec<String>, Error> {
2413        let hdr = self.file.parse_header(self.address)?;
2414        self.file.attr_message_names_of(&hdr)
2415    }
2416
2417    /// Get a dataset within this group by name.
2418    ///
2419    /// The dataset uses the file-wide chunk-cache default. To override the cache
2420    /// for this one dataset, use
2421    /// [`dataset_with_options`](Self::dataset_with_options).
2422    pub fn dataset(&self, name: &str) -> Result<Dataset, Error> {
2423        self.dataset_with_options(name, DatasetAccessProperties::new())
2424    }
2425
2426    /// Get a dataset within this group by name, applying per-dataset
2427    /// [`DatasetAccessProperties`] that override file-wide access defaults (HDF5's
2428    /// `dapl`; see `H5Pset_chunk_cache`).
2429    pub fn dataset_with_options(
2430        &self,
2431        name: &str,
2432        properties: DatasetAccessProperties,
2433    ) -> Result<Dataset, Error> {
2434        let entries = self.children()?;
2435        let entry = entries
2436            .iter()
2437            .find(|e| e.name == name)
2438            .ok_or_else(|| Error::Format(FormatError::PathNotFound(name.to_string())))?;
2439        let hdr = self.file.parse_header(entry.object_header_address)?;
2440        if !has_message(&hdr, MessageType::DataLayout) {
2441            return Err(Error::NotADataset(name.to_string()));
2442        }
2443        let chunk_cache = properties.resolved_chunk_cache(self.file.access_properties.chunk_cache);
2444        Ok(Dataset {
2445            file: self.file.clone(),
2446            address: entry.object_header_address,
2447            header: hdr,
2448            chunk_cache: ChunkCache::with_config(chunk_cache),
2449            chunk_cache_config: chunk_cache,
2450            path: self.child_path(name),
2451        })
2452    }
2453
2454    /// Get a subgroup within this group by name.
2455    pub fn group(&self, name: &str) -> Result<Group, Error> {
2456        let entries = self.children()?;
2457        let entry = entries
2458            .iter()
2459            .find(|e| e.name == name)
2460            .ok_or_else(|| Error::Format(FormatError::PathNotFound(name.to_string())))?;
2461        Ok(Group {
2462            file: self.file.clone(),
2463            address: entry.object_header_address,
2464            path: self.child_path(name),
2465        })
2466    }
2467
2468    /// The root-relative path of a child named `name`, or `None` if this group
2469    /// itself has no resolvable path (reached by object reference).
2470    fn child_path(&self, name: &str) -> Option<String> {
2471        self.path.as_ref().map(|p| {
2472            if p.is_empty() {
2473                name.to_string()
2474            } else {
2475                format!("{p}/{name}")
2476            }
2477        })
2478    }
2479
2480    /// Create an empty subgroup `name` within this group, staged until
2481    /// [`File::commit`].
2482    ///
2483    /// To give the new group attributes or children in the same commit, use
2484    /// [`create_group_with`](Self::create_group_with).
2485    ///
2486    /// Requires a read-write file ([`File::open_rw`]), else
2487    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2488    ///
2489    /// ```no_run
2490    /// # use hdf5_pure::File;
2491    /// # fn main() -> Result<(), hdf5_pure::Error> {
2492    /// let file = File::open_rw("runs.h5")?;
2493    /// file.root().create_group("run2")?;
2494    /// file.commit()?;
2495    /// # Ok(())
2496    /// # }
2497    /// ```
2498    pub fn create_group(&self, name: &str) -> Result<(), Error> {
2499        self.create_group_with(name, |_| {})
2500    }
2501
2502    /// Create a subgroup `name` within this group, configuring it through
2503    /// `build` (attributes, nested groups and datasets), staged until
2504    /// [`File::commit`].
2505    ///
2506    /// The closure exists because [`set_attr`](Self::set_attr) needs a group
2507    /// that already *resolves*, so it cannot reach a group that is itself still
2508    /// staged; this can, and the creation and its attributes land in one commit.
2509    /// For a plain empty group use [`create_group`](Self::create_group).
2510    ///
2511    /// The closure records into a buffer rather than into the file itself, and
2512    /// nothing it stages resolves until [`File::commit`], so reading the same
2513    /// [`File`] from inside it sees the file as it was before this call.
2514    ///
2515    /// Requires a read-write file ([`File::open_rw`]), else
2516    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2517    ///
2518    /// ```no_run
2519    /// # use hdf5_pure::{AttrValue, File};
2520    /// # fn main() -> Result<(), hdf5_pure::Error> {
2521    /// let file = File::open_rw("runs.h5")?;
2522    /// file.root().create_group_with("run2", |g| {
2523    ///     g.set_attr("count", AttrValue::I64(7));
2524    ///     g.set_attr("label", AttrValue::String("second".into()));
2525    /// })?;
2526    /// file.commit()?;
2527    /// # Ok(())
2528    /// # }
2529    /// ```
2530    pub fn create_group_with(
2531        &self,
2532        name: &str,
2533        build: impl FnOnce(&mut StagedGroup<'_>),
2534    ) -> Result<(), Error> {
2535        let child = self.child_edit_path(name)?;
2536        let mut ops = vec![StagedOp::CreateGroup(child.clone())];
2537        build(&mut StagedGroup {
2538            ops: &mut ops,
2539            path: child,
2540        });
2541        self.apply_staged(ops)
2542    }
2543
2544    /// Create a dataset `name` within this group, configuring it through `build`
2545    /// (shape, data, chunks, filters, …), staged until [`File::commit`].
2546    ///
2547    /// As with [`create_group_with`](Self::create_group_with), the closure
2548    /// configures a builder rather than the file, so it may read the same
2549    /// [`File`] — it will see the file as it was before this call.
2550    ///
2551    /// Requires a read-write file ([`File::open_rw`]), else
2552    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2553    pub fn create_dataset(
2554        &self,
2555        name: &str,
2556        build: impl FnOnce(&mut DatasetBuilder),
2557    ) -> Result<(), Error> {
2558        let child = self.child_edit_path(name)?;
2559        let mut builder = DatasetBuilder::new(name);
2560        build(&mut builder);
2561        self.apply_staged(vec![StagedOp::CreateDataset {
2562            path: child,
2563            builder: Box::new(builder),
2564        }])
2565    }
2566
2567    /// Delete the object named `name` from this group, staged until
2568    /// [`File::commit`]. See [`create_group`](Self::create_group) for the
2569    /// file-mode rules.
2570    pub fn delete(&self, name: &str) -> Result<(), Error> {
2571        self.with_child_session(name, |session, child| {
2572            session.delete(child);
2573            Ok(())
2574        })
2575    }
2576
2577    /// Add or update a compact attribute on this group, staged until
2578    /// [`File::commit`]. Use [`remove_attr`](Self::remove_attr) to remove one.
2579    /// The [`root`](File::root) group's attributes are edited the same way.
2580    ///
2581    /// Requires a read-write file ([`File::open_rw`]), else
2582    /// [`Error::ReadOnly`](crate::Error::ReadOnly). An attribute set too large
2583    /// for compact storage, or a group using dense (fractal-heap) attribute
2584    /// storage, is refused on [`File::commit`].
2585    pub fn set_attr(&self, name: &str, value: AttrValue) -> Result<(), Error> {
2586        self.with_own_session(|session, path| {
2587            session.set_group_attr(path, name, value);
2588            Ok(())
2589        })
2590    }
2591
2592    /// Remove a compact attribute from this group, staged until [`File::commit`].
2593    /// See [`set_attr`](Self::set_attr) for the file-mode rules.
2594    pub fn remove_attr(&self, name: &str) -> Result<(), Error> {
2595        self.with_own_session(|session, path| {
2596            session.remove_group_attr(path, name);
2597            Ok(())
2598        })
2599    }
2600
2601    /// Run `f` with the writable session and the root-relative path of child
2602    /// `name`. Returns [`Error::ReadOnly`](crate::Error::ReadOnly) if the file is
2603    /// read-only or this group has no resolvable path.
2604    fn with_child_session<R>(
2605        &self,
2606        name: &str,
2607        f: impl FnOnce(&mut WriteEngine, &str) -> Result<R, Error>,
2608    ) -> Result<R, Error> {
2609        let Backend::Edit(m) = &self.file.backend else {
2610            return Err(Error::ReadOnly);
2611        };
2612        self.file.check_mutable(true)?;
2613        let child = self.child_path(name).ok_or(Error::ReadOnly)?;
2614        let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2615        f(&mut session, &child)
2616    }
2617
2618    /// Validate that this group can stage an edit to child `name` and return the
2619    /// child's root-relative path, *without* taking the session lock.
2620    ///
2621    /// Paired with [`apply_staged`](Self::apply_staged): the checks run first so
2622    /// a read-only or sealed file is reported before any user closure runs, the
2623    /// closure then runs unlocked, and the lock is taken only to record what it
2624    /// built (issue #200).
2625    fn child_edit_path(&self, name: &str) -> Result<String, Error> {
2626        self.file.check_staged_writable()?;
2627        self.child_path(name).ok_or(Error::ReadOnly)
2628    }
2629
2630    /// Record already-built edits on the writable session, holding the lock only
2631    /// for the duration of the replay.
2632    ///
2633    /// The file is re-checked here because the closure that produced `ops` ran
2634    /// unlocked and could have closed the file in the meantime; staging into a
2635    /// sealed file would otherwise be silently accepted and then dropped.
2636    fn apply_staged(&self, ops: Vec<StagedOp>) -> Result<(), Error> {
2637        self.file.check_staged_writable()?;
2638        let Backend::Edit(m) = &self.file.backend else {
2639            // `check_staged_writable` accepts only a mirror backend, and a
2640            // file's backend is fixed for its lifetime.
2641            return Err(Error::ReadOnly);
2642        };
2643        let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2644        for op in ops {
2645            op.apply(&mut session);
2646        }
2647        Ok(())
2648    }
2649
2650    /// Run `f` with the writable session and this group's *own* root-relative
2651    /// path (for attribute edits, which act on the group itself rather than a
2652    /// child). Returns [`Error::ReadOnly`](crate::Error::ReadOnly) if the file is
2653    /// read-only or this group has no resolvable path, and
2654    /// [`Error::FileClosed`](crate::Error::FileClosed) once the file is sealed.
2655    fn with_own_session<R>(
2656        &self,
2657        f: impl FnOnce(&mut WriteEngine, &str) -> Result<R, Error>,
2658    ) -> Result<R, Error> {
2659        let Backend::Edit(m) = &self.file.backend else {
2660            return Err(Error::ReadOnly);
2661        };
2662        self.file.check_mutable(true)?;
2663        let path = self.path.clone().ok_or(Error::ReadOnly)?;
2664        let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2665        f(&mut session, &path)
2666    }
2667
2668    fn children(&self) -> Result<Vec<GroupEntry>, Error> {
2669        let hdr = self.file.parse_header(self.address)?;
2670        self.file.group_children(&hdr)
2671    }
2672}
2673
2674// ---------------------------------------------------------------------------
2675// Dataset handle
2676// ---------------------------------------------------------------------------
2677
2678/// An owned handle to an HDF5 dataset.
2679pub struct Dataset {
2680    file: Arc<FileInner>,
2681    /// Address of this dataset's object header (base-adjusted, file-absolute).
2682    /// Used to resolve object references that point at this dataset.
2683    address: u64,
2684    header: ObjectHeader,
2685    // Held per-dataset: the chunk index is keyed only by chunk coordinate, so
2686    // a file-level cache would alias chunk addresses across datasets.
2687    chunk_cache: ChunkCache,
2688    // The effective chunk-cache config for this dataset: the file-wide default
2689    // or a per-dataset DAPL override. Reported by `chunk_cache_config`.
2690    chunk_cache_config: ChunkCacheConfig,
2691    /// Root-relative path of this dataset, used to address it for write
2692    /// operations on a read-write file. `None` for a dataset reached by object
2693    /// reference ([`Dataset::dereference`]), which has no resolvable path.
2694    path: Option<String>,
2695}
2696
2697impl std::fmt::Debug for Dataset {
2698    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2699        f.debug_struct("Dataset")
2700            .field("messages", &self.header.messages.len())
2701            .finish()
2702    }
2703}
2704
2705impl Dataset {
2706    /// Address of this dataset's object header (base-adjusted, file-absolute).
2707    /// Used to resolve object references that point at this dataset.
2708    pub(crate) fn header_address(&self) -> u64 {
2709        self.address
2710    }
2711
2712    /// Append `data` to this dataset in place, growing it along its first
2713    /// (unlimited) dimension, and refresh this handle so subsequent reads observe
2714    /// the new length.
2715    ///
2716    /// The file must have been opened for writing with [`File::open_rw`] or
2717    /// [`File::open_rw_bounded`]; a read-only file returns
2718    /// [`Error::ReadOnly`](crate::Error::ReadOnly). The target must be a chunked,
2719    /// rank-1, unlimited, Extensible-Array-indexed dataset, and filtered datasets
2720    /// take whole chunks where unfiltered ones take any length; anything else
2721    /// returns
2722    /// [`Error::AppendInPlaceUnsupported`](crate::Error::AppendInPlaceUnsupported).
2723    /// The append is immediate and crash-atomic (no `commit` needed).
2724    ///
2725    /// A handle reached by object reference ([`dereference`](Self::dereference))
2726    /// has no resolvable path, so it names its dataset by the object-header
2727    /// address it was reached through and can append like any other — until the
2728    /// session stages or commits an edit. A commit can move that header, and the
2729    /// bytes it vacates still parse as the dataset they were, so an append
2730    /// against the old address would land in a header nothing points at. Rather
2731    /// than do that silently, such an append is refused once edits are staged or
2732    /// a commit has run; re-open the dataset by path to keep appending. A
2733    /// path-named handle is unaffected, because the path is resolved afresh every
2734    /// time.
2735    pub fn append<T: H5Element>(&mut self, data: &[T]) -> Result<(), Error> {
2736        let g = self.append_geometry()?;
2737        self.append_batches(g, data.len() as u64, |b, r| {
2738            b.append(&data[r]);
2739        })
2740    }
2741
2742    /// Append raw little-endian element bytes to this dataset in place. Prefer
2743    /// [`append`](Self::append) when the element type is known; see it for the
2744    /// file-mode and eligibility rules.
2745    pub fn append_raw(&mut self, bytes: &[u8]) -> Result<(), Error> {
2746        let g = self.append_geometry()?;
2747        let es = g.element_size.max(1);
2748        // Whole-element length is checked before any batch applies, so the
2749        // refusal is atomic (the per-batch validation would only reject the
2750        // final, short batch after earlier ones had durably committed).
2751        if bytes.len() % es != 0 {
2752            return Err(Error::AppendInPlaceUnsupported(
2753                "appended byte length is not a whole number of elements",
2754            ));
2755        }
2756        let total = (bytes.len() / es) as u64;
2757        self.append_batches(g, total, |b, r| {
2758            b.append_raw(&bytes[r.start * es..r.end * es]);
2759        })
2760    }
2761
2762    /// How an append names this dataset to the session: by path when the handle
2763    /// has one, so the session can check the target against its own staged
2764    /// edits, and otherwise by the object-header address the handle was reached
2765    /// through — which is what lets a handle obtained by object reference append
2766    /// at all.
2767    fn append_target(&self) -> AppendTarget<'_> {
2768        match &self.path {
2769            Some(path) => AppendTarget::Path(path),
2770            None => AppendTarget::Header(self.address),
2771        }
2772    }
2773
2774    /// Fetch (locating on first use) this dataset's append geometry from the
2775    /// write session, which also applies every refusal that does not depend on
2776    /// the bytes being appended.
2777    fn append_geometry(&self) -> Result<AppendGeometry, Error> {
2778        let Backend::Edit(m) = &self.file.backend else {
2779            return Err(Error::ReadOnly);
2780        };
2781        self.file.check_mutable(false)?;
2782        let mut engine = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2783        engine.append_geometry(self.append_target())
2784    }
2785
2786    /// Immediate in-place append, driven batch by batch. The call is split into
2787    /// aligned batches — the trailing partial chunk is filled first, then
2788    /// whole-chunk batches under the session's byte budget — and `fill` builds
2789    /// each batch's bytes on demand, so a bounded session's peak memory holds
2790    /// one batch rather than the whole call. A session that keeps the whole file
2791    /// resident reports one unbounded batch, so the call stays a single
2792    /// crash-atomic apply there.
2793    ///
2794    /// Every predictable refusal (wrong datatype, ineligible dataset,
2795    /// non-chunk-aligned filtered append) is raised before the first batch is
2796    /// applied. The cached header and chunk cache are then refreshed so later
2797    /// reads on this handle observe the new length.
2798    fn append_batches(
2799        &mut self,
2800        g: AppendGeometry,
2801        total_elems: u64,
2802        fill: impl Fn(&mut AppendBuilder, std::ops::Range<usize>),
2803    ) -> Result<(), Error> {
2804        let Backend::Edit(m) = &self.file.backend else {
2805            return Err(Error::ReadOnly);
2806        };
2807        // Atomic refusal before any batch: a filtered append must be
2808        // whole-chunk (the engine re-checks per batch as a backstop).
2809        if g.filtered && (g.current_dim % g.chunk_elems != 0 || total_elems % g.chunk_elems != 0) {
2810            return Err(Error::AppendInPlaceUnsupported(
2811                "a filtered dataset can only be appended in place in whole chunks (the current \
2812                 length and the appended length must both be multiples of the chunk length); \
2813                 use Dataset::append_staged for a non-chunk-aligned filtered append",
2814            ));
2815        }
2816        let mut dim = g.current_dim;
2817        let mut done = 0u64;
2818        loop {
2819            // An empty append still runs one (empty) engine call, so datatype
2820            // validation happens whether or not there are elements.
2821            self.file.check_mutable(false)?;
2822            let to_boundary = (g.chunk_elems - dim % g.chunk_elems) % g.chunk_elems;
2823            let take = (total_elems - done).min(to_boundary.saturating_add(g.full_batch_elems));
2824            let mut b = AppendBuilder::new();
2825            fill(&mut b, done.to_usize()?..(done + take).to_usize()?);
2826            {
2827                let mut engine = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2828                engine.append_inplace_gathered(self.append_target(), &b, 4)?;
2829            }
2830            dim += take;
2831            done += take;
2832            if done >= total_elems {
2833                break;
2834            }
2835        }
2836        self.header = self.file.parse_header(self.address)?;
2837        // Same staleness rule as `with_session_mut`: the append repointed or
2838        // extended the chunk index this handle may have cached.
2839        self.chunk_cache.clear();
2840        Ok(())
2841    }
2842
2843    /// Overwrite this dataset's values, staged until [`File::commit`]. The new
2844    /// data must match the dataset's existing shape and datatype.
2845    ///
2846    /// The file must have been opened with [`File::open_rw`], else
2847    /// [`Error::ReadOnly`](crate::Error::ReadOnly). Unlike [`append`](Self::append)
2848    /// (immediate), this is a staged edit applied on [`File::commit`].
2849    pub fn write<T: H5Element>(&mut self, data: &[T]) -> Result<(), Error> {
2850        // Build off the lock — `H5Element` is user-implementable, so
2851        // `write_into` is potentially user code (see `write_staged`).
2852        self.check_staged_edit()?;
2853        let mut builder = DatasetBuilder::new("");
2854        T::write_into(&mut builder, data);
2855        self.with_session_mut(true, |session, path| {
2856            session.stage_dataset_write(path, builder);
2857            Ok(())
2858        })
2859    }
2860
2861    /// Overwrite this dataset's values through its full [`DatasetBuilder`],
2862    /// staged until [`File::commit`] — the builder-level counterpart of
2863    /// [`write`](Self::write), for element kinds that are not [`H5Element`]
2864    /// (variable-length strings, raw bytes with an explicit datatype).
2865    ///
2866    /// The replacement must match the on-disk datatype and shape exactly; a
2867    /// reshape or retype is refused on [`File::commit`].
2868    ///
2869    /// The file must have been opened with [`File::open_rw`], else
2870    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2871    ///
2872    /// ```no_run
2873    /// # use hdf5_pure::File;
2874    /// # fn main() -> Result<(), hdf5_pure::Error> {
2875    /// let file = File::open_rw("labels.h5")?;
2876    /// let mut ds = file.dataset("names")?;
2877    /// ds.write_staged(|b| {
2878    ///     b.with_vlen_strings(&["ada", "grace", "katherine"]);
2879    /// })?;
2880    /// file.commit()?;
2881    /// # Ok(())
2882    /// # }
2883    /// ```
2884    /// The closure configures a standalone builder, not the file, so it may read
2885    /// the same [`File`]; nothing it stages resolves until [`File::commit`].
2886    pub fn write_staged(&mut self, build: impl FnOnce(&mut DatasetBuilder)) -> Result<(), Error> {
2887        // Report a read-only, sealed, or unaddressable dataset before running the
2888        // closure, then run it with no lock held; `stage_dataset_write` names the
2889        // builder from the dataset's path (issue #200).
2890        self.check_staged_edit()?;
2891        let mut builder = DatasetBuilder::new("");
2892        build(&mut builder);
2893        self.with_session_mut(true, |session, path| {
2894            session.stage_dataset_write(path, builder);
2895            Ok(())
2896        })
2897    }
2898
2899    /// Stage an append to this dataset applied on [`File::commit`] — the staged,
2900    /// index-rebuilding counterpart of the immediate [`append`](Self::append).
2901    ///
2902    /// Unlike [`append`](Self::append) (immediate, amortized `O(1)`,
2903    /// Extensible-Array only, unfiltered any-length / filtered whole-chunk), this
2904    /// rebuilds the chunk index on commit and so also grows **filtered** datasets
2905    /// by any length (a trailing partial chunk is rewritten) and datasets whose
2906    /// Extensible-Array index is not yet allocated. Configure the appended
2907    /// elements through `build` on the [`AppendBuilder`]; repeated calls within
2908    /// the builder concatenate in order. The dataset must be chunked, unlimited
2909    /// along axis 0, Extensible-Array indexed, rank 1, use a re-encodable filter
2910    /// pipeline, and have a single hard link, otherwise
2911    /// [`Error::AppendUnsupported`](crate::Error::AppendUnsupported) is returned
2912    /// on [`File::commit`].
2913    ///
2914    /// The file must have been opened with [`File::open_rw`], else
2915    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2916    /// The closure configures a standalone builder, not the file, so it may read
2917    /// the same [`File`]; nothing it stages resolves until [`File::commit`].
2918    pub fn append_staged(&mut self, build: impl FnOnce(&mut AppendBuilder)) -> Result<(), Error> {
2919        self.check_staged_edit()?;
2920        let mut builder = AppendBuilder::new();
2921        build(&mut builder);
2922        self.with_session_mut(true, |session, path| {
2923            session.stage_dataset_append(path, builder);
2924            Ok(())
2925        })
2926    }
2927
2928    /// Add or update a compact attribute on this dataset, staged until
2929    /// [`File::commit`]. Use [`remove_attr`](Self::remove_attr) to remove one.
2930    ///
2931    /// The file must have been opened with [`File::open_rw`], else
2932    /// [`Error::ReadOnly`](crate::Error::ReadOnly).
2933    pub fn set_attr(&mut self, name: &str, value: AttrValue) -> Result<(), Error> {
2934        self.with_session_mut(true, |session, path| {
2935            session.set_dataset_attr(path, name, value);
2936            Ok(())
2937        })
2938    }
2939
2940    /// Remove a compact attribute from this dataset, staged until
2941    /// [`File::commit`]. See [`set_attr`](Self::set_attr) for the file-mode rules.
2942    pub fn remove_attr(&mut self, name: &str) -> Result<(), Error> {
2943        self.with_session_mut(true, |session, path| {
2944            session.remove_dataset_attr(path, name);
2945            Ok(())
2946        })
2947    }
2948
2949    /// Gate a staged edit on this dataset *without* taking the session lock:
2950    /// the file must accept staged edits, and this handle must have a resolvable
2951    /// path.
2952    ///
2953    /// The path check belongs here rather than only in
2954    /// [`with_session_mut`](Self::with_session_mut) so that every reason to
2955    /// refuse is reported *before* a user closure runs, not after. A handle
2956    /// reached by object reference ([`dereference`](Self::dereference)) has no
2957    /// path, and would otherwise have its closure run and its result discarded.
2958    fn check_staged_edit(&self) -> Result<(), Error> {
2959        self.file.check_staged_writable()?;
2960        if self.path.is_none() {
2961            return Err(Error::ReadOnly);
2962        }
2963        Ok(())
2964    }
2965
2966    /// Run `f` with the writable session and this dataset's path, then refresh
2967    /// the cached header so a later read on this handle reflects any immediate
2968    /// change (e.g. an append's new dimension). Returns
2969    /// [`Error::ReadOnly`](crate::Error::ReadOnly) if the file is read-only or the
2970    /// handle has no resolvable path (reached by object reference).
2971    fn with_session_mut<R>(
2972        &mut self,
2973        staged: bool,
2974        f: impl FnOnce(&mut WriteEngine, &str) -> Result<R, Error>,
2975    ) -> Result<R, Error> {
2976        let Backend::Edit(m) = &self.file.backend else {
2977            return Err(Error::ReadOnly);
2978        };
2979        self.file.check_mutable(staged)?;
2980        let path = self.path.clone().ok_or(Error::ReadOnly)?;
2981        let out = {
2982            let mut session = m.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
2983            f(&mut session, &path)?
2984        };
2985        self.header = self.file.parse_header(self.address)?;
2986        // An append relocates the trailing chunk and grows the chunk index, so
2987        // this handle's cached index and retained chunks are stale; drop them
2988        // so the next read re-walks the live index.
2989        self.chunk_cache.clear();
2990        Ok(out)
2991    }
2992
2993    /// The effective raw chunk-cache configuration for this dataset.
2994    ///
2995    /// This reflects the per-dataset [`DatasetAccessProperties`] override when one
2996    /// was supplied to [`File::dataset_with_options`] /
2997    /// [`Group::dataset_with_options`], otherwise the file-wide default. It is
2998    /// the read-side analogue of HDF5's `H5Pget_chunk_cache`.
2999    pub const fn chunk_cache_config(&self) -> ChunkCacheConfig {
3000        self.chunk_cache_config
3001    }
3002
3003    /// A point-in-time snapshot of this dataset handle's chunk-cache occupancy.
3004    ///
3005    /// Lets callers confirm a chunk-cache configuration (set with
3006    /// [`FileAccessProperties::with_chunk_cache`]) is taking effect: after a
3007    /// chunked read, an enabled cache reports a loaded index and retained
3008    /// chunks; a disabled one (or one over its budget) reports fewer or none.
3009    /// The cache is per-handle, so a freshly opened [`Dataset`] reports an empty
3010    /// snapshot until its first read.
3011    pub fn chunk_cache_stats(&self) -> ChunkCacheStats {
3012        self.chunk_cache.stats()
3013    }
3014
3015    /// Returns the shape (dimensions) of the dataset.
3016    pub fn shape(&self) -> Result<Vec<u64>, Error> {
3017        let ds = self.dataspace()?;
3018        Ok(ds.dimensions.clone())
3019    }
3020
3021    /// The dataset's maximum dimensions, when it is extensible. An unlimited
3022    /// dimension is reported as `u64::MAX`. Returns `Ok(None)` for a fixed-shape
3023    /// dataset (no maximum-dimensions record, or one equal to the current shape).
3024    ///
3025    /// Together with [`is_chunked`](Self::is_chunked) and
3026    /// [`chunk_shape`](Self::chunk_shape), this lets a caller check up front
3027    /// whether a dataset is eligible for
3028    /// [`Dataset::append_staged`](crate::Dataset::append_staged)
3029    /// (which requires a chunked dataset whose first maximum dimension is
3030    /// `u64::MAX`) instead of relying on the append's refusal error.
3031    pub fn maxshape(&self) -> Result<Option<Vec<u64>>, Error> {
3032        let ds = self.dataspace()?;
3033        match &ds.max_dimensions {
3034            Some(md) if *md != ds.dimensions => Ok(Some(md.clone())),
3035            _ => Ok(None),
3036        }
3037    }
3038
3039    /// Whether the dataset uses chunked storage (as opposed to contiguous or
3040    /// compact). Filtered datasets are always chunked. Returns `false` for a
3041    /// dataset with no data-layout message or a non-chunked layout.
3042    pub fn is_chunked(&self) -> bool {
3043        matches!(self.data_layout(), Ok(DataLayout::Chunked { .. }))
3044    }
3045
3046    /// The dataset's chunk dimensions (one per dataset rank), or `Ok(None)` when
3047    /// the dataset is not chunked. The element-size dimension the on-disk layout
3048    /// appends is stripped, so the result lines up with
3049    /// [`shape`](Self::shape) / [`maxshape`](Self::maxshape).
3050    pub fn chunk_shape(&self) -> Result<Option<Vec<u64>>, Error> {
3051        let DataLayout::Chunked {
3052            chunk_dimensions, ..
3053        } = self.data_layout()?
3054        else {
3055            return Ok(None);
3056        };
3057        let rank = self.dataspace()?.dimensions.len();
3058        if chunk_dimensions.len() <= rank {
3059            return Ok(None);
3060        }
3061        Ok(Some(
3062            chunk_dimensions[..rank]
3063                .iter()
3064                .map(|&c| u64::from(c))
3065                .collect(),
3066        ))
3067    }
3068
3069    /// The HDF5 filter IDs applied to this dataset's chunks, in pipeline
3070    /// (application) order, or an empty vector when the dataset is unfiltered.
3071    /// The IDs are the registered HDF5 filter numbers — e.g. 1 = deflate,
3072    /// 2 = shuffle, 3 = fletcher32, 6 = scale-offset — so a caller can inspect
3073    /// the pipeline without decoding a chunk.
3074    pub fn filters(&self) -> Vec<u16> {
3075        self.filter_pipeline_parsed()
3076            .map(|p| p.filters.iter().map(|f| f.filter_id).collect())
3077            .unwrap_or_default()
3078    }
3079
3080    /// How and where this dataset's raw data is stored: compact, contiguous,
3081    /// chunked, or virtual.
3082    ///
3083    /// The structured companion to [`is_chunked`](Self::is_chunked) and
3084    /// [`chunk_shape`](Self::chunk_shape), which it subsumes: one call that
3085    /// classifies the layout and, for a [`Layout::Contiguous`] dataset, gives the
3086    /// absolute address and byte size to seek to, or for a [`Layout::Chunked`]
3087    /// dataset the chunk shape and [`ChunkIndex`] kind. This parses only the
3088    /// data-layout message; it never walks the chunk index or reads any data —
3089    /// use [`chunks`](Self::chunks) for per-chunk locations. The curated analogue
3090    /// of `H5Pget_layout`.
3091    ///
3092    /// Returns `Err` if the dataset has no data-layout message, if it cannot be
3093    /// parsed, or if a chunked dataset uses an index kind this crate does not
3094    /// recognize.
3095    pub fn layout(&self) -> Result<Layout, Error> {
3096        Ok(match self.data_layout()? {
3097            DataLayout::Compact { data } => Layout::Compact {
3098                size: data.len() as u64,
3099            },
3100            DataLayout::Contiguous { address, size } => Layout::Contiguous {
3101                address: self.absolute_address(address)?,
3102                size,
3103            },
3104            DataLayout::Chunked {
3105                version,
3106                chunk_index_type,
3107                ..
3108            } => Layout::Chunked {
3109                // Reuse `chunk_shape` so the two accessors can never disagree on
3110                // how the element-size dimension is stripped.
3111                chunk_shape: self.chunk_shape()?.unwrap_or_default(),
3112                index: ChunkIndex::from_layout(version, chunk_index_type)?,
3113            },
3114            DataLayout::Virtual { .. } => Layout::Virtual,
3115        })
3116    }
3117
3118    /// The [`ChunkIndex`] kind of this chunked dataset, or `Ok(None)` when the
3119    /// dataset is not chunked.
3120    ///
3121    /// A convenience shortcut for the `index` of [`Layout::Chunked`], for the
3122    /// common up-front append-eligibility check
3123    /// ([`ChunkIndex::supports_inplace_append`]). Complements
3124    /// [`maxshape`](Self::maxshape) and [`chunk_shape`](Self::chunk_shape).
3125    ///
3126    /// Returns `Err` if the data-layout message is missing or cannot be parsed,
3127    /// or if a chunked dataset uses an index kind this crate does not recognize.
3128    pub fn chunk_index(&self) -> Result<Option<ChunkIndex>, Error> {
3129        match self.data_layout()? {
3130            DataLayout::Chunked {
3131                version,
3132                chunk_index_type,
3133                ..
3134            } => Ok(Some(ChunkIndex::from_layout(version, chunk_index_type)?)),
3135            _ => Ok(None),
3136        }
3137    }
3138
3139    /// Enumerate every allocated chunk of this chunked dataset — one [`Chunk`]
3140    /// (logical offset, absolute file address, on-disk stored size, filter mask)
3141    /// per chunk, in index order.
3142    ///
3143    /// This reads only the chunk index, not the chunk data, so a caller can seek
3144    /// to and decode chunks one at a time without materializing the whole
3145    /// dataset. The curated analogue of `H5Dget_num_chunks` + `H5Dget_chunk_info`
3146    /// (`chunks()?.len()` is the chunk count).
3147    ///
3148    /// Returns `Ok(vec![])` for a chunked dataset whose storage has not been
3149    /// allocated yet (including a not-yet-written dataset that will use a
3150    /// [`ChunkIndex::BTreeV2`] index). Returns `Err` if the dataset is not chunked
3151    /// (check [`layout`](Self::layout) or [`is_chunked`](Self::is_chunked) first),
3152    /// or if its allocated storage is indexed by a [`ChunkIndex::BTreeV2`] index,
3153    /// which has no enumerator yet.
3154    pub fn chunks(&self) -> Result<Vec<Chunk>, Error> {
3155        let rank = self.dataspace()?.dimensions.len();
3156        Ok(self
3157            .raw_chunks()?
3158            .into_iter()
3159            .map(|c| Chunk {
3160                offset: c.offsets.into_iter().take(rank).collect(),
3161                address: c.address,
3162                storage_size: u64::from(c.chunk_size),
3163                filter_mask: c.filter_mask,
3164            })
3165            .collect())
3166    }
3167
3168    /// This dataset's filter pipeline as an ordered list of [`Filter`]s — each
3169    /// with its identifier, optional name, optional/mandatory flag, and client
3170    /// data — or an empty vector when the dataset is unfiltered.
3171    ///
3172    /// The detailed companion to [`filters`](Self::filters), which returns just
3173    /// the identifiers. Filters are listed in application (write) order — the
3174    /// on-disk pipeline order, matching [`filters`](Self::filters); a reader
3175    /// inverts them in the *reverse* of this order to decode a chunk. The curated
3176    /// analogue of `H5Pget_nfilters` + `H5Pget_filter2`.
3177    pub fn filter_pipeline(&self) -> Vec<Filter> {
3178        self.filter_pipeline_parsed()
3179            .map(|p| {
3180                p.filters
3181                    .into_iter()
3182                    .map(|f| Filter {
3183                        id: f.filter_id,
3184                        name: f.name,
3185                        is_optional: f.flags & 0x1 != 0,
3186                        client_data: f.client_data,
3187                    })
3188                    .collect()
3189            })
3190            .unwrap_or_default()
3191    }
3192
3193    /// Shift a base-relative on-disk address to an absolute file offset using the
3194    /// superblock base address (`addr_offset`). A no-op for the common
3195    /// base-zero file. Returns `Ok(None)` for an unallocated (undefined) address.
3196    fn absolute_address(&self, address: Option<u64>) -> Result<Option<u64>, Error> {
3197        match address {
3198            Some(rel) => Ok(Some(rel.checked_add(self.file.addr_offset).ok_or(
3199                crate::error::FormatError::OffsetOverflow {
3200                    offset: rel,
3201                    length: 0,
3202                },
3203            )?)),
3204            None => Ok(None),
3205        }
3206    }
3207
3208    /// Returns the simplified datatype of the dataset.
3209    pub fn dtype(&self) -> Result<DType, Error> {
3210        let dt = self.datatype()?;
3211        Ok(classify_datatype(&dt))
3212    }
3213
3214    /// The size in bytes of one on-disk element of this dataset's datatype —
3215    /// HDF5's datatype storage size (`H5Tget_size`).
3216    ///
3217    /// This is the byte width of a single stored element: 8 for `f64`, the
3218    /// declared length for a fixed-length string, the record size for a compound
3219    /// type, or the reference/descriptor size for a variable-length type (whose
3220    /// payload lives separately in the file's global heaps).
3221    ///
3222    /// Multiplied by the element count from [`shape`](Self::shape), it is the
3223    /// exact number of raw bytes a full [`read_raw`](Self::read_raw)
3224    /// materializes. A caller reading an untrusted file can use it to bound that
3225    /// allocation up front rather than trusting the file's declared extent: a
3226    /// dataset can name a small element count yet a per-element size of billions
3227    /// of bytes, so the product — not the count alone — is what a read allocates.
3228    pub fn element_size(&self) -> Result<u64, Error> {
3229        Ok(u64::from(self.datatype()?.type_size()))
3230    }
3231
3232    /// The raw bytes of this dataset's user-defined fill value, encoded in its
3233    /// datatype, or `None` when no user-defined fill value is set (the library
3234    /// default or an explicitly undefined fill). Reads whichever Fill Value
3235    /// message the header carries — the current `0x0005` (versions 1/2/3) or the
3236    /// legacy `0x0004` — so files from this crate, the reference C library, and
3237    /// h5py are all handled.
3238    pub(crate) fn defined_fill_bytes(&self) -> Result<Option<Vec<u8>>, Error> {
3239        let msg = self
3240            .header
3241            .messages
3242            .iter()
3243            .find(|m| m.msg_type == MessageType::FillValue)
3244            .or_else(|| {
3245                self.header
3246                    .messages
3247                    .iter()
3248                    .find(|m| m.msg_type == MessageType::FillValueOld)
3249            });
3250        match msg {
3251            Some(m) => Ok(crate::fill_value::parse_defined_fill_value(
3252                m.msg_type, &m.data,
3253            )?),
3254            None => Ok(None),
3255        }
3256    }
3257
3258    /// Read all data as `f64` values.
3259    pub fn read_f64(&self) -> Result<Vec<f64>, Error> {
3260        let raw = self.read_raw()?;
3261        let dt = self.datatype()?;
3262        Ok(data_read::read_as_f64(&raw, &dt)?)
3263    }
3264
3265    /// Read all data as `f32` values.
3266    pub fn read_f32(&self) -> Result<Vec<f32>, Error> {
3267        let raw = self.read_raw()?;
3268        let dt = self.datatype()?;
3269        Ok(data_read::read_as_f32(&raw, &dt)?)
3270    }
3271
3272    /// Read all data as `i32` values.
3273    pub fn read_i32(&self) -> Result<Vec<i32>, Error> {
3274        let raw = self.read_raw()?;
3275        let dt = self.datatype()?;
3276        Ok(data_read::read_as_i32(&raw, &dt)?)
3277    }
3278
3279    /// Read all data as `i64` values.
3280    pub fn read_i64(&self) -> Result<Vec<i64>, Error> {
3281        let raw = self.read_raw()?;
3282        let dt = self.datatype()?;
3283        Ok(data_read::read_as_i64(&raw, &dt)?)
3284    }
3285
3286    /// Read all data as `u64` values.
3287    pub fn read_u64(&self) -> Result<Vec<u64>, Error> {
3288        let raw = self.read_raw()?;
3289        let dt = self.datatype()?;
3290        Ok(data_read::read_as_u64(&raw, &dt)?)
3291    }
3292
3293    /// Read all data as `u8` values.
3294    pub fn read_u8(&self) -> Result<Vec<u8>, Error> {
3295        self.read_raw()
3296    }
3297
3298    /// Read all data as `i8` values.
3299    #[expect(
3300        clippy::cast_possible_wrap,
3301        reason = "read_i8 reinterprets each stored byte as the signed i8 the caller requested"
3302    )]
3303    pub fn read_i8(&self) -> Result<Vec<i8>, Error> {
3304        let raw = self.read_raw()?;
3305        Ok(raw.iter().map(|&b| b as i8).collect())
3306    }
3307
3308    /// Read all data as `i16` values.
3309    pub fn read_i16(&self) -> Result<Vec<i16>, Error> {
3310        let raw = self.read_raw()?;
3311        let dt = self.datatype()?;
3312        Ok(data_read::read_as_i16(&raw, &dt)?)
3313    }
3314
3315    /// Read all data as `u16` values.
3316    pub fn read_u16(&self) -> Result<Vec<u16>, Error> {
3317        let raw = self.read_raw()?;
3318        let dt = self.datatype()?;
3319        Ok(data_read::read_as_u16(&raw, &dt)?)
3320    }
3321
3322    /// Read all data as `u32` values.
3323    pub fn read_u32(&self) -> Result<Vec<u32>, Error> {
3324        let raw = self.read_raw()?;
3325        let dt = self.datatype()?;
3326        Ok(data_read::read_as_u32(&raw, &dt)?)
3327    }
3328
3329    /// Read all data as `String` values.
3330    ///
3331    /// Fixed-length and variable-length HDF5 string datasets are both
3332    /// supported. Use [`read_vlen_strings`](Self::read_vlen_strings) when
3333    /// variable-length allocation limits are required.
3334    pub fn read_string(&self) -> Result<Vec<String>, Error> {
3335        let dt = self.datatype()?;
3336        if vl_data::is_vlen_string_datatype(&dt) {
3337            self.read_vlen_strings(VlenStringReadOptions::default())
3338        } else {
3339            let raw = self.read_raw()?;
3340            Ok(data_read::read_as_strings(&raw, &dt)?)
3341        }
3342    }
3343
3344    /// Return the total bytes referenced by this VL string dataset.
3345    ///
3346    /// This is the payload equivalent of HDF5's `H5Dvlen_get_buf_size`: it
3347    /// excludes `Vec<String>` and `String` allocation metadata.
3348    pub fn vlen_string_payload_size(&self) -> Result<u64, Error> {
3349        let datatype = self.datatype()?;
3350        if !vl_data::is_vlen_string_datatype(&datatype) {
3351            return Err(FormatError::TypeMismatch {
3352                expected: "VariableLength string",
3353                actual: "non-VariableLength string",
3354            }
3355            .into());
3356        }
3357        let dataspace = self.dataspace()?;
3358        let raw = self.read_raw()?;
3359        Ok(vl_data::vlen_string_payload_size(
3360            &raw,
3361            dataspace.num_elements(),
3362            self.file.offset_size(),
3363        )?)
3364    }
3365
3366    /// Read a VL string dataset with explicit allocation limits.
3367    ///
3368    /// Both limits are checked before any string payload is materialized.
3369    pub fn read_vlen_strings(&self, options: VlenStringReadOptions) -> Result<Vec<String>, Error> {
3370        let mut strings = Vec::new();
3371        self.visit_vlen_strings(options, |string| strings.push(string.to_owned()))?;
3372        Ok(strings)
3373    }
3374
3375    /// Visit a VL string dataset one element at a time.
3376    ///
3377    /// The string slice passed to `visitor` is valid only for the duration of
3378    /// that callback. This avoids retaining all decoded string payloads at once.
3379    ///
3380    /// On a read-write file ([`File::open_rw`] / [`File::open_rw_bounded`]) the
3381    /// visitor runs while the file's engine lock is held, so it must not read
3382    /// or write through this file (or a clone / handle of it) — doing so
3383    /// deadlocks. Collect values and act on them after the call instead.
3384    pub fn visit_vlen_strings<F>(
3385        &self,
3386        options: VlenStringReadOptions,
3387        visitor: F,
3388    ) -> Result<(), Error>
3389    where
3390        F: FnMut(&str),
3391    {
3392        let datatype = self.datatype()?;
3393        if !vl_data::is_vlen_string_datatype(&datatype) {
3394            return Err(FormatError::TypeMismatch {
3395                expected: "VariableLength string",
3396                actual: "non-VariableLength string",
3397            }
3398            .into());
3399        }
3400        let dataspace = self.dataspace()?;
3401        if let Some(limit) = options.max_elements()
3402            && dataspace.num_elements() > limit as u64
3403        {
3404            return Err(FormatError::VariableLengthElementLimitExceeded {
3405                limit,
3406                actual: dataspace.num_elements(),
3407            }
3408            .into());
3409        }
3410        let raw = self.read_raw()?;
3411        self.file.with_source(|source| {
3412            Ok(vl_data::visit_vl_strings_from_source(
3413                source,
3414                &raw,
3415                dataspace.num_elements(),
3416                self.file.offset_size(),
3417                self.file.length_size(),
3418                self.file.addr_offset,
3419                options,
3420                visitor,
3421            )?)
3422        })
3423    }
3424
3425    /// Read a VL string dataset's exact heap bytes, preserving the
3426    /// null-vs-empty distinction and never lossily decoding.
3427    ///
3428    /// Unlike [`read_vlen_strings`](Self::read_vlen_strings), which returns
3429    /// `String`s via `from_utf8_lossy` and so cannot reproduce embedded NULs or
3430    /// non-UTF-8 payloads, this yields each element's raw bytes (or a null
3431    /// marker). It underpins faithful rewriting (e.g. repack) of VL strings.
3432    pub(crate) fn read_vlen_string_bytes(
3433        &self,
3434        options: VlenStringReadOptions,
3435    ) -> Result<Vec<vl_data::VlByteObject>, Error> {
3436        let datatype = self.datatype()?;
3437        if !vl_data::is_vlen_string_datatype(&datatype) {
3438            return Err(FormatError::TypeMismatch {
3439                expected: "VariableLength string",
3440                actual: "non-VariableLength string",
3441            }
3442            .into());
3443        }
3444        let dataspace = self.dataspace()?;
3445        if let Some(limit) = options.max_elements()
3446            && dataspace.num_elements() > limit as u64
3447        {
3448            return Err(FormatError::VariableLengthElementLimitExceeded {
3449                limit,
3450                actual: dataspace.num_elements(),
3451            }
3452            .into());
3453        }
3454        let raw = self.read_raw()?;
3455        self.file.with_source(|source| {
3456            Ok(vl_data::read_vl_byte_objects_from_source(
3457                source,
3458                &raw,
3459                dataspace.num_elements(),
3460                self.file.offset_size(),
3461                self.file.length_size(),
3462                self.file.addr_offset,
3463                1, // a VL string's base type is a single byte
3464                options,
3465            )?)
3466        })
3467    }
3468
3469    /// Read every element of a *non-string* variable-length (sequence) dataset as
3470    /// its exact heap bytes, alongside the base-type element size in bytes.
3471    ///
3472    /// Each element's heap object holds `length * element_size` bytes, where
3473    /// `length` is the stored element count and `element_size` is the byte width
3474    /// of the sequence's base type. Returning the raw bytes (not decoded values)
3475    /// keeps a faithful rewrite (repack) byte-exact for any base type whose bytes
3476    /// carry no embedded heap or file addresses. Errors with a
3477    /// [`TypeMismatch`](crate::FormatError::TypeMismatch) if the datatype is not a
3478    /// non-string VL datatype.
3479    pub(crate) fn read_vlen_sequence_bytes(
3480        &self,
3481        options: VlenStringReadOptions,
3482    ) -> Result<(Vec<vl_data::VlByteObject>, usize), Error> {
3483        let datatype = self.datatype()?;
3484        let Datatype::VariableLength { base_type, .. } = &datatype else {
3485            return Err(FormatError::TypeMismatch {
3486                expected: "non-string VariableLength",
3487                actual: "non-VariableLength",
3488            }
3489            .into());
3490        };
3491        if vl_data::is_vlen_string_datatype(&datatype) {
3492            return Err(FormatError::TypeMismatch {
3493                expected: "non-string VariableLength",
3494                actual: "VariableLength string",
3495            }
3496            .into());
3497        }
3498        let element_size = base_type.type_size() as usize;
3499        if element_size == 0 {
3500            return Err(
3501                FormatError::VlDataError("non-string VL base type has zero size".into()).into(),
3502            );
3503        }
3504        let dataspace = self.dataspace()?;
3505        if let Some(limit) = options.max_elements()
3506            && dataspace.num_elements() > limit as u64
3507        {
3508            return Err(FormatError::VariableLengthElementLimitExceeded {
3509                limit,
3510                actual: dataspace.num_elements(),
3511            }
3512            .into());
3513        }
3514        let raw = self.read_raw()?;
3515        let objects = self.file.with_source(|source| {
3516            vl_data::read_vl_byte_objects_from_source(
3517                source,
3518                &raw,
3519                dataspace.num_elements(),
3520                self.file.offset_size(),
3521                self.file.length_size(),
3522                self.file.addr_offset,
3523                element_size,
3524                options,
3525            )
3526        })?;
3527        Ok((objects, element_size))
3528    }
3529
3530    /// Read a dataset whose datatype *contains* variable-length references
3531    /// without being variable-length itself — a compound with a VL member, or an
3532    /// array of them (issue #201).
3533    ///
3534    /// Returns everything a rewrite needs: the element bytes, where each embedded
3535    /// reference sits within them, and the heap payload each one names. That lets
3536    /// the writer re-stage the payloads into a new file's global heap and rewrite
3537    /// the references in place, which is what keeps a rewrite from carrying the
3538    /// source file's heap addresses into the destination.
3539    ///
3540    /// The references are resolved one slot at a time, so `options`' limits apply
3541    /// per slot rather than across the whole dataset.
3542    pub(crate) fn read_embedded_vlen_bytes(
3543        &self,
3544        slots: &[vl_data::EmbeddedVlSlot],
3545        options: VlenStringReadOptions,
3546    ) -> Result<vl_data::EmbeddedVlData, Error> {
3547        let stride = self.datatype()?.type_size() as usize;
3548        let dataspace = self.dataspace()?;
3549        let n = dataspace.num_elements();
3550        if let Some(limit) = options.max_elements()
3551            && n > limit as u64
3552        {
3553            return Err(
3554                FormatError::VariableLengthElementLimitExceeded { limit, actual: n }.into(),
3555            );
3556        }
3557
3558        // A zero-element dataset owns no element bytes, and the C library leaves
3559        // such a dataset's storage unallocated — reading it would fail for a
3560        // missing chunk address rather than yield an empty buffer.
3561        let raw = if n == 0 { Vec::new() } else { self.read_raw()? };
3562        let n_usize = n.to_usize()?;
3563        let needed = n_usize
3564            .checked_mul(stride)
3565            .ok_or(FormatError::OffsetOverflow {
3566                offset: n,
3567                length: stride as u64,
3568            })?;
3569        if raw.len() < needed {
3570            return Err(FormatError::UnexpectedEof {
3571                expected: needed,
3572                available: raw.len(),
3573            }
3574            .into());
3575        }
3576
3577        let mut offsets = Vec::with_capacity(n_usize * slots.len());
3578        let mut objects = Vec::with_capacity(n_usize * slots.len());
3579        for slot in slots {
3580            // Gather this slot's reference from every element into a dense buffer,
3581            // which is the shape the shared VL reader consumes. Each slot has its
3582            // own base-type width, so they are resolved a slot at a time rather
3583            // than in one pass.
3584            let mut dense = Vec::with_capacity(n_usize * VL_REF_SIZE);
3585            for e in 0..n_usize {
3586                let at = e * stride + slot.byte_offset;
3587                dense.extend_from_slice(&raw[at..at + VL_REF_SIZE]);
3588                offsets.push(at);
3589            }
3590            let resolved = self.file.with_source(|source| {
3591                vl_data::read_vl_byte_objects_from_source(
3592                    source,
3593                    &dense,
3594                    n,
3595                    self.file.offset_size(),
3596                    self.file.length_size(),
3597                    self.file.addr_offset,
3598                    slot.element_size,
3599                    options,
3600                )
3601            })?;
3602            objects.extend(resolved);
3603        }
3604        Ok(vl_data::EmbeddedVlData {
3605            raw,
3606            offsets,
3607            objects,
3608        })
3609    }
3610
3611    /// Read all attributes of this dataset.
3612    ///
3613    /// The variant of each value describes its on-disk encoding; see
3614    /// [`Group::attrs`] for what that means for matching on it, and prefer the
3615    /// [`AttrValue`] accessors.
3616    pub fn attrs(&self) -> Result<HashMap<String, AttrValue>, Error> {
3617        self.file.attrs_of(&self.header)
3618    }
3619
3620    /// Names of every attribute on this dataset, including any whose datatype
3621    /// [`attrs`](Self::attrs) cannot represent. Used by repack to detect an
3622    /// attribute it would otherwise drop.
3623    pub(crate) fn attr_names(&self) -> Result<Vec<String>, Error> {
3624        self.file.attr_message_names_of(&self.header)
3625    }
3626
3627    /// Returns the exact HDF5 datatype, including compound field offsets and
3628    /// total record size.
3629    pub fn datatype(&self) -> Result<Datatype, Error> {
3630        let msg = find_message(&self.header, MessageType::Datatype)?;
3631        let (dt, _) = Datatype::parse(&msg.data)?;
3632        Ok(dt)
3633    }
3634
3635    pub(crate) fn dataspace(&self) -> Result<Dataspace, Error> {
3636        let msg = find_message(&self.header, MessageType::Dataspace)?;
3637        Ok(Dataspace::parse(&msg.data, self.file.length_size())?)
3638    }
3639
3640    pub(crate) fn data_layout(&self) -> Result<DataLayout, Error> {
3641        let msg = find_message(&self.header, MessageType::DataLayout)?;
3642        Ok(DataLayout::parse(
3643            &msg.data,
3644            self.file.offset_size(),
3645            self.file.length_size(),
3646        )?)
3647    }
3648
3649    pub(crate) fn filter_pipeline_parsed(&self) -> Option<FilterPipeline> {
3650        self.header
3651            .messages
3652            .iter()
3653            .find(|m| m.msg_type == MessageType::FilterPipeline)
3654            .and_then(|msg| FilterPipeline::parse(&msg.data).ok())
3655    }
3656
3657    /// The raw, still-compressed on-disk bytes of every allocated chunk of this
3658    /// chunked dataset, with each chunk's `(address, on-disk size, filter mask,
3659    /// logical offset)` — the same `ChunkInfo`s the chunked reader walks before
3660    /// decompressing. Used by repack to copy compressed chunks verbatim without
3661    /// ever decoding them.
3662    ///
3663    /// Returns `Err` if the layout is not chunked. Returns `Ok(vec![])` for an
3664    /// empty / never-allocated chunked dataset (no index address). Covers every
3665    /// index type the reader supports (v3 B-tree and v4 single-chunk, implicit,
3666    /// fixed-array, and extensible-array).
3667    pub(crate) fn raw_chunks(&self) -> Result<Vec<crate::chunked_read::ChunkInfo>, Error> {
3668        let DataLayout::Chunked {
3669            chunk_dimensions,
3670            btree_address,
3671            version,
3672            chunk_index_type,
3673            single_chunk_filtered_size,
3674            single_chunk_filter_mask,
3675        } = self.data_layout()?
3676        else {
3677            return Err(Error::Format(crate::error::FormatError::ChunkedReadError(
3678                "chunk enumeration requires a chunked dataset".into(),
3679            )));
3680        };
3681        // An undefined index address means no storage is allocated yet.
3682        let Some(addr) = btree_address else {
3683            return Ok(Vec::new());
3684        };
3685        let dataspace = self.dataspace()?;
3686        let elem_size = self.datatype()?.type_size() as usize;
3687        let base = self.file.addr_offset;
3688        // The chunk index — its root at `addr` and every internal node — stores
3689        // addresses relative to the base address. Walk it through a base-relative
3690        // view so those resolve, then shift each returned chunk address back to an
3691        // absolute file offset, since callers (repack) read the chunk bytes from
3692        // the full file source.
3693        self.file.with_source(|source| {
3694            if base == 0 {
3695                return Ok(crate::chunked_read::collect_chunks_for_layout_from_source(
3696                    source,
3697                    version,
3698                    chunk_index_type,
3699                    addr,
3700                    single_chunk_filtered_size,
3701                    single_chunk_filter_mask,
3702                    &chunk_dimensions,
3703                    &dataspace,
3704                    elem_size,
3705                    self.file.offset_size(),
3706                    self.file.length_size(),
3707                )?);
3708            }
3709            let framed = BaseOffsetSource {
3710                inner: source,
3711                base,
3712            };
3713            let mut chunks = crate::chunked_read::collect_chunks_for_layout_from_source(
3714                &framed,
3715                version,
3716                chunk_index_type,
3717                addr,
3718                single_chunk_filtered_size,
3719                single_chunk_filter_mask,
3720                &chunk_dimensions,
3721                &dataspace,
3722                elem_size,
3723                self.file.offset_size(),
3724                self.file.length_size(),
3725            )?;
3726            for c in &mut chunks {
3727                c.address = c.address.checked_add(base).ok_or(
3728                    crate::error::FormatError::OffsetOverflow {
3729                        offset: c.address,
3730                        length: 0,
3731                    },
3732                )?;
3733            }
3734            Ok(chunks)
3735        })
3736    }
3737
3738    /// The raw `FilterPipeline` message bytes from this dataset's object header,
3739    /// if it has one. Repack reuses this verbatim so that every filter — including
3740    /// ones this crate cannot itself apply (ZFP, SZIP, unknown) — is reproduced
3741    /// byte-for-byte in the repacked file's pipeline message.
3742    pub(crate) fn filter_pipeline_message_bytes(&self) -> Option<Vec<u8>> {
3743        self.header
3744            .messages
3745            .iter()
3746            .find(|m| m.msg_type == MessageType::FilterPipeline)
3747            .map(|msg| msg.data.clone())
3748    }
3749
3750    /// Read the dataset's exact unfiltered element bytes.
3751    ///
3752    /// For compound datasets this preserves all file padding and uses the
3753    /// offsets reported by [`datatype`](Self::datatype).
3754    pub fn read_raw(&self) -> Result<Vec<u8>, Error> {
3755        let dt = self.datatype()?;
3756        let ds = self.dataspace()?;
3757        let dl = self.data_layout()?;
3758        // The data layout's on-disk addresses are left base-relative here;
3759        // `read_dataset_raw` applies the base address centrally (for both
3760        // contiguous and chunked layouts) by reading from a base-relative view of
3761        // the file.
3762        let pipeline = self.filter_pipeline_parsed();
3763        Ok(self
3764            .file
3765            .read_dataset_raw(&dl, &ds, &dt, pipeline.as_ref(), &self.chunk_cache)?)
3766    }
3767
3768    /// Read the raw element bytes of the row window `[start_row, start_row + num_rows)`
3769    /// — a range along the first dimension.
3770    ///
3771    /// The windowed companion to [`read_raw`](Self::read_raw): only the storage the
3772    /// window overlaps is read — a bounded sub-read for compact and contiguous
3773    /// layouts, just the overlapping chunks for chunked layouts — so peak memory
3774    /// scales with the window, not the dataset. Use it to stream a large dataset a
3775    /// fixed number of rows at a time.
3776    ///
3777    /// Each row keeps its full inner shape, and the bytes match what
3778    /// [`read_raw`](Self::read_raw) produces for those rows, so the typed
3779    /// `read_*_rows` helpers decode a window like their whole-dataset forms. The
3780    /// window is clamped to the first dimension: a read past the end returns only
3781    /// the rows that exist, and a 0-D scalar is one row. A window covering every
3782    /// row delegates to [`read_raw`](Self::read_raw), so a full-range window never
3783    /// costs more than a whole read. Variable-length string
3784    /// bytes are heap references, not text — use
3785    /// [`read_string_rows`](Self::read_string_rows).
3786    pub fn read_raw_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u8>, Error> {
3787        let dt = self.datatype()?;
3788        let ds = self.dataspace()?;
3789        let dl = self.data_layout()?;
3790
3791        let n0 = ds.dimensions.first().copied().unwrap_or(1);
3792        let start = start_row.min(n0);
3793        let count = num_rows.min(n0 - start);
3794
3795        // A window covering every row is exactly a whole read: delegate, so it
3796        // never costs a window-shaped copy on top of one.
3797        if start == 0 && count == n0 {
3798            let pipeline = self.filter_pipeline_parsed();
3799            return Ok(self.file.read_dataset_raw(
3800                &dl,
3801                &ds,
3802                &dt,
3803                pipeline.as_ref(),
3804                &self.chunk_cache,
3805            )?);
3806        }
3807
3808        Ok(self.file.read_dataset_raw_rows(
3809            &dl,
3810            &ds,
3811            &dt,
3812            self.filter_pipeline_parsed().as_ref(),
3813            &self.chunk_cache,
3814            start,
3815            count,
3816        )?)
3817    }
3818
3819    /// Windowed [`read_f64`](Self::read_f64) — decodes only the row window.
3820    pub fn read_f64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<f64>, Error> {
3821        let raw = self.read_raw_rows(start_row, num_rows)?;
3822        Ok(data_read::read_as_f64(&raw, &self.datatype()?)?)
3823    }
3824
3825    /// Windowed [`read_f32`](Self::read_f32) — decodes only the row window.
3826    pub fn read_f32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<f32>, Error> {
3827        let raw = self.read_raw_rows(start_row, num_rows)?;
3828        Ok(data_read::read_as_f32(&raw, &self.datatype()?)?)
3829    }
3830
3831    /// Windowed [`read_i8`](Self::read_i8) — decodes only the row window.
3832    #[expect(
3833        clippy::cast_possible_wrap,
3834        reason = "read_i8 reinterprets each stored byte as the signed i8 the caller requested"
3835    )]
3836    pub fn read_i8_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i8>, Error> {
3837        let raw = self.read_raw_rows(start_row, num_rows)?;
3838        Ok(raw.iter().map(|&b| b as i8).collect())
3839    }
3840
3841    /// Windowed [`read_i16`](Self::read_i16) — decodes only the row window.
3842    pub fn read_i16_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i16>, Error> {
3843        let raw = self.read_raw_rows(start_row, num_rows)?;
3844        Ok(data_read::read_as_i16(&raw, &self.datatype()?)?)
3845    }
3846
3847    /// Windowed [`read_i32`](Self::read_i32) — decodes only the row window.
3848    pub fn read_i32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i32>, Error> {
3849        let raw = self.read_raw_rows(start_row, num_rows)?;
3850        Ok(data_read::read_as_i32(&raw, &self.datatype()?)?)
3851    }
3852
3853    /// Windowed [`read_i64`](Self::read_i64) — decodes only the row window.
3854    pub fn read_i64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<i64>, Error> {
3855        let raw = self.read_raw_rows(start_row, num_rows)?;
3856        Ok(data_read::read_as_i64(&raw, &self.datatype()?)?)
3857    }
3858
3859    /// Windowed [`read_u8`](Self::read_u8) — reads only the row window.
3860    pub fn read_u8_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u8>, Error> {
3861        self.read_raw_rows(start_row, num_rows)
3862    }
3863
3864    /// Windowed [`read_u16`](Self::read_u16) — decodes only the row window.
3865    pub fn read_u16_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u16>, Error> {
3866        let raw = self.read_raw_rows(start_row, num_rows)?;
3867        Ok(data_read::read_as_u16(&raw, &self.datatype()?)?)
3868    }
3869
3870    /// Windowed [`read_u32`](Self::read_u32) — decodes only the row window.
3871    pub fn read_u32_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u32>, Error> {
3872        let raw = self.read_raw_rows(start_row, num_rows)?;
3873        Ok(data_read::read_as_u32(&raw, &self.datatype()?)?)
3874    }
3875
3876    /// Windowed [`read_u64`](Self::read_u64) — decodes only the row window.
3877    pub fn read_u64_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<u64>, Error> {
3878        let raw = self.read_raw_rows(start_row, num_rows)?;
3879        Ok(data_read::read_as_u64(&raw, &self.datatype()?)?)
3880    }
3881
3882    /// Windowed [`read_string`](Self::read_string).
3883    ///
3884    /// Fixed-length strings decode straight from the window. Variable-length
3885    /// strings resolve only the window's heap references, so the window memory
3886    /// bound holds for them too: peak allocation is the window's references,
3887    /// its text, and the metadata of the heap collections it touches.
3888    pub fn read_string_rows(&self, start_row: u64, num_rows: u64) -> Result<Vec<String>, Error> {
3889        let dt = self.datatype()?;
3890        if vl_data::is_vlen_string_datatype(&dt) {
3891            // The window's heap references, read memory-bounded like any other
3892            // fixed-size element (4-byte length + collection address + 4-byte
3893            // object index), one row spanning its inner dimensions. Resolving
3894            // only those against the global heap keeps the bound — the same
3895            // resolution `read_string` runs over the whole dataset's references.
3896            let raw = self.read_raw_rows(start_row, num_rows)?;
3897            let ref_size = 4 + self.file.offset_size() as usize + 4;
3898            let num_elements = (raw.len() / ref_size) as u64;
3899            let mut strings = Vec::new();
3900            self.file.with_source(|source| -> Result<(), Error> {
3901                Ok(vl_data::visit_vl_strings_from_source(
3902                    source,
3903                    &raw,
3904                    num_elements,
3905                    self.file.offset_size(),
3906                    self.file.length_size(),
3907                    self.file.addr_offset,
3908                    VlenStringReadOptions::default(),
3909                    |string| strings.push(String::from(string)),
3910                )?)
3911            })?;
3912            return Ok(strings);
3913        }
3914        let raw = self.read_raw_rows(start_row, num_rows)?;
3915        Ok(data_read::read_as_strings(&raw, &dt)?)
3916    }
3917
3918    /// Interpret this dataset as an array of HDF5 object references
3919    /// (`H5R_OBJECT`) and resolve each, in storage order, to the [`Object`] it
3920    /// points at.
3921    ///
3922    /// MATLAB cell arrays and the `#subsystem#` machinery store their members
3923    /// this way: the dataset holds one object-header address per element, each
3924    /// naming an object elsewhere in the file (conventionally under the hidden
3925    /// `#refs#` group).
3926    ///
3927    /// # Errors
3928    ///
3929    /// - [`FormatError::TypeMismatch`] if this dataset's datatype is not an
3930    ///   object reference.
3931    /// - [`FormatError::InvalidObjectReference`] if an element is a null or
3932    ///   undefined reference, or does not point at a group or dataset.
3933    pub fn dereference(&self) -> Result<Vec<Object>, Error> {
3934        let dt = self.datatype()?;
3935        if !matches!(
3936            dt,
3937            Datatype::Reference {
3938                ref_type: ReferenceType::Object,
3939                ..
3940            }
3941        ) {
3942            return Err(FormatError::TypeMismatch {
3943                expected: "object reference",
3944                actual: "non-reference datatype",
3945            }
3946            .into());
3947        }
3948        // An object reference stores an 8-byte object-header address. Refuse a
3949        // sub-address-width element rather than read a truncated address.
3950        let elem_size = dt.type_size().to_usize()?;
3951        if elem_size < 8 {
3952            return Err(FormatError::TypeMismatch {
3953                expected: "8-byte object reference",
3954                actual: "object reference narrower than 8 bytes",
3955            }
3956            .into());
3957        }
3958        let raw = self.read_raw()?;
3959        if raw.is_empty() {
3960            return Ok(Vec::new());
3961        }
3962        if !raw.len().is_multiple_of(elem_size) {
3963            return Err(FormatError::DataSizeMismatch {
3964                expected: elem_size,
3965                actual: raw.len(),
3966            }
3967            .into());
3968        }
3969        let mut out = Vec::with_capacity(raw.len() / elem_size);
3970        for chunk in raw.chunks_exact(elem_size) {
3971            let addr = u64::from_le_bytes(chunk[..8].try_into().expect("chunk has >= 8 bytes"));
3972            out.push(FileInner::object_at_relative(&self.file, addr)?);
3973        }
3974        Ok(out)
3975    }
3976
3977    /// Decode all elements of a compound dataset field by field.
3978    ///
3979    /// Built-in implementations support numeric tuples with one through twelve
3980    /// fields. Decoding uses the file's field offsets rather than Rust's tuple
3981    /// memory layout, so padded compound records are supported safely.
3982    pub fn read_compound<T: CompoundType>(&self) -> Result<Vec<T>, Error> {
3983        let datatype = self.datatype()?;
3984        let element_size = datatype.type_size().to_usize()?;
3985        if !matches!(datatype, Datatype::Compound { .. }) {
3986            return Err(FormatError::TypeMismatch {
3987                expected: "Compound",
3988                actual: "non-Compound",
3989            }
3990            .into());
3991        }
3992        let raw = self.read_raw()?;
3993        if element_size == 0 || !raw.len().is_multiple_of(element_size) {
3994            return Err(FormatError::DataSizeMismatch {
3995                expected: element_size,
3996                actual: raw.len(),
3997            }
3998            .into());
3999        }
4000        raw.chunks_exact(element_size)
4001            .map(|bytes| T::decode(&datatype, bytes).map_err(Error::from))
4002            .collect()
4003    }
4004
4005    /// Verify this dataset against its stored provenance hash.
4006    ///
4007    /// Recomputes the SHA-256 of the dataset's raw bytes and compares it with
4008    /// the `_provenance_sha256` attribute written by
4009    /// [`DatasetBuilder::with_provenance`](crate::DatasetBuilder::with_provenance).
4010    /// Returns [`VerifyResult::NoHash`](crate::VerifyResult::NoHash) when the
4011    /// dataset carries no provenance hash, so a missing hash is distinguishable
4012    /// from an actual mismatch.
4013    #[cfg(feature = "provenance")]
4014    pub fn verify_provenance(&self) -> Result<crate::provenance::VerifyResult, Error> {
4015        use crate::provenance::{ATTR_SHA256, VerifyResult, sha256_hex};
4016
4017        let attrs = self.attrs()?;
4018        let stored = match attrs.get(ATTR_SHA256).and_then(AttrValue::as_str) {
4019            Some(s) => s.trim_end_matches('\0').to_string(),
4020            None => return Ok(VerifyResult::NoHash),
4021        };
4022
4023        let computed = sha256_hex(&self.read_raw()?);
4024        if computed == stored {
4025            Ok(VerifyResult::Ok)
4026        } else {
4027            Ok(VerifyResult::Mismatch { stored, computed })
4028        }
4029    }
4030}
4031
4032// ---------------------------------------------------------------------------
4033// Helpers
4034// ---------------------------------------------------------------------------
4035
4036fn find_message(
4037    header: &ObjectHeader,
4038    msg_type: MessageType,
4039) -> Result<&crate::object_header::HeaderMessage, Error> {
4040    header
4041        .messages
4042        .iter()
4043        .find(|m| m.msg_type == msg_type)
4044        .ok_or(Error::MissingMessage(msg_type))
4045}
4046
4047/// Normalize a user-supplied object path to the root-relative form the write
4048/// session addresses by: strip any leading/trailing `/` so `"/a/b"` and `"a/b"`
4049/// name the same object.
4050fn normalize_path(path: &str) -> String {
4051    path.trim_matches('/').to_string()
4052}
4053
4054fn has_message(header: &ObjectHeader, msg_type: MessageType) -> bool {
4055    header.messages.iter().any(|m| m.msg_type == msg_type)
4056}
4057
4058fn is_group(header: &ObjectHeader) -> bool {
4059    header.messages.iter().any(|m| {
4060        m.msg_type == MessageType::LinkInfo
4061            || m.msg_type == MessageType::Link
4062            || m.msg_type == MessageType::SymbolTable
4063    })
4064}
4065
4066#[cfg(test)]
4067mod tests {
4068    use super::*;
4069    use crate::FileBuilder;
4070
4071    /// Read everything a read-write file can serve through the paired read
4072    /// paths, as comparable text.
4073    ///
4074    /// Each entry exercises a different `with_engine` call site: path
4075    /// resolution, object-header parsing, group listing, attribute reads (both
4076    /// the compact and the dense form), a whole-dataset read, and a row-range
4077    /// read. Errors are formatted rather than unwrapped so that a *divergence in
4078    /// which error* is reported also fails the comparison.
4079    fn read_everything(file: &File) -> Vec<String> {
4080        let mut out = Vec::new();
4081        out.push(format!("root groups: {:?}", file.root().groups()));
4082        out.push(format!("root datasets: {:?}", file.root().datasets()));
4083        out.push(format!("root attrs: {:?}", sorted(file.root().attrs())));
4084
4085        for path in ["plain", "g/nested", "many_attrs", "missing", "g/missing"] {
4086            match file.dataset(path) {
4087                Ok(ds) => {
4088                    out.push(format!("{path}: shape {:?}", ds.shape()));
4089                    out.push(format!("{path}: attrs {:?}", sorted(ds.attrs())));
4090                    out.push(format!("{path}: all {:?}", ds.read_i32()));
4091                    out.push(format!("{path}: rows {:?}", ds.read_i32_rows(1, 2)));
4092                    out.push(format!("{path}: raw rows {:?}", ds.read_raw_rows(0, 1)));
4093                }
4094                Err(e) => out.push(format!("{path}: error {e}")),
4095            }
4096        }
4097        out
4098    }
4099
4100    /// Attribute maps compare only after ordering; `HashMap`'s `Debug` is not
4101    /// deterministic, and an ordering difference here would be noise rather
4102    /// than the divergence this is looking for.
4103    fn sorted(attrs: Result<HashMap<String, AttrValue>, Error>) -> Vec<String> {
4104        match attrs {
4105            Ok(map) => {
4106                let mut v: Vec<String> =
4107                    map.iter().map(|(k, val)| format!("{k}={val:?}")).collect();
4108                v.sort();
4109                v
4110            }
4111            Err(e) => vec![format!("error {e}")],
4112        }
4113    }
4114
4115    /// Drive `bytes` down both forms of every read a read-write file serves and
4116    /// require identical answers.
4117    ///
4118    /// Every read has a slice form (walking the whole-file mirror) and a
4119    /// `Source` form, and until a mirrorless backing lands (issue #198) only the
4120    /// slice form ever runs. This makes the other form reachable now, so it
4121    /// cannot quietly drift as its twin is edited.
4122    fn assert_both_read_paths_agree(bytes: &[u8], what: &str) {
4123        let dir = tempfile::tempdir().unwrap();
4124        let path = dir.path().join("both.h5");
4125        std::fs::write(&path, bytes).unwrap();
4126
4127        // One session at a time: `open_rw` takes an exclusive lock, and holding
4128        // two over one path fails outright where OS locks are mandatory.
4129        let via_mirror = {
4130            let f = File::open_rw(&path).unwrap();
4131            read_everything(&f)
4132        };
4133        let via_source = {
4134            let f = File::open_rw_source_only(&path).unwrap();
4135            read_everything(&f)
4136        };
4137
4138        assert_eq!(
4139            via_mirror.len(),
4140            via_source.len(),
4141            "{what}: the two read paths produced different numbers of results"
4142        );
4143        for (m, s) in via_mirror.iter().zip(&via_source) {
4144            assert_eq!(m, s, "{what}: slice and Source read paths disagree");
4145        }
4146        // Guard the guard: a helper that read nothing would make the comparison
4147        // vacuous, and a file whose datasets all failed to open would too.
4148        assert!(
4149            via_mirror.iter().any(|r| r.contains("all Ok(")),
4150            "{what}: no dataset read succeeded, so this compared nothing"
4151        );
4152    }
4153
4154    /// A file exercising each paired read: a plain dataset, a nested one behind
4155    /// a group (path resolution), and one carrying enough attributes to force
4156    /// the dense (fractal-heap) attribute layout rather than compact messages.
4157    fn both_paths_file_bytes(userblock: Option<u64>) -> Vec<u8> {
4158        let mut b = FileBuilder::new();
4159        if let Some(ub) = userblock {
4160            b.with_userblock(ub);
4161        }
4162        b.create_dataset("plain")
4163            .with_i32_data(&(0..24).collect::<Vec<i32>>())
4164            .with_shape(&[6, 4])
4165            .set_attr("units", AttrValue::String("m".into()));
4166        // Well past the eight-attribute compact limit, so the header converts to
4167        // the dense layout and the dense extraction path is the one that runs.
4168        {
4169            let ds = b
4170                .create_dataset("many_attrs")
4171                .with_i32_data(&(0..8).collect::<Vec<i32>>());
4172            for i in 0..24 {
4173                ds.set_attr(&format!("attr_{i:02}"), AttrValue::I64(i));
4174            }
4175        }
4176        let mut g = b.create_group("g");
4177        g.create_dataset("nested")
4178            .with_i32_data(&(100..112).collect::<Vec<i32>>())
4179            .with_shape(&[3, 4]);
4180        b.add_group(g.finish());
4181        b.finish().unwrap()
4182    }
4183
4184    #[test]
4185    fn both_read_paths_agree() {
4186        assert_both_read_paths_agree(&both_paths_file_bytes(None), "no userblock");
4187    }
4188
4189    /// The userblock case is the one where the two forms are built differently:
4190    /// the slice form reframes by slicing at the base address, the `Source` form
4191    /// wraps in a `BaseOffsetSource`. A file with a nonzero base is the only way
4192    /// to compare them.
4193    #[test]
4194    fn both_read_paths_agree_with_a_userblock() {
4195        assert_both_read_paths_agree(&both_paths_file_bytes(Some(512)), "512-byte userblock");
4196    }
4197
4198    /// One 256-element i32 dataset, chunked into 32-element chunks, in memory.
4199    fn chunked_file_bytes() -> Vec<u8> {
4200        let data: Vec<i32> = (0..256).collect();
4201        let mut b = FileBuilder::new();
4202        b.create_dataset("chunked")
4203            .with_i32_data(&data)
4204            .with_shape(&[256])
4205            .with_chunks(&[32]);
4206        b.finish().unwrap()
4207    }
4208
4209    // The DAPL override must drive the *live* `ChunkCache`, not merely the value
4210    // reported by `chunk_cache_config()`. These assertions reach the crate's
4211    // `#[cfg(test)]` cache introspection (unavailable to integration tests), so
4212    // they fail if the resolved config ever stops flowing into the real cache.
4213
4214    #[test]
4215    fn enabled_override_populates_live_cache_over_disabled_file_default() {
4216        let file = File::from_bytes_with_options(
4217            chunked_file_bytes(),
4218            FileAccessProperties::new().with_chunk_cache(ChunkCacheConfig::disabled()),
4219        )
4220        .unwrap();
4221
4222        let ds = file
4223            .dataset_with_options(
4224                "chunked",
4225                DatasetAccessProperties::new().with_chunk_cache(ChunkCacheConfig::new()),
4226            )
4227            .unwrap();
4228        assert_eq!(ds.read_i32().unwrap(), (0..256).collect::<Vec<i32>>());
4229
4230        // The enabled override built the chunk index and retained chunks; the
4231        // disabled file default would have left both empty.
4232        assert!(ds.chunk_cache_stats().index_loaded());
4233        assert!(ds.chunk_cache_stats().cached_chunks() > 0);
4234    }
4235
4236    #[test]
4237    fn disabled_override_suppresses_live_cache_over_enabled_file_default() {
4238        let file = File::from_bytes_with_options(
4239            chunked_file_bytes(),
4240            FileAccessProperties::new().with_chunk_cache(ChunkCacheConfig::new()),
4241        )
4242        .unwrap();
4243
4244        let ds = file
4245            .dataset_with_options(
4246                "chunked",
4247                DatasetAccessProperties::new().with_chunk_cache(ChunkCacheConfig::disabled()),
4248            )
4249            .unwrap();
4250        assert_eq!(ds.read_i32().unwrap(), (0..256).collect::<Vec<i32>>());
4251
4252        // The disabled override suppressed the index and chunk retention; the
4253        // enabled file default would have populated both.
4254        assert!(!ds.chunk_cache_stats().index_loaded());
4255        assert_eq!(ds.chunk_cache_stats().cached_chunks(), 0);
4256    }
4257
4258    /// A group child whose stored (base-relative) object-header address overflows
4259    /// `u64` once the base address is added must be rejected, not wrapped or
4260    /// panicked on. Reaching this needs a nonzero base address, so the file
4261    /// carries a userblock; the child link's stored address is then rewritten to
4262    /// `HADDR_UNDEF` (all ones) so `group_children`'s normalization overflows.
4263    #[test]
4264    fn group_child_address_base_overflow_is_rejected() {
4265        const UB: u64 = 512;
4266        let mut b = FileBuilder::new();
4267        b.with_userblock(UB);
4268        let mut child = b.create_group("child");
4269        child.create_dataset("inner").with_i32_data(&[1, 2, 3]);
4270        b.add_group(child.finish());
4271        let mut bytes = b.finish().unwrap();
4272
4273        // Baseline: the file reads and the subgroup is listed.
4274        let file = File::from_bytes(bytes.clone()).unwrap();
4275        assert_eq!(file.root().groups().unwrap(), vec!["child".to_string()]);
4276
4277        // Rewrite the child's stored object-header address to HADDR_UNDEF. It is
4278        // stored base-relative (absolute minus the userblock base) and, for this
4279        // single-child file, appears exactly once in the bytes. The link lives in
4280        // the root object header's chunk-0.
4281        let stored = file.root().group("child").unwrap().address - UB;
4282        let needle = stored.to_le_bytes();
4283        let matches: Vec<usize> = bytes
4284            .windows(8)
4285            .enumerate()
4286            .filter(|(_, w)| *w == needle)
4287            .map(|(i, _)| i)
4288            .collect();
4289        assert_eq!(
4290            matches.len(),
4291            1,
4292            "stored child address {stored:#x} was not uniquely locatable: {matches:?}"
4293        );
4294        bytes[matches[0]..matches[0] + 8].copy_from_slice(&u64::MAX.to_le_bytes());
4295
4296        // The v2 object header is checksum-protected, so a real crafted file would
4297        // carry a matching checksum; recompute the root header's over the edited
4298        // bytes so parsing reaches the address normalization rather than failing on
4299        // the checksum first. Mirrors the chunk-0 extent from `parse_v2`.
4300        #[cfg(feature = "checksum")]
4301        {
4302            let root_addr = file.root().address as usize;
4303            assert_eq!(&bytes[root_addr..root_addr + 4], b"OHDR");
4304            let flags = bytes[root_addr + 5];
4305            let mut pos = root_addr + 6;
4306            if flags & 0x20 != 0 {
4307                pos += 16;
4308            }
4309            if flags & 0x10 != 0 {
4310                pos += 4;
4311            }
4312            let width = 1usize << (flags & 0x03);
4313            let chunk0 = (0..width).fold(0usize, |acc, i| {
4314                acc | ((bytes[pos + i] as usize) << (8 * i))
4315            });
4316            pos += width;
4317            let chunk0_end = pos + chunk0;
4318            assert!(
4319                matches[0] < chunk0_end,
4320                "patched link address is outside the root header's chunk-0"
4321            );
4322            let cs = crate::checksum::jenkins_lookup3(&bytes[root_addr..chunk0_end]);
4323            bytes[chunk0_end..chunk0_end + 4].copy_from_slice(&cs.to_le_bytes());
4324        }
4325
4326        // Iterating the root now normalizes `u64::MAX + base` and must surface the
4327        // overflow as a format error rather than panicking or wrapping.
4328        let file = File::from_bytes(bytes).unwrap();
4329        match file.root().groups() {
4330            Err(Error::Format(FormatError::OffsetOverflow { offset, length })) => {
4331                assert_eq!(offset, u64::MAX);
4332                assert_eq!(length, UB);
4333            }
4334            other => panic!("expected group-child address overflow, got {other:?}"),
4335        }
4336    }
4337
4338    /// A zero-row window returns `Ok(empty)` uniformly, even over an unallocated
4339    /// contiguous dataset where the whole-dataset reader errors with
4340    /// `NoDataAllocated`. Without the early return in `read_rows_framed`, the
4341    /// contiguous arm's `address.ok_or(NoDataAllocated)?` would error here, while
4342    /// the chunked arm returns `Ok(empty)` — the cross-layout divergence this
4343    /// guards against.
4344    #[test]
4345    fn read_rows_framed_zero_row_window_is_ok_even_when_unallocated() {
4346        let dl = DataLayout::Contiguous {
4347            address: None,
4348            size: 0,
4349        };
4350        let ds = Dataspace {
4351            space_type: crate::dataspace::DataspaceType::Simple,
4352            rank: 1,
4353            dimensions: vec![0],
4354            max_dimensions: None,
4355        };
4356        let dt = Datatype::FixedPoint {
4357            size: 8,
4358            byte_order: crate::datatype::DatatypeByteOrder::LittleEndian,
4359            signed: false,
4360            bit_offset: 0,
4361            bit_precision: 64,
4362        };
4363        let cache = ChunkCache::new();
4364        let out = read_rows_framed(
4365            &BytesSource::new(b""),
4366            &dl,
4367            &ds,
4368            &dt,
4369            None,
4370            8,
4371            8,
4372            &cache,
4373            0,
4374            0,
4375            8,
4376        )
4377        .expect("a zero-row window must be Ok(empty), not NoDataAllocated");
4378        assert!(out.is_empty());
4379
4380        // A Virtual layout is unsupported and must still error for a zero-row
4381        // window, matching `read_raw`, rather than being swallowed by the early
4382        // return.
4383        let virtual_dl = DataLayout::Virtual { version: 4 };
4384        let err = read_rows_framed(
4385            &BytesSource::new(b""),
4386            &virtual_dl,
4387            &ds,
4388            &dt,
4389            None,
4390            8,
4391            8,
4392            &cache,
4393            0,
4394            0,
4395            8,
4396        )
4397        .expect_err("a virtual layout must error even for a zero-row window");
4398        assert!(
4399            matches!(err, FormatError::UnsupportedVirtualLayout),
4400            "expected UnsupportedVirtualLayout, got {err:?}"
4401        );
4402    }
4403}