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//! [`EditSession`](crate::EditSession) 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//! - Contiguous/compact **variable-length** datasets (1D and ND): string-shaped
29//! (`is_string: true` and the MATLAB VLEN-of-1-byte-ASCII-string shape) and
30//! non-string sequences over any base type that embeds no addresses. Each
31//! element's exact heap bytes are read and re-staged through a fresh global
32//! heap, preserving charset, padding, the null-vs-empty distinction, embedded
33//! NULs, and non-UTF-8 payloads.
34//! - Contiguous/compact **object-reference** datasets: each stored address is
35//! rewritten to its target object's new location in the compacted file (null
36//! and undefined references are carried verbatim).
37//! - Group hierarchy of arbitrary depth.
38//! - Attributes representable as [`AttrValue`] (numbers, fixed and
39//! variable-length strings and their arrays), on datasets, groups, and root.
40//! - The source file's file-space management strategy (with its page size and
41//! threshold), carried into the compact output as non-persistent — a repacked
42//! file has no free space to persist.
43//!
44//! The verbatim chunk copy never decodes, so it eliminates the
45//! decompress→recompress round-trip and the per-dataset decompression blowup,
46//! and a lossy filter survives byte-exact. Two paths still re-encode and so
47//! require **lossless** filters: a *contiguous/compact* filtered dataset, and a
48//! *sparse* chunked dataset (one with unallocated chunk-grid holes, which the
49//! dense verbatim path cannot lay out). A lossy pipeline on either of those is
50//! refused.
51//!
52//! Refused (named, never dropped silently): chunked, filtered, or resizable
53//! variable-length (string or sequence) and object-reference datasets (their
54//! element references live inside compressed chunks written before the global
55//! heap addresses are known, so they cannot be patched in); region references and
56//! non-8-byte object references; an object reference to a dropped object or to a
57//! target outside the hard-link hierarchy (a dangling, named-datatype, or region
58//! target), and object references in a userblock file (non-zero base address); a
59//! non-string vlen sequence whose base type embeds an address (nested vlen or
60//! reference); virtual and external data layouts; a lossy filter on the
61//! contiguous re-encode or sparse-chunked fallback path; and any attribute whose
62//! datatype the reader cannot decode into an [`AttrValue`] (e.g. an enumeration,
63//! compound, reference, or boolean attribute). An object that cannot be
64//! reproduced fails the repack by name rather than being silently dropped.
65//!
66//! # Memory
67//!
68//! Repack is **out-of-core** (issue [#82]): it opens the source with
69//! [`File::open_streaming`], reading metadata and one working chunk on demand
70//! rather than buffering the whole file, copies each chunked dataset's
71//! compressed chunks verbatim one at a time, and streams the output straight to
72//! the destination. Peak memory is therefore bounded by a single chunk plus the
73//! file's metadata, independent of dataset (or file) size, so a file whose data
74//! exceeds available RAM repacks successfully.
75//!
76//! [#82]: https://github.com/stephenberry/hdf5-pure/issues/82
77
78use std::collections::{BTreeMap, BTreeSet, HashMap};
79use std::path::Path;
80use std::sync::Arc;
81
82use crate::chunked_read::ChunkInfo;
83use crate::chunked_write::{ChunkMeta, ChunkProvider};
84use crate::convert::TryToUsize;
85use crate::data_layout::DataLayout;
86use crate::datatype::{Datatype, ReferenceType};
87use crate::error::{Error, FormatError};
88use crate::filter_pipeline::{
89 FILTER_DEFLATE, FILTER_FLETCHER32, FILTER_SCALEOFFSET, FILTER_SHUFFLE, FilterPipeline,
90};
91use crate::reader::{Dataset, File, Group};
92use crate::scaleoffset::{self, ScaleOffset};
93use crate::source::FileSource;
94use crate::type_builders::{
95 AttrValue, DatasetBuilder, FinishedGroup, GroupBuilder, ObjectRefTarget, VlStringElement,
96};
97use crate::vl_data::{VlByteObject, VlenStringReadOptions, is_vlen_string_datatype};
98use crate::writer::FileBuilder;
99
100/// Options controlling a [`repack`].
101#[derive(Debug, Default, Clone)]
102pub struct RepackOptions {
103 /// Full paths of objects to omit from the output (e.g. `"grp/old"` or
104 /// `"/grp/old"`; leading and trailing slashes are ignored). Dropping a group
105 /// drops its whole subtree. Every listed path must exist in the source, or
106 /// the repack fails — a no-op drop is treated as a mistake rather than
107 /// silently ignored.
108 pub drop: Vec<String>,
109}
110
111impl RepackOptions {
112 /// Options that drop nothing — a pure compaction copy.
113 pub fn new() -> Self {
114 Self::default()
115 }
116
117 /// Add a path to omit from the output. Chainable.
118 pub fn drop_path(mut self, path: &str) -> Self {
119 self.drop.push(path.to_string());
120 self
121 }
122}
123
124/// Repack `src` into a new file at `dst`, applying `options`.
125///
126/// Reads every object of `src` not excluded by [`RepackOptions::drop`] and
127/// writes them into a fresh, compact file at `dst`. On success `dst` is a normal
128/// HDF5 file holding exactly the surviving objects with no dead space.
129///
130/// The fidelity checks run first: every object is validated while the output is
131/// staged, so an [`Error::RepackUnsupported`] (an object that cannot be
132/// reproduced faithfully, or a drop path that does not exist) is reported before
133/// any byte is written to `dst`. Dataset *chunk bytes*, by contrast, are streamed
134/// from `src` to `dst` during the write rather than buffered, so an I/O error
135/// reading the source or writing the destination partway through can leave a
136/// partial `dst` (remove it and retry).
137///
138/// See the [module documentation](self) for the exact fidelity contract.
139pub fn repack<P: AsRef<Path>, Q: AsRef<Path>>(
140 src: P,
141 dst: Q,
142 options: &RepackOptions,
143) -> Result<(), Error> {
144 // Open the source for on-demand streaming reads: metadata and one working
145 // chunk are resident at a time, never the whole file. Shared so each streamed
146 // dataset's chunk provider can pull from the same handle during the write
147 // without an extra open.
148 let file = Arc::new(File::open_streaming(src)?);
149
150 // Normalize the drop set to canonical slash-free paths and remember which
151 // ones actually match, so an unmatched drop can be reported as an error.
152 let drop: BTreeSet<String> = options.drop.iter().map(|p| normalize(p)).collect();
153 let mut matched: BTreeSet<String> = BTreeSet::new();
154
155 let mut builder = FileBuilder::new();
156 // Carry the source's file-space strategy forward. The repacked file is
157 // compact with no free space, so the strategy and its page size/threshold
158 // are preserved but `persist` is reset to false — there is nothing to
159 // persist, and writing persistent free-space blocks is a separate feature.
160 if let Some(info) = file.file_space_info() {
161 builder
162 .with_file_space_strategy(info.strategy, false, info.threshold)
163 .with_file_space_page_size(info.page_size);
164 }
165 // Map every source object's (relative) header address to its path, so an
166 // object-reference dataset can be rewritten to point at the same objects in
167 // the compacted output rather than at their stale source addresses.
168 let addr_map = build_object_address_map(&file)?;
169
170 let root = file.root();
171 populate(
172 &mut builder,
173 &root,
174 "",
175 &drop,
176 &mut matched,
177 &file,
178 &addr_map,
179 )?;
180
181 // Every requested drop must have named a real object.
182 if let Some(missing) = drop.iter().find(|d| !matched.contains(*d)) {
183 return Err(Error::RepackUnsupported(format!(
184 "drop path does not exist in the source: {missing}"
185 )));
186 }
187
188 builder.write(dst)?;
189 Ok(())
190}
191
192/// A destination that group contents can be added to. Implemented for both the
193/// top-level [`FileBuilder`] (the root group) and [`GroupBuilder`] (subgroups)
194/// so one recursive walk handles every level.
195trait GroupSink {
196 fn sink_dataset(&mut self, name: &str) -> &mut DatasetBuilder;
197 fn sink_add_group(&mut self, group: FinishedGroup);
198 fn sink_set_attr(&mut self, name: &str, value: AttrValue);
199}
200
201impl GroupSink for FileBuilder {
202 fn sink_dataset(&mut self, name: &str) -> &mut DatasetBuilder {
203 self.create_dataset(name)
204 }
205 fn sink_add_group(&mut self, group: FinishedGroup) {
206 self.add_group(group);
207 }
208 fn sink_set_attr(&mut self, name: &str, value: AttrValue) {
209 self.set_attr(name, value);
210 }
211}
212
213impl GroupSink for GroupBuilder {
214 fn sink_dataset(&mut self, name: &str) -> &mut DatasetBuilder {
215 self.create_dataset(name)
216 }
217 fn sink_add_group(&mut self, group: FinishedGroup) {
218 self.add_group(group);
219 }
220 fn sink_set_attr(&mut self, name: &str, value: AttrValue) {
221 self.set_attr(name, value);
222 }
223}
224
225/// Copy `src`'s attributes, datasets, and subgroups (recursively) into `sink`,
226/// skipping anything whose path is in `drop`. `path` is the slash-free path of
227/// `src` itself (empty for the root).
228fn populate<S: GroupSink>(
229 sink: &mut S,
230 src: &Group,
231 path: &str,
232 drop: &BTreeSet<String>,
233 matched: &mut BTreeSet<String>,
234 file: &Arc<File>,
235 addr_map: &HashMap<u64, String>,
236) -> Result<(), Error> {
237 // Attributes, in name order for a deterministic output. Refuse if any
238 // attribute on this group cannot be represented (and would be dropped).
239 let attrs = src.attrs()?;
240 let owner = if path.is_empty() {
241 "root group".to_string()
242 } else {
243 format!("group {path}")
244 };
245 check_attr_completeness(&attrs, &src.attr_names()?, &owner)?;
246 for (name, value) in sorted(attrs) {
247 sink.sink_set_attr(&name, value);
248 }
249
250 // Datasets, sorted by name.
251 let mut dataset_names = src.datasets()?;
252 dataset_names.sort();
253 for name in dataset_names {
254 let child_path = join(path, &name);
255 if drop.contains(&child_path) {
256 matched.insert(child_path);
257 continue;
258 }
259 let ds = src.dataset(&name)?;
260 emit_dataset(
261 sink.sink_dataset(&name),
262 &ds,
263 &child_path,
264 file,
265 drop,
266 addr_map,
267 )?;
268 }
269
270 // Subgroups, sorted by name; built depth-first into a FinishedGroup.
271 let mut group_names = src.groups()?;
272 group_names.sort();
273 for name in group_names {
274 let child_path = join(path, &name);
275 if drop.contains(&child_path) {
276 matched.insert(child_path);
277 continue;
278 }
279 let child = src.group(&name)?;
280 let mut gb = GroupBuilder::new(&name);
281 populate(&mut gb, &child, &child_path, drop, matched, file, addr_map)?;
282 sink.sink_add_group(gb.finish());
283 }
284 Ok(())
285}
286
287/// Capture one dataset's full description and stage it on `db`, or fail with a
288/// named [`Error::RepackUnsupported`] if any part cannot be reproduced.
289fn emit_dataset(
290 db: &mut DatasetBuilder,
291 ds: &Dataset,
292 path: &str,
293 file: &Arc<File>,
294 drop: &BTreeSet<String>,
295 addr_map: &HashMap<u64, String>,
296) -> Result<(), Error> {
297 let datatype = ds.datatype()?;
298 let dataspace = ds.dataspace()?;
299 let layout = ds.data_layout()?;
300 let pipeline = ds.filter_pipeline();
301
302 check_datatype(&datatype, path)?;
303 check_layout(&layout, path)?;
304
305 let dims = dataspace.dimensions.clone();
306 let n_elements: u64 = dims.iter().product();
307
308 // Variable-length string datasets take a dedicated path: their element
309 // references point into the global heap, so they are re-emitted by reading
310 // each element's exact heap bytes and re-staging them, not by copying raw
311 // element bytes (whose stored heap addresses would go stale on rewrite).
312 if is_vlen_string_datatype(&datatype) {
313 emit_vlen_string_dataset(db, ds, path, &datatype, &dims, &layout)?;
314 // VL-string datasets carry attributes the same way as any other.
315 let attrs = ds.attrs()?;
316 check_attr_completeness(&attrs, &ds.attr_names()?, &format!("dataset {path}"))?;
317 for (name, value) in sorted(attrs) {
318 db.set_attr(&name, value);
319 }
320 return Ok(());
321 }
322
323 // Non-string variable-length (sequence) datasets take the same global-heap
324 // re-staging path as VL strings: each element's exact heap bytes are read and
325 // re-emitted through a fresh global heap, so the stored heap addresses are
326 // rebuilt rather than copied stale. Routed here before the verbatim chunk-copy
327 // path so a chunked one is refused (not copied with stale references).
328 if is_nonstring_vlen(&datatype) {
329 emit_vlen_sequence_dataset(db, ds, path, &datatype, &dims, &layout)?;
330 let attrs = ds.attrs()?;
331 check_attr_completeness(&attrs, &ds.attr_names()?, &format!("dataset {path}"))?;
332 for (name, value) in sorted(attrs) {
333 db.set_attr(&name, value);
334 }
335 return Ok(());
336 }
337
338 // Object-reference datasets store absolute object-header addresses that would
339 // go stale on rewrite, so each reference is resolved to its target's *new*
340 // address (via the source address->path map and the writer's path resolution)
341 // rather than copied. Routed here before the verbatim chunk-copy path so a
342 // chunked one is refused (not copied with stale addresses).
343 if is_object_reference(&datatype) {
344 emit_object_reference_dataset(db, ds, path, &dims, &layout, file, drop, addr_map)?;
345 let attrs = ds.attrs()?;
346 check_attr_completeness(&attrs, &ds.attr_names()?, &format!("dataset {path}"))?;
347 for (name, value) in sorted(attrs) {
348 db.set_attr(&name, value);
349 }
350 return Ok(());
351 }
352
353 // A chunked dataset with allocated chunks is copied chunk-by-chunk, verbatim:
354 // each compressed chunk is laid into the output without decoding, so any
355 // filter — including lossy ones (float scale-offset, ZFP) and ones this crate
356 // cannot itself apply (SZIP, unknown) — is reproduced byte-exact. This avoids
357 // the decompress→recompress round-trip and the whole-dataset decompression
358 // blowup of the read-raw path. `check_pipeline` is intentionally skipped here:
359 // never decoding makes every filter safe to carry. The datatype check above
360 // still refuses time/variable-length/reference types, whose reproduction or
361 // embedded addresses are unsafe even when copied verbatim.
362 if let DataLayout::Chunked {
363 chunk_dimensions, ..
364 } = &layout
365 && n_elements > 0
366 {
367 let rank = dims.len();
368 let chunk_dims: Vec<u64> = chunk_dimensions
369 .iter()
370 .take(rank)
371 .map(|&c| c as u64)
372 .collect();
373
374 if let Some(DenseChunkPlan { meta, grid_order }) =
375 try_plan_dense_chunks(ds, &dims, &chunk_dims)?
376 {
377 let maxshape = dataspace
378 .max_dimensions
379 .as_ref()
380 .filter(|ms| *ms != &dims)
381 .map(|ms| ms.as_slice());
382 let elem_size = datatype.type_size() as usize;
383 // Stream the chunks from the source at write time rather than reading
384 // them all now: the provider holds an `Arc<File>` and fetches one
385 // chunk at a time, so a huge dataset never sits in memory.
386 let provider = DatasetChunkProvider {
387 file: Arc::clone(file),
388 grid_order,
389 };
390 db.with_raw_chunks_lazy(
391 datatype,
392 &dims,
393 maxshape,
394 &chunk_dims,
395 elem_size,
396 ds.filter_pipeline_message_bytes(),
397 meta,
398 Box::new(provider),
399 );
400
401 // Carry the dataset's attributes, refusing any that cannot be
402 // represented.
403 let attrs = ds.attrs()?;
404 check_attr_completeness(&attrs, &ds.attr_names()?, &format!("dataset {path}"))?;
405 for (name, value) in sorted(attrs) {
406 db.set_attr(&name, value);
407 }
408 return Ok(());
409 }
410
411 // Sparse (holes) chunked dataset: the verbatim path needs a dense grid,
412 // so fall through to the read-raw + re-encode path below. That path
413 // re-encodes, so it is only faithful for lossless filters; a lossy
414 // pipeline on a sparse dataset is refused by `check_pipeline`.
415 }
416
417 // Contiguous/compact, or a sparse chunked dataset: read the decompressed
418 // bytes and re-encode. This path can only reproduce lossless filters, so
419 // refuse a lossy pipeline before reading.
420 check_pipeline(pipeline.as_ref(), path)?;
421
422 if n_elements == 0 {
423 // An empty dataset owns no element bytes: carry just the datatype and
424 // shape so the reconstructed dataset has the same signature.
425 db.with_dtype(datatype).with_shape(&dims);
426 } else {
427 let raw = ds.read_raw()?;
428 db.with_raw_data(datatype, raw, n_elements)
429 .with_shape(&dims);
430 }
431
432 // A max-shape that differs from the current shape means a resizable dataset.
433 if let Some(maxshape) = &dataspace.max_dimensions
434 && maxshape != &dims
435 {
436 db.with_maxshape(maxshape);
437 }
438
439 // Chunking: the v3 layout appends the element size as a trailing chunk
440 // dimension, so keep only the first `rank` entries; v4 already stores `rank`.
441 if let DataLayout::Chunked {
442 chunk_dimensions, ..
443 } = &layout
444 {
445 let rank = dims.len();
446 let logical: Vec<u64> = chunk_dimensions
447 .iter()
448 .take(rank)
449 .map(|&c| c as u64)
450 .collect();
451 db.with_chunks(&logical);
452 }
453
454 // Re-apply supported filters in their stored order. `check_pipeline` has
455 // already rejected anything not in this set, so the match is exhaustive.
456 if let Some(p) = &pipeline {
457 for f in &p.filters {
458 match f.filter_id {
459 FILTER_SHUFFLE => {
460 db.with_shuffle();
461 }
462 FILTER_FLETCHER32 => {
463 db.with_fletcher32();
464 }
465 FILTER_DEFLATE => {
466 // Client-data[0] is the deflate level; default to 6 if absent.
467 db.with_deflate(f.client_data.first().copied().unwrap_or(6));
468 }
469 FILTER_SCALEOFFSET => {
470 // `check_pipeline` guarantees integer (lossless) mode here.
471 // Re-apply with the source's minbits parameter; integer
472 // scale-offset reconstructs the exact element bytes.
473 if let Some(mode @ ScaleOffset::Integer(_)) =
474 scaleoffset::scale_offset_mode(&f.client_data)
475 {
476 db.with_scale_offset(mode);
477 } else {
478 unreachable!("check_pipeline rejected non-integer scale-offset");
479 }
480 }
481 _ => unreachable!("check_pipeline rejected unsupported filters"),
482 }
483 }
484 }
485
486 // Carry the dataset's attributes, refusing if any cannot be represented.
487 let attrs = ds.attrs()?;
488 check_attr_completeness(&attrs, &ds.attr_names()?, &format!("dataset {path}"))?;
489 for (name, value) in sorted(attrs) {
490 db.set_attr(&name, value);
491 }
492
493 Ok(())
494}
495
496/// Re-emit a variable-length string dataset faithfully: read each element's
497/// exact heap bytes (preserving null-vs-empty, charset, padding, and the source
498/// VL datatype shape) and re-stage them through the writer's VL-string path.
499///
500/// Chunked/filtered/resizable VL-string layouts are refused by name: their
501/// element references live inside compressed chunks written before the global
502/// heap addresses are known, so they cannot be patched in.
503fn emit_vlen_string_dataset(
504 db: &mut DatasetBuilder,
505 ds: &Dataset,
506 path: &str,
507 datatype: &Datatype,
508 dims: &[u64],
509 layout: &DataLayout,
510) -> Result<(), Error> {
511 if matches!(layout, DataLayout::Chunked { .. }) {
512 return Err(Error::RepackUnsupported(format!(
513 "dataset {path}: chunked or filtered variable-length string datasets cannot be \
514 repacked (their element references live inside compressed chunks before the global \
515 heap addresses are known)"
516 )));
517 }
518 if let Some(maxshape) = &ds.dataspace()?.max_dimensions
519 && maxshape != dims
520 {
521 return Err(Error::RepackUnsupported(format!(
522 "dataset {path}: resizable variable-length string datasets cannot be repacked"
523 )));
524 }
525
526 // Read each element's exact heap bytes, preserving the null-vs-empty
527 // distinction. Reading bytes (not the lossily UTF-8-decoded `String`) keeps
528 // embedded NULs and non-UTF-8 payloads byte-exact.
529 let objects = ds.read_vlen_string_bytes(VlenStringReadOptions::default())?;
530 let elements: Vec<VlStringElement> = objects
531 .into_iter()
532 .map(|o| match o {
533 VlByteObject::Null => VlStringElement::Null,
534 VlByteObject::Bytes(bytes) => VlStringElement::Bytes(bytes),
535 })
536 .collect();
537
538 // Re-stage with the exact source datatype, then set the shape. ND datasets
539 // round-trip because the element references are stored row-major, matching
540 // the order `read_vlen_string_bytes` returns.
541 db.with_vlen_string_elements(datatype.clone(), &elements)
542 .map_err(Error::Format)?;
543 db.with_shape(dims);
544 Ok(())
545}
546
547/// Re-emit a non-string variable-length (sequence) dataset faithfully: read each
548/// element's exact heap bytes and re-stage them through a fresh global heap, so
549/// the rewritten file's heap addresses are rebuilt rather than copied stale.
550///
551/// Chunked/filtered/resizable layouts are refused by name for the same reason as
552/// VL strings: the element references live inside compressed chunks written
553/// before the global heap addresses are known.
554fn emit_vlen_sequence_dataset(
555 db: &mut DatasetBuilder,
556 ds: &Dataset,
557 path: &str,
558 datatype: &Datatype,
559 dims: &[u64],
560 layout: &DataLayout,
561) -> Result<(), Error> {
562 if matches!(layout, DataLayout::Chunked { .. }) {
563 return Err(Error::RepackUnsupported(format!(
564 "dataset {path}: chunked or filtered non-string variable-length datasets cannot be \
565 repacked (their element references live inside compressed chunks before the global \
566 heap addresses are known)"
567 )));
568 }
569 if let Some(maxshape) = &ds.dataspace()?.max_dimensions
570 && maxshape != dims
571 {
572 return Err(Error::RepackUnsupported(format!(
573 "dataset {path}: resizable non-string variable-length datasets cannot be repacked"
574 )));
575 }
576
577 // Read each element's exact heap bytes (preserving the null-vs-empty
578 // distinction and any embedded NULs), then re-stage with the source datatype.
579 let (objects, _element_size) = ds.read_vlen_sequence_bytes(VlenStringReadOptions::default())?;
580 let elements: Vec<VlStringElement> = objects
581 .into_iter()
582 .map(|o| match o {
583 VlByteObject::Null => VlStringElement::Null,
584 VlByteObject::Bytes(bytes) => VlStringElement::Bytes(bytes),
585 })
586 .collect();
587
588 db.with_vlen_sequence_elements(datatype.clone(), &elements)
589 .map_err(Error::Format)?;
590 db.with_shape(dims);
591 Ok(())
592}
593
594/// A [`ChunkProvider`] that streams a dense chunked dataset's chunks from the
595/// source file one at a time during the write, so repack never holds more than a
596/// single chunk's bytes. Holds an `Arc<File>` (so it owns its source with no
597/// borrowed lifetime) and the source [`ChunkInfo`] for each grid slot.
598struct DatasetChunkProvider {
599 file: Arc<File>,
600 /// Source chunk descriptors in dense row-major grid order, one per slot.
601 grid_order: Vec<ChunkInfo>,
602}
603
604impl ChunkProvider for DatasetChunkProvider {
605 fn chunk_bytes(&self, index: usize) -> Result<Vec<u8>, FormatError> {
606 // Read exactly the chunk's compressed bytes at its recorded address, with
607 // no decode and no `addr_offset` adjustment — the same slice the chunked
608 // reader consumes. `read_exact_at` returns exactly `chunk_size` bytes or
609 // errors, and the emitter additionally checks the length against the
610 // planned size, so the layout cannot silently desync from the data.
611 let info = &self.grid_order[index];
612 self.file
613 .source()
614 .read_exact_at(info.address, info.chunk_size as usize)
615 }
616}
617
618/// A planned dense chunked dataset: per-chunk sizes/masks (enough to lay out the
619/// destination) plus the source chunk descriptors, both in dense grid order.
620struct DenseChunkPlan {
621 meta: Vec<ChunkMeta>,
622 grid_order: Vec<ChunkInfo>,
623}
624
625/// Plan a chunked dataset's verbatim copy without reading any chunk bytes: if
626/// every chunk-grid slot is present exactly once (a dense grid), return the
627/// per-chunk [`ChunkMeta`] (sizes + filter masks) and the source [`ChunkInfo`]
628/// for each slot, both in dense row-major grid order. Returns `Ok(None)` when
629/// the grid has holes (a sparse dataset), so the caller falls back to read-raw.
630///
631/// `dims` is the dataspace shape; `chunk_dims` the logical (rank-only) chunk
632/// dimensions. The grid has `num_chunks_per_dim[d] = ceil(dims[d]/chunk_dims[d])`
633/// slots per dimension; a chunk at N-d offset `o` maps to grid coordinate
634/// `o[d]/chunk_dims[d]` and linear (row-major) index over the grid.
635fn try_plan_dense_chunks(
636 ds: &Dataset,
637 dims: &[u64],
638 chunk_dims: &[u64],
639) -> Result<Option<DenseChunkPlan>, Error> {
640 // Map the source chunks onto the dense grid via the shared planner (the
641 // single owner of grid-mapping logic, also used by the in-place editor); a
642 // sparse grid (holes/duplicates/misalignment) returns `None`.
643 let Some(grid) = crate::chunked_read::plan_dense_grid(ds.raw_chunks()?, dims, chunk_dims)
644 else {
645 return Ok(None);
646 };
647 let grid_order = grid.grid_order;
648 let meta = grid_order
649 .iter()
650 .map(|info| ChunkMeta {
651 compressed_size: u64::from(info.chunk_size),
652 filter_mask: info.filter_mask,
653 })
654 .collect();
655 Ok(Some(DenseChunkPlan { meta, grid_order }))
656}
657
658/// Refuse the repack if `owner` has an attribute the reader cannot represent as
659/// an [`AttrValue`] and would therefore drop. `names` is every attribute on the
660/// object; `decoded` is the subset that read back, keyed by name. Any name not
661/// in `decoded` is an attribute that would be silently lost.
662fn check_attr_completeness(
663 decoded: &std::collections::HashMap<String, AttrValue>,
664 names: &[String],
665 owner: &str,
666) -> Result<(), Error> {
667 for name in names {
668 if !decoded.contains_key(name) {
669 return Err(Error::RepackUnsupported(format!(
670 "{owner}: attribute {name:?} has a datatype that cannot be repacked faithfully yet"
671 )));
672 }
673 }
674 Ok(())
675}
676
677/// Reject datatypes whose on-disk form this crate cannot re-emit faithfully,
678/// recursing into compound members, enumeration bases, and array element types so
679/// a nested occurrence is caught too. Region and non-8-byte object references are
680/// the remaining refusals (their stored selections/addresses are not yet
681/// rewritten); 8-byte object references are handled by the reference rewrite path.
682fn check_datatype(dt: &Datatype, path: &str) -> Result<(), Error> {
683 let bad = |what: &str| {
684 Err(Error::RepackUnsupported(format!(
685 "dataset {path}: {what} datatype cannot be repacked faithfully yet"
686 )))
687 };
688 match dt {
689 // Scalar and opaque-bytes datatypes whose on-disk form `Datatype::serialize`
690 // reproduces exactly (including the time type's byte order), so reading the
691 // raw element bytes and re-emitting them is byte-for-byte faithful.
692 Datatype::FixedPoint { .. }
693 | Datatype::FloatingPoint { .. }
694 | Datatype::Time { .. }
695 | Datatype::String { .. }
696 | Datatype::BitField { .. }
697 | Datatype::Opaque { .. } => Ok(()),
698 // String-shaped variable-length datatypes (`is_string: true`, or the
699 // MATLAB VLEN-of-1-byte-ASCII-string shape) are reproduced by reading
700 // each element's exact heap bytes and re-staging them through the
701 // writer's VL-string path; the layout/filter checks gate chunked ones.
702 Datatype::VariableLength { .. } if is_vlen_string_datatype(dt) => Ok(()),
703 // Non-string VL (sequences of arbitrary base types) are re-staged the
704 // same way, but only when the base type's bytes carry no embedded heap or
705 // file addresses that a verbatim copy would leave stale.
706 Datatype::VariableLength { base_type, .. } => check_vlen_base_type(base_type, path),
707 // Object references (8-byte object-header addresses) are repacked by
708 // rewriting each address to its target's new location. Region references
709 // (which embed a dataspace selection in the global heap) and non-8-byte
710 // object references are not reproduced yet.
711 Datatype::Reference {
712 ref_type: ReferenceType::Object,
713 size: 8,
714 } => Ok(()),
715 Datatype::Reference {
716 ref_type: ReferenceType::Object,
717 ..
718 } => bad("non-8-byte object reference"),
719 Datatype::Reference {
720 ref_type: ReferenceType::DatasetRegion,
721 ..
722 } => bad("dataset-region reference"),
723 Datatype::Compound { members, .. } => {
724 for m in members {
725 check_datatype(&m.datatype, path)?;
726 }
727 Ok(())
728 }
729 Datatype::Enumeration { base_type, .. } => check_datatype(base_type, path),
730 Datatype::Array { base_type, .. } => check_datatype(base_type, path),
731 }
732}
733
734/// Whether `dt` is a non-string variable-length (sequence) datatype — the kind
735/// re-emitted by [`emit_vlen_sequence_dataset`]. Excludes the string-shaped VL
736/// datatypes, which [`emit_vlen_string_dataset`] handles.
737fn is_nonstring_vlen(dt: &Datatype) -> bool {
738 matches!(dt, Datatype::VariableLength { .. }) && !is_vlen_string_datatype(dt)
739}
740
741/// A non-string VL sequence is repacked by re-staging each element's exact heap
742/// bytes verbatim. That is faithful only when the base type's bytes embed no
743/// addresses that would go stale on rewrite: a nested variable-length type (its
744/// elements are themselves global-heap references) and a reference (a stale file
745/// address) are refused, recursing through compound members, array elements, and
746/// enumeration bases so a nested occurrence is caught too.
747fn check_vlen_base_type(dt: &Datatype, path: &str) -> Result<(), Error> {
748 let bad = |what: &str| {
749 Err(Error::RepackUnsupported(format!(
750 "dataset {path}: variable-length sequence of {what} cannot be repacked faithfully yet"
751 )))
752 };
753 match dt {
754 Datatype::FixedPoint { .. }
755 | Datatype::FloatingPoint { .. }
756 | Datatype::Time { .. }
757 | Datatype::String { .. }
758 | Datatype::BitField { .. }
759 | Datatype::Opaque { .. } => Ok(()),
760 Datatype::Reference { .. } => bad("references"),
761 Datatype::VariableLength { .. } => bad("variable-length elements"),
762 Datatype::Compound { members, .. } => {
763 for m in members {
764 check_vlen_base_type(&m.datatype, path)?;
765 }
766 Ok(())
767 }
768 Datatype::Enumeration { base_type, .. } => check_vlen_base_type(base_type, path),
769 Datatype::Array { base_type, .. } => check_vlen_base_type(base_type, path),
770 }
771}
772
773/// Whether `dt` is an object-reference datatype handled by
774/// [`emit_object_reference_dataset`].
775fn is_object_reference(dt: &Datatype) -> bool {
776 matches!(
777 dt,
778 Datatype::Reference {
779 ref_type: ReferenceType::Object,
780 ..
781 }
782 )
783}
784
785/// Whether `path` is dropped from the output: either listed in `drop`, or nested
786/// under a dropped group (so its whole subtree is gone).
787fn is_dropped(path: &str, drop: &BTreeSet<String>) -> bool {
788 if drop.contains(path) {
789 return true;
790 }
791 let mut p = path;
792 while let Some(idx) = p.rfind('/') {
793 p = &p[..idx];
794 if drop.contains(p) {
795 return true;
796 }
797 }
798 false
799}
800
801/// Build a map from each source object's header address to its slash-free path,
802/// for resolving object references. With a zero base address (the case object
803/// references are repacked for) the stored reference value is exactly this
804/// header address, so the lookup is direct.
805fn build_object_address_map(file: &File) -> Result<HashMap<u64, String>, Error> {
806 let mut map = HashMap::new();
807 let root = file.root();
808 // The root group can itself be referenced (the writer registers it under the
809 // empty path).
810 map.insert(root.header_address(), String::new());
811 collect_addresses(&root, "", &mut map)?;
812 Ok(map)
813}
814
815/// Recursively record `(header address -> path)` for every dataset and subgroup.
816fn collect_addresses(
817 group: &Group,
818 prefix: &str,
819 map: &mut HashMap<u64, String>,
820) -> Result<(), Error> {
821 for name in group.datasets()? {
822 let ds = group.dataset(&name)?;
823 map.insert(ds.header_address(), join(prefix, &name));
824 }
825 for name in group.groups()? {
826 let child = group.group(&name)?;
827 let child_path = join(prefix, &name);
828 map.insert(child.header_address(), child_path.clone());
829 collect_addresses(&child, &child_path, map)?;
830 }
831 Ok(())
832}
833
834/// Re-emit an object-reference dataset faithfully: rewrite each stored address to
835/// point at its target's destination location instead of its stale source one.
836///
837/// Each reference is read, resolved through `addr_map` to a source path, and
838/// re-staged as a path target the writer resolves once destination addresses are
839/// known. Null (address 0) and undefined (`HADDR_UNDEF`) references are carried
840/// verbatim. Refused by name: chunked/filtered or resizable layouts, a non-zero
841/// base address, a reference to a dropped object, and a reference whose target is
842/// not a hard-linked group or dataset in the source (dangling, or a named
843/// datatype / region target not modelled yet).
844#[allow(clippy::too_many_arguments)]
845fn emit_object_reference_dataset(
846 db: &mut DatasetBuilder,
847 ds: &Dataset,
848 path: &str,
849 dims: &[u64],
850 layout: &DataLayout,
851 file: &Arc<File>,
852 drop: &BTreeSet<String>,
853 addr_map: &HashMap<u64, String>,
854) -> Result<(), Error> {
855 if matches!(layout, DataLayout::Chunked { .. }) {
856 return Err(Error::RepackUnsupported(format!(
857 "dataset {path}: chunked or filtered object-reference datasets cannot be repacked \
858 (their addresses live inside compressed chunks and would need rewriting in place)"
859 )));
860 }
861 if let Some(maxshape) = &ds.dataspace()?.max_dimensions
862 && maxshape != dims
863 {
864 return Err(Error::RepackUnsupported(format!(
865 "dataset {path}: resizable object-reference datasets cannot be repacked"
866 )));
867 }
868 // Object references store addresses relative to the base address; the rewrite
869 // path assumes a zero base (the universal case), so a userblock file is
870 // refused rather than risk a mis-resolved address.
871 if file.base_address() != 0 {
872 return Err(Error::RepackUnsupported(format!(
873 "dataset {path}: object references in a file with a non-zero base address (userblock) \
874 cannot be repacked yet"
875 )));
876 }
877
878 let n_elements: usize = dims.iter().product::<u64>().to_usize()?;
879 let targets = if n_elements == 0 {
880 Vec::new()
881 } else {
882 let raw = ds.read_raw()?;
883 let needed = n_elements
884 .checked_mul(8)
885 .ok_or(FormatError::OffsetOverflow {
886 offset: n_elements as u64,
887 length: 8,
888 })?;
889 if raw.len() < needed {
890 return Err(FormatError::UnexpectedEof {
891 expected: needed,
892 available: raw.len(),
893 }
894 .into());
895 }
896 let mut targets = Vec::with_capacity(n_elements);
897 for chunk in raw[..needed].chunks_exact(8) {
898 let v = u64::from_le_bytes(chunk.try_into().expect("chunks_exact(8) yields 8 bytes"));
899 // 0 = null, all-ones = HADDR_UNDEF: both point at nothing, carried as-is.
900 if v == 0 || v == u64::MAX {
901 targets.push(ObjectRefTarget::Raw(v));
902 continue;
903 }
904 match addr_map.get(&v) {
905 Some(target_path) if is_dropped(target_path, drop) => {
906 return Err(Error::RepackUnsupported(format!(
907 "dataset {path}: object reference to dropped object {target_path:?} \
908 cannot be repacked"
909 )));
910 }
911 Some(target_path) => targets.push(ObjectRefTarget::Path(target_path.clone())),
912 None => {
913 return Err(Error::RepackUnsupported(format!(
914 "dataset {path}: object reference to address {v:#x} resolves to no \
915 hard-linked object in the source (dangling, or a named-datatype / \
916 region target not supported yet)"
917 )));
918 }
919 }
920 }
921 targets
922 };
923
924 db.with_object_references(targets);
925 db.with_shape(dims);
926 Ok(())
927}
928
929/// Reject data layouts that cannot be read and re-emitted (virtual datasets;
930/// contiguous/chunked with an undefined address are allowed — they are empty).
931fn check_layout(layout: &DataLayout, path: &str) -> Result<(), Error> {
932 match layout {
933 DataLayout::Compact { .. } | DataLayout::Contiguous { .. } | DataLayout::Chunked { .. } => {
934 Ok(())
935 }
936 DataLayout::Virtual { .. } => Err(Error::RepackUnsupported(format!(
937 "dataset {path}: virtual data layout cannot be repacked"
938 ))),
939 }
940}
941
942/// Reject any filter that cannot be reproduced **by the re-encoding path**, so a
943/// filtered dataset is never silently rewritten without its filters.
944///
945/// This guards only the two paths that read each dataset's *decompressed* bytes
946/// and re-apply its filters from scratch: a contiguous/compact filtered dataset,
947/// and the sparse-chunked fallback. A filter is safe there only when it is
948/// **lossless** — then the re-encoded chunks decompress to the exact same bytes.
949/// Deflate, shuffle, fletcher32, and integer scale-offset qualify. Float D-scale
950/// scale-offset and ZFP are lossy: re-encoding already-decompressed values is not
951/// guaranteed idempotent, so reproducing them could silently perturb the data,
952/// and they are refused. SZIP this crate cannot write at all.
953///
954/// The dense chunked path (the common case) copies compressed chunks verbatim
955/// and never calls this — there every filter is safe because nothing is decoded.
956fn check_pipeline(pipeline: Option<&FilterPipeline>, path: &str) -> Result<(), Error> {
957 let Some(p) = pipeline else {
958 return Ok(());
959 };
960 for f in &p.filters {
961 match f.filter_id {
962 FILTER_DEFLATE | FILTER_SHUFFLE | FILTER_FLETCHER32 => {}
963 FILTER_SCALEOFFSET => match scaleoffset::scale_offset_mode(&f.client_data) {
964 Some(ScaleOffset::Integer(_)) => {}
965 _ => {
966 return Err(Error::RepackUnsupported(format!(
967 "dataset {path}: only lossless integer scale-offset with an undefined fill value can be repacked faithfully"
968 )));
969 }
970 },
971 other => {
972 return Err(Error::RepackUnsupported(format!(
973 "dataset {path}: filter id {other} cannot be repacked yet"
974 )));
975 }
976 }
977 }
978 Ok(())
979}
980
981/// Sort a name→value attribute map into a deterministic, ordered list.
982fn sorted(attrs: std::collections::HashMap<String, AttrValue>) -> Vec<(String, AttrValue)> {
983 attrs
984 .into_iter()
985 .collect::<BTreeMap<_, _>>()
986 .into_iter()
987 .collect()
988}
989
990/// Canonicalize a path to slash-free form: split on `/`, drop empty components,
991/// rejoin. `"/a//b/"` and `"a/b"` both become `"a/b"`.
992fn normalize(path: &str) -> String {
993 path.split('/')
994 .filter(|c| !c.is_empty())
995 .collect::<Vec<_>>()
996 .join("/")
997}
998
999/// Join a parent path (slash-free, possibly empty) with a child name.
1000fn join(parent: &str, name: &str) -> String {
1001 if parent.is_empty() {
1002 name.to_string()
1003 } else {
1004 format!("{parent}/{name}")
1005 }
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010 use super::*;
1011
1012 #[test]
1013 fn repack_preserves_big_endian_time_dataset() {
1014 // The reference C library cannot create H5T_TIME, so this round-trips a
1015 // big-endian time dataset through our own writer and reader: repack must
1016 // preserve both the byte order (bf0 bit 0) and the raw element bytes.
1017 use crate::datatype::{Datatype, DatatypeByteOrder};
1018 use crate::reader::File;
1019 use crate::writer::FileBuilder;
1020
1021 let dir = std::env::temp_dir();
1022 let src = dir.join("hdf5_pure_repack_time_src.h5");
1023 let dst = dir.join("hdf5_pure_repack_time_dst.h5");
1024
1025 let dt = Datatype::Time {
1026 size: 4,
1027 byte_order: DatatypeByteOrder::BigEndian,
1028 bit_precision: 32,
1029 };
1030 let raw: Vec<u8> = vec![
1031 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x03,
1032 ];
1033 {
1034 let mut b = FileBuilder::new();
1035 b.create_dataset("t")
1036 .with_raw_data(dt.clone(), raw.clone(), 3)
1037 .with_shape(&[3]);
1038 b.write(&src).unwrap();
1039 }
1040
1041 repack(&src, &dst, &RepackOptions::new()).unwrap();
1042
1043 let f = File::open(&dst).unwrap();
1044 let ds = f.dataset("t").unwrap();
1045 assert_eq!(
1046 ds.datatype().unwrap(),
1047 dt,
1048 "time datatype incl. byte order must survive repack"
1049 );
1050 assert_eq!(
1051 ds.read_raw().unwrap(),
1052 raw,
1053 "time element bytes must be preserved"
1054 );
1055
1056 std::fs::remove_file(&src).ok();
1057 std::fs::remove_file(&dst).ok();
1058 }
1059
1060 #[test]
1061 fn is_dropped_matches_self_and_ancestors() {
1062 let drop: BTreeSet<String> = ["g/old", "lone"].iter().map(|s| s.to_string()).collect();
1063 // The dropped path itself.
1064 assert!(is_dropped("lone", &drop));
1065 assert!(is_dropped("g/old", &drop));
1066 // A descendant of a dropped group is dropped (the whole subtree goes).
1067 assert!(is_dropped("g/old/child", &drop));
1068 assert!(is_dropped("g/old/a/b", &drop));
1069 // Unrelated paths and partial-name collisions are not dropped.
1070 assert!(!is_dropped("g", &drop));
1071 assert!(!is_dropped("g/older", &drop));
1072 assert!(!is_dropped("lonely", &drop));
1073 assert!(!is_dropped("other/old", &drop));
1074 }
1075}