feat(tools): h5rs, pure-Rust HDF5 tools (ls, dump, stat, diff, check)

New workspace crate clawhdf5-tools with one binary, h5rs, built only on the
clawhdf5 facade and clawhdf5-format (no libhdf5, no C):

- ls [-r] [-v] FILE[/path]: h5ls's listing (same text in its first two
  columns) plus the datatype; -v adds address, link count, layout and chunk
  index, chunk size, storage, filters, datatype and attributes.
- dump [--json] [-A] [-p] [-d PATH] FILE: h5dump DDL (byte-identical to
  h5dump 1.14.6 on the test files) or hdf5-json.
- stat FILE: h5stat's object/link/rank/layout/filter/attribute counts, raw
  data and total size.
- diff [-r] [-q] [-d D] [-p R] A B [OBJ1 [OBJ2]]: structural and value
  differences, exit 0/1/2 like h5diff.
- check [--data] FILE: walks every object, parses every message, verifies
  the checksums of every v2+ structure (including the fractal heap blocks
  the library never checks), checks chunk indexes against their datasets
  and raw data for out-of-file or overlapping extents; every problem with
  its address.

Values over --max-bytes are reported, not read; dense-storage heaps are
verified before objects are read from them; panics are caught (exit 3).
Tests compare with h5ls, h5stat, h5dump and h5diff and with h5py's values,
and flip the checksum of every checksummed structure in a v1.14-format file.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-09-26 00:29:28 -05:00
co-authored by Claude Opus 5.5
parent bb78d70b99
commit 310448bfcb
18 changed files with 6550 additions and 0 deletions
+703
View File
@@ -0,0 +1,703 @@
//! `h5rs diff`: compare two files (or two objects) like h5diff.
use std::cell::OnceCell;
use std::collections::{BTreeMap, HashMap};
use clawhdf5_format::attribute::AttributeMessage;
use clawhdf5_format::dataspace::{Dataspace, DataspaceType};
use clawhdf5_format::datatype::Datatype;
use clawhdf5_format::object_header::ObjectHeader;
use crate::cli::{Args, Out};
use crate::h5::{H5, Kind, LinkKind};
use crate::value::{self, Decoder, Value};
pub const USAGE: &str = "\
usage: h5rs diff [options] FILE1 FILE2 [OBJ1 [OBJ2]]
Compare FILE1 and FILE2 (or OBJ1 in FILE1 with OBJ2 in FILE2, and everything
below them): the objects present, their kinds, datatypes, shapes, attribute
sets and values, and soft/external link targets.
-r, --report list every differing element (position, values, difference)
-q, --quiet print nothing; only the exit status
-d, --delta D numbers differ only when |a - b| > D
-p, --relative R numbers differ only when |a - b| / |a| > R
-c, --count N list at most N differing elements per object with -r
--max-bytes N largest dataset read (default 1 GiB); a larger one is an error
Two NaNs compare equal. Unlike h5diff, objects that cannot be compared
(different kinds, datatype classes or shapes) count as a difference.
Exit status: 0 no differences, 1 differences found, 2 error (a file or
object could not be opened or read).";
#[derive(Clone, Copy)]
enum Tol {
Exact,
Delta(f64),
Relative(f64),
}
struct Opts {
report: bool,
quiet: bool,
tol: Tol,
count: usize,
}
/// What a relative path names in one file.
#[derive(Clone)]
enum Entry {
Obj(u64, Kind),
Soft(String),
External(String, String),
UserDefined(u8),
Broken(String),
}
struct Side<'a> {
h5: &'a H5,
label: String,
base: String,
/// Relative path -> what it is.
entries: BTreeMap<String, Entry>,
/// Address -> relative path, for comparing references.
rel_of: OnceCell<HashMap<u64, String>>,
}
impl Side<'_> {
fn full(&self, rel: &str) -> String {
if rel.is_empty() {
if self.base.is_empty() {
"/".into()
} else {
self.base.clone()
}
} else {
format!("{}{rel}", self.base)
}
}
fn rel_paths(&self) -> &HashMap<u64, String> {
self.rel_of.get_or_init(|| {
let mut m = HashMap::new();
let _ = self.h5.walk(|it| {
if let (Some(a), None) = (it.addr, it.first_path) {
m.entry(a).or_insert_with(|| it.path.to_string());
}
});
m
})
}
}
struct Diff {
opts: Opts,
diffs: u64,
/// Differences already reported under an object heading.
per_object: u64,
errors: u64,
}
pub fn run(args: &mut Args, out: &mut Out) -> std::io::Result<i32> {
let mut opts = Opts {
report: false,
quiet: false,
tol: Tol::Exact,
count: usize::MAX,
};
let mut max_bytes = None;
let mut pos = Vec::new();
while let Some(a) = args.next() {
match a.as_str() {
"-r" | "--report" => opts.report = true,
"-q" | "--quiet" => opts.quiet = true,
"-d" | "--delta" => match args.number::<f64>() {
Some(d) if d >= 0.0 => opts.tol = Tol::Delta(d),
_ => return args.usage_error(out, "--delta needs a number >= 0", USAGE),
},
"-p" | "--relative" => match args.number::<f64>() {
Some(r) if r >= 0.0 => opts.tol = Tol::Relative(r),
_ => return args.usage_error(out, "--relative needs a number >= 0", USAGE),
},
"-c" | "--count" => match args.number() {
Some(n) => opts.count = n,
None => return args.usage_error(out, "--count needs a number", USAGE),
},
"--max-bytes" => match args.number() {
Some(n) => max_bytes = Some(n),
None => return args.usage_error(out, "--max-bytes needs a number", USAGE),
},
"-h" | "--help" => {
writeln!(out.o, "{USAGE}")?;
return Ok(0);
}
s if s.starts_with('-') && s.len() > 1 => {
return args.usage_error(out, &format!("unknown option {a}"), USAGE);
}
_ => pos.push(a),
}
}
if pos.len() < 2 || pos.len() > 4 {
return args.usage_error(out, "expected FILE1 FILE2 [OBJ1 [OBJ2]]", USAGE);
}
let mut files = Vec::new();
for f in &pos[..2] {
match H5::open(std::path::Path::new(f)) {
Ok(mut h) => {
if let Some(m) = max_bytes {
h.max_bytes = m;
}
files.push(h);
}
Err(e) => {
writeln!(out.e, "h5rs diff: {e}")?;
return Ok(2);
}
}
}
let obj1 = pos.get(2).cloned().unwrap_or_else(|| "/".into());
let obj2 = pos.get(3).cloned().unwrap_or_else(|| obj1.clone());
let mut sides = Vec::new();
for (h5, (obj, f)) in files.iter().zip([(&obj1, &pos[0]), (&obj2, &pos[1])]) {
let addr = match h5.resolve(obj) {
Ok(a) => a,
Err(_) => {
writeln!(
out.e,
"h5rs diff: object <{obj}> could not be found in <{f}>"
)?;
return Ok(2);
}
};
let base = obj.trim_end_matches('/').to_string();
let base = if base.is_empty() || base.starts_with('/') {
base
} else {
format!("/{base}")
};
let mut entries = BTreeMap::new();
let walk = h5.walk_from(addr, "", &mut |it| {
let e = match (it.link.map(|l| &l.kind), it.header) {
(Some(LinkKind::Soft(t)), _) => Entry::Soft(t.clone()),
(Some(LinkKind::External { file, path }), _) => {
Entry::External(file.clone(), path.clone())
}
(Some(LinkKind::UserDefined(t)), _) => Entry::UserDefined(*t),
(_, Some(Err(e))) => Entry::Broken(e.to_string()),
(_, Some(Ok(h))) => Entry::Obj(it.addr.unwrap_or(0), Kind::of(h)),
// A second hard link to an object already compared.
(_, None) => return,
};
entries.insert(it.path.to_string(), e);
});
if let Err(e) = walk {
writeln!(out.e, "h5rs diff: {f}: {e}")?;
return Ok(2);
}
sides.push(Side {
h5,
label: f.clone(),
base,
entries,
rel_of: OnceCell::new(),
});
}
let (a, b) = (&sides[0], &sides[1]);
let mut d = Diff {
opts,
diffs: 0,
per_object: 0,
errors: 0,
};
let mut names: Vec<&String> = a.entries.keys().chain(b.entries.keys()).collect();
names.sort();
names.dedup();
for rel in names {
match (a.entries.get(rel), b.entries.get(rel)) {
(Some(_), None) => {
d.diffs += 1;
d.say(
out,
&format!("<{}> exists only in <{}>", a.full(rel), a.label),
)?;
}
(None, Some(_)) => {
d.diffs += 1;
d.say(
out,
&format!("<{}> exists only in <{}>", b.full(rel), b.label),
)?;
}
(Some(ea), Some(eb)) => d.entry(out, a, b, rel, ea, eb)?,
(None, None) => {}
}
}
// Per-object counts were printed with each object; add a total when
// they do not already tell the whole story.
if !d.opts.quiet && d.diffs > 0 && d.diffs != d.per_object {
writeln!(out.o, "{} difference(s) found in total", d.diffs)?;
}
Ok(if d.errors > 0 {
2
} else if d.diffs > 0 {
1
} else {
0
})
}
fn kind_word(k: Kind) -> &'static str {
match k {
Kind::Group => "group",
Kind::Dataset => "dataset",
Kind::Datatype => "datatype",
Kind::Unknown => "object",
}
}
impl Diff {
fn say(&self, out: &mut Out, msg: &str) -> std::io::Result<()> {
if self.opts.quiet {
Ok(())
} else {
writeln!(out.o, "{msg}")
}
}
fn error(&mut self, out: &mut Out, msg: &str) -> std::io::Result<()> {
self.errors += 1;
writeln!(out.e, "h5rs diff: {msg}")
}
fn entry(
&mut self,
out: &mut Out,
a: &Side,
b: &Side,
rel: &str,
ea: &Entry,
eb: &Entry,
) -> std::io::Result<()> {
let (pa, pb) = (a.full(rel), b.full(rel));
match (ea, eb) {
(Entry::Broken(e), _) => self.error(out, &format!("<{pa}> in <{}>: {e}", a.label)),
(_, Entry::Broken(e)) => self.error(out, &format!("<{pb}> in <{}>: {e}", b.label)),
(Entry::Soft(x), Entry::Soft(y)) => {
if x != y {
self.diffs += 1;
self.say(out, &format!("soft link: <{pa}> -> {x} and <{pb}> -> {y}"))?;
}
Ok(())
}
(Entry::External(f1, p1), Entry::External(f2, p2)) => {
if (f1, p1) != (f2, p2) {
self.diffs += 1;
self.say(
out,
&format!("external link: <{pa}> -> {f1}:{p1} and <{pb}> -> {f2}:{p2}"),
)?;
}
Ok(())
}
(Entry::UserDefined(x), Entry::UserDefined(y)) => {
if x != y {
self.diffs += 1;
self.say(out, &format!("user-defined link: <{pa}> and <{pb}> differ"))?;
}
Ok(())
}
(Entry::Obj(aa, ka), Entry::Obj(ab, kb)) if ka == kb => {
let (ha, hb) = match (a.h5.header(*aa), b.h5.header(*ab)) {
(Ok(x), Ok(y)) => (x, y),
(Err(e), _) | (_, Err(e)) => return self.error(out, &format!("<{pa}>: {e}")),
};
let before = self.diffs;
let mut rows = Vec::new();
match ka {
Kind::Dataset => self.dataset(out, a, b, &pa, &pb, &ha, &hb, &mut rows)?,
Kind::Datatype => match (a.h5.datatype(&ha), b.h5.datatype(&hb)) {
(Ok(x), Ok(y)) => {
if x != y {
self.diffs += 1;
rows.push("datatypes differ".to_string());
}
}
(Err(e), _) | (_, Err(e)) => self.error(out, &format!("<{pa}>: {e}"))?,
},
_ => {}
}
self.attributes(out, a, b, &pa, &ha, &hb, &mut rows)?;
let n = self.diffs - before;
if n > 0 && !self.opts.quiet {
writeln!(out.o, "{}: <{pa}> and <{pb}>", kind_word(*ka))?;
for r in &rows {
writeln!(out.o, "{r}")?;
}
writeln!(out.o, "{n} difference(s) found")?;
self.per_object += n;
}
Ok(())
}
_ => {
self.diffs += 1;
self.say(
out,
&format!(
"Not comparable: <{pa}> is a {} and <{pb}> is a {}",
entry_word(ea),
entry_word(eb)
),
)
}
}
}
#[allow(clippy::too_many_arguments)]
fn dataset(
&mut self,
out: &mut Out,
a: &Side,
b: &Side,
pa: &str,
pb: &str,
ha: &ObjectHeader,
hb: &ObjectHeader,
rows: &mut Vec<String>,
) -> std::io::Result<()> {
let got = (|| -> crate::h5::Result<_> {
Ok((
a.h5.datatype(ha)?,
a.h5.resolved_dataspace(pa, ha)?,
b.h5.datatype(hb)?,
b.h5.resolved_dataspace(pb, hb)?,
))
})();
let (dta, dsa, dtb, dsb) = match got {
Ok(x) => x,
Err(e) => return self.error(out, &format!("<{pa}>: {e}")),
};
if let Some(why) = not_comparable(&dta, &dsa, &dtb, &dsb) {
self.diffs += 1;
rows.push(format!("Not comparable: {why}"));
return Ok(());
}
let raw =
a.h5.read_dataset(pa, &dta, &dsa)
.and_then(|x| Ok((x, b.h5.read_dataset(pb, &dtb, &dsb)?)));
let (ra, rb) = match raw {
Ok(x) => x,
Err(e) => return self.error(out, &format!("<{pa}>: {e}")),
};
self.values(out, a, b, pa, (&dta, &ra), (&dtb, &rb), &dsa, rows)
}
#[allow(clippy::too_many_arguments)]
fn values(
&mut self,
out: &mut Out,
a: &Side,
b: &Side,
what: &str,
(dta, ra): (&Datatype, &[u8]),
(dtb, rb): (&Datatype, &[u8]),
ds: &Dataspace,
rows: &mut Vec<String>,
) -> std::io::Result<()> {
let n = crate::h5::num_elements(ds).unwrap_or(0) as usize;
let (da, db) = (Decoder::new(a.h5), Decoder::new(b.h5));
let dims: Vec<u64> = match ds.space_type {
DataspaceType::Simple => ds.dimensions.clone(),
_ => vec![1],
};
let mut found = 0u64;
let mut header = false;
for i in 0..n {
let va = da.element(dta, ra, i);
let vb = db.element(dtb, rb, i);
if let (Value::Error(e), _) | (_, Value::Error(e)) = (&va, &vb) {
return self.error(out, &format!("<{what}> element {i}: {e}"));
}
if self.equal(a, b, &va, &vb) {
continue;
}
found += 1;
if self.opts.report && (found as usize) <= self.opts.count {
if !header {
header = true;
rows.push(format!(
"{:<24}{:<24}{:<24}{}",
"position", "value 1", "value 2", "difference"
));
rows.push("-".repeat(80));
}
let pos = format!("[ {} ]", index(i as u64, &dims));
let ta = value::text(&va, &|_| None);
let tb = value::text(&vb, &|_| None);
let dif = match (number(&va), number(&vb)) {
(Some(x), Some(y)) => value::fmt_float((x - y).abs(), 64),
_ => String::new(),
};
rows.push(format!("{pos:<24}{ta:<24}{tb:<24}{dif}"));
}
}
self.diffs += found;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn attributes(
&mut self,
out: &mut Out,
a: &Side,
b: &Side,
path: &str,
ha: &ObjectHeader,
hb: &ObjectHeader,
rows: &mut Vec<String>,
) -> std::io::Result<()> {
let (la, lb) = match (a.h5.attributes(ha), b.h5.attributes(hb)) {
(Ok(x), Ok(y)) => (x, y),
(Err(e), _) | (_, Err(e)) => return self.error(out, &format!("<{path}>: {e}")),
};
for e in la.1.iter().chain(lb.1.iter()) {
self.error(out, &format!("<{path}>: attribute: {e}"))?;
}
let ma: BTreeMap<&str, &AttributeMessage> =
la.0.iter().map(|x| (x.name.as_str(), x)).collect();
let mb: BTreeMap<&str, &AttributeMessage> =
lb.0.iter().map(|x| (x.name.as_str(), x)).collect();
let mut names: Vec<&str> = ma.keys().chain(mb.keys()).copied().collect();
names.sort_unstable();
names.dedup();
for n in names {
match (ma.get(n), mb.get(n)) {
(Some(x), Some(y)) => {
if let Some(why) =
not_comparable(&x.datatype, &x.dataspace, &y.datatype, &y.dataspace)
{
self.diffs += 1;
rows.push(format!("attribute \"{n}\": not comparable: {why}"));
continue;
}
for (m, h5) in [(x, a.h5), (y, b.h5)] {
let need =
crate::h5::byte_len(&m.dataspace, &m.datatype).unwrap_or(u64::MAX);
if need > h5.max_bytes || (m.raw_data.len() as u64) < need {
return self.error(
out,
&format!(
"<{path}> attribute \"{n}\": value is truncated or too large"
),
);
}
}
let before = rows.len();
let what = format!("{path}\" attribute \"{n}");
self.values(
out,
a,
b,
&what,
(&x.datatype, &x.raw_data),
(&y.datatype, &y.raw_data),
&x.dataspace,
rows,
)?;
if rows.len() > before {
rows.insert(before, format!("attribute \"{n}\":"));
}
}
(Some(_), None) => {
self.diffs += 1;
rows.push(format!("attribute \"{n}\" exists only in <{}>", a.label));
}
(None, Some(_)) => {
self.diffs += 1;
rows.push(format!("attribute \"{n}\" exists only in <{}>", b.label));
}
(None, None) => {}
}
}
Ok(())
}
fn equal(&self, a: &Side, b: &Side, x: &Value, y: &Value) -> bool {
if let (Some(p), Some(q)) = (number(x), number(y)) {
let exact_ints = matches!((x, y), (Value::Int(_), Value::Int(_)))
|| matches!((x, y), (Value::Enum(..), Value::Enum(..)));
if exact_ints && matches!(self.opts.tol, Tol::Exact) {
return int_of(x) == int_of(y);
}
return close(p, q, self.opts.tol);
}
match (x, y) {
(Value::Str(p), Value::Str(q)) => p == q,
(Value::Bytes(p), Value::Bytes(q)) | (Value::OtherRef(p), Value::OtherRef(q)) => p == q,
(Value::Compound(p), Value::Compound(q)) => {
p.len() == q.len()
&& p.iter()
.zip(q)
.all(|((_, u), (_, v))| self.equal(a, b, u, v))
}
(Value::Array(p), Value::Array(q)) | (Value::Seq(p), Value::Seq(q)) => {
p.len() == q.len() && p.iter().zip(q).all(|(u, v)| self.equal(a, b, u, v))
}
(Value::Ref(None), Value::Ref(None)) => true,
(Value::Ref(Some(p)), Value::Ref(Some(q))) => {
// Addresses mean nothing across files: compare the paths the
// references lead to.
let (pp, qq) = (a.rel_paths().get(p), b.rel_paths().get(q));
pp.is_some() && pp == qq
}
_ => false,
}
}
}
fn int_of(v: &Value) -> Option<i128> {
match v {
Value::Int(i) | Value::Enum(_, i) => Some(*i),
_ => None,
}
}
fn number(v: &Value) -> Option<f64> {
match v {
Value::Int(i) | Value::Enum(_, i) => Some(*i as f64),
Value::Float(f, _) => Some(*f),
_ => None,
}
}
fn close(a: f64, b: f64, tol: Tol) -> bool {
if a.is_nan() || b.is_nan() {
return a.is_nan() && b.is_nan();
}
if a == b {
return true;
}
let d = (a - b).abs();
match tol {
Tol::Exact => false,
Tol::Delta(t) => d <= t,
Tol::Relative(r) => a != 0.0 && d / a.abs() <= r,
}
}
fn entry_word(e: &Entry) -> &'static str {
match e {
Entry::Obj(_, k) => kind_word(*k),
Entry::Soft(_) => "soft link",
Entry::External(..) => "external link",
Entry::UserDefined(_) => "user-defined link",
Entry::Broken(_) => "unreadable object",
}
}
/// Why two datasets/attributes cannot be compared element by element.
fn not_comparable(
dta: &Datatype,
dsa: &Dataspace,
dtb: &Datatype,
dsb: &Dataspace,
) -> Option<String> {
let rank = |d: &Dataspace| match d.space_type {
DataspaceType::Simple => Some(d.dimensions.clone()),
DataspaceType::Scalar => Some(Vec::new()),
DataspaceType::Null => None,
};
let (sa, sb) = (rank(dsa), rank(dsb));
if sa != sb {
let show = |s: &Option<Vec<u64>>| match s {
None => "null".to_string(),
Some(d) if d.is_empty() => "scalar".to_string(),
Some(d) => format!("{d:?}"),
};
return Some(format!("shapes differ: {} and {}", show(&sa), show(&sb)));
}
if !types_comparable(dta, dtb) {
return Some(format!(
"datatypes differ: {} and {}",
crate::dtype::short(dta),
crate::dtype::short(dtb)
));
}
None
}
fn types_comparable(a: &Datatype, b: &Datatype) -> bool {
use crate::dtype::class;
let numeric = |t: &Datatype| matches!(class(t), "integer" | "float");
if numeric(a) && numeric(b) {
return class(a) == class(b);
}
if class(a) != class(b) {
return false;
}
match (a, b) {
(Datatype::Compound { members: ma, .. }, Datatype::Compound { members: mb, .. }) => {
ma.len() == mb.len()
&& ma
.iter()
.zip(mb)
.all(|(x, y)| x.name == y.name && types_comparable(&x.datatype, &y.datatype))
}
(
Datatype::Array {
base_type: x,
dimensions: dx,
},
Datatype::Array {
base_type: y,
dimensions: dy,
},
) => dx == dy && types_comparable(x, y),
(
Datatype::VariableLength { base_type: x, .. },
Datatype::VariableLength { base_type: y, .. },
) => class(a) == "string" || types_comparable(x, y),
(
Datatype::Enumeration { base_type: x, .. },
Datatype::Enumeration { base_type: y, .. },
) => types_comparable(x, y),
_ => true,
}
}
fn index(mut i: u64, dims: &[u64]) -> String {
let mut idx = vec![0u64; dims.len()];
for (k, &d) in dims.iter().enumerate().rev() {
let d = d.max(1);
idx[k] = i % d;
i /= d;
}
idx.iter()
.map(|x| x.to_string())
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tolerances() {
assert!(close(1.0, 1.0, Tol::Exact));
assert!(!close(1.0, 1.001, Tol::Exact));
assert!(close(1.0, 1.001, Tol::Delta(0.01)));
assert!(!close(1.0, 1.1, Tol::Delta(0.01)));
assert!(close(100.0, 101.0, Tol::Relative(0.02)));
assert!(!close(0.0, 1e-9, Tol::Relative(0.5)));
assert!(close(f64::NAN, f64::NAN, Tol::Exact));
assert!(!close(f64::NAN, 1.0, Tol::Delta(1e9)));
}
#[test]
fn positions() {
assert_eq!(index(5, &[3, 4]), "1 1");
assert_eq!(index(0, &[1]), "0");
}
}