Skip to main content

hdf5_pure/
repack.rs

1//! Whole-file repack (issue #21): copy an existing HDF5 file into a fresh,
2//! compact one, optionally dropping objects.
3//!
4//! [`File::open_rw`](crate::File::open_rw) deletes objects in place but reclaims
5//! space only within a session and cannot return a single deleted-and-closed
6//! file's bytes to the OS. Repack is the complementary answer — the same one the
7//! HDF5 C ecosystem ships as `h5repack`: it reads every surviving object and
8//! rewrites the whole file from scratch through [`FileBuilder`], so the result
9//! has no dead space and is strictly smaller when objects are dropped.
10//!
11//! # Fidelity contract
12//!
13//! Repack never silently degrades data. Every surviving object is reproduced
14//! faithfully — datatype, shape, max-shape, chunking, filters, and byte-exact
15//! element data — or the whole operation fails with [`Error::RepackUnsupported`]
16//! naming the object and the reason. It refuses rather than approximate.
17//! Currently reproducible:
18//!
19//! - Datasets with fixed-point, floating-point, time, fixed-length string,
20//!   bit-field, opaque, compound, enumeration, and array datatypes,
21//!   contiguous/compact or chunked.
22//! - **Chunked** datasets copy their compressed chunks **verbatim** (chunk by
23//!   chunk, never decoded), so *every* filter is preserved byte-exact: deflate,
24//!   shuffle, fletcher32, integer **and** float scale-offset, ZFP, SZIP, and
25//!   even filters this crate cannot itself apply. The destination always uses a
26//!   v4 chunk index (single-chunk / fixed-array / extensible-array) regardless
27//!   of the source index type.
28//! - **Variable-length** datasets (1D and ND, contiguous/compact as well as
29//!   chunked, filtered, or resizable): string-shaped (`is_string: true` and the
30//!   MATLAB VLEN-of-1-byte-ASCII-string shape) and non-string sequences over any
31//!   base type that embeds no addresses. Each element's exact heap bytes are read
32//!   and re-staged through a fresh global heap, preserving charset, padding, the
33//!   null-vs-empty distinction, embedded NULs, and non-UTF-8 payloads, and the
34//!   source's chunk geometry, filters, and resizability are carried onto the
35//!   rebuilt dataset.
36//! - Datatypes that *contain* an address without being one — a compound with a
37//!   variable-length member, an object-reference member, or both; an array of
38//!   such compounds; and nesting of either. The embedded heap references are
39//!   re-staged and the embedded object addresses resolved exactly as their
40//!   top-level counterparts are, in a single pass, while every other byte of the
41//!   element is carried through untouched.
42//! - Contiguous/compact **object-reference** datasets, and contiguous/compact
43//!   datatypes containing an object-reference member: each stored address is
44//!   rewritten to its target object's new location in the compacted file (null
45//!   and undefined references are carried verbatim). A reference to a committed
46//!   datatype object is one of those targets, since it is an object like any
47//!   other.
48//! - **Committed (`H5Tcommit`) datatypes**: the named datatype object is
49//!   recreated at the same path, and every dataset and attribute that named it
50//!   names the *same* object in the output rather than getting an inline copy of
51//!   the encoding, so `h5dump` still reports `DATATYPE "/mytype"` and the
52//!   object's reference count still matches its users.
53//! - **Unallocated storage**: a dataset the reference library created and never
54//!   wrote to holds nothing, and comes out holding nothing rather than holding
55//!   the fill value a read answers with for every element it declares (issue
56//!   #293). A *resizable* destination keeps the eagerly built chunk index every
57//!   empty resizable dataset here gets, since an in-place append needs one to
58//!   exist; it stores no chunk either way.
59//! - Group hierarchy of arbitrary depth.
60//! - Attributes, on datasets, groups, and root, carried across with the
61//!   encoding the source gave them rather than rebuilt from an [`AttrValue`],
62//!   which is a decoded view and cannot express a byte order, a sub-width
63//!   precision, a variable-length string, or a rank above one.
64//! - The source file's file-space management strategy (with its page size and
65//!   threshold), carried into the compact output as non-persistent — a repacked
66//!   file has no free space to persist.
67//!
68//! The verbatim chunk copy never decodes, so it eliminates the
69//! decompress→recompress round-trip and the per-dataset decompression blowup,
70//! and a lossy filter survives byte-exact. Two paths still re-encode and so
71//! require **lossless** filters: a *contiguous/compact* filtered dataset, and a
72//! *sparse* chunked dataset (one with unallocated chunk-grid holes, which the
73//! dense verbatim path cannot lay out). A lossy pipeline on either of those is
74//! refused.
75//!
76//! Refused (named, never dropped silently): chunked, filtered, or resizable
77//! datasets whose datatype is or contains an object reference (their element
78//! addresses are resolved as elements are re-staged, which a compressed chunk
79//! would need rewritten in place);
80//! region references and
81//! non-8-byte object references; an object reference to a dropped object or to a
82//! target outside the hard-link hierarchy (a dangling or region target), and
83//! object references in a userblock file (non-zero base address); a
84//! non-string vlen sequence whose base type embeds an address (nested vlen or
85//! reference); a virtual data layout, and external data storage
86//! (`H5Pset_external`), whose bytes live in files this crate does not read; a
87//! lossy filter on the
88//! contiguous re-encode or sparse-chunked fallback path; an attribute whose
89//! datatype is or contains a reference (its stored address is not rewritten yet,
90//! and no [`AttrValue`] can re-encode it); and a use of a committed datatype the
91//! repack drops or that no hard link reaches. An object that cannot be
92//! reproduced fails the repack by name rather than being silently dropped.
93//!
94//! One thing is resolved rather than reproduced: a *shared* message, which only
95//! a file with a shared-message (SOHM) table has, is written back inline. Unlike
96//! a committed datatype, a message in the shared-message heap has no name and no
97//! HDF5 call reports one as shared, so there is nothing an inline copy loses —
98//! the repacked file holds the same messages, one copy per user, and shares
99//! nothing.
100//!
101//! # Memory
102//!
103//! Repack is **out-of-core** (issue [#82]): it opens the source with
104//! [`File::open_streaming`], reading metadata and one working chunk on demand
105//! rather than buffering the whole file, copies each chunked dataset's
106//! compressed chunks verbatim one at a time, and streams the output straight to
107//! the destination. Peak memory is therefore bounded by a single chunk plus the
108//! file's metadata, independent of dataset (or file) size, so a file whose data
109//! exceeds available RAM repacks successfully.
110//!
111//! [#82]: https://github.com/CramBL/hdf5-pure/issues/82
112
113use std::collections::{BTreeSet, HashMap};
114use std::path::Path;
115use std::sync::Arc;
116
117use crate::access_mode::AccessMode;
118use crate::address::StoredAddress;
119use crate::attribute::AttributeMessage;
120use crate::chunked_read::ChunkInfo;
121use crate::chunked_write::{ChunkMeta, ChunkProvider, FilterKind, FilterSpec};
122use crate::convert::Narrow;
123use crate::data_layout::DataLayout;
124use crate::dataspace::MaxExtent;
125use crate::datatype::{Datatype, ReferenceType, embedded_reference_slots};
126use crate::error::{Error, FormatError};
127use crate::filter_pipeline::{
128    FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_LZF, FILTER_SCALEOFFSET, FILTER_SHUFFLE,
129    FilterPipeline,
130};
131use crate::libver::LibVer;
132use crate::object_path::{LinkNameBuf, ObjectPathBuf};
133use crate::reader::{Dataset, File, Group};
134use crate::scaleoffset::{self, ScaleOffset};
135use crate::shared_message::DatatypeLocation;
136use crate::source::Source;
137use crate::type_builders::{
138    AttrValue, DatasetBuilder, FinishedGroup, GroupBuilder, ObjectRefPatch, ObjectRefTarget,
139    VlStringElement,
140};
141use crate::vl_data::{
142    EmbeddedVlSlot, VlByteObject, VlenStringReadOptions, embedded_vlen_slots,
143    is_vlen_string_datatype,
144};
145use crate::writer::FileBuilder;
146
147/// Options controlling a [`repack`].
148///
149/// Built with [`new`](Self::new) and [`drop_path`](Self::drop_path); the fields
150/// are private so a future option is an additive change.
151#[derive(Debug, Default, Clone)]
152pub struct RepackOptions {
153    /// Paths of the objects to omit from the output, each from the root group as
154    /// [`drop_path`](Self::drop_path) parsed it.
155    drop: Vec<String>,
156    /// Library-version bounds for the output, from
157    /// [`with_libver_bounds`](Self::with_libver_bounds). `None` carries the
158    /// source's own format forward.
159    libver_bounds: Option<(LibVer, LibVer)>,
160    /// Whether the source's object headers are parsed as a writer parses them, from
161    /// [`reject_unknown_messages_only_a_writer_must_understand`](Self::reject_unknown_messages_only_a_writer_must_understand).
162    /// See [`source_access_mode`](Self::source_access_mode) for the mode it selects.
163    reject_unknown_messages_only_a_writer_must_understand: bool,
164}
165
166impl RepackOptions {
167    /// Options that drop nothing — a pure compaction copy.
168    pub fn new() -> Self {
169        Self::default()
170    }
171
172    /// Omits the object at `path` from the output, such as `"grp/old"`, and its whole subtree
173    /// where that object is a group.
174    ///
175    /// `path` identifies an object whichever way it is spelled, as it does for
176    /// [`Group::dataset`](crate::Group::dataset).
177    ///
178    /// Every path must identify an object in the source, and a path that identifies the root
179    /// group is outside what a repack drops. [`repack`](fn@crate::repack) reports
180    /// [`Error::RepackUnsupported`] for either.
181    ///
182    /// Chainable.
183    pub fn drop_path(mut self, path: &str) -> Self {
184        self.drop.push(ObjectPathBuf::parse(path).to_string());
185        self
186    }
187
188    /// Returns the paths this repack omits, each from the root group, in the order they were
189    /// added.
190    pub fn drop_paths(&self) -> &[String] {
191        &self.drop
192    }
193
194    /// Write the output in the format these bounds select, rather than in the
195    /// source's own format. Same meaning as
196    /// [`FileBuilder::with_libver_bounds`](crate::FileBuilder::with_libver_bounds):
197    /// `high` picks the format, and content it cannot express is refused with
198    /// [`FormatError::LibverTooOldForContent`] rather than written in a newer
199    /// one.
200    ///
201    /// A repack carries the source's format forward by default, because
202    /// compacting a file is not a request to re-target it — but it does upgrade
203    /// where the content leaves no choice, since a chunked dataset needs the 1.10
204    /// chunk indices and there is no older one this crate writes. Set this to make
205    /// the format a guarantee instead: an `Earliest..=V18` bound turns that
206    /// upgrade into an error, so a file that must stay loadable by an old reader
207    /// says so rather than finding out later.
208    ///
209    /// ```no_run
210    /// use hdf5_pure::{LibVer, RepackOptions, repack};
211    ///
212    /// // Fails rather than producing a file MATLAB before R2021b cannot open.
213    /// let options = RepackOptions::new().with_libver_bounds(LibVer::Earliest, LibVer::V18);
214    /// repack("data.mat", "compact.mat", &options)?;
215    /// # Ok::<(), hdf5_pure::Error>(())
216    /// ```
217    ///
218    /// Chainable.
219    pub fn with_libver_bounds(mut self, low: LibVer, high: LibVer) -> Self {
220        self.libver_bounds = Some((low, high));
221        self
222    }
223
224    /// The library-version bounds this repack will write to, or `None` when it
225    /// carries the source's format forward.
226    pub fn libver_bounds(&self) -> Option<(LibVer, LibVer)> {
227        self.libver_bounds
228    }
229
230    /// Rejects a source holding an object header message of a type this crate cannot
231    /// name that only a decoder with write access must understand.
232    ///
233    /// The flag is `H5O_MSG_FLAG_FAIL_IF_UNKNOWN_AND_OPEN_FOR_WRITE`, bit 3 of the
234    /// message record's flags. The source is opened read-only either way, and this
235    /// option parses its object headers as a writer does, so
236    /// [`repack`](fn@crate::repack) reports [`FormatError::UnsupportedMessage`] with
237    /// the message's type before any byte reaches the destination. A message flagged
238    /// `H5O_MSG_FLAG_FAIL_IF_UNKNOWN_ALWAYS` is rejected with this option and
239    /// without it.
240    ///
241    /// The default is what `h5repack` does. `h5repack` also reads its input
242    /// read-only, and this crate rebuilds every object without a message it cannot
243    /// name. This option is this crate's own, and `h5repack` has no counterpart for
244    /// it. Set it where a repack must fail on a message the source marked for a
245    /// decoder with write access.
246    ///
247    /// # Examples
248    ///
249    /// ```rust
250    /// # let dir = tempfile::tempdir()?;
251    /// # let src = dir.path().join("data.h5");
252    /// # let dst = dir.path().join("compact.h5");
253    /// # let mut builder = hdf5_pure::FileBuilder::new();
254    /// # builder.create_dataset("values").with_i32_data(&[1, 2, 3]);
255    /// # builder.write(&src)?;
256    /// use hdf5_pure::{File, RepackOptions, repack};
257    ///
258    /// // Rejects a source holding a message flagged for a decoder with write access.
259    /// let options = RepackOptions::new().reject_unknown_messages_only_a_writer_must_understand();
260    /// repack(&src, &dst, &options)?;
261    ///
262    /// assert_eq!(File::open(&dst)?.dataset("values")?.read_i32()?, vec![1, 2, 3]);
263    /// # Ok::<(), hdf5_pure::Error>(())
264    /// ```
265    pub fn reject_unknown_messages_only_a_writer_must_understand(mut self) -> Self {
266        self.reject_unknown_messages_only_a_writer_must_understand = true;
267        self
268    }
269
270    /// Returns `true` if this repack reads its source as a writer would, which
271    /// [`reject_unknown_messages_only_a_writer_must_understand`](Self::reject_unknown_messages_only_a_writer_must_understand)
272    /// sets.
273    pub fn rejects_unknown_messages_only_a_writer_must_understand(&self) -> bool {
274        self.reject_unknown_messages_only_a_writer_must_understand
275    }
276
277    /// The mode the source's object headers are parsed under: [`AccessMode::ReadWrite`]
278    /// with [`reject_unknown_messages_only_a_writer_must_understand`](Self::reject_unknown_messages_only_a_writer_must_understand)
279    /// set, which is the mode a parse rejects a message of an unknown type flagged for
280    /// a decoder with write access under.
281    pub(crate) fn source_access_mode(&self) -> AccessMode {
282        if self.reject_unknown_messages_only_a_writer_must_understand {
283            AccessMode::ReadWrite
284        } else {
285            AccessMode::ReadOnly
286        }
287    }
288}
289
290/// Repack `src` into a new file at `dst`, applying `options`.
291///
292/// Reads every object of `src` not excluded by [`RepackOptions::drop_path`] and
293/// writes them into a fresh, compact file at `dst`. On success `dst` is a normal
294/// HDF5 file holding exactly the surviving objects with no dead space.
295///
296/// The fidelity checks run first: every object is validated while the output is
297/// staged, so an [`Error::RepackUnsupported`] (an object that cannot be
298/// reproduced faithfully, or a drop path that does not exist) is reported before
299/// any byte is written to `dst`. Dataset *chunk bytes*, by contrast, are streamed
300/// from `src` to `dst` during the write rather than buffered, so an I/O error
301/// reading the source or writing the destination partway through can leave a
302/// partial `dst` (remove it and retry).
303///
304/// See [`Error::RepackUnsupported`] for the objects that cannot be reproduced
305/// faithfully.
306///
307/// `src` is opened for streaming reads, so a file whose superblock marks it as
308/// held by a writer is rejected with [`Error::FileMarkedInUse`], since repacking
309/// a file a writer is still growing would capture a torn view. Clear a flag a
310/// crashed writer left with [`File::clear_swmr_flag`](crate::File::clear_swmr_flag)
311/// first, as `h5repack` needs `h5clear`.
312///
313/// The source's object headers are parsed read-only by default, so a message of a
314/// type this crate cannot name that only a decoder with write access must
315/// understand is read past, and `repack` writes the object into `dst` without it,
316/// wherever the message sits. `h5repack` opens its input read-only as well and
317/// rebuilds a group or a named datatype, so both tools drop such a message from
318/// those headers. A dataset that `h5repack` copies with `H5Ocopy` keeps the message
319/// in the output (`h5repack_copy.c` and `H5Ocopy.c`, HDF5 2.2.0).
320///
321/// [`RepackOptions::reject_unknown_messages_only_a_writer_must_understand`] parses
322/// them as a writer does, so such a message is reported as
323/// [`FormatError::UnsupportedMessage`] before any byte reaches `dst`.
324pub fn repack<P: AsRef<Path>, Q: AsRef<Path>>(
325    src: P,
326    dst: Q,
327    options: &RepackOptions,
328) -> Result<(), Error> {
329    // Open the source for on-demand streaming reads: metadata and one working
330    // chunk are resident at a time, never the whole file. Shared so each streamed
331    // dataset's chunk provider can pull from the same handle during the write
332    // without an extra open.
333    let file = Arc::new(File::open_streaming_with_access_mode(
334        src.as_ref(),
335        options.source_access_mode(),
336    )?);
337
338    // Parse the drop set and remember which of its paths matched an object, so an
339    // unmatched drop can be reported as an error.
340    let drop: BTreeSet<ObjectPathBuf> = options
341        .drop
342        .iter()
343        .map(|path| ObjectPathBuf::parse(path))
344        .collect();
345    let mut matched: BTreeSet<ObjectPathBuf> = BTreeSet::new();
346    // No link reaches the root group, so there is none to leave out of the output,
347    // and `is_dropped` tests a prefix, under which the root group's path covers
348    // every object in the file.
349    if drop.iter().any(ObjectPathBuf::is_empty) {
350        return Err(Error::RepackUnsupported(String::from(
351            "a repack cannot drop the root group",
352        )));
353    }
354
355    let mut builder = FileBuilder::new();
356    // Carry the source's file-space strategy forward. The repacked file is
357    // compact with no free space, so the strategy and its page size/threshold
358    // are preserved but `persist` is reset to false — there is nothing to
359    // persist, and writing persistent free-space blocks is a separate feature.
360    if let Some(info) = file.file_space_info() {
361        builder
362            .with_file_space_strategy(info.strategy, false, info.threshold)
363            .with_file_space_page_size(info.page_size);
364    }
365    // Map every source object's (relative) header address to its path, so an
366    // object-reference dataset can be rewritten to point at the same objects in
367    // the compacted output rather than at their stale source addresses.
368    let addr_map = build_object_address_map(&file)?;
369
370    let root = file.root();
371    populate(
372        &mut builder,
373        &root,
374        &ObjectPathBuf::root(),
375        &drop,
376        &mut matched,
377        &file,
378        &addr_map,
379    )?;
380
381    // Choose the output format now that the content is staged.
382    //
383    // A repack compacts a file; it is not a request to re-target it, and the
384    // format is the one property whose loss is invisible until an old reader
385    // refuses the output — a `.mat` written to the 1.8 bound so MATLAB can load
386    // it used to come back with a version 3 superblock MATLAB cannot open at
387    // all. So the source's format is carried forward.
388    //
389    // Except where the content will not fit in it. A chunked dataset needs the
390    // version 4 data-layout message and a 1.10 chunk index, and 1.8's version 1
391    // B-tree index is one this crate reads but does not write — so a great many
392    // files the C library wrote (chunked data under a version 0 or 2 superblock)
393    // have no older encoding to be repacked back into. Upgrading there is forced
394    // rather than gratuitous, and refusing instead would take repack away from
395    // most real HDF5 files. `h5repack` makes the same trade, writing whatever
396    // format the content needs unless `--low`/`--high` say otherwise.
397    //
398    // `RepackOptions::with_libver_bounds` is that "unless": set explicitly, the
399    // bound is a guarantee, and content it cannot express is refused by the
400    // writer rather than upgraded past it.
401    //
402    // The carried-forward ceiling is floored at `WRITER_OLDEST` because a
403    // version 0/1 superblock source is older than anything this crate writes:
404    // that format can be read but not produced, so the oldest writable one is as
405    // close as the output gets. `Earliest` as the low bound rather than the
406    // source's own version, since `high` is what selects the format and a floor
407    // equal to it would leave nothing satisfiable.
408    let (low, high) = match options.libver_bounds {
409        Some(explicit) => explicit,
410        None if builder.needs_latest_format() => (LibVer::Earliest, LibVer::WRITER_DEFAULT),
411        None => (
412            LibVer::Earliest,
413            file.libver_bound().max(LibVer::WRITER_OLDEST),
414        ),
415    };
416    builder.with_libver_bounds(low, high);
417
418    // Every requested drop must have named a real object.
419    if let Some(missing) = drop.iter().find(|d| !matched.contains(*d)) {
420        return Err(Error::RepackUnsupported(format!(
421            "drop path does not exist in the source: {missing}"
422        )));
423    }
424
425    builder.write(dst)?;
426    Ok(())
427}
428
429/// A destination that group contents can be added to. Implemented for both the
430/// top-level [`FileBuilder`] (the root group) and [`GroupBuilder`] (subgroups)
431/// so one recursive walk handles every level.
432trait GroupSink: AttrSink {
433    fn sink_dataset(&mut self, name: &str) -> &mut DatasetBuilder;
434    fn sink_add_group(&mut self, group: FinishedGroup);
435    fn sink_commit_datatype(&mut self, name: &str, datatype: Datatype);
436}
437
438/// Anything a repacked attribute can be attached to: the root group, a subgroup,
439/// or a dataset. Split from [`GroupSink`] because a dataset takes attributes but
440/// holds no children, so one attribute-copying routine serves all three.
441trait AttrSink {
442    fn sink_set_attr(&mut self, name: &str, value: AttrValue);
443    fn sink_set_attr_verbatim(&mut self, message: AttributeMessage);
444    fn sink_set_attr_var_len_verbatim(&mut self, message: AttributeMessage, strings: Vec<String>);
445}
446
447impl AttrSink for FileBuilder {
448    fn sink_set_attr(&mut self, name: &str, value: AttrValue) {
449        self.set_attr(name, value);
450    }
451    fn sink_set_attr_verbatim(&mut self, message: AttributeMessage) {
452        self.set_attr_verbatim(message);
453    }
454    fn sink_set_attr_var_len_verbatim(&mut self, message: AttributeMessage, strings: Vec<String>) {
455        self.set_attr_var_len_verbatim(message, strings);
456    }
457}
458
459impl AttrSink for GroupBuilder {
460    fn sink_set_attr(&mut self, name: &str, value: AttrValue) {
461        self.set_attr(name, value);
462    }
463    fn sink_set_attr_verbatim(&mut self, message: AttributeMessage) {
464        self.set_attr_verbatim(message);
465    }
466    fn sink_set_attr_var_len_verbatim(&mut self, message: AttributeMessage, strings: Vec<String>) {
467        self.set_attr_var_len_verbatim(message, strings);
468    }
469}
470
471impl AttrSink for DatasetBuilder {
472    fn sink_set_attr(&mut self, name: &str, value: AttrValue) {
473        self.set_attr(name, value);
474    }
475    fn sink_set_attr_verbatim(&mut self, message: AttributeMessage) {
476        self.set_attr_verbatim(message);
477    }
478    fn sink_set_attr_var_len_verbatim(&mut self, message: AttributeMessage, strings: Vec<String>) {
479        self.set_attr_var_len_verbatim(message, strings);
480    }
481}
482
483impl GroupSink for FileBuilder {
484    fn sink_dataset(&mut self, name: &str) -> &mut DatasetBuilder {
485        self.create_dataset(name)
486    }
487    fn sink_add_group(&mut self, group: FinishedGroup) {
488        self.add_group(group);
489    }
490    fn sink_commit_datatype(&mut self, name: &str, datatype: Datatype) {
491        self.commit_datatype(name, datatype);
492    }
493}
494
495impl GroupSink for GroupBuilder {
496    fn sink_dataset(&mut self, name: &str) -> &mut DatasetBuilder {
497        self.create_dataset(name)
498    }
499    fn sink_add_group(&mut self, group: FinishedGroup) {
500        self.add_group(group);
501    }
502    fn sink_commit_datatype(&mut self, name: &str, datatype: Datatype) {
503        self.commit_datatype(name, datatype);
504    }
505}
506
507/// Copies `src`'s attributes, datasets, and subgroups into `sink`, recursively, skipping every
508/// object whose path is in `drop`. `path` identifies `src` from the root group, and has no
509/// components for the root group itself.
510fn populate<S: GroupSink>(
511    sink: &mut S,
512    src: &Group,
513    path: &ObjectPathBuf,
514    drop: &BTreeSet<ObjectPathBuf>,
515    matched: &mut BTreeSet<ObjectPathBuf>,
516    file: &Arc<File>,
517    addr_map: &HashMap<u64, ObjectPathBuf>,
518) -> Result<(), Error> {
519    // Attributes, copied verbatim where their bytes travel and re-encoded where
520    // they do not; refused rather than dropped if neither is possible.
521    let owner = if path.is_empty() {
522        "root group".to_string()
523    } else {
524        format!("group {path}")
525    };
526    copy_attrs(
527        sink,
528        src.attr_messages()?,
529        || src.attrs(),
530        &owner,
531        drop,
532        addr_map,
533    )?;
534
535    // Committed (`H5Tcommit`) datatype objects. Neither a group nor a dataset, so
536    // the two walks below never see one: without this loop the object and its
537    // link leave the output, and every dataset that named the type through it
538    // stops resolving. Placed before the datasets so the type exists in the
539    // output before anything references it (the writer resolves by path, so the
540    // order is not load-bearing, but it matches how the file reads).
541    for name in src.named_datatypes()? {
542        let name = link_name_in(&owner, &name)?;
543        let child_path = path.join(&name);
544        if drop.contains(&child_path) {
545            matched.insert(child_path);
546            continue;
547        }
548        let (datatype, _) = src.named_datatype_at(name.as_str())?;
549        check_datatype(&datatype, &format!("committed datatype {child_path}"))?;
550        sink.sink_commit_datatype(name.as_str(), datatype);
551    }
552
553    // Datasets, sorted by name.
554    let mut dataset_names = src.datasets()?;
555    dataset_names.sort_unstable();
556    for name in dataset_names {
557        let name = link_name_in(&owner, &name)?;
558        let child_path = path.join(&name);
559        if drop.contains(&child_path) {
560            matched.insert(child_path);
561            continue;
562        }
563        let ds = src.dataset(name.as_str())?;
564        emit_dataset(
565            sink.sink_dataset(name.as_str()),
566            &ds,
567            &child_path,
568            file,
569            drop,
570            addr_map,
571        )?;
572    }
573
574    // Subgroups, sorted by name; built depth-first into a FinishedGroup.
575    let mut group_names = src.groups()?;
576    group_names.sort_unstable();
577    for name in group_names {
578        let name = link_name_in(&owner, &name)?;
579        let child_path = path.join(&name);
580        if drop.contains(&child_path) {
581            matched.insert(child_path);
582            continue;
583        }
584        let child = src.group(name.as_str())?;
585        let mut gb = GroupBuilder::new(name.as_str());
586        populate(&mut gb, &child, &child_path, drop, matched, file, addr_map)?;
587        sink.sink_add_group(gb.finish());
588    }
589    Ok(())
590}
591
592/// Capture one dataset's full description and stage it on `db`, or fail with a
593/// named [`Error::RepackUnsupported`] if any part cannot be reproduced.
594fn emit_dataset(
595    db: &mut DatasetBuilder,
596    ds: &Dataset,
597    path: &ObjectPathBuf,
598    file: &Arc<File>,
599    drop: &BTreeSet<ObjectPathBuf>,
600    addr_map: &HashMap<u64, ObjectPathBuf>,
601) -> Result<(), Error> {
602    // A committed (`H5Tcommit`) element type lives in its own object header. The
603    // dataset is reproduced naming the same type rather than inlining a copy of
604    // it, which would read back correctly and still lose the name every C-library
605    // reader reports.
606    if let Some(address) = ds.committed_datatype_address()? {
607        let type_path = committed_type_path(address, &format!("dataset {path}"), drop, addr_map)?;
608        db.with_committed_datatype_path(type_path);
609    }
610
611    let datatype = ds.datatype()?;
612    let dataspace = ds.dataspace()?;
613    let layout = ds.data_layout()?;
614    let pipeline = ds.filter_pipeline_parsed();
615
616    check_datatype(&datatype, &format!("dataset {path}"))?;
617    check_layout(&layout, path)?;
618    // An externally stored dataset carries a contiguous layout message with an
619    // undefined data address, exactly as a never-written one does, and this crate
620    // does not follow the external files. Refused rather than reproduced, which
621    // would silently emit a schema-only dataset in place of one holding data.
622    if ds.has_external_storage()? {
623        return Err(Error::RepackUnsupported(format!(
624            "dataset {path}: external data storage (H5Pset_external) cannot be repacked -- its \
625             element bytes live in files this crate does not read"
626        )));
627    }
628
629    let dims = dataspace.dimensions.clone();
630    let n_elements: u64 = dims.iter().product();
631
632    // The chunks the source holds, walked once. Both the unallocated test below
633    // and the verbatim planner further down need the list, and each walk is the
634    // whole chunk index. Empty for a layout that has no chunks at all, which is
635    // the answer both of them want for one.
636    let source_chunks = match &layout {
637        DataLayout::Chunked { .. } => ds.raw_chunks()?,
638        _ => Vec::new(),
639    };
640
641    // Every variable-length reference the datatype reaches, whether it *is*
642    // variable-length or merely contains one through a compound member or array
643    // entry. Both kinds are re-staged rather than copied, so both disqualify the
644    // verbatim and fill-value paths below.
645    let vlen_slots = embedded_vlen_slots(&datatype).ok_or_else(|| {
646        Error::RepackUnsupported(format!(
647            "dataset {path}: datatype declares variable-length members its own element \
648             size cannot hold"
649        ))
650    })?;
651    // Likewise every object-header address it reaches. Both kinds of address are
652    // rewritten rather than copied, so both disqualify the paths that move
653    // element bytes unchanged.
654    let reference_slots = embedded_reference_slots(&datatype).ok_or_else(|| {
655        Error::RepackUnsupported(format!(
656            "dataset {path}: datatype declares object references this rewrite cannot locate in \
657             its element bytes -- a width other than eight, an enumeration over one, a \
658             variable-length sequence of them, or offsets its own element size cannot hold"
659        ))
660    })?;
661
662    // A user-defined fill value is fixed element bytes in the datatype, so it
663    // reproduces exactly on the fixed-size paths below (dense-chunked verbatim,
664    // and contiguous/compact/sparse re-encode). The variable-length and
665    // object-reference paths that follow rebuild global-heap references and
666    // object addresses from scratch, so a fill value there could carry stale heap
667    // or address bytes; refuse rather than copy it, matching the crate's
668    // never-degrade-on-rewrite contract.
669    let fill = ds.defined_fill_bytes()?;
670    if fill.is_some() && !(vlen_slots.is_empty() && reference_slots.is_empty()) {
671        return Err(Error::RepackUnsupported(format!(
672            "dataset {path}: a fill value on a variable-length or object-reference \
673             dataset cannot be repacked faithfully"
674        )));
675    }
676
677    // Storage the source never allocated is reproduced as storage, not as the
678    // values reading it answers with. By default the reference library does not
679    // allocate a contiguous or chunked dataset's storage until something is
680    // written to it, so one created and never written holds nothing at all
681    // (compact data is inline and is always present); since #292 reading it
682    // answers the fill value for every element, and re-writing those values
683    // would turn a schema-only file into a fully materialized one of the size
684    // its shape declares (issue #293).
685    //
686    // Placed after every refusal the datatype and layout share and before the
687    // paths that move element bytes, all of which exist to rebuild an address
688    // this dataset does not store: there is no data to carry, so each of them
689    // would be reproducing the fill value it read rather than anything the
690    // source held. `check_pipeline` still runs, so a filter this crate could not
691    // re-apply is refused here exactly as it is on the re-encode path below —
692    // the filters are reproduced through the same `carry_shape_and_pipeline`.
693    if storage_is_unallocated(&layout, &source_chunks) {
694        let filters = check_pipeline(pipeline.as_ref(), path)?;
695        db.fill = fill;
696        db.with_unallocated_storage(datatype, &dims);
697        carry_shape_and_pipeline(
698            db,
699            &dims,
700            dataspace.extensible_max_dimensions(),
701            &layout,
702            &filters,
703        );
704        copy_dataset_attrs(db, ds, path, drop, addr_map)?;
705        return Ok(());
706    }
707
708    // Variable-length string datasets take a dedicated path: their element
709    // references point into the global heap, so they are re-emitted by reading
710    // each element's exact heap bytes and re-staging them, not by copying raw
711    // element bytes (whose stored heap addresses would go stale on rewrite).
712    if is_vlen_string_datatype(&datatype) {
713        emit_vlen_string_dataset(db, ds, path, &datatype, &dims, &layout, &pipeline)?;
714        // VL-string datasets carry attributes the same way as any other.
715        copy_dataset_attrs(db, ds, path, drop, addr_map)?;
716        return Ok(());
717    }
718
719    // Non-string variable-length (sequence) datasets take the same global-heap
720    // re-staging path as VL strings: each element's exact heap bytes are read and
721    // re-emitted through a fresh global heap, so the stored heap addresses are
722    // rebuilt rather than copied stale. Routed here before the verbatim chunk-copy
723    // path so a chunked one is refused (not copied with stale references).
724    if is_nonstring_vlen(&datatype) {
725        emit_vlen_sequence_dataset(db, ds, path, &datatype, &dims, &layout, &pipeline)?;
726        copy_dataset_attrs(db, ds, path, drop, addr_map)?;
727        return Ok(());
728    }
729
730    // Object-reference datasets store absolute object-header addresses that would
731    // go stale on rewrite, so each reference is resolved to its target's *new*
732    // address (via the source address->path map and the writer's path resolution)
733    // rather than copied. Routed here before the verbatim chunk-copy path so a
734    // chunked one is refused (not copied with stale addresses).
735    if is_object_reference(&datatype) {
736        emit_object_reference_dataset(db, ds, path, &dims, &layout, file, drop, addr_map)?;
737        copy_dataset_attrs(db, ds, path, drop, addr_map)?;
738        return Ok(());
739    }
740
741    // A datatype that merely *contains* a variable-length member or an object
742    // reference — a compound with either, or an array of them — embeds the
743    // address inside each element. Those addresses go stale on rewrite exactly as
744    // a top-level one does, so this dataset is re-staged rather than copied.
745    // Routed before the verbatim chunk-copy path so a chunked one is rebuilt, not
746    // copied with source addresses (issue #201).
747    //
748    // Both kinds are handled together rather than in sequence: a compound can
749    // carry one of each, and rewriting only the first kind found would leave the
750    // other pointing into the source file — reintroducing the very bug this path
751    // exists to fix, and skipping the other kind's refusals with it.
752    if !vlen_slots.is_empty() || !reference_slots.is_empty() {
753        emit_embedded_address_dataset(
754            db,
755            ds,
756            path,
757            &datatype,
758            &dims,
759            &layout,
760            &pipeline,
761            &vlen_slots,
762            &reference_slots,
763            file,
764            drop,
765            addr_map,
766        )?;
767        copy_dataset_attrs(db, ds, path, drop, addr_map)?;
768        return Ok(());
769    }
770
771    // Past the reference-bearing paths: carry the fill value through the
772    // fixed-size storage paths below, both of which reproduce it exactly.
773    db.fill = fill;
774
775    // A chunked dataset with allocated chunks is copied chunk-by-chunk, verbatim:
776    // each compressed chunk is laid into the output without decoding, so any
777    // filter — including lossy ones (float scale-offset, ZFP) and ones this crate
778    // cannot itself apply (SZIP, unknown) — is reproduced byte-exact. This avoids
779    // the decompress→recompress round-trip and the whole-dataset decompression
780    // blowup of the read-raw path. `check_pipeline` is intentionally skipped here:
781    // never decoding makes every filter safe to carry. The datatype check above
782    // still refuses time/variable-length/reference types, whose reproduction or
783    // embedded addresses are unsafe even when copied verbatim.
784    if let DataLayout::Chunked {
785        chunk_dimensions, ..
786    } = &layout
787        && n_elements > 0
788    {
789        let rank = dims.len();
790        let chunk_dims: Vec<u64> = chunk_dimensions.iter().take(rank).copied().collect();
791
792        if let Some(DenseChunkPlan { meta, grid_order }) =
793            try_plan_dense_chunks(source_chunks, &dims, &chunk_dims)
794        {
795            let maxshape = dataspace.extensible_max_dimensions();
796            let elem_size = datatype.element_size_usize()?;
797            // Stream the chunks from the source at write time rather than reading
798            // them all now: the provider holds an `Arc<File>` and fetches one
799            // chunk at a time, so a huge dataset never sits in memory.
800            let provider = DatasetChunkProvider {
801                file: Arc::clone(file),
802                grid_order,
803            };
804            db.with_raw_chunks_lazy(
805                datatype,
806                &dims,
807                maxshape,
808                &chunk_dims,
809                elem_size,
810                ds.filter_pipeline_message_bytes()?,
811                meta,
812                Box::new(provider),
813            );
814
815            // Carry the dataset's attributes, refusing any that cannot be
816            // represented.
817            copy_dataset_attrs(db, ds, path, drop, addr_map)?;
818            return Ok(());
819        }
820
821        // Sparse (holes) chunked dataset: the verbatim path needs a dense grid,
822        // so fall through to the read-raw + re-encode path below. That path
823        // re-encodes, so it is only faithful for lossless filters; a lossy
824        // pipeline on a sparse dataset is refused by `check_pipeline`.
825    }
826
827    // Contiguous/compact, or a sparse chunked dataset: read the decompressed
828    // bytes and re-encode. This path can only reproduce lossless filters, so
829    // refuse a lossy pipeline before reading.
830    let filters = check_pipeline(pipeline.as_ref(), path)?;
831
832    if n_elements == 0 {
833        // An empty dataset owns no element bytes: carry just the datatype and
834        // shape so the reconstructed dataset has the same signature.
835        db.with_dtype(datatype).with_shape(&dims);
836    } else {
837        let raw = ds.read_raw()?;
838        db.with_raw_data(datatype, raw, n_elements)
839            .with_shape(&dims);
840    }
841
842    carry_shape_and_pipeline(
843        db,
844        &dims,
845        dataspace.extensible_max_dimensions(),
846        &layout,
847        &filters,
848    );
849
850    // Carry the dataset's attributes, refusing if any cannot be represented.
851    copy_dataset_attrs(db, ds, path, drop, addr_map)?;
852
853    Ok(())
854}
855
856/// Carry `ds`'s attributes onto `db`, refusing any that cannot be represented.
857///
858/// Every exit from [`emit_dataset`] ends here with the same arguments, and each
859/// one has to: a dataset that reaches the output without its attributes is a
860/// silent loss, not a refusal.
861fn copy_dataset_attrs(
862    db: &mut DatasetBuilder,
863    ds: &Dataset,
864    path: &ObjectPathBuf,
865    drop: &BTreeSet<ObjectPathBuf>,
866    addr_map: &HashMap<u64, ObjectPathBuf>,
867) -> Result<(), Error> {
868    copy_attrs(
869        db,
870        ds.attr_messages()?,
871        || ds.attrs(),
872        &format!("dataset {path}"),
873        drop,
874        addr_map,
875    )
876}
877
878/// Carry the source dataset's resizability, chunk geometry, and filter pipeline
879/// onto the rebuilt dataset, so a repack reproduces the layout it read rather
880/// than flattening it. Shared by the fixed-size re-encode path and the
881/// variable-length re-staging paths, which both rebuild their elements from
882/// scratch and so must reapply the layout themselves.
883///
884/// `filters` are the ones [`check_pipeline`] decoded, which is what makes them
885/// safe to re-apply: there is no way to reach this with a pipeline that was not
886/// proved reproducible first.
887fn carry_shape_and_pipeline(
888    db: &mut DatasetBuilder,
889    dims: &[u64],
890    max_dimensions: Option<&[MaxExtent]>,
891    layout: &DataLayout,
892    filters: &[FilterSpec],
893) {
894    // A max-shape that differs from the current shape means a resizable dataset.
895    if let Some(maxshape) = max_dimensions {
896        db.with_maxshape(maxshape);
897    }
898
899    // Chunking: the v3 layout appends the element size as a trailing chunk
900    // dimension, so keep only the first `rank` entries; v4 already stores `rank`.
901    if let DataLayout::Chunked {
902        chunk_dimensions, ..
903    } = layout
904    {
905        let rank = dims.len();
906        let logical: Vec<u64> = chunk_dimensions.iter().take(rank).copied().collect();
907        db.with_chunks(&logical);
908    }
909
910    // Re-apply the source's filters in the source's own order, each carrying
911    // the optional flag it was stored with (issue #333).
912    //
913    // Both are observable, and neither is necessarily what this crate's own
914    // writer would choose. `fletcher32` between shuffle and deflate checksums
915    // the shuffled bytes; moved to the end it checksums the deflated ones — a
916    // different checksum over different data, and a different thing for a
917    // reader to verify. A filter that loses its optional flag becomes one a
918    // reader must be able to apply, where before it could skip it.
919    //
920    // Pushed rather than set through the `with_*` switches, which name a filter
921    // to apply and place it at this crate's canonical rank: that is right for a
922    // caller composing a pipeline and wrong for reproducing one that exists.
923    for &spec in filters {
924        db.chunk_options.push_filter(spec);
925    }
926}
927
928/// Re-emit a variable-length string dataset faithfully: read each element's
929/// exact heap bytes (preserving null-vs-empty, charset, padding, and the source
930/// VL datatype shape) and re-stage them through the writer's VL-string path,
931/// then reapply the source's chunk geometry, filters, and resizability.
932///
933/// The rebuilt references carry the *new* file's heap addresses. For a chunked
934/// or filtered layout the writer places those collections ahead of the chunk
935/// data so the addresses are known before encoding (issue #109), which is what
936/// makes this path reproduce such a dataset rather than refuse it.
937fn emit_vlen_string_dataset(
938    db: &mut DatasetBuilder,
939    ds: &Dataset,
940    path: &ObjectPathBuf,
941    datatype: &Datatype,
942    dims: &[u64],
943    layout: &DataLayout,
944    pipeline: &Option<FilterPipeline>,
945) -> Result<(), Error> {
946    // A lossy pipeline cannot be reproduced, so refuse it before reading — the
947    // same guard the fixed-size re-encode path applies.
948    let filters = check_pipeline(pipeline.as_ref(), path)?;
949
950    // Read each element's exact heap bytes, preserving the null-vs-empty
951    // distinction. Reading bytes (not the lossily UTF-8-decoded `String`) keeps
952    // embedded NULs and non-UTF-8 payloads byte-exact.
953    let objects = ds.read_vlen_string_bytes(VlenStringReadOptions::default())?;
954    let elements: Vec<VlStringElement> = objects
955        .into_iter()
956        .map(|o| match o {
957            VlByteObject::Null => VlStringElement::Null,
958            VlByteObject::Bytes(bytes) => VlStringElement::Bytes(bytes),
959        })
960        .collect();
961
962    // Re-stage with the exact source datatype, then set the shape. ND datasets
963    // round-trip because the element references are stored row-major, matching
964    // the order `read_vlen_string_bytes` returns.
965    db.with_vlen_string_elements(datatype.clone(), &elements)
966        .map_err(Error::Format)?;
967    db.with_shape(dims);
968    carry_shape_and_pipeline(
969        db,
970        dims,
971        ds.dataspace()?.extensible_max_dimensions(),
972        layout,
973        &filters,
974    );
975    Ok(())
976}
977
978/// Re-emit a non-string variable-length (sequence) dataset faithfully: read each
979/// element's exact heap bytes and re-stage them through a fresh global heap, so
980/// the rewritten file's heap addresses are rebuilt rather than copied stale.
981///
982/// A chunked, filtered, or resizable layout is reproduced rather than refused:
983/// sequences stage through the same global-heap path as VL strings, so they
984/// inherit the early collection placement that makes the heap addresses known
985/// before the chunks are encoded (issue #109).
986fn emit_vlen_sequence_dataset(
987    db: &mut DatasetBuilder,
988    ds: &Dataset,
989    path: &ObjectPathBuf,
990    datatype: &Datatype,
991    dims: &[u64],
992    layout: &DataLayout,
993    pipeline: &Option<FilterPipeline>,
994) -> Result<(), Error> {
995    let filters = check_pipeline(pipeline.as_ref(), path)?;
996
997    // Read each element's exact heap bytes (preserving the null-vs-empty
998    // distinction and any embedded NULs), then re-stage with the source datatype.
999    let (objects, _element_size) = ds.read_vlen_sequence_bytes(VlenStringReadOptions::default())?;
1000    let elements: Vec<VlStringElement> = objects
1001        .into_iter()
1002        .map(|o| match o {
1003            VlByteObject::Null => VlStringElement::Null,
1004            VlByteObject::Bytes(bytes) => VlStringElement::Bytes(bytes),
1005        })
1006        .collect();
1007
1008    db.with_vlen_sequence_elements(datatype.clone(), &elements)
1009        .map_err(Error::Format)?;
1010    db.with_shape(dims);
1011    carry_shape_and_pipeline(
1012        db,
1013        dims,
1014        ds.dataspace()?.extensible_max_dimensions(),
1015        layout,
1016        &filters,
1017    );
1018    Ok(())
1019}
1020
1021/// Re-emit a dataset whose datatype *contains* an address without being one — a
1022/// compound with a variable-length or object-reference member, an array of such
1023/// compounds, or nesting of either (issue #201).
1024///
1025/// The element bytes are read as they stand; each embedded variable-length
1026/// reference is resolved against the source's global heap and its payload
1027/// re-staged into the destination's own, and each embedded object address is
1028/// resolved to the path it names so the writer can fill in the target's *new*
1029/// location. Copying the element bytes verbatim instead would leave every
1030/// address pointing into the source file, which the destination never
1031/// reproduces — a file that reads back plausibly here and not at all in the
1032/// reference C library.
1033///
1034/// Both kinds are handled in one pass because a single compound can carry one of
1035/// each, and they patch disjoint byte ranges of the same buffer. Everything
1036/// outside those ranges is carried byte-for-byte, so the fixed-size members keep
1037/// their exact stored bytes and byte order.
1038#[allow(clippy::too_many_arguments)]
1039fn emit_embedded_address_dataset(
1040    db: &mut DatasetBuilder,
1041    ds: &Dataset,
1042    path: &ObjectPathBuf,
1043    datatype: &Datatype,
1044    dims: &[u64],
1045    layout: &DataLayout,
1046    pipeline: &Option<FilterPipeline>,
1047    vlen_slots: &[EmbeddedVlSlot],
1048    reference_slots: &[usize],
1049    file: &Arc<File>,
1050    drop: &BTreeSet<ObjectPathBuf>,
1051    addr_map: &HashMap<u64, ObjectPathBuf>,
1052) -> Result<(), Error> {
1053    // This path re-encodes, so a lossy filter cannot be reproduced — the same
1054    // guard the other re-staging paths apply.
1055    let filters = check_pipeline(pipeline.as_ref(), path)?;
1056
1057    // An embedded object address is resolved as the elements are re-staged, which
1058    // a compressed chunk would need rewritten in place, so the layout guards that
1059    // protect a top-level object-reference dataset apply here too. They are
1060    // checked before anything is read, and before the variable-length work, so a
1061    // compound carrying both kinds is refused rather than half-rewritten.
1062    if !reference_slots.is_empty() {
1063        check_embedded_reference_layout(ds, path, layout, file)?;
1064    }
1065
1066    let n_elements: u64 = dims.iter().product();
1067
1068    // Read the element bytes once, resolving the variable-length payloads in the
1069    // same pass when there are any.
1070    let (raw, vl_offsets, vl_elements) = if vlen_slots.is_empty() {
1071        let raw = if n_elements == 0 {
1072            Vec::new()
1073        } else {
1074            ds.read_raw()?
1075        };
1076        (raw, Vec::new(), Vec::new())
1077    } else {
1078        let data = ds.read_embedded_vlen_bytes(vlen_slots, VlenStringReadOptions::default())?;
1079        let elements: Vec<VlStringElement> = data
1080            .objects
1081            .into_iter()
1082            .map(|o| match o {
1083                VlByteObject::Null => VlStringElement::Null,
1084                VlByteObject::Bytes(bytes) => VlStringElement::Bytes(bytes),
1085            })
1086            .collect();
1087        (data.raw, data.offsets, elements)
1088    };
1089
1090    // Resolve the object addresses from the bytes as read, before the
1091    // variable-length staging below consumes `raw`. The two kinds occupy disjoint
1092    // slots, so reading one is unaffected by rewriting the other.
1093    let reference_patches =
1094        resolve_embedded_references(&raw, datatype, dims, path, drop, addr_map, reference_slots)?;
1095
1096    if vlen_slots.is_empty() {
1097        db.with_embedded_object_references(datatype.clone(), raw, n_elements, reference_patches);
1098    } else {
1099        db.with_embedded_vlen_elements(
1100            datatype.clone(),
1101            raw,
1102            n_elements,
1103            &vl_offsets,
1104            &vl_elements,
1105        );
1106        if !reference_patches.is_empty() {
1107            db.reference_targets = Some(reference_patches);
1108        }
1109    }
1110    db.with_shape(dims);
1111    carry_shape_and_pipeline(
1112        db,
1113        dims,
1114        ds.dataspace()?.extensible_max_dimensions(),
1115        layout,
1116        &filters,
1117    );
1118    Ok(())
1119}
1120
1121/// Turn every embedded object address in `raw` into the target the writer
1122/// resolves at serialization time.
1123fn resolve_embedded_references(
1124    raw: &[u8],
1125    datatype: &Datatype,
1126    dims: &[u64],
1127    path: &ObjectPathBuf,
1128    drop: &BTreeSet<ObjectPathBuf>,
1129    addr_map: &HashMap<u64, ObjectPathBuf>,
1130    slots: &[usize],
1131) -> Result<Vec<ObjectRefPatch>, Error> {
1132    if slots.is_empty() {
1133        return Ok(Vec::new());
1134    }
1135    let stride = datatype.type_size() as usize;
1136    let n_elements: usize = dims.iter().product::<u64>().to_usize()?;
1137    let needed = n_elements
1138        .checked_mul(stride)
1139        .ok_or(FormatError::OffsetOverflow {
1140            offset: n_elements as u64,
1141            length: stride as u64,
1142        })?;
1143    if raw.len() < needed {
1144        return Err(FormatError::UnexpectedEof {
1145            expected: needed,
1146            available: raw.len(),
1147        }
1148        .into());
1149    }
1150
1151    let mut patches = Vec::with_capacity(n_elements * slots.len());
1152    for e in 0..n_elements {
1153        for &slot in slots {
1154            let at = e * stride + slot;
1155            let v = StoredAddress::new(u64::from_le_bytes(
1156                raw[at..at + 8]
1157                    .try_into()
1158                    .expect("slot offsets leave 8 bytes inside the element"),
1159            ));
1160            patches.push(ObjectRefPatch {
1161                byte_offset: at,
1162                target: resolve_reference_address(v, path, drop, addr_map)?,
1163            });
1164        }
1165    }
1166    Ok(patches)
1167}
1168
1169/// A [`ChunkProvider`] that streams a dense chunked dataset's chunks from the
1170/// source file one at a time during the write, so repack never holds more than a
1171/// single chunk's bytes. Holds an `Arc<File>` (so it owns its source with no
1172/// borrowed lifetime) and the source [`ChunkInfo`] for each grid slot.
1173struct DatasetChunkProvider {
1174    file: Arc<File>,
1175    /// Source chunk descriptors in dense row-major grid order, one per slot.
1176    grid_order: Vec<ChunkInfo>,
1177}
1178
1179impl ChunkProvider for DatasetChunkProvider {
1180    fn chunk_bytes(&self, index: usize, out: &mut Vec<u8>) -> Result<(), FormatError> {
1181        // Read exactly the chunk's compressed bytes at its recorded address, with
1182        // no decode, the same slice the chunked reader consumes. The address is the
1183        // one the chunk index stores, and this source spans the file from byte
1184        // zero, so this adds the base address first. `read_at` fills the whole
1185        // buffer or errors, and the emitter additionally checks the length against
1186        // the planned size, so the layout cannot silently desync from the data.
1187        // Reading straight into the emitter's reused buffer keeps repack at one
1188        // chunk-sized allocation for the whole dataset.
1189        let info = &self.grid_order[index];
1190        let source = self.file.source();
1191        let len = info.chunk_size.to_usize()?;
1192        let at = self.file.base_address().absolute(info.address)?;
1193        // Bounds-check before growing the buffer, the way `Source::read_exact_at`
1194        // does and for its reason: `chunk_size` comes from the source's chunk
1195        // index, so a malformed file could name a 4 GiB chunk and have this zero
1196        // that much memory only for the read to fail EOF anyway.
1197        let end = at
1198            .checked_add(len as u64)
1199            .ok_or(FormatError::OffsetOverflow {
1200                offset: at,
1201                length: len as u64,
1202            })?;
1203        if end > source.len() {
1204            return Err(FormatError::UnexpectedEof {
1205                expected: end.to_usize().unwrap_or(usize::MAX),
1206                available: source.len().to_usize().unwrap_or(usize::MAX),
1207            });
1208        }
1209        let start = out.len();
1210        out.resize(start + len, 0);
1211        source.read_at(at, &mut out[start..])
1212    }
1213}
1214
1215/// A planned dense chunked dataset: per-chunk sizes/masks (enough to lay out the
1216/// destination) plus the source chunk descriptors, both in dense grid order.
1217struct DenseChunkPlan {
1218    meta: Vec<ChunkMeta>,
1219    grid_order: Vec<ChunkInfo>,
1220}
1221
1222/// Plan a chunked dataset's verbatim copy without reading any chunk bytes: if
1223/// every chunk-grid slot is present exactly once (a dense grid), return the
1224/// per-chunk [`ChunkMeta`] (sizes + filter masks) and the source [`ChunkInfo`]
1225/// for each slot, both in dense row-major grid order. Returns `None` when the
1226/// grid has holes (a sparse dataset), so the caller falls back to read-raw.
1227///
1228/// `chunks` is the source's chunk list, which the caller has walked already;
1229/// `dims` is the dataspace shape and `chunk_dims` the logical (rank-only) chunk
1230/// dimensions. The grid has `num_chunks_per_dim[d] = ceil(dims[d]/chunk_dims[d])`
1231/// slots per dimension; a chunk at N-d offset `o` maps to grid coordinate
1232/// `o[d]/chunk_dims[d]` and linear (row-major) index over the grid.
1233fn try_plan_dense_chunks(
1234    chunks: Vec<ChunkInfo>,
1235    dims: &[u64],
1236    chunk_dims: &[u64],
1237) -> Option<DenseChunkPlan> {
1238    // Map the source chunks onto the dense grid via the shared planner (the
1239    // single owner of grid-mapping logic, also used by the in-place editor); a
1240    // sparse grid (holes/duplicates/misalignment) returns `None`.
1241    let grid = crate::chunked_read::plan_dense_grid(chunks, dims, chunk_dims)?;
1242    let grid_order = grid.grid_order;
1243    let meta = grid_order
1244        .iter()
1245        .map(|info| ChunkMeta {
1246            compressed_size: info.chunk_size.get(),
1247            filter_mask: info.filter_mask,
1248        })
1249        .collect();
1250    Some(DenseChunkPlan { meta, grid_order })
1251}
1252
1253/// Whether an attribute's element bytes mean the same thing in another file.
1254///
1255/// An attribute message is otherwise self-contained — name, datatype, dataspace
1256/// and data all travel together — so the only thing that stops a byte-for-byte
1257/// copy is an element that stores a *location* rather than a value: a
1258/// variable-length datum, whose bytes are a global-heap collection address plus
1259/// an index, and a reference, whose bytes are an object-header address. Both
1260/// point into the source file and would dangle in the destination.
1261///
1262/// Nested occurrences count: a compound with a variable-length member, or an
1263/// array of references, embeds the same address inside each element.
1264///
1265/// Every class is named rather than defaulted through a `_` arm, so adding one to
1266/// [`Datatype`] stops the build here and forces the question to be answered. A
1267/// wrong answer in the permissive direction writes a dangling address into the
1268/// copy and returns `Ok`, which is precisely the failure this function exists to
1269/// prevent, so the compiler is the right place to catch a new class rather than
1270/// a default that silently picks a side.
1271fn attr_bytes_are_position_independent(dt: &Datatype) -> bool {
1272    match dt {
1273        Datatype::FixedPoint { .. }
1274        | Datatype::FloatingPoint { .. }
1275        | Datatype::Time { .. }
1276        | Datatype::String { .. }
1277        | Datatype::BitField { .. }
1278        | Datatype::Opaque { .. } => true,
1279        Datatype::Compound { members, .. } => members
1280            .iter()
1281            .all(|m| attr_bytes_are_position_independent(&m.datatype)),
1282        Datatype::Enumeration { base_type, .. } | Datatype::Array { base_type, .. } => {
1283            attr_bytes_are_position_independent(base_type)
1284        }
1285        Datatype::VariableLength { .. } | Datatype::Reference { .. } => false,
1286    }
1287}
1288
1289/// Copy every attribute of `owner` onto `sink`, in name order for a
1290/// deterministic output.
1291///
1292/// An attribute whose bytes are position-independent is copied *verbatim*: the
1293/// source's own datatype, dataspace and element bytes go straight into the
1294/// destination message. That is what keeps a rewrite faithful, because
1295/// [`AttrValue`] is a decoded view and cannot express a big-endian integer, a
1296/// sub-width precision, a variable-length string, a rank above one, or a
1297/// string's padding — so anything routed through it comes out re-encoded in this
1298/// crate's own layout, flattened to rank 1 (issue #241).
1299///
1300/// The rest — variable-length data and references — hold source-file addresses,
1301/// so their bytes cannot travel. Those fall back to `decode`, the decoded
1302/// [`AttrValue`] map, which the writer re-encodes against a heap of the
1303/// destination's own. An attribute that has no representation there either is
1304/// refused rather than dropped.
1305///
1306/// A variable-length *string* takes a third path, because the two above each get
1307/// half of it wrong: its element bytes address the source heap and cannot be
1308/// copied, but its datatype and dataspace say "variable-length UTF-8" and
1309/// "scalar", neither of which [`AttrValue`] can express — so a plain re-encode
1310/// turns `str` into `bytes` for every consumer. Keeping the source's datatype and
1311/// dataspace while restaging only the strings gets both halves. This is the
1312/// common case in practice, not an exotic one: it is what `h5py` writes for
1313/// every plain-string attribute.
1314///
1315/// `decode` is a closure so the decoding pass is paid for only when some
1316/// attribute actually needs it, which for most objects is never.
1317fn copy_attrs<S, F>(
1318    sink: &mut S,
1319    mut messages: Vec<AttributeMessage>,
1320    decode: F,
1321    owner: &str,
1322    drop: &BTreeSet<ObjectPathBuf>,
1323    addr_map: &HashMap<u64, ObjectPathBuf>,
1324) -> Result<(), Error>
1325where
1326    S: AttrSink + ?Sized,
1327    F: FnOnce() -> Result<std::collections::HashMap<String, AttrValue>, Error>,
1328{
1329    // Stable, because equal keys here are not interchangeable: a malformed source
1330    // header carrying one name twice carries two different payloads under it, and
1331    // an unstable sort would pick between them arbitrarily. Header order decides.
1332    messages.sort_by(|a, b| a.name.cmp(&b.name));
1333    // An attribute naming a committed (`H5Tcommit`) datatype is re-pointed at the
1334    // same type in the output, exactly as a dataset's committed element type is.
1335    // The source address is what says *which* committed object, so two attributes
1336    // sharing one type still share one in the output rather than each getting a
1337    // copy of the encoding.
1338    for message in &mut messages {
1339        let DatatypeLocation::Committed(address) = message.datatype_location else {
1340            continue;
1341        };
1342        let name = &message.name;
1343        let type_path = committed_type_path(
1344            address.get(),
1345            &format!("{owner} attribute {name:?}"),
1346            drop,
1347            addr_map,
1348        )?;
1349        message.datatype_location = DatatypeLocation::CommittedPath(type_path);
1350    }
1351    let any_needs_decoding = messages
1352        .iter()
1353        .any(|m| !attr_bytes_are_position_independent(&m.datatype));
1354    let decoded = if any_needs_decoding {
1355        decode()?
1356    } else {
1357        std::collections::HashMap::new()
1358    };
1359
1360    for message in messages {
1361        if attr_bytes_are_position_independent(&message.datatype) {
1362            sink.sink_set_attr_verbatim(message);
1363        } else if let Some(value) = decoded.get(&message.name) {
1364            // A variable-length datatype the decode resolved into strings is
1365            // exactly the case that keeps its datatype and dataspace but restages
1366            // its payload. Gating on what the decode *produced* rather than
1367            // re-classifying the datatype here keeps the two from drifting apart.
1368            match (&message.datatype, value.as_strings()) {
1369                (Datatype::VariableLength { .. }, Some(strings)) => {
1370                    let strings = strings.to_vec();
1371                    sink.sink_set_attr_var_len_verbatim(message, strings);
1372                }
1373                _ => sink.sink_set_attr(&message.name, value.clone()),
1374            }
1375        } else {
1376            let name = &message.name;
1377            return Err(Error::RepackUnsupported(format!(
1378                "{owner}: attribute {name:?} has a datatype that cannot be repacked faithfully yet"
1379            )));
1380        }
1381    }
1382    Ok(())
1383}
1384
1385/// Reject datatypes whose on-disk form this crate cannot re-emit faithfully,
1386/// recursing into compound members, enumeration bases, and array element types so
1387/// a nested occurrence is caught too. Region and non-8-byte object references are
1388/// the remaining refusals (their stored selections/addresses are not yet
1389/// rewritten); 8-byte object references are handled by the reference rewrite path.
1390fn check_datatype(dt: &Datatype, owner: &str) -> Result<(), Error> {
1391    let bad = |what: &str| {
1392        Err(Error::RepackUnsupported(format!(
1393            "{owner}: {what} datatype cannot be repacked faithfully yet"
1394        )))
1395    };
1396    match dt {
1397        // Scalar and opaque-bytes datatypes whose on-disk form `Datatype::serialize`
1398        // reproduces exactly (including the time type's byte order), so reading the
1399        // raw element bytes and re-emitting them is byte-for-byte faithful.
1400        Datatype::FixedPoint { .. }
1401        | Datatype::FloatingPoint { .. }
1402        | Datatype::Time { .. }
1403        | Datatype::String { .. }
1404        | Datatype::BitField { .. }
1405        | Datatype::Opaque { .. } => Ok(()),
1406        // String-shaped variable-length datatypes (`is_string: true`, or the
1407        // MATLAB VLEN-of-1-byte-ASCII-string shape) are reproduced by reading
1408        // each element's exact heap bytes and re-staging them through the
1409        // writer's VL-string path; the layout/filter checks gate chunked ones.
1410        Datatype::VariableLength { .. } if is_vlen_string_datatype(dt) => Ok(()),
1411        // Non-string VL (sequences of arbitrary base types) are re-staged the
1412        // same way, but only when the base type's bytes carry no embedded heap or
1413        // file addresses that a verbatim copy would leave stale.
1414        Datatype::VariableLength { base_type, .. } => check_vlen_base_type(base_type, owner),
1415        // Object references (8-byte object-header addresses) are repacked by
1416        // rewriting each address to its target's new location. Region references
1417        // (which embed a dataspace selection in the global heap) and non-8-byte
1418        // object references are not reproduced yet.
1419        Datatype::Reference {
1420            ref_type: ReferenceType::Object,
1421            size: 8,
1422        } => Ok(()),
1423        Datatype::Reference {
1424            ref_type: ReferenceType::Object,
1425            ..
1426        } => bad("non-8-byte object reference"),
1427        Datatype::Reference {
1428            ref_type: ReferenceType::DatasetRegion,
1429            ..
1430        } => bad("dataset-region reference"),
1431        Datatype::Compound { members, .. } => {
1432            for m in members {
1433                check_datatype(&m.datatype, owner)?;
1434            }
1435            Ok(())
1436        }
1437        Datatype::Enumeration { base_type, .. } => check_datatype(base_type, owner),
1438        Datatype::Array { base_type, .. } => check_datatype(base_type, owner),
1439    }
1440}
1441
1442/// Whether `dt` is a non-string variable-length (sequence) datatype — the kind
1443/// re-emitted by [`emit_vlen_sequence_dataset`]. Excludes the string-shaped VL
1444/// datatypes, which [`emit_vlen_string_dataset`] handles.
1445fn is_nonstring_vlen(dt: &Datatype) -> bool {
1446    matches!(dt, Datatype::VariableLength { .. }) && !is_vlen_string_datatype(dt)
1447}
1448
1449/// A non-string VL sequence is repacked by re-staging each element's exact heap
1450/// bytes verbatim. That is faithful only when the base type's bytes embed no
1451/// addresses that would go stale on rewrite: a nested variable-length type (its
1452/// elements are themselves global-heap references) and a reference (a stale file
1453/// address) are refused, recursing through compound members, array elements, and
1454/// enumeration bases so a nested occurrence is caught too.
1455fn check_vlen_base_type(dt: &Datatype, owner: &str) -> Result<(), Error> {
1456    let bad = |what: &str| {
1457        Err(Error::RepackUnsupported(format!(
1458            "{owner}: variable-length sequence of {what} cannot be repacked faithfully yet"
1459        )))
1460    };
1461    match dt {
1462        Datatype::FixedPoint { .. }
1463        | Datatype::FloatingPoint { .. }
1464        | Datatype::Time { .. }
1465        | Datatype::String { .. }
1466        | Datatype::BitField { .. }
1467        | Datatype::Opaque { .. } => Ok(()),
1468        Datatype::Reference { .. } => bad("references"),
1469        Datatype::VariableLength { .. } => bad("variable-length elements"),
1470        Datatype::Compound { members, .. } => {
1471            for m in members {
1472                check_vlen_base_type(&m.datatype, owner)?;
1473            }
1474            Ok(())
1475        }
1476        Datatype::Enumeration { base_type, .. } => check_vlen_base_type(base_type, owner),
1477        Datatype::Array { base_type, .. } => check_vlen_base_type(base_type, owner),
1478    }
1479}
1480
1481/// Whether `dt` is an object-reference datatype handled by
1482/// [`emit_object_reference_dataset`].
1483fn is_object_reference(dt: &Datatype) -> bool {
1484    matches!(
1485        dt,
1486        Datatype::Reference {
1487            ref_type: ReferenceType::Object,
1488            ..
1489        }
1490    )
1491}
1492
1493/// Returns the link name `name` spells, in the group `owner` describes.
1494///
1495/// # Errors
1496///
1497/// Returns [`Error::RepackUnsupported`] naming `owner` and `name` if `name` is not a component
1498/// of an object path: the empty name, `.`, or a name holding `/`. A source can hold such a
1499/// link, and the object under it has no path in the output.
1500fn link_name_in(owner: &str, name: &str) -> Result<LinkNameBuf, Error> {
1501    LinkNameBuf::new(name).ok_or_else(|| {
1502        Error::RepackUnsupported(format!(
1503            "{owner}: the link {name:?} has a name that is not a component of an object path, \
1504             so the object it identifies has no path in the output"
1505        ))
1506    })
1507}
1508
1509/// Whether `path` is dropped from the output: either listed in `drop`, or nested
1510/// under a dropped group (so its whole subtree is gone).
1511fn is_dropped(path: &ObjectPathBuf, drop: &BTreeSet<ObjectPathBuf>) -> bool {
1512    drop.iter().any(|dropped| path.starts_with(dropped))
1513}
1514
1515/// Returns a map from each source object's header address to its path from the root group, for
1516/// resolving object references. With a zero base address (the case object
1517/// references are repacked for) the stored reference value is exactly this
1518/// header address, so the lookup is direct.
1519fn build_object_address_map(file: &File) -> Result<HashMap<u64, ObjectPathBuf>, Error> {
1520    let mut map = HashMap::new();
1521    let root = file.root();
1522    // The root group can itself be referenced (the writer registers it under its
1523    // own path, the one with no components).
1524    map.insert(root.header_address()?, ObjectPathBuf::root());
1525    collect_addresses(&root, &ObjectPathBuf::root(), &mut map)?;
1526    Ok(map)
1527}
1528
1529/// Recursively record `(header address -> path)` for every dataset, committed
1530/// datatype, and subgroup.
1531fn collect_addresses(
1532    group: &Group,
1533    prefix: &ObjectPathBuf,
1534    map: &mut HashMap<u64, ObjectPathBuf>,
1535) -> Result<(), Error> {
1536    // A link whose name is not a component of an object path leaves its object with no
1537    // path in the output. It is left out of the map here, and `populate` reports it
1538    // where the object would be copied.
1539    for (name, ds) in group.iter_datasets()? {
1540        if let Some(name) = LinkNameBuf::new(&name) {
1541            map.insert(ds.header_address()?, prefix.join(&name));
1542        }
1543    }
1544    // A committed datatype is an object with an address like any other: a dataset
1545    // or attribute naming one is resolved through this map, and an object
1546    // reference may point straight at it.
1547    for name in group.named_datatypes()? {
1548        let (_, address) = group.named_datatype_at(&name)?;
1549        if let Some(name) = LinkNameBuf::new(&name) {
1550            map.insert(address, prefix.join(&name));
1551        }
1552    }
1553    for (name, child) in group.iter_groups()? {
1554        let Some(name) = LinkNameBuf::new(&name) else {
1555            continue;
1556        };
1557        let child_path = prefix.join(&name);
1558        map.insert(child.header_address()?, child_path.clone());
1559        collect_addresses(&child, &child_path, map)?;
1560    }
1561    Ok(())
1562}
1563
1564/// The destination path of the committed datatype at source `address`.
1565///
1566/// Refused by name, rather than resolved to something else, in the two cases
1567/// where naming it would be wrong: the type was dropped from the output, or it
1568/// sits outside the hard-link hierarchy this walk covers (so nothing in the
1569/// output holds it). Both would otherwise surface as a dataset pointing at an
1570/// object that is not there.
1571fn committed_type_path(
1572    address: u64,
1573    user: &str,
1574    drop: &BTreeSet<ObjectPathBuf>,
1575    addr_map: &HashMap<u64, ObjectPathBuf>,
1576) -> Result<ObjectPathBuf, Error> {
1577    let path = addr_map.get(&address).ok_or_else(|| {
1578        Error::RepackUnsupported(format!(
1579            "{user}: names a committed datatype that is not reachable by a hard link in the \
1580             source, so it has no place in the output"
1581        ))
1582    })?;
1583    // `is_dropped` rather than a membership test: dropping a group drops its whole
1584    // subtree, so a committed type inside one leaves the output without ever being
1585    // named in the drop set.
1586    if is_dropped(path, drop) {
1587        return Err(Error::RepackUnsupported(format!(
1588            "{user}: names the committed datatype {:?}, which this repack drops",
1589            path.to_string()
1590        )));
1591    }
1592    Ok(path.clone())
1593}
1594
1595/// Re-emit an object-reference dataset faithfully: rewrite each stored address to
1596/// point at its target's destination location instead of its stale source one.
1597///
1598/// Each reference is read, resolved through `addr_map` to a source path, and
1599/// re-staged as a path target the writer resolves once destination addresses are
1600/// known. Null (address 0) and undefined (`HADDR_UNDEF`) references are carried
1601/// verbatim. Refused by name: chunked/filtered or resizable layouts, a non-zero
1602/// base address, a reference to a dropped object, and a reference whose target is
1603/// not a hard-linked group or dataset in the source (dangling, or a named
1604/// datatype / region target not modelled yet).
1605#[allow(clippy::too_many_arguments)]
1606fn emit_object_reference_dataset(
1607    db: &mut DatasetBuilder,
1608    ds: &Dataset,
1609    path: &ObjectPathBuf,
1610    dims: &[u64],
1611    layout: &DataLayout,
1612    file: &Arc<File>,
1613    drop: &BTreeSet<ObjectPathBuf>,
1614    addr_map: &HashMap<u64, ObjectPathBuf>,
1615) -> Result<(), Error> {
1616    if matches!(layout, DataLayout::Chunked { .. }) {
1617        return Err(Error::RepackUnsupported(format!(
1618            "dataset {path}: chunked or filtered object-reference datasets cannot be repacked \
1619             (their addresses live inside compressed chunks and would need rewriting in place)"
1620        )));
1621    }
1622    if ds.dataspace()?.extensible_max_dimensions().is_some() {
1623        return Err(Error::RepackUnsupported(format!(
1624            "dataset {path}: resizable object-reference datasets cannot be repacked"
1625        )));
1626    }
1627    // Object references store addresses relative to the base address; the rewrite
1628    // path assumes a zero base (the universal case), so a userblock file is
1629    // refused rather than risk a mis-resolved address.
1630    if !file.base_address().is_zero() {
1631        return Err(Error::RepackUnsupported(format!(
1632            "dataset {path}: object references in a file with a non-zero base address (userblock) \
1633             cannot be repacked yet"
1634        )));
1635    }
1636
1637    let n_elements: usize = dims.iter().product::<u64>().to_usize()?;
1638    let targets = if n_elements == 0 {
1639        Vec::new()
1640    } else {
1641        let raw = ds.read_raw()?;
1642        let needed = n_elements
1643            .checked_mul(8)
1644            .ok_or(FormatError::OffsetOverflow {
1645                offset: n_elements as u64,
1646                length: 8,
1647            })?;
1648        if raw.len() < needed {
1649            return Err(FormatError::UnexpectedEof {
1650                expected: needed,
1651                available: raw.len(),
1652            }
1653            .into());
1654        }
1655        let mut targets = Vec::with_capacity(n_elements);
1656        for chunk in raw[..needed].as_chunks::<8>().0 {
1657            let v = StoredAddress::new(u64::from_le_bytes(*chunk));
1658            targets.push(resolve_reference_address(v, path, drop, addr_map)?);
1659        }
1660        targets
1661    };
1662
1663    db.with_object_references(targets);
1664    db.with_shape(dims);
1665    Ok(())
1666}
1667
1668/// The layout guards that protect a dataset carrying an embedded object address.
1669///
1670/// The address is resolved as the elements are re-staged, which a compressed
1671/// chunk would need rewritten in place, and a userblock shifts every address by a
1672/// base this rewrite path does not model. Each is refused by name rather than
1673/// risking a mis-resolved address.
1674fn check_embedded_reference_layout(
1675    ds: &Dataset,
1676    path: &ObjectPathBuf,
1677    layout: &DataLayout,
1678    file: &Arc<File>,
1679) -> Result<(), Error> {
1680    if matches!(layout, DataLayout::Chunked { .. }) {
1681        return Err(Error::RepackUnsupported(format!(
1682            "dataset {path}: chunked or filtered datasets with an object-reference member cannot \
1683             be repacked (their addresses live inside compressed chunks and would need rewriting \
1684             in place)"
1685        )));
1686    }
1687    if ds.dataspace()?.extensible_max_dimensions().is_some() {
1688        return Err(Error::RepackUnsupported(format!(
1689            "dataset {path}: resizable datasets with an object-reference member cannot be repacked"
1690        )));
1691    }
1692    if !file.base_address().is_zero() {
1693        return Err(Error::RepackUnsupported(format!(
1694            "dataset {path}: object references in a file with a non-zero base address (userblock) \
1695             cannot be repacked yet"
1696        )));
1697    }
1698    Ok(())
1699}
1700
1701/// Turns one stored object-reference address into the target the writer resolves
1702/// at serialization time. Null (0) and undefined (`HADDR_UNDEF`) point at nothing
1703/// and are carried verbatim; anything else must name a hard-linked object that
1704/// survives the repack.
1705fn resolve_reference_address(
1706    address: StoredAddress,
1707    path: &ObjectPathBuf,
1708    drop: &BTreeSet<ObjectPathBuf>,
1709    addr_map: &HashMap<u64, ObjectPathBuf>,
1710) -> Result<ObjectRefTarget, Error> {
1711    if address.get() == 0 || address.is_undefined(OBJECT_REFERENCE_WIDTH) {
1712        return Ok(ObjectRefTarget::Raw(address));
1713    }
1714    match addr_map.get(&address.get()) {
1715        Some(target_path) if is_dropped(target_path, drop) => {
1716            Err(Error::RepackUnsupported(format!(
1717                "dataset {path}: object reference to dropped object {:?} cannot be repacked",
1718                target_path.to_string()
1719            )))
1720        }
1721        Some(target_path) => Ok(ObjectRefTarget::Path(target_path.clone())),
1722        None => Err(Error::RepackUnsupported(format!(
1723            "dataset {path}: object reference to address {:#x} resolves to no hard-linked \
1724             object in the source (dangling, or a region target not supported yet)",
1725            address.get()
1726        ))),
1727    }
1728}
1729
1730/// Byte width of the object references this repack resolves.
1731///
1732/// The C library sizes an object reference at `H5R_OBJ_REF_BUF_SIZE`,
1733/// `sizeof(haddr_t)` (`H5Rpublic.h`, HDF5 2.2.0), so the element is eight bytes
1734/// whatever address width the file declares, and [`embedded_reference_slots`]
1735/// locates no other form: an undefined reference has all eight of those bytes
1736/// set.
1737const OBJECT_REFERENCE_WIDTH: u8 = 8;
1738
1739/// Whether the source stores nothing at all: no chunk, no contiguous data
1740/// region.
1741///
1742/// Read from the file's own statement of where its data is rather than from the
1743/// values a read answers with, which cannot tell the two apart — a dataset that
1744/// stores a grid of fill values reads exactly like one that stores nothing.
1745///
1746/// A contiguous dataset says so with the undefined data address — but only once
1747/// external storage is out of the way, since `H5Pset_external` uses that same
1748/// encoding for a dataset whose bytes live in other files. `emit_dataset` refuses
1749/// one above, which is what leaves the address meaning what this reads it as. A
1750/// chunked one
1751/// is answered from `chunks` rather than from its index address, because the two
1752/// can disagree: an index that exists and holds no chunk is still a dataset with
1753/// nothing in it — this crate builds one eagerly for every empty resizable
1754/// dataset — and it is the chunks that a rewrite would materialize. Only the
1755/// chunked arm reads `chunks`, so a layout that has none passes an empty slice.
1756///
1757/// Compact data is inline in the layout message, so it is always allocated;
1758/// virtual layouts are refused by [`check_layout`] before this is asked.
1759fn storage_is_unallocated(layout: &DataLayout, chunks: &[ChunkInfo]) -> bool {
1760    match layout {
1761        DataLayout::Contiguous { address, .. } => address.is_none(),
1762        DataLayout::Chunked { .. } => chunks.is_empty(),
1763        DataLayout::Compact { .. } | DataLayout::Virtual => false,
1764    }
1765}
1766
1767/// Reject data layouts that cannot be read and re-emitted (virtual datasets;
1768/// contiguous/chunked with an undefined address are allowed — they store
1769/// nothing, and [`storage_is_unallocated`] keeps them that way).
1770fn check_layout(layout: &DataLayout, path: &ObjectPathBuf) -> Result<(), Error> {
1771    match layout {
1772        DataLayout::Compact { .. } | DataLayout::Contiguous { .. } | DataLayout::Chunked { .. } => {
1773            Ok(())
1774        }
1775        DataLayout::Virtual => Err(Error::RepackUnsupported(format!(
1776            "dataset {path}: virtual data layout cannot be repacked"
1777        ))),
1778    }
1779}
1780
1781/// Reject any filter that cannot be reproduced **by the re-encoding path**, so a
1782/// filtered dataset is never silently rewritten without its filters.
1783///
1784/// This guards only the two paths that read each dataset's *decompressed* bytes
1785/// and re-apply its filters from scratch: a contiguous/compact filtered dataset,
1786/// and the sparse-chunked fallback. A filter is safe there only when it is
1787/// **lossless** — then the re-encoded chunks decompress to the exact same bytes.
1788/// Deflate, shuffle, fletcher32, LZF, and integer scale-offset qualify. Float D-scale
1789/// scale-offset and ZFP are lossy: re-encoding already-decompressed values is not
1790/// guaranteed idempotent, so reproducing them could silently perturb the data,
1791/// and they are refused. SZIP this crate cannot write at all.
1792///
1793/// The dense chunked path (the common case) copies compressed chunks verbatim
1794/// and never calls this — there every filter is safe because nothing is decoded.
1795fn check_pipeline(
1796    pipeline: Option<&FilterPipeline>,
1797    path: &ObjectPathBuf,
1798) -> Result<Vec<FilterSpec>, Error> {
1799    let Some(p) = pipeline else {
1800        return Ok(Vec::new());
1801    };
1802    // Re-encoding replays the filters through the builder, which refuses lzf and
1803    // deflate on one dataset — they fill the same byte-compressor slot — so a
1804    // foreign pipeline carrying both cannot be reproduced. Refused here, by
1805    // name, rather than left to surface as the builder's combination error.
1806    let has = |id| p.filters.iter().any(|f| f.filter_id == id);
1807    if has(FILTER_LZF) && has(FILTER_DEFLATE) {
1808        return Err(Error::RepackUnsupported(format!(
1809            "dataset {path}: an lzf + deflate pipeline cannot be re-encoded faithfully"
1810        )));
1811    }
1812    p.filters
1813        .iter()
1814        .map(|f| {
1815            let kind = match f.filter_id {
1816                FILTER_SHUFFLE => FilterKind::Shuffle,
1817                FILTER_FLETCHER32 => FilterKind::Fletcher32,
1818                // Client-data[0] is the deflate level; default to 6 if absent.
1819                FILTER_DEFLATE => FilterKind::Deflate(f.client_data.first().copied().unwrap_or(6)),
1820                FILTER_LZF => FilterKind::Lzf,
1821                // Only the lossless integer mode qualifies, re-applied with the
1822                // source's minbits parameter. Its fill availability comes along
1823                // too: the value the filter records is the dataset's own fill
1824                // value, carried separately, so only the availability needs
1825                // saying — and saying it keeps a source that recorded none from
1826                // gaining one, which would re-encode every chunk a code point
1827                // wider.
1828                FILTER_SCALEOFFSET => match scaleoffset::scale_offset_mode(&f.client_data) {
1829                    Some((mode @ ScaleOffset::Integer(_), fill)) => {
1830                        FilterKind::ScaleOffset(mode, fill)
1831                    }
1832                    _ => {
1833                        return Err(Error::RepackUnsupported(format!(
1834                            "dataset {path}: only lossless integer scale-offset can be repacked faithfully"
1835                        )));
1836                    }
1837                },
1838                other => {
1839                    return Err(Error::RepackUnsupported(format!(
1840                        "dataset {path}: filter id {other} cannot be repacked yet"
1841                    )));
1842                }
1843            };
1844            Ok(FilterSpec {
1845                kind,
1846                optional: f.is_optional(),
1847            })
1848        })
1849        .collect()
1850}
1851
1852#[cfg(test)]
1853mod tests {
1854    use rstest::rstest;
1855
1856    use super::*;
1857    use crate::DatatypeByteOrder;
1858    use crate::address::StoredAddress;
1859    use crate::data_layout::{ChunkIndexLayout, ChunkedLayoutFlags};
1860    use crate::filter_pipeline::FilterDescription;
1861    use crate::scaleoffset::{FillAvailability, ScaleOffsetFill};
1862
1863    /// A source's scale-offset fill availability is carried onto the rebuilt
1864    /// dataset, in both directions.
1865    ///
1866    /// It has to be said explicitly, because the rebuilt dataset's fill value
1867    /// says nothing about it: this crate records a defined fill value for every
1868    /// scale-offset dataset it writes, so a source that recorded none would
1869    /// otherwise gain one — re-encoding every chunk a code point wider, to
1870    /// reserve a sentinel for a value the source never treated as fill.
1871    ///
1872    /// Synthetic pipelines rather than a file-level test, for the same reason
1873    /// [`lzf_plus_deflate_pipeline_is_refused`] uses one: no writer left in this
1874    /// crate produces a filter that records no fill value, so the only files
1875    /// carrying one are those written before it and those a C caller set the
1876    /// fill value undefined on.
1877    #[test]
1878    fn a_repacked_scale_offset_filter_keeps_the_sources_fill_availability() {
1879        let ty = crate::scaleoffset::scale_offset_type_from_datatype(&crate::make_i32_type())
1880            .expect("i32 is a scale-offset type");
1881        let carried = |fill: ScaleOffsetFill<'_>| {
1882            let pipeline = Some(FilterPipeline {
1883                version: 2,
1884                filters: vec![FilterDescription {
1885                    filter_id: FILTER_SCALEOFFSET,
1886                    name: None,
1887                    flags: 0,
1888                    client_data: scaleoffset::build_cd_values(
1889                        ScaleOffset::Integer(0),
1890                        ty,
1891                        4,
1892                        16,
1893                        fill,
1894                    )
1895                    .unwrap(),
1896                }],
1897            });
1898            let mut db = DatasetBuilder::new("d");
1899            carry_shape_and_pipeline(
1900                &mut db,
1901                &[16],
1902                None,
1903                &DataLayout::Chunked {
1904                    flags: ChunkedLayoutFlags::NONE,
1905                    // A v3 layout appends the element size, which is what the
1906                    // caller trims back off.
1907                    chunk_dimensions: vec![16, 4],
1908                    index: ChunkIndexLayout::BTreeV1 {
1909                        address: Some(StoredAddress::new(0x1000)),
1910                    },
1911                },
1912                // Routed through `check_pipeline`, as every production caller
1913                // is: it is the step that decodes the availability out of the
1914                // source's parameters.
1915                &check_pipeline(pipeline.as_ref(), &ObjectPathBuf::parse("d"))
1916                    .expect("integer scale-offset repacks"),
1917            );
1918            let [
1919                FilterSpec {
1920                    kind: FilterKind::ScaleOffset(_, fill),
1921                    ..
1922                },
1923            ] = db.chunk_options.filters[..]
1924            else {
1925                panic!(
1926                    "expected one scale-offset filter, got {:?}",
1927                    db.chunk_options.filters
1928                );
1929            };
1930            fill
1931        };
1932
1933        assert_eq!(
1934            carried(ScaleOffsetFill::Undefined),
1935            FillAvailability::Undefined
1936        );
1937        assert_eq!(
1938            carried(ScaleOffsetFill::Defined(None)),
1939            FillAvailability::Defined
1940        );
1941    }
1942
1943    /// A foreign pipeline carrying both lzf and deflate is refused with the
1944    /// repack error naming the pair, not the builder's combination error raised
1945    /// later from deeper down. The builder cannot produce such a file, hence a
1946    /// synthetic pipeline rather than a file-level test.
1947    #[test]
1948    fn lzf_plus_deflate_pipeline_is_refused() {
1949        let pipeline = FilterPipeline {
1950            version: 2,
1951            filters: vec![
1952                FilterDescription {
1953                    filter_id: FILTER_LZF,
1954                    name: Some("lzf".into()),
1955                    flags: 0,
1956                    client_data: vec![],
1957                },
1958                FilterDescription {
1959                    filter_id: FILTER_DEFLATE,
1960                    name: None,
1961                    flags: 0,
1962                    client_data: vec![6],
1963                },
1964            ],
1965        };
1966        let err = check_pipeline(Some(&pipeline), &ObjectPathBuf::parse("d")).unwrap_err();
1967        assert!(
1968            matches!(&err, Error::RepackUnsupported(msg) if msg.contains("lzf + deflate")),
1969            "unexpected error: {err:?}"
1970        );
1971    }
1972
1973    #[test]
1974    fn repack_preserves_big_endian_time_dataset() {
1975        // The reference C library cannot create H5T_TIME, so this round-trips a
1976        // big-endian time dataset through our own writer and reader: repack must
1977        // preserve both the byte order (bf0 bit 0) and the raw element bytes.
1978
1979        let dir = tempfile::tempdir().unwrap();
1980        let dir = dir.path();
1981        let src = dir.join("hdf5_pure_repack_time_src.h5");
1982        let dst = dir.join("hdf5_pure_repack_time_dst.h5");
1983
1984        let dt = Datatype::Time {
1985            size: 4,
1986            byte_order: DatatypeByteOrder::BigEndian,
1987            bit_precision: 32,
1988        };
1989        let raw: Vec<u8> = vec![
1990            0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03,
1991        ];
1992        {
1993            let mut b = FileBuilder::new();
1994            b.create_dataset("t")
1995                .with_raw_data(dt.clone(), raw.clone(), 3)
1996                .with_shape(&[3]);
1997            b.write(&src).unwrap();
1998        }
1999
2000        repack(&src, &dst, &RepackOptions::new()).unwrap();
2001
2002        let f = File::open(&dst).unwrap();
2003        let ds = f.dataset("t").unwrap();
2004        assert_eq!(
2005            ds.datatype().unwrap(),
2006            dt,
2007            "time datatype incl. byte order must survive repack"
2008        );
2009        assert_eq!(
2010            ds.read_raw().unwrap(),
2011            raw,
2012            "time element bytes must be preserved"
2013        );
2014
2015        std::fs::remove_file(&src).ok();
2016        std::fs::remove_file(&dst).ok();
2017    }
2018
2019    /// The dropped path itself, and every path under a dropped group, since dropping a group
2020    /// drops its whole subtree. A partial-name collision is not one of them.
2021    #[rstest]
2022    #[case("lone", true)]
2023    #[case("g/old", true)]
2024    #[case("g/old/child", true)]
2025    #[case("g/old/a/b", true)]
2026    #[case("g", false)]
2027    #[case("g/older", false)]
2028    #[case("lonely", false)]
2029    #[case("other/old", false)]
2030    fn is_dropped_matches_self_and_ancestors(#[case] path: &str, #[case] dropped: bool) {
2031        let drop: BTreeSet<ObjectPathBuf> = ["g/old", "lone"]
2032            .iter()
2033            .map(|path| ObjectPathBuf::parse(path))
2034            .collect();
2035        assert_eq!(is_dropped(&ObjectPathBuf::parse(path), &drop), dropped);
2036    }
2037}
2038
2039/// Repack must carry an attribute across *as it is encoded*, not as
2040/// [`AttrValue`] renders it.
2041///
2042/// These sit at the library level rather than in `tests/` because the encoding
2043/// is the thing under test and only [`Group::attr_messages`] exposes it; a
2044/// public read decodes, and a decode is exactly what erases the difference
2045/// (issue #241).
2046#[cfg(test)]
2047mod attribute_fidelity_tests {
2048    use super::*;
2049    use crate::dataspace::{Dataspace, DataspaceType};
2050    use crate::datatype::StringPadding;
2051    use crate::datatype::layout::FixedPointLayout;
2052    use crate::datatype::{CharacterSet, CompoundMember, ReferenceType};
2053    use crate::{DatatypeByteOrder, File, FileBuilder, RepackOptions};
2054    use std::collections::BTreeMap;
2055
2056    fn i32_type() -> Datatype {
2057        Datatype::FixedPoint {
2058            size: 4,
2059            byte_order: DatatypeByteOrder::LittleEndian,
2060            layout: FixedPointLayout {
2061                signed: true,
2062                bit_offset: 0,
2063                bit_precision: 32,
2064            },
2065        }
2066    }
2067
2068    /// Attribute messages keyed by name, for an object reached by `path`
2069    /// (`""` for the root group).
2070    fn messages_at(path: &str, file: &Path) -> BTreeMap<String, AttributeMessage> {
2071        let f = File::open(file).unwrap();
2072        let messages = if path.is_empty() {
2073            f.root().attr_messages().unwrap()
2074        } else if let Ok(ds) = f.dataset(path) {
2075            ds.attr_messages().unwrap()
2076        } else {
2077            f.group(path).unwrap().attr_messages().unwrap()
2078        };
2079        messages.into_iter().map(|m| (m.name.clone(), m)).collect()
2080    }
2081
2082    /// The core claim, stated as an identity rather than as a list of properties:
2083    /// every attribute whose bytes are position-independent comes out of a repack
2084    /// as the very message that went in.
2085    ///
2086    /// Asserting the whole message covers width, charset, string padding,
2087    /// dataspace kind and rank, and the element bytes together — including the
2088    /// ones no accessor on this crate would notice, since two messages that
2089    /// decode to the same value can still differ on disk. It also covers all
2090    /// three kinds of owner, because the root group, a subgroup and a dataset
2091    /// each reach the writer by a different path.
2092    #[test]
2093    fn a_position_independent_attribute_crosses_a_repack_unchanged() {
2094        let dir = tempfile::tempdir().unwrap();
2095        let (src, dst) = (dir.path().join("src.h5"), dir.path().join("dst.h5"));
2096
2097        let attrs = || {
2098            [
2099                // The width cases. A decode keeps these now (#350), so what
2100                // they pin here is that the copy does too — the byte-order case
2101                // a decode still cannot express is in the test below.
2102                ("i32", AttrValue::I32(-7)),
2103                ("u32", AttrValue::U32(4_294_967_295)),
2104                // Charset and padding: an ASCII string and a UTF-8 one differ only
2105                // in the datatype's charset bit.
2106                ("ascii", AttrValue::AsciiString("m/s".into())),
2107                ("utf8", AttrValue::String("µm".into())),
2108                // A scalar and a one-element array are different dataspaces.
2109                ("one_elem", AttrValue::I64Array(vec![9])),
2110                ("scalar", AttrValue::I64(9)),
2111                ("f64s", AttrValue::F64Array(vec![1.5, -2.5])),
2112            ]
2113        };
2114
2115        let mut b = FileBuilder::new();
2116        for (name, value) in attrs() {
2117            b.set_attr(name, value);
2118        }
2119        let ds = b.create_dataset("data").with_f64_data(&[1.0, 2.0]);
2120        for (name, value) in attrs() {
2121            ds.set_attr(name, value);
2122        }
2123        let mut g = b.create_group("grp");
2124        for (name, value) in attrs() {
2125            g.set_attr(name, value);
2126        }
2127        g.create_dataset("inner").with_i32_data(&[1]);
2128        b.add_group(g.finish());
2129        b.write(&src).unwrap();
2130
2131        repack(&src, &dst, &RepackOptions::new()).unwrap();
2132
2133        for owner in ["", "data", "grp"] {
2134            let before = messages_at(owner, &src);
2135            let after = messages_at(owner, &dst);
2136            assert_eq!(
2137                before.keys().collect::<Vec<_>>(),
2138                after.keys().collect::<Vec<_>>(),
2139                "repack changed which attributes {owner:?} has"
2140            );
2141            for (name, source_message) in &before {
2142                assert_eq!(
2143                    after.get(name),
2144                    Some(source_message),
2145                    "attribute {name:?} on {owner:?} was re-encoded rather than copied"
2146                );
2147            }
2148        }
2149    }
2150
2151    /// The two rows of issue #241's table that the reference C library's own API
2152    /// cannot express, so no crosscheck can state them: a fixed-width string's
2153    /// *padding*, and a Null dataspace.
2154    ///
2155    /// The source here is built through the same verbatim seam the fix
2156    /// introduced, which is the only way this crate can write these encodings —
2157    /// but the file is written, closed and re-read, so what is compared is what
2158    /// two independent parses of two real files made of them, not a struct
2159    /// handed back to itself.
2160    #[test]
2161    fn an_encoding_this_crate_has_no_attr_value_for_still_crosses_a_repack() {
2162        let dir = tempfile::tempdir().unwrap();
2163        let (src, dst) = (dir.path().join("src.h5"), dir.path().join("dst.h5"));
2164
2165        let exotic = [
2166            // Declared width 16 for three bytes of content, null-*terminated* —
2167            // the writer's own fixed-width path hardcodes null-padding, so a
2168            // rebuild from a value changes both.
2169            AttributeMessage {
2170                name: "units".into(),
2171                datatype: Datatype::String {
2172                    size: 16,
2173                    padding: StringPadding::NullTerminate,
2174                    charset: CharacterSet::Ascii,
2175                },
2176                dataspace: Dataspace {
2177                    space_type: DataspaceType::Scalar,
2178                    rank: 0,
2179                    dimensions: vec![],
2180                    max_dimensions: None,
2181                },
2182                raw_data: {
2183                    let mut v = b"m/s".to_vec();
2184                    v.resize(16, 0);
2185                    v
2186                },
2187                datatype_location: crate::shared_message::DatatypeLocation::Inline,
2188            },
2189            // Space-padded, to prove the padding field is carried rather than
2190            // normalized to whichever value happens to be first.
2191            AttributeMessage {
2192                name: "spaced".into(),
2193                datatype: Datatype::String {
2194                    size: 8,
2195                    padding: StringPadding::SpacePad,
2196                    charset: CharacterSet::Utf8,
2197                },
2198                dataspace: Dataspace {
2199                    space_type: DataspaceType::Scalar,
2200                    rank: 0,
2201                    dimensions: vec![],
2202                    max_dimensions: None,
2203                },
2204                raw_data: b"ab      ".to_vec(),
2205                datatype_location: crate::shared_message::DatatypeLocation::Inline,
2206            },
2207            // A Null dataspace holds no elements at all, which is distinct from a
2208            // rank-1 dataspace of length zero — the shape a decode gives it.
2209            AttributeMessage {
2210                name: "nothing".into(),
2211                datatype: i32_type(),
2212                dataspace: Dataspace {
2213                    space_type: DataspaceType::Null,
2214                    rank: 0,
2215                    dimensions: vec![],
2216                    max_dimensions: None,
2217                },
2218                raw_data: vec![],
2219                datatype_location: crate::shared_message::DatatypeLocation::Inline,
2220            },
2221            // Big-endian, which every `AttrValue` integer variant normalizes to
2222            // little-endian: the width survives a decode since #350, the layout
2223            // of the bytes inside it does not.
2224            AttributeMessage {
2225                name: "big_endian".into(),
2226                datatype: Datatype::FixedPoint {
2227                    size: 4,
2228                    byte_order: DatatypeByteOrder::BigEndian,
2229                    layout: FixedPointLayout {
2230                        signed: true,
2231                        bit_offset: 0,
2232                        bit_precision: 32,
2233                    },
2234                },
2235                dataspace: Dataspace {
2236                    space_type: DataspaceType::Scalar,
2237                    rank: 0,
2238                    dimensions: vec![],
2239                    max_dimensions: None,
2240                },
2241                raw_data: (-7i32).to_be_bytes().to_vec(),
2242                datatype_location: crate::shared_message::DatatypeLocation::Inline,
2243            },
2244            // Rank 2, which every `AttrValue` array variant flattens.
2245            AttributeMessage {
2246                name: "grid".into(),
2247                datatype: i32_type(),
2248                dataspace: Dataspace {
2249                    space_type: DataspaceType::Simple,
2250                    rank: 2,
2251                    dimensions: vec![2, 3],
2252                    max_dimensions: None,
2253                },
2254                raw_data: (1i32..=6).flat_map(i32::to_le_bytes).collect(),
2255                datatype_location: crate::shared_message::DatatypeLocation::Inline,
2256            },
2257        ];
2258
2259        let mut b = FileBuilder::new();
2260        for message in &exotic {
2261            b.set_attr_verbatim(message.clone());
2262        }
2263        let ds = b.create_dataset("data").with_f64_data(&[1.0]);
2264        for message in &exotic {
2265            ds.set_attr_verbatim(message.clone());
2266        }
2267        b.write(&src).unwrap();
2268
2269        repack(&src, &dst, &RepackOptions::new()).unwrap();
2270
2271        for owner in ["", "data"] {
2272            let before = messages_at(owner, &src);
2273            let after = messages_at(owner, &dst);
2274            for message in &exotic {
2275                let name = &message.name;
2276                // The source file must really hold what was asked for, or the
2277                // comparison below would only prove repack is self-consistent.
2278                assert_eq!(
2279                    before.get(name),
2280                    Some(message),
2281                    "the source file did not record {name:?} as written"
2282                );
2283                assert_eq!(
2284                    after.get(name),
2285                    Some(message),
2286                    "attribute {name:?} on {owner:?} changed across the repack"
2287                );
2288            }
2289        }
2290        assert_fixture_holds(&src, &exotic);
2291    }
2292
2293    /// `tests/data/pure/exotic_attributes.h5` is the source file of the
2294    /// test above, committed so that `crates/crosscheck/tests/main/repack.rs` can
2295    /// repack it through the public API and read the result with the reference
2296    /// C library. Without that the test proves only that this crate agrees with
2297    /// itself, which a message it encodes wrongly and parses back just as
2298    /// wrongly would satisfy. The seam that writes these encodings is
2299    /// crate-private, so the fixture cannot be built over there.
2300    ///
2301    /// Not pinned byte for byte, since the encodings are what matter: the
2302    /// committed copy has to hold them, and `HDF5_PURE_UPDATE_TEST_DATA` rewrites
2303    /// it.
2304    fn assert_fixture_holds(src: &Path, exotic: &[AttributeMessage]) {
2305        let fixture = crate::test_data::path("pure/exotic_attributes.h5");
2306        if std::env::var_os(crate::test_data::UPDATE).is_some() {
2307            std::fs::create_dir_all(fixture.parent().unwrap()).unwrap();
2308            std::fs::copy(src, &fixture).unwrap();
2309        }
2310        for owner in ["", "data"] {
2311            let messages = messages_at(owner, &fixture);
2312            for message in exotic {
2313                assert_eq!(
2314                    messages.get(&message.name),
2315                    Some(message),
2316                    "{} does not hold {:?} on {owner:?} as this test writes it; \
2317                     rerun with {}=1 to refresh it",
2318                    fixture.display(),
2319                    message.name,
2320                    crate::test_data::UPDATE
2321                );
2322            }
2323        }
2324    }
2325
2326    /// The other half of the rule: an attribute whose bytes are a *location* must
2327    /// not be copied, because the location is in the source file.
2328    ///
2329    /// A variable-length string attribute stores a global-heap collection address,
2330    /// so the repacked message is required to differ in exactly that way — same
2331    /// datatype and dataspace, different raw bytes — while the value it resolves
2332    /// to stays put. Asserting the raw bytes moved is what distinguishes a correct
2333    /// re-encode from a verbatim copy that happens to still resolve because the
2334    /// destination heap landed at the same address.
2335    #[test]
2336    fn an_attribute_addressing_the_heap_is_re_encoded_not_copied() {
2337        let dir = tempfile::tempdir().unwrap();
2338        let (src, dst) = (dir.path().join("src.h5"), dir.path().join("dst.h5"));
2339        let fields: Vec<String> = vec!["x".into(), "y".into(), "velocity".into()];
2340
2341        let mut b = FileBuilder::new();
2342        // Bulk that only the source carries, so the destination's heap cannot
2343        // land at the source's address by coincidence.
2344        b.create_dataset("bulk").with_f64_data(&vec![0.0; 4096]);
2345        b.set_attr("fields", AttrValue::VarLenAsciiCharArray(fields.clone()));
2346        b.write(&src).unwrap();
2347
2348        repack(&src, &dst, &RepackOptions::new().drop_path("bulk")).unwrap();
2349
2350        let before = &messages_at("", &src)["fields"];
2351        let after = &messages_at("", &dst)["fields"];
2352        assert_eq!(
2353            after.datatype, before.datatype,
2354            "the attribute must stay variable-length"
2355        );
2356        assert_eq!(after.dataspace, before.dataspace);
2357        assert_ne!(
2358            after.raw_data, before.raw_data,
2359            "a heap reference copied verbatim would point into the source file"
2360        );
2361        assert_eq!(
2362            File::open(&dst).unwrap().root().attrs().unwrap()["fields"],
2363            AttrValue::VarLenAsciiCharArray(fields),
2364            "and it must still resolve to its own strings"
2365        );
2366    }
2367
2368    /// A datatype that merely *contains* a heap reference or an address is
2369    /// position-dependent too — the address sits inside each element rather than
2370    /// being the whole of it, and copying the element copies the address.
2371    #[test]
2372    fn a_nested_address_makes_the_whole_datatype_position_dependent() {
2373        let vlen = Datatype::VariableLength {
2374            is_string: true,
2375            padding: None,
2376            charset: Some(CharacterSet::Ascii),
2377            base_type: Box::new(i32_type()),
2378        };
2379        let reference = Datatype::Reference {
2380            size: 8,
2381            ref_type: ReferenceType::Object,
2382        };
2383        let compound_of = |member: Datatype| Datatype::Compound {
2384            size: 24,
2385            members: vec![
2386                CompoundMember {
2387                    name: "plain".into(),
2388                    byte_offset: 0,
2389                    datatype: i32_type(),
2390                },
2391                CompoundMember {
2392                    name: "nested".into(),
2393                    byte_offset: 8,
2394                    datatype: member,
2395                },
2396            ],
2397        };
2398        let array_of = |base: Datatype| Datatype::Array {
2399            base_type: Box::new(base),
2400            dimensions: vec![2, 3],
2401        };
2402
2403        for dependent in [
2404            vlen.clone(),
2405            reference.clone(),
2406            compound_of(vlen.clone()),
2407            compound_of(reference.clone()),
2408            array_of(vlen.clone()),
2409            array_of(reference.clone()),
2410            // Two levels down, which a non-recursive check would let through.
2411            array_of(compound_of(vlen)),
2412            compound_of(array_of(reference)),
2413        ] {
2414            assert!(
2415                !attr_bytes_are_position_independent(&dependent),
2416                "{dependent:?} holds an address and must not be copied verbatim"
2417            );
2418        }
2419
2420        for independent in [
2421            i32_type(),
2422            Datatype::String {
2423                size: 8,
2424                padding: crate::datatype::StringPadding::NullPad,
2425                charset: CharacterSet::Utf8,
2426            },
2427            compound_of(i32_type()),
2428            array_of(i32_type()),
2429            Datatype::Enumeration {
2430                size: 4,
2431                base_type: Box::new(i32_type()),
2432                members: vec![],
2433            },
2434        ] {
2435            assert!(
2436                attr_bytes_are_position_independent(&independent),
2437                "{independent:?} holds no address and can be copied verbatim"
2438            );
2439        }
2440    }
2441}