feat(format): nested groups, soft/hard/external links and creation order in the writer

FileWriter wrote the root group plus one level of groups, and refused
path-like names. The writer now flattens its builders into a group tree
(writer_tree.rs) before layout:

- A name may be a path ("a/b/x", "/a/b/x" at the root); missing
  intermediate groups are created as h5py does, and GroupBuilder gains
  create_group/add_group so builders nest to any depth. A group added at a
  path that already holds a group is merged into it (require_group);
  any other repeated name, an empty or "." component, or an absolute path
  below the root is an error.
- add_soft_link, add_hard_link and add_external_link on FileWriter,
  FileBuilder and GroupBuilder. Hard-link targets are resolved to objects
  at finish (through other hard links; a missing target, a soft link on the
  way or a cycle of paths is an error). Objects with several hard links get
  an Object Reference Count message so libhdf5 can delete one link without
  freeing the object.
- track_order(true) per group, or as the file default, tracks and indexes
  link creation order: Link Info flags and max order, the order in each
  Link message, and a type-6 creation-order B-tree for dense groups.
- A group's link index is one B-tree leaf; more than 65535 links is an
  error.

Groups are laid out depth-first from the root, datasets group by group,
and untracked groups keep writing datasets, then groups, then other links:
files with one level of groups are byte-identical to before.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 08:33:46 -05:00
co-authored by Claude Opus 5.5
parent 8cbbef3fae
commit d102c06306
11 changed files with 1694 additions and 433 deletions
@@ -528,33 +528,50 @@ fn h5py_reads_all_attributes_next_to_an_empty_string() {
// ---- 6. path-like names ----
#[test]
fn slash_in_a_group_or_dataset_name_is_an_error() {
// Measured: create_group("a/b") wrote one link literally named "a/b",
// which h5py cannot reach ("component not found"). The writer has no
// nested groups, so such names are refused.
fn path_names_create_nested_groups() {
// create_group("a/b") used to write one link literally named "a/b",
// which h5py cannot reach ("component not found"); then such names were
// refused. Now a path creates its missing intermediate groups, as h5py
// does.
let mut fw = FileWriter::new();
let mut g = fw.create_group("a/b");
g.create_dataset("c").with_f64_data(&[1.0]);
fw.add_group(g.finish());
assert!(fw.finish().is_err());
let mut fw = FileWriter::new();
fw.create_dataset("x/y").with_f64_data(&[1.0]);
assert!(fw.finish().is_err());
let mut fw = FileWriter::new();
fw.create_dataset("x/y").with_f64_data(&[2.0]);
fw.create_dataset("/a/b/z").with_f64_data(&[3.0]);
let mut g = fw.create_group("g");
g.create_dataset("x/y").with_f64_data(&[1.0]);
g.create_dataset("x/y").with_f64_data(&[4.0]);
fw.add_group(g.finish());
assert!(fw.finish().is_err());
let bytes = fw.finish().unwrap();
for path in ["a", "a/b", "a/b/c", "a/b/z", "x", "x/y", "g/x", "g/x/y"] {
header_at(&bytes, path);
}
}
for bad in ["", "."] {
#[test]
fn names_that_are_not_valid_link_names_are_errors() {
for bad in ["", ".", "a//b", "a/", "a/./b", "/"] {
let mut fw = FileWriter::new();
fw.create_dataset(bad).with_f64_data(&[1.0]);
assert!(fw.finish().is_err(), "{bad:?}");
}
// An absolute path inside a group, and a name used twice.
let mut fw = FileWriter::new();
let mut g = fw.create_group("g");
g.create_dataset("/x").with_f64_data(&[1.0]);
fw.add_group(g.finish());
assert!(fw.finish().is_err());
let mut fw = FileWriter::new();
fw.create_dataset("x").with_f64_data(&[1.0]);
fw.create_dataset("x").with_f64_data(&[1.0]);
assert!(fw.finish().is_err());
// A dataset in the way of a path.
let mut fw = FileWriter::new();
fw.create_dataset("x").with_f64_data(&[1.0]);
fw.create_dataset("x/y").with_f64_data(&[1.0]);
assert!(fw.finish().is_err());
// One level of groups still works, and '/' stays legal in attribute names.
// '/' stays legal in attribute names.
let mut fw = FileWriter::new();
let mut g = fw.create_group("g");
g.create_dataset("c").with_f64_data(&[1.0]);