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}