Opening one dataset of a v1 (symbol table) group read every symbol table node and every name of the group to find it: over openUrl, 74 requests and 193 MB to read one 64 KiB dataset of the reviewer's 3000-dataset h5py file (libver earliest) at 1 MiB blocks, 515 requests and 34 MB at 64 KiB. Locally it made a lookup O(entries). `group_v1::find_v1_entry` looks the name up as libhdf5's `H5G__stab_lookup` does: `H5B_find`'s binary search at each B-tree node with `H5G__node_cmp3` (left key < name <= right key, keys being names in the local heap, compared bytewise like strcmp), then the one symbol table node, after the heap's free list is checked as the listing does. The heap's data segment (up to 1 MiB) is hinted, since the keys are read one after another. Path resolution uses it for a v1 group; only when it does not find a hard link of that name (a soft link, or a B-tree out of name order, damaged or hand-made, where libhdf5 would report the name missing) does it read every entry as before. A storage error is returned as is (over the lazy reader, a miss: reading every entry would not get further). The result differs from before only in a group holding two entries of one name, where the B-tree's is now the one found, as in libhdf5. Measured with tests/lazy.rs listing_cost_of_a_given_file, open + read one 64 KiB dataset of the reviewer-like file, passes/requests/bytes, before -> after (open included): earliest, 1 MiB: 7/74/193.6 MB -> 6/5/5.2 MB earliest, 64 KiB: 9/515/34.1 MB -> 8/7/524 KB latest (dense groups, already a name-index lookup): 1 MiB 8/7/6.7 MB -> 7/7/6.7 MB, 64 KiB 9/8/581 KB -> 8/8/581 KB (the previous commit's hints: the name index header with the heap header) New tests, failing before: every child of v1_groups_400.h5 resolves to its listed address reading under 1/8 of the listing's bytes, and missing names are not found; a name moved out of B-tree order is still found (by the fallback); reading one of 2000 datasets lazily at 512-byte blocks takes at most 7 passes and 6 requests (earliest; 529 requests, 333 kB before) and 8 passes, 9 requests (latest). Conformance 600 of 697 (baseline 600), no file's class or detail changed against a run of main. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1098 lines
40 KiB
Rust
1098 lines
40 KiB
Rust
//! V2 group traversal: resolve group children and navigate paths.
|
|
//!
|
|
//! Handles both compact storage (Link messages in object header) and
|
|
//! dense storage (fractal heap + B-tree v2).
|
|
|
|
#[cfg(not(feature = "std"))]
|
|
use alloc::{string::String, vec::Vec};
|
|
|
|
#[cfg(not(feature = "std"))]
|
|
use alloc::collections::BTreeSet;
|
|
#[cfg(feature = "std")]
|
|
use std::collections::BTreeSet;
|
|
|
|
use crate::addr::checked_addr;
|
|
use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records_in, find_btree_v2_records_in};
|
|
use crate::checksum::jenkins_lookup3;
|
|
use crate::error::FormatError;
|
|
use crate::fractal_heap::FractalHeapHeader;
|
|
use crate::group_v1::{self, GroupEntry};
|
|
use crate::link_info::LinkInfoMessage;
|
|
use crate::link_message::{LinkMessage, LinkTarget};
|
|
use crate::message_type::MessageType;
|
|
use crate::object_header::ObjectHeader;
|
|
use crate::storage::Storage;
|
|
use crate::superblock::Superblock;
|
|
use crate::symbol_table::SymbolTableMessage;
|
|
|
|
/// Resolve v2 group entries from an object header.
|
|
///
|
|
/// Handles both compact (Link messages) and dense (fractal heap + B-tree v2) storage.
|
|
pub fn resolve_v2_group_entries(
|
|
file_data: &[u8],
|
|
object_header: &ObjectHeader,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
) -> Result<Vec<GroupEntry>, FormatError> {
|
|
resolve_v2_group_entries_in(file_data, object_header, offset_size, length_size)
|
|
}
|
|
|
|
/// [`resolve_v2_group_entries`] over any [`Storage`].
|
|
pub fn resolve_v2_group_entries_in<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
object_header: &ObjectHeader,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
) -> Result<Vec<GroupEntry>, FormatError> {
|
|
// Look for Link Info message to determine storage type
|
|
let link_info = find_link_info(object_header, offset_size)?;
|
|
|
|
if let Some(fh_addr) = link_info.fractal_heap_address {
|
|
// Dense storage
|
|
resolve_dense_entries(file_data, &link_info, fh_addr, offset_size, length_size)
|
|
} else {
|
|
// Compact storage: links are stored directly as Link messages
|
|
resolve_compact_entries(object_header, offset_size)
|
|
}
|
|
}
|
|
|
|
/// First user-defined link type (HDF5 reserves 2-63; 64 is external).
|
|
const FIRST_USER_DEFINED_LINK_TYPE: u8 = 65;
|
|
|
|
/// Parse a Link message, or `None` for a user-defined link (type 65-255).
|
|
///
|
|
/// A user-defined link's target is only meaningful to the application that
|
|
/// registered its class, so, like libhdf5 without that class, we cannot
|
|
/// follow it. Leaving it out lets the rest of the group be listed and
|
|
/// resolved instead of one such link failing the whole group; reserved
|
|
/// types (2-63) are still an error.
|
|
fn parse_link(data: &[u8], offset_size: u8) -> Result<Option<LinkMessage>, FormatError> {
|
|
match LinkMessage::parse(data, offset_size) {
|
|
Ok(link) => Ok(Some(link)),
|
|
Err(FormatError::InvalidLinkType(t)) if t >= FIRST_USER_DEFINED_LINK_TYPE => Ok(None),
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
/// Extract link entries from Link messages directly in the object header (compact storage).
|
|
fn resolve_compact_entries(
|
|
object_header: &ObjectHeader,
|
|
offset_size: u8,
|
|
) -> Result<Vec<GroupEntry>, FormatError> {
|
|
let mut entries = Vec::new();
|
|
for msg in &object_header.messages {
|
|
if msg.msg_type == MessageType::Link {
|
|
let Some(link) = parse_link(&msg.data, offset_size)? else {
|
|
continue;
|
|
};
|
|
if let LinkTarget::Hard {
|
|
object_header_address,
|
|
} = link.link_target
|
|
{
|
|
entries.push(GroupEntry {
|
|
name: link.name,
|
|
object_header_address,
|
|
cache_type: 0,
|
|
});
|
|
}
|
|
// Skip soft and external links for path resolution
|
|
}
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
/// What a version-2 B-tree header takes with 8-byte offsets and lengths
|
|
/// (22 bytes of fields, the root node's address and record count, and the
|
|
/// checksum), rounded up: hinted before one is read.
|
|
const BTREE_V2_HEADER_HINT_LEN: usize = 64;
|
|
|
|
/// The fractal heap of a dense group. The name index's header and the
|
|
/// heap's root block are read next, whatever the lookup: they are hinted
|
|
/// (see [`Storage::hint`]) so that a storage fetching between attempts
|
|
/// gets them in the same round trip as the heap's header.
|
|
fn dense_heap<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
link_info: &LinkInfoMessage,
|
|
fh_addr: u64,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
) -> Result<FractalHeapHeader, FormatError> {
|
|
if let Some(btree_addr) = link_info.btree_name_index_address {
|
|
file_data.hint(btree_addr, BTREE_V2_HEADER_HINT_LEN);
|
|
}
|
|
let fh =
|
|
FractalHeapHeader::parse_in(file_data, checked_addr(fh_addr)?, offset_size, length_size)?;
|
|
fh.hint_root_block(file_data);
|
|
Ok(fh)
|
|
}
|
|
|
|
/// Visit every link in dense storage (fractal heap + B-tree v2 name index).
|
|
///
|
|
/// With `hint_headers` (a listing, whose children are usually opened
|
|
/// next), the object header of every hard link is hinted (see
|
|
/// [`Storage::hint`]) as soon as the link is read, even after a failure.
|
|
fn for_each_dense_link<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
link_info: &LinkInfoMessage,
|
|
fh_addr: u64,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
hint_headers: bool,
|
|
mut visit: impl FnMut(LinkMessage),
|
|
) -> Result<(), FormatError> {
|
|
let fh = dense_heap(file_data, link_info, fh_addr, offset_size, length_size)?;
|
|
if hint_headers {
|
|
// Every link is read: so is every block of the heap.
|
|
fh.hint_managed_blocks(file_data);
|
|
}
|
|
|
|
// Parse B-tree v2 for name index
|
|
let btree_addr = link_info
|
|
.btree_name_index_address
|
|
.ok_or_else(|| FormatError::PathNotFound(String::from("no B-tree v2 name index")))?;
|
|
let btree_hdr = BTreeV2Header::parse_in(
|
|
file_data,
|
|
checked_addr(btree_addr)?,
|
|
offset_size,
|
|
length_size,
|
|
)?;
|
|
let records = collect_btree_v2_records_in(file_data, &btree_hdr, offset_size, length_size)?;
|
|
|
|
// After the first link that fails, the others are only read, not
|
|
// visited (a touch, see `storage::touch`); that error is returned.
|
|
let mut failed = None;
|
|
for record in &records {
|
|
// For type 5 (name index): hash(4) + heap_id(heap_id_length)
|
|
// For type 6 (creation order): creation_order(8) + heap_id(heap_id_length)
|
|
let id_offset = if btree_hdr.tree_type == 5 {
|
|
4 // skip hash
|
|
} else {
|
|
8 // skip creation_order
|
|
};
|
|
|
|
if record.data.len() < id_offset + fh.heap_id_length as usize {
|
|
continue;
|
|
}
|
|
let id_bytes = &record.data[id_offset..id_offset + fh.heap_id_length as usize];
|
|
|
|
// Read managed object from fractal heap
|
|
let link = fh
|
|
.read_managed_object_in(file_data, id_bytes, offset_size)
|
|
.and_then(|d| parse_link(&d, offset_size));
|
|
if hint_headers
|
|
&& let Ok(Some(LinkMessage {
|
|
link_target:
|
|
LinkTarget::Hard {
|
|
object_header_address,
|
|
},
|
|
..
|
|
})) = &link
|
|
{
|
|
file_data.hint(
|
|
*object_header_address,
|
|
crate::object_header::OBJECT_HEADER_HINT_LEN,
|
|
);
|
|
}
|
|
if failed.is_some() {
|
|
continue;
|
|
}
|
|
match link {
|
|
Ok(Some(link)) => visit(link),
|
|
Ok(None) => {}
|
|
Err(e) => failed = Some(e),
|
|
}
|
|
}
|
|
match failed {
|
|
Some(e) => Err(e),
|
|
None => Ok(()),
|
|
}
|
|
}
|
|
|
|
/// Resolve entries from dense storage (fractal heap + B-tree v2).
|
|
fn resolve_dense_entries<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
link_info: &LinkInfoMessage,
|
|
fh_addr: u64,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
) -> Result<Vec<GroupEntry>, FormatError> {
|
|
let mut entries = Vec::new();
|
|
for_each_dense_link(
|
|
file_data,
|
|
link_info,
|
|
fh_addr,
|
|
offset_size,
|
|
length_size,
|
|
true,
|
|
|link| {
|
|
if let LinkTarget::Hard {
|
|
object_header_address,
|
|
} = link.link_target
|
|
{
|
|
entries.push(GroupEntry {
|
|
name: link.name,
|
|
object_header_address,
|
|
cache_type: 0,
|
|
});
|
|
}
|
|
},
|
|
)?;
|
|
Ok(entries)
|
|
}
|
|
|
|
/// The soft link called `name` in a v1 (symbol table) group, if there is
|
|
/// one. Hard links are what `resolve_group_entries` returns; this is
|
|
/// consulted only when a path component isn't among them.
|
|
fn find_v1_symbolic_link<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
object_header: &ObjectHeader,
|
|
name: &str,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
) -> Result<Option<LinkTarget>, FormatError> {
|
|
let Some(sym_msg) = object_header
|
|
.messages
|
|
.iter()
|
|
.find(|m| m.msg_type == MessageType::SymbolTable)
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
let stm = SymbolTableMessage::parse(&sym_msg.data, offset_size)?;
|
|
group_v1::find_v1_soft_link_in(file_data, &stm, name, offset_size, length_size)
|
|
.map(|target| target.map(|target_path| LinkTarget::Soft { target_path }))
|
|
}
|
|
|
|
/// B-tree v2 record type of a dense group's link name index.
|
|
const LINK_NAME_INDEX: u8 = 5;
|
|
|
|
/// The links called `name` in a v2 group (a valid group has at most one),
|
|
/// in storage order: header message order for a compact group, name index
|
|
/// order for a dense one.
|
|
///
|
|
/// In dense storage the link name index (a v2 B-tree of lookup3 name
|
|
/// hashes, record type 5) is descended to the records with the name's hash,
|
|
/// and only their links are read from the heap — O(log n) instead of every
|
|
/// link. libhdf5 orders records with equal hashes by name; all of them are
|
|
/// read and compared here, so that order does not matter. An index of
|
|
/// another type is scanned in full.
|
|
fn links_named<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
object_header: &ObjectHeader,
|
|
name: &str,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
) -> Result<Vec<LinkMessage>, FormatError> {
|
|
let mut found = Vec::new();
|
|
let link_info = find_link_info(object_header, offset_size)?;
|
|
let Some(fh_addr) = link_info.fractal_heap_address else {
|
|
for msg in &object_header.messages {
|
|
if msg.msg_type == MessageType::Link
|
|
&& let Some(link) = parse_link(&msg.data, offset_size)?
|
|
&& link.name == name
|
|
{
|
|
found.push(link);
|
|
}
|
|
}
|
|
return Ok(found);
|
|
};
|
|
|
|
let fh = dense_heap(file_data, &link_info, fh_addr, offset_size, length_size)?;
|
|
let btree_addr = link_info
|
|
.btree_name_index_address
|
|
.ok_or_else(|| FormatError::PathNotFound(String::from("no B-tree v2 name index")))?;
|
|
let btree_hdr = BTreeV2Header::parse_in(
|
|
file_data,
|
|
checked_addr(btree_addr)?,
|
|
offset_size,
|
|
length_size,
|
|
)?;
|
|
if btree_hdr.tree_type != LINK_NAME_INDEX {
|
|
for_each_dense_link(
|
|
file_data,
|
|
&link_info,
|
|
fh_addr,
|
|
offset_size,
|
|
length_size,
|
|
false,
|
|
|link| {
|
|
if link.name == name {
|
|
found.push(link);
|
|
}
|
|
},
|
|
)?;
|
|
return Ok(found);
|
|
}
|
|
|
|
// Record: hash(4) + heap ID.
|
|
let hash = jenkins_lookup3(name.as_bytes());
|
|
let records = find_btree_v2_records_in(file_data, &btree_hdr, offset_size, &mut |r| {
|
|
match r.get(..4) {
|
|
Some(h) => u32::from_le_bytes([h[0], h[1], h[2], h[3]]).cmp(&hash),
|
|
// Too short to hold a hash (a corrupt record size): never a match.
|
|
None => core::cmp::Ordering::Less,
|
|
}
|
|
})?;
|
|
let id_len = usize::from(fh.heap_id_length);
|
|
for record in &records {
|
|
let Some(id_bytes) = record.data.get(4..4 + id_len) else {
|
|
continue;
|
|
};
|
|
let link_data = fh.read_managed_object_in(file_data, id_bytes, offset_size)?;
|
|
if let Some(link) = parse_link(&link_data, offset_size)?
|
|
&& link.name == name
|
|
{
|
|
found.push(link);
|
|
}
|
|
}
|
|
Ok(found)
|
|
}
|
|
|
|
/// The link called `name` in a v2 group, if any.
|
|
///
|
|
/// A valid group has at most one; libhdf5 cannot create two. If a damaged
|
|
/// or hand-made group has several, the first wins and the rest are
|
|
/// ignored, whatever their kind and even if the first cannot be followed.
|
|
/// That is libhdf5's rule for a compact group (`H5G__compact_lookup` stops
|
|
/// at the first Link message of that name; h5py then fails to open a
|
|
/// dangling first link although a later one resolves). For a dense group
|
|
/// "first" is first in name index order; libhdf5 binary-searches the index
|
|
/// and may land on another of several exact duplicates. The listing
|
|
/// ([`resolve_group_children`]), [`resolve_child`] and path resolution all
|
|
/// apply this rule, so they agree.
|
|
fn first_link_named<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
object_header: &ObjectHeader,
|
|
name: &str,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
) -> Result<Option<LinkMessage>, FormatError> {
|
|
Ok(
|
|
links_named(file_data, object_header, name, offset_size, length_size)?
|
|
.into_iter()
|
|
.next(),
|
|
)
|
|
}
|
|
|
|
/// The link [`resolve_path_any`] follows for one path component `name` of
|
|
/// the group with header `object_header`: a hard link (as `Hard`), else a
|
|
/// soft or external link of that name, else `None`. Fails with
|
|
/// `PathNotFound` if the object is not a group.
|
|
fn lookup_link<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
object_header: &ObjectHeader,
|
|
name: &str,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
) -> Result<Option<LinkTarget>, FormatError> {
|
|
if is_v1_group(object_header) {
|
|
// Down the group's B-tree, as libhdf5 looks a name up; only when
|
|
// that does not find a hard link of that name is every entry read
|
|
// (a soft link, a group whose B-tree is out of order). A storage
|
|
// error (a read a restartable storage has not fetched yet) is
|
|
// returned as is: reading every entry would not get further.
|
|
if let Some(sym_msg) = object_header
|
|
.messages
|
|
.iter()
|
|
.find(|m| m.msg_type == MessageType::SymbolTable)
|
|
{
|
|
let stm = SymbolTableMessage::parse(&sym_msg.data, offset_size)?;
|
|
match group_v1::find_v1_entry(file_data, &stm, name, offset_size, length_size) {
|
|
Ok(Some(e)) if e.object_header_address != u64::MAX => {
|
|
return Ok(Some(LinkTarget::Hard {
|
|
object_header_address: e.object_header_address,
|
|
}));
|
|
}
|
|
Err(e @ FormatError::Storage(_)) => return Err(e),
|
|
_ => {}
|
|
}
|
|
}
|
|
let entries = resolve_group_entries(file_data, object_header, offset_size, length_size)?;
|
|
if let Some(e) = entries
|
|
.iter()
|
|
.find(|e| e.name == name && e.object_header_address != u64::MAX)
|
|
{
|
|
return Ok(Some(LinkTarget::Hard {
|
|
object_header_address: e.object_header_address,
|
|
}));
|
|
}
|
|
return find_v1_symbolic_link(file_data, object_header, name, offset_size, length_size);
|
|
}
|
|
if !is_v2_group(object_header) {
|
|
return Err(FormatError::PathNotFound(String::from(
|
|
"object header is not a group",
|
|
)));
|
|
}
|
|
Ok(
|
|
first_link_named(file_data, object_header, name, offset_size, length_size)?
|
|
.map(|link| link.link_target)
|
|
.filter(|t| {
|
|
!matches!(
|
|
t,
|
|
LinkTarget::Hard {
|
|
object_header_address: u64::MAX
|
|
}
|
|
)
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// The object header address of the child called `name` of the group at
|
|
/// `group_address`: the address [`resolve_group_children`] lists under that
|
|
/// name, or `PathNotFound` if it lists none.
|
|
///
|
|
/// A dense group's child is found through its link name index (see
|
|
/// [`links_named`]) and only the named link is read and, if it is a soft
|
|
/// link, followed — not every link in the group. A v1 group is listed.
|
|
pub fn resolve_child(
|
|
file_data: &[u8],
|
|
superblock: &Superblock,
|
|
group_address: u64,
|
|
name: &str,
|
|
) -> Result<u64, FormatError> {
|
|
resolve_child_core(file_data, superblock, group_address, name)
|
|
}
|
|
|
|
/// [`resolve_child`] over any [`Storage`]. One with the whole file in memory
|
|
/// is read as the slice, by code compiled in this crate (see
|
|
/// [`crate::storage`], "Slice entry points").
|
|
#[inline]
|
|
pub fn resolve_child_in<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
superblock: &Superblock,
|
|
group_address: u64,
|
|
name: &str,
|
|
) -> Result<u64, FormatError> {
|
|
match file_data.as_contiguous() {
|
|
Some(all) => resolve_child(all, superblock, group_address, name),
|
|
None => resolve_child_core(file_data, superblock, group_address, name),
|
|
}
|
|
}
|
|
|
|
fn resolve_child_core<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
superblock: &Superblock,
|
|
group_address: u64,
|
|
name: &str,
|
|
) -> Result<u64, FormatError> {
|
|
let os = superblock.offset_size;
|
|
let ls = superblock.length_size;
|
|
let not_found = || FormatError::PathNotFound(String::from(name));
|
|
let header = ObjectHeader::parse_in(file_data, checked_addr(group_address)?, os, ls)?;
|
|
if !is_v2_group(&header) || is_v1_group(&header) {
|
|
return group_children(file_data, superblock, group_address, false)?
|
|
.into_iter()
|
|
.find(|e| e.name == name)
|
|
.map(|e| e.object_header_address)
|
|
.ok_or_else(not_found);
|
|
}
|
|
// The first link of that name only, as the listing (see
|
|
// `first_link_named`).
|
|
match first_link_named(file_data, &header, name, os, ls)?.map(|l| l.link_target) {
|
|
Some(LinkTarget::Hard {
|
|
object_header_address,
|
|
}) => Ok(object_header_address),
|
|
Some(LinkTarget::Soft { target_path }) => {
|
|
match resolve_path_from_in(file_data, superblock, group_address, &target_path) {
|
|
// Left out of the listing: dangling, cyclic, or in another file.
|
|
Err(
|
|
FormatError::PathNotFound(_)
|
|
| FormatError::NestingDepthExceeded
|
|
| FormatError::ExternalLinkUnsupported { .. },
|
|
) => Err(not_found()),
|
|
other => other,
|
|
}
|
|
}
|
|
Some(LinkTarget::External { .. }) | None => Err(not_found()),
|
|
}
|
|
}
|
|
|
|
/// Find and parse the Link Info message from an object header.
|
|
fn find_link_info(
|
|
object_header: &ObjectHeader,
|
|
offset_size: u8,
|
|
) -> Result<LinkInfoMessage, FormatError> {
|
|
for msg in &object_header.messages {
|
|
if msg.msg_type == MessageType::LinkInfo {
|
|
return LinkInfoMessage::parse(&msg.data, offset_size);
|
|
}
|
|
}
|
|
// No Link Info message — might have direct Link messages
|
|
// Return a "compact" link info with no fractal heap
|
|
Ok(LinkInfoMessage {
|
|
max_creation_order: None,
|
|
fractal_heap_address: None,
|
|
btree_name_index_address: None,
|
|
btree_creation_order_address: None,
|
|
})
|
|
}
|
|
|
|
/// Detect whether an object header represents a v1 group, v2 group, or neither.
|
|
fn is_v2_group(object_header: &ObjectHeader) -> bool {
|
|
object_header
|
|
.messages
|
|
.iter()
|
|
.any(|m| m.msg_type == MessageType::LinkInfo || m.msg_type == MessageType::Link)
|
|
}
|
|
|
|
fn is_v1_group(object_header: &ObjectHeader) -> bool {
|
|
object_header
|
|
.messages
|
|
.iter()
|
|
.any(|m| m.msg_type == MessageType::SymbolTable)
|
|
}
|
|
|
|
/// Unified path resolution that works for both v1 and v2 groups.
|
|
///
|
|
/// Detects group version from object header messages and dispatches accordingly.
|
|
pub fn resolve_path_any(
|
|
file_data: &[u8],
|
|
superblock: &Superblock,
|
|
path: &str,
|
|
) -> Result<u64, FormatError> {
|
|
resolve_path_any_core(file_data, superblock, path)
|
|
}
|
|
|
|
/// [`resolve_path_any`] over any [`Storage`]. One with the whole file in memory
|
|
/// is read as the slice, by code compiled in this crate (see
|
|
/// [`crate::storage`], "Slice entry points").
|
|
#[inline]
|
|
pub fn resolve_path_any_in<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
superblock: &Superblock,
|
|
path: &str,
|
|
) -> Result<u64, FormatError> {
|
|
match file_data.as_contiguous() {
|
|
Some(all) => resolve_path_any(all, superblock, path),
|
|
None => resolve_path_any_core(file_data, superblock, path),
|
|
}
|
|
}
|
|
|
|
fn resolve_path_any_core<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
superblock: &Superblock,
|
|
path: &str,
|
|
) -> Result<u64, FormatError> {
|
|
resolve_path_following_links(
|
|
file_data,
|
|
superblock,
|
|
superblock.root_group_address,
|
|
path,
|
|
0,
|
|
)
|
|
}
|
|
|
|
/// Resolve `path` relative to the group at `group_address` (an absolute path
|
|
/// starts at the root group instead), following soft links. This is how a
|
|
/// relative soft link's target is resolved: from the group holding the link.
|
|
pub fn resolve_path_from(
|
|
file_data: &[u8],
|
|
superblock: &Superblock,
|
|
group_address: u64,
|
|
path: &str,
|
|
) -> Result<u64, FormatError> {
|
|
resolve_path_from_in(file_data, superblock, group_address, path)
|
|
}
|
|
|
|
/// [`resolve_path_from`] over any [`Storage`].
|
|
pub fn resolve_path_from_in<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
superblock: &Superblock,
|
|
group_address: u64,
|
|
path: &str,
|
|
) -> Result<u64, FormatError> {
|
|
let start = if path.starts_with('/') {
|
|
superblock.root_group_address
|
|
} else {
|
|
group_address
|
|
};
|
|
resolve_path_following_links(file_data, superblock, start, path, 0)
|
|
}
|
|
|
|
/// The children of the group at `group_address` that can be opened, as h5py
|
|
/// lists them: hard links, and soft links resolved to the object they point
|
|
/// at (under the soft link's own name). Links that cannot be followed are
|
|
/// left out rather than failing the listing — a dangling or cyclic soft link
|
|
/// (h5py lists its name but cannot open it), an external link (another
|
|
/// file), and a user-defined link. An object header that is not a group has
|
|
/// no children.
|
|
///
|
|
/// Any other error, such as a corrupt structure met while resolving a soft
|
|
/// link, is returned.
|
|
pub fn resolve_group_children(
|
|
file_data: &[u8],
|
|
superblock: &Superblock,
|
|
group_address: u64,
|
|
) -> Result<Vec<GroupEntry>, FormatError> {
|
|
resolve_group_children_core(file_data, superblock, group_address)
|
|
}
|
|
|
|
/// [`resolve_group_children`] over any [`Storage`]. One with the whole file in memory
|
|
/// is read as the slice, by code compiled in this crate (see
|
|
/// [`crate::storage`], "Slice entry points").
|
|
#[inline]
|
|
pub fn resolve_group_children_in<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
superblock: &Superblock,
|
|
group_address: u64,
|
|
) -> Result<Vec<GroupEntry>, FormatError> {
|
|
match file_data.as_contiguous() {
|
|
Some(all) => resolve_group_children(all, superblock, group_address),
|
|
None => resolve_group_children_core(file_data, superblock, group_address),
|
|
}
|
|
}
|
|
|
|
fn resolve_group_children_core<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
superblock: &Superblock,
|
|
group_address: u64,
|
|
) -> Result<Vec<GroupEntry>, FormatError> {
|
|
group_children(file_data, superblock, group_address, true)
|
|
}
|
|
|
|
/// [`resolve_group_children`]; with `hint_headers`, every child's object
|
|
/// header is hinted (see [`Storage::hint`]) as soon as its address is
|
|
/// known, for a listing whose children are opened next.
|
|
fn group_children<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
superblock: &Superblock,
|
|
group_address: u64,
|
|
hint_headers: bool,
|
|
) -> Result<Vec<GroupEntry>, FormatError> {
|
|
let os = superblock.offset_size;
|
|
let ls = superblock.length_size;
|
|
let header = ObjectHeader::parse_in(file_data, checked_addr(group_address)?, os, ls)?;
|
|
|
|
let mut entries = Vec::new();
|
|
let mut soft = Vec::new();
|
|
if is_v1_group(&header) {
|
|
let sym_msg = header
|
|
.messages
|
|
.iter()
|
|
.find(|m| m.msg_type == MessageType::SymbolTable)
|
|
.ok_or_else(|| FormatError::PathNotFound(String::from("no symbol table message")))?;
|
|
let stm = SymbolTableMessage::parse(&sym_msg.data, os)?;
|
|
let all = group_v1::v1_group_entries(file_data, &stm, os, ls, hint_headers)?;
|
|
if all.iter().any(|e| e.name.is_empty()) {
|
|
return Err(FormatError::InvalidLinkName);
|
|
}
|
|
if all.iter().any(group_v1::is_v1_soft_link) {
|
|
soft = group_v1::v1_soft_links_in(file_data, &stm, os, ls)?;
|
|
}
|
|
entries.extend(all.into_iter().filter(|e| !group_v1::is_v1_soft_link(e)));
|
|
} else if is_v2_group(&header) {
|
|
// Only the first link of each name counts (see `first_link_named`).
|
|
let mut seen = BTreeSet::new();
|
|
let mut visit = |link: LinkMessage| {
|
|
if !seen.insert(link.name.clone()) {
|
|
return;
|
|
}
|
|
match link.link_target {
|
|
LinkTarget::Hard {
|
|
object_header_address,
|
|
} => entries.push(GroupEntry {
|
|
name: link.name,
|
|
object_header_address,
|
|
cache_type: 0,
|
|
}),
|
|
LinkTarget::Soft { target_path } => soft.push((link.name, target_path)),
|
|
LinkTarget::External { .. } => {}
|
|
}
|
|
};
|
|
let link_info = find_link_info(&header, os)?;
|
|
if let Some(fh_addr) = link_info.fractal_heap_address {
|
|
for_each_dense_link(file_data, &link_info, fh_addr, os, ls, hint_headers, visit)?;
|
|
} else {
|
|
for msg in &header.messages {
|
|
if msg.msg_type == MessageType::Link
|
|
&& let Some(link) = parse_link(&msg.data, os)?
|
|
{
|
|
visit(link);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (name, target) in soft {
|
|
match resolve_path_from_in(file_data, superblock, group_address, &target) {
|
|
Ok(object_header_address) => entries.push(GroupEntry {
|
|
name,
|
|
object_header_address,
|
|
cache_type: 0,
|
|
}),
|
|
// Dangling, cyclic, or ending in another file: not openable here.
|
|
Err(
|
|
FormatError::PathNotFound(_)
|
|
| FormatError::NestingDepthExceeded
|
|
| FormatError::ExternalLinkUnsupported { .. },
|
|
) => {}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
Ok(entries)
|
|
}
|
|
|
|
/// Soft links followed while resolving one path. Guards against link cycles
|
|
/// (`a -> b -> a`), which are legal to create.
|
|
const MAX_SOFT_LINK_DEPTH: u8 = 16;
|
|
|
|
/// Walk `path` from the group at `start`, following soft links.
|
|
fn resolve_path_following_links<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
superblock: &Superblock,
|
|
start: u64,
|
|
path: &str,
|
|
depth: u8,
|
|
) -> Result<u64, FormatError> {
|
|
let components: Vec<&str> = path
|
|
.split('/')
|
|
.filter(|s| !s.is_empty() && *s != ".")
|
|
.collect();
|
|
if components.is_empty() {
|
|
return Ok(start);
|
|
}
|
|
|
|
let os = superblock.offset_size;
|
|
let ls = superblock.length_size;
|
|
|
|
let mut current_addr = start;
|
|
let mut current_header = ObjectHeader::parse_in(file_data, checked_addr(start)?, os, ls)?;
|
|
|
|
for (i, component) in components.iter().enumerate() {
|
|
match lookup_link(file_data, ¤t_header, component, os, ls)? {
|
|
Some(LinkTarget::Hard {
|
|
object_header_address,
|
|
}) => {
|
|
if i == components.len() - 1 {
|
|
return Ok(object_header_address);
|
|
}
|
|
current_addr = object_header_address;
|
|
current_header =
|
|
ObjectHeader::parse_in(file_data, checked_addr(current_addr)?, os, ls)?;
|
|
}
|
|
found => {
|
|
return match found {
|
|
Some(LinkTarget::Soft { target_path }) => {
|
|
if depth >= MAX_SOFT_LINK_DEPTH {
|
|
return Err(FormatError::NestingDepthExceeded);
|
|
}
|
|
// A relative target is relative to the group holding
|
|
// the link; then the rest of the original path.
|
|
let from = if target_path.starts_with('/') {
|
|
superblock.root_group_address
|
|
} else {
|
|
current_addr
|
|
};
|
|
let mut full = target_path;
|
|
for rest in &components[i + 1..] {
|
|
full.push('/');
|
|
full.push_str(rest);
|
|
}
|
|
resolve_path_following_links(file_data, superblock, from, &full, depth + 1)
|
|
}
|
|
Some(LinkTarget::External {
|
|
filename,
|
|
object_path,
|
|
}) => Err(FormatError::ExternalLinkUnsupported {
|
|
filename,
|
|
object_path,
|
|
}),
|
|
_ => Err(FormatError::PathNotFound(String::from(*component))),
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(current_addr)
|
|
}
|
|
|
|
/// Resolve group entries from an object header, auto-detecting v1 vs v2.
|
|
fn resolve_group_entries<S: Storage + ?Sized>(
|
|
file_data: &S,
|
|
object_header: &ObjectHeader,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
) -> Result<Vec<GroupEntry>, FormatError> {
|
|
if is_v1_group(object_header) {
|
|
// v1: find SymbolTableMessage and use existing v1 code
|
|
let sym_msg = object_header
|
|
.messages
|
|
.iter()
|
|
.find(|m| m.msg_type == MessageType::SymbolTable)
|
|
.ok_or_else(|| FormatError::PathNotFound(String::from("no symbol table message")))?;
|
|
let stm = SymbolTableMessage::parse(&sym_msg.data, offset_size)?;
|
|
// A lookup: an entry with an empty name (which fails a listing) is
|
|
// skipped by the name comparison, as in libhdf5.
|
|
group_v1::v1_group_entries(file_data, &stm, offset_size, length_size, false)
|
|
} else if is_v2_group(object_header) {
|
|
resolve_v2_group_entries_in(file_data, object_header, offset_size, length_size)
|
|
} else {
|
|
Err(FormatError::PathNotFound(String::from(
|
|
"object header is not a group",
|
|
)))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::data_layout::DataLayout;
|
|
use crate::data_read;
|
|
use crate::dataspace::Dataspace;
|
|
use crate::datatype::Datatype;
|
|
use crate::signature;
|
|
|
|
fn extract_dataset(
|
|
_file_data: &[u8],
|
|
hdr: &ObjectHeader,
|
|
offset_size: u8,
|
|
length_size: u8,
|
|
) -> (Datatype, Dataspace, DataLayout) {
|
|
let dt_data = &hdr
|
|
.messages
|
|
.iter()
|
|
.find(|m| m.msg_type == MessageType::Datatype)
|
|
.unwrap()
|
|
.data;
|
|
let ds_data = &hdr
|
|
.messages
|
|
.iter()
|
|
.find(|m| m.msg_type == MessageType::Dataspace)
|
|
.unwrap()
|
|
.data;
|
|
let dl_data = &hdr
|
|
.messages
|
|
.iter()
|
|
.find(|m| m.msg_type == MessageType::DataLayout)
|
|
.unwrap()
|
|
.data;
|
|
let (dt, _) = Datatype::parse(dt_data).unwrap();
|
|
let ds = Dataspace::parse(ds_data, length_size).unwrap();
|
|
let dl = DataLayout::parse(dl_data, offset_size, length_size).unwrap();
|
|
(dt, ds, dl)
|
|
}
|
|
|
|
#[test]
|
|
fn compact_storage_link_messages() {
|
|
// Build a v2 object header with Link messages (compact storage)
|
|
// We'll test with the actual v2_groups.h5 file since building synthetic v2 headers
|
|
// with proper checksums is complex.
|
|
|
|
// Instead, test the resolve_compact_entries path with a simple object header
|
|
let link_data = {
|
|
// Build a Link message: hard link, name="test", addr=0x1000
|
|
let mut d = Vec::new();
|
|
d.push(1); // version
|
|
d.push(0x00); // flags: no creation order, no link type (=hard), no charset, name_size=1byte
|
|
d.push(4); // name length = 4
|
|
d.extend_from_slice(b"test");
|
|
d.extend_from_slice(&0x1000u64.to_le_bytes()); // address
|
|
d
|
|
};
|
|
|
|
let oh = ObjectHeader {
|
|
version: 2,
|
|
messages: vec![
|
|
crate::object_header::HeaderMessage {
|
|
msg_type: MessageType::LinkInfo,
|
|
size: 18,
|
|
flags: 0,
|
|
creation_order: None,
|
|
data: {
|
|
let mut d = Vec::new();
|
|
d.push(0); // version
|
|
d.push(0); // flags
|
|
d.extend_from_slice(&0xFFFF_FFFF_FFFF_FFFFu64.to_le_bytes()); // fh undef
|
|
d.extend_from_slice(&0xFFFF_FFFF_FFFF_FFFFu64.to_le_bytes()); // btree undef
|
|
d
|
|
},
|
|
},
|
|
crate::object_header::HeaderMessage {
|
|
msg_type: MessageType::Link,
|
|
size: link_data.len(),
|
|
flags: 0,
|
|
creation_order: None,
|
|
data: link_data,
|
|
},
|
|
],
|
|
reference_count: None,
|
|
flags: 0,
|
|
access_time: None,
|
|
modification_time: None,
|
|
change_time: None,
|
|
birth_time: None,
|
|
};
|
|
|
|
let entries = resolve_v2_group_entries(&[], &oh, 8, 8).unwrap();
|
|
assert_eq!(entries.len(), 1);
|
|
assert_eq!(entries[0].name, "test");
|
|
assert_eq!(entries[0].object_header_address, 0x1000);
|
|
}
|
|
|
|
#[test]
|
|
fn integration_v2_groups_temperature() {
|
|
let file_data: &[u8] = include_bytes!("../tests/fixtures/v2_groups.h5");
|
|
let sig_offset = signature::find_signature(file_data).unwrap();
|
|
let sb = Superblock::parse(file_data, sig_offset).unwrap();
|
|
assert!(sb.version >= 2); // v2/v3 superblock
|
|
|
|
let addr = resolve_path_any(file_data, &sb, "sensors/temperature").unwrap();
|
|
let hdr =
|
|
ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size).unwrap();
|
|
let (dt, ds, dl) = extract_dataset(file_data, &hdr, sb.offset_size, sb.length_size);
|
|
let raw = data_read::read_raw_data(file_data, &dl, &ds, &dt).unwrap();
|
|
let values = data_read::read_as_f64(&raw, &dt).unwrap();
|
|
assert_eq!(values, vec![22.5, 23.1, 21.8]);
|
|
}
|
|
|
|
#[test]
|
|
fn integration_v2_groups_humidity() {
|
|
let file_data: &[u8] = include_bytes!("../tests/fixtures/v2_groups.h5");
|
|
let sig_offset = signature::find_signature(file_data).unwrap();
|
|
let sb = Superblock::parse(file_data, sig_offset).unwrap();
|
|
|
|
let addr = resolve_path_any(file_data, &sb, "sensors/humidity").unwrap();
|
|
let hdr =
|
|
ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size).unwrap();
|
|
let (dt, ds, dl) = extract_dataset(file_data, &hdr, sb.offset_size, sb.length_size);
|
|
let raw = data_read::read_raw_data(file_data, &dl, &ds, &dt).unwrap();
|
|
let values = data_read::read_as_i32(&raw, &dt).unwrap();
|
|
assert_eq!(values, vec![45, 50, 55]);
|
|
}
|
|
|
|
#[test]
|
|
fn integration_v2_many_links() {
|
|
let file_data: &[u8] = include_bytes!("../tests/fixtures/v2_many_links.h5");
|
|
let sig_offset = signature::find_signature(file_data).unwrap();
|
|
let sb = Superblock::parse(file_data, sig_offset).unwrap();
|
|
|
|
let addr = resolve_path_any(file_data, &sb, "dataset_015").unwrap();
|
|
let hdr =
|
|
ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size).unwrap();
|
|
let (dt, ds, dl) = extract_dataset(file_data, &hdr, sb.offset_size, sb.length_size);
|
|
let raw = data_read::read_raw_data(file_data, &dl, &ds, &dt).unwrap();
|
|
let values = data_read::read_as_f64(&raw, &dt).unwrap();
|
|
assert_eq!(values, vec![15.0]);
|
|
}
|
|
|
|
#[test]
|
|
fn integration_resolve_path_any_v1() {
|
|
// Test that resolve_path_any also works for v1 files
|
|
let file_data: &[u8] = include_bytes!("../tests/fixtures/two_groups.h5");
|
|
let sig_offset = signature::find_signature(file_data).unwrap();
|
|
let sb = Superblock::parse(file_data, sig_offset).unwrap();
|
|
|
|
let addr = resolve_path_any(file_data, &sb, "group1/values").unwrap();
|
|
let hdr =
|
|
ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size).unwrap();
|
|
let (dt, ds, dl) = extract_dataset(file_data, &hdr, sb.offset_size, sb.length_size);
|
|
let raw = data_read::read_raw_data(file_data, &dl, &ds, &dt).unwrap();
|
|
let values = data_read::read_as_i32(&raw, &dt).unwrap();
|
|
assert_eq!(values, vec![10, 20, 30]);
|
|
}
|
|
|
|
#[test]
|
|
fn integration_resolve_path_any_v2() {
|
|
let file_data: &[u8] = include_bytes!("../tests/fixtures/v2_groups.h5");
|
|
let sig_offset = signature::find_signature(file_data).unwrap();
|
|
let sb = Superblock::parse(file_data, sig_offset).unwrap();
|
|
|
|
let addr = resolve_path_any(file_data, &sb, "sensors/temperature").unwrap();
|
|
let hdr =
|
|
ObjectHeader::parse(file_data, addr as usize, sb.offset_size, sb.length_size).unwrap();
|
|
let (dt, ds, dl) = extract_dataset(file_data, &hdr, sb.offset_size, sb.length_size);
|
|
let raw = data_read::read_raw_data(file_data, &dl, &ds, &dt).unwrap();
|
|
let values = data_read::read_as_f64(&raw, &dt).unwrap();
|
|
assert_eq!(values, vec![22.5, 23.1, 21.8]);
|
|
}
|
|
|
|
/// `v1_groups_400.h5` from its superblock on (it has a user block).
|
|
fn v1_groups_400() -> (Vec<u8>, Superblock) {
|
|
let all: &[u8] = include_bytes!("../tests/fixtures/v1_groups_400.h5");
|
|
let data = all[signature::find_signature(all).unwrap()..].to_vec();
|
|
let sb = Superblock::parse(&data, 0).unwrap();
|
|
(data, sb)
|
|
}
|
|
|
|
/// Every child of a v1 group resolves by name, down the group's B-tree,
|
|
/// to the address the listing gives, reading a small part of what the
|
|
/// listing reads; a name the group does not hold is not found.
|
|
#[test]
|
|
fn v1_lookup_down_the_btree_agrees_with_the_listing() {
|
|
let (data, sb) = v1_groups_400();
|
|
let children = resolve_group_children(&data, &sb, sb.root_group_address).unwrap();
|
|
assert_eq!(children.len(), 401);
|
|
for c in &children {
|
|
let path = format!("/{}", c.name);
|
|
assert_eq!(
|
|
resolve_path_any(&data, &sb, &path).unwrap(),
|
|
c.object_header_address,
|
|
"{path}"
|
|
);
|
|
}
|
|
for missing in ["/g0400", "/a", "/g", "/g00000", "/zz", "/x0"] {
|
|
assert!(
|
|
matches!(
|
|
resolve_path_any(&data, &sb, missing),
|
|
Err(FormatError::PathNotFound(_))
|
|
),
|
|
"{missing}"
|
|
);
|
|
}
|
|
let st = crate::storage::CountingStorage::new(data.clone());
|
|
resolve_group_children_in(&st, &sb, sb.root_group_address).unwrap();
|
|
let listing = st.bytes_read();
|
|
st.reset();
|
|
let last = children.last().unwrap();
|
|
assert_eq!(
|
|
resolve_path_any_in(&st, &sb, &format!("/{}", last.name)).unwrap(),
|
|
last.object_header_address
|
|
);
|
|
assert!(
|
|
st.bytes_read() * 8 < listing,
|
|
"lookup read {} bytes, listing {listing}",
|
|
st.bytes_read()
|
|
);
|
|
}
|
|
|
|
/// A v1 group whose B-tree is out of name order (a name changed in the
|
|
/// heap so that it sorts past every key) is still looked up by reading
|
|
/// every entry, as before the lookup went down the B-tree.
|
|
#[test]
|
|
fn v1_lookup_falls_back_when_the_btree_is_out_of_order() {
|
|
let (mut data, sb) = v1_groups_400();
|
|
let at: Vec<usize> = data
|
|
.windows(6)
|
|
.enumerate()
|
|
.filter(|(_, w)| *w == b"g0200\0")
|
|
.map(|(i, _)| i)
|
|
.collect();
|
|
assert_eq!(at.len(), 1, "one heap string");
|
|
data[at[0]] = b'~';
|
|
let children = resolve_group_children(&data, &sb, sb.root_group_address).unwrap();
|
|
let moved = children.iter().find(|c| c.name == "~0200").unwrap();
|
|
assert_eq!(
|
|
resolve_path_any(&data, &sb, "/~0200").unwrap(),
|
|
moved.object_header_address
|
|
);
|
|
assert!(resolve_path_any(&data, &sb, "/g0200").is_err());
|
|
for c in &children {
|
|
let path = format!("/{}", c.name);
|
|
assert_eq!(
|
|
resolve_path_any(&data, &sb, &path).unwrap(),
|
|
c.object_header_address,
|
|
"{path}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn path_not_found_v2() {
|
|
let file_data: &[u8] = include_bytes!("../tests/fixtures/v2_groups.h5");
|
|
let sig_offset = signature::find_signature(file_data).unwrap();
|
|
let sb = Superblock::parse(file_data, sig_offset).unwrap();
|
|
|
|
let err = resolve_path_any(file_data, &sb, "nonexistent").unwrap_err();
|
|
assert!(matches!(err, FormatError::PathNotFound(_)));
|
|
}
|
|
}
|