//! 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::to_usize; use crate::btree_v2::{BTreeV2Header, collect_btree_v2_records, find_btree_v2_records}; 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::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, 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, 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, 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) } /// Visit every link in dense storage (fractal heap + B-tree v2 name index). fn for_each_dense_link( file_data: &[u8], link_info: &LinkInfoMessage, fh_addr: u64, offset_size: u8, length_size: u8, mut visit: impl FnMut(LinkMessage), ) -> Result<(), FormatError> { // Parse fractal heap let fh = FractalHeapHeader::parse(file_data, to_usize(fh_addr)?, offset_size, length_size)?; // 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(file_data, to_usize(btree_addr)?, offset_size, length_size)?; let records = collect_btree_v2_records(file_data, &btree_hdr, offset_size, length_size)?; 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_data = fh.read_managed_object(file_data, id_bytes, offset_size)?; if let Some(link) = parse_link(&link_data, offset_size)? { visit(link); } } Ok(()) } /// Resolve entries from dense storage (fractal heap + B-tree v2). fn resolve_dense_entries( file_data: &[u8], link_info: &LinkInfoMessage, fh_addr: u64, offset_size: u8, length_size: u8, ) -> Result, FormatError> { let mut entries = Vec::new(); for_each_dense_link( file_data, link_info, fh_addr, offset_size, length_size, |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( file_data: &[u8], object_header: &ObjectHeader, name: &str, offset_size: u8, length_size: u8, ) -> Result, 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(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( file_data: &[u8], object_header: &ObjectHeader, name: &str, offset_size: u8, length_size: u8, ) -> Result, 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 = FractalHeapHeader::parse(file_data, to_usize(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(file_data, to_usize(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, |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(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(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( file_data: &[u8], object_header: &ObjectHeader, name: &str, offset_size: u8, length_size: u8, ) -> Result, 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( file_data: &[u8], object_header: &ObjectHeader, name: &str, offset_size: u8, length_size: u8, ) -> Result, FormatError> { if is_v1_group(object_header) { 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 { let os = superblock.offset_size; let ls = superblock.length_size; let not_found = || FormatError::PathNotFound(String::from(name)); let header = ObjectHeader::parse(file_data, to_usize(group_address)?, os, ls)?; if !is_v2_group(&header) || is_v1_group(&header) { return resolve_group_children(file_data, superblock, group_address)? .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(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 { 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 { 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 { 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, FormatError> { let os = superblock.offset_size; let ls = superblock.length_size; let header = ObjectHeader::parse(file_data, to_usize(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::resolve_v1_group_entries(file_data, &stm, os, ls)?; if all.iter().any(group_v1::is_v1_soft_link) { soft = group_v1::v1_soft_links(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, 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(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( file_data: &[u8], superblock: &Superblock, start: u64, path: &str, depth: u8, ) -> Result { 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(file_data, to_usize(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(file_data, to_usize(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( file_data: &[u8], object_header: &ObjectHeader, offset_size: u8, length_size: u8, ) -> Result, 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) } else if is_v2_group(object_header) { resolve_v2_group_entries(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]); } #[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(_))); } }