Skip to main content

hdf5_pure/
repack.rs

1//! Whole-file repack (issue #21): copy an existing HDF5 file into a fresh,
2//! compact one, optionally dropping objects.
3//!
4//! [`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}