Merge branch 'feat/p2-vl-strings' into feat/p2-perf-coverage
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -125,6 +125,11 @@ pub enum Datatype {
|
||||
},
|
||||
/// Class 9: Variable-length type.
|
||||
VariableLength {
|
||||
/// Size of one element as stored in the file: a sequence length (4
|
||||
/// bytes), a global heap collection address (the file's
|
||||
/// `offset_size`) and an object index (4 bytes) — 16 in a file with
|
||||
/// 8-byte offsets, 12 with 4-byte offsets.
|
||||
size: u32,
|
||||
is_string: bool,
|
||||
padding: Option<StringPadding>,
|
||||
charset: Option<CharacterSet>,
|
||||
@@ -771,6 +776,7 @@ impl Datatype {
|
||||
pos += consumed;
|
||||
Ok((
|
||||
Datatype::VariableLength {
|
||||
size,
|
||||
is_string,
|
||||
padding,
|
||||
charset,
|
||||
@@ -1017,6 +1023,7 @@ impl Datatype {
|
||||
Self::build_header(3, 1, [bf0, 0, 0], *size)
|
||||
}
|
||||
Datatype::VariableLength {
|
||||
size,
|
||||
is_string,
|
||||
padding,
|
||||
charset,
|
||||
@@ -1039,7 +1046,7 @@ impl Datatype {
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mut buf = Self::build_header(9, 1, [bf0, bf1, 0], 16);
|
||||
let mut buf = Self::build_header(9, 1, [bf0, bf1, 0], *size);
|
||||
buf.extend_from_slice(&base_type.serialize());
|
||||
buf
|
||||
}
|
||||
@@ -1208,7 +1215,7 @@ impl Datatype {
|
||||
Datatype::Compound { size, .. } => *size,
|
||||
Datatype::Reference { size, .. } => *size,
|
||||
Datatype::Enumeration { size, .. } => *size,
|
||||
Datatype::VariableLength { .. } => 16, // typically pointer + length
|
||||
Datatype::VariableLength { size, .. } => *size,
|
||||
Datatype::Array {
|
||||
base_type,
|
||||
dimensions,
|
||||
@@ -1889,11 +1896,13 @@ mod tests {
|
||||
let (dt, _) = Datatype::parse(&buf).unwrap();
|
||||
match dt {
|
||||
Datatype::VariableLength {
|
||||
size,
|
||||
is_string,
|
||||
padding,
|
||||
charset,
|
||||
base_type,
|
||||
} => {
|
||||
assert_eq!(size, 16);
|
||||
assert!(is_string);
|
||||
assert_eq!(padding, Some(StringPadding::NullTerminate));
|
||||
assert_eq!(charset, Some(CharacterSet::Utf8));
|
||||
@@ -1914,11 +1923,13 @@ mod tests {
|
||||
let (dt, _) = Datatype::parse(&buf).unwrap();
|
||||
match dt {
|
||||
Datatype::VariableLength {
|
||||
size,
|
||||
is_string,
|
||||
padding,
|
||||
charset,
|
||||
base_type,
|
||||
} => {
|
||||
assert_eq!(size, 16);
|
||||
assert!(!is_string);
|
||||
assert_eq!(padding, None);
|
||||
assert_eq!(charset, None);
|
||||
@@ -1928,6 +1939,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_length_size_is_the_stored_size() {
|
||||
// A file with 4-byte offsets stores 12-byte VL elements (length 4 +
|
||||
// address 4 + index 4); the type used to report 16 regardless, so
|
||||
// every read laid the elements out 16 bytes apart.
|
||||
let mut buf = build_dt_header(9, 1, [0x01, 0x00, 0], 12);
|
||||
buf.extend_from_slice(&build_fixed_point(1, false, false, 0, 8));
|
||||
let (dt, _) = Datatype::parse(&buf).unwrap();
|
||||
assert_eq!(dt.type_size(), 12);
|
||||
// And it is written back as stored.
|
||||
assert_eq!(dt.serialize()[4..8], 12u32.to_le_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_array_2d() {
|
||||
// Array [3][4] of i32 LE, version 3
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! HDF5 Global Heap collection parsing.
|
||||
|
||||
#[cfg(not(feature = "std"))]
|
||||
use alloc::vec::Vec;
|
||||
use alloc::{format, string::String, vec::Vec};
|
||||
|
||||
use crate::error::FormatError;
|
||||
|
||||
@@ -52,11 +52,42 @@ fn read_length(data: &[u8], offset: usize, length_size: u8) -> Result<u64, Forma
|
||||
})
|
||||
}
|
||||
|
||||
fn object_overrun_msg(index: u16, size: usize, collection_size: u64) -> String {
|
||||
format!(
|
||||
"global heap object {index} ({size} bytes) runs past the end of its \
|
||||
{collection_size}-byte collection"
|
||||
)
|
||||
}
|
||||
|
||||
/// Round up to next multiple of 8.
|
||||
fn pad8(x: usize) -> usize {
|
||||
(x + 7) & !7
|
||||
}
|
||||
|
||||
/// Where one object of a global heap collection lies in the file, without
|
||||
/// its data: see [`GlobalHeapCollection::parse_index`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct GlobalHeapObjectRef {
|
||||
/// Object index (1-based; 0 is the free space marker).
|
||||
pub index: u16,
|
||||
/// Reference count.
|
||||
pub reference_count: u16,
|
||||
/// Offset of the object's data in the file data the collection was
|
||||
/// parsed from.
|
||||
pub offset: usize,
|
||||
/// Size of the object's data in bytes.
|
||||
pub size: usize,
|
||||
}
|
||||
|
||||
/// A global heap collection's objects, located but not copied.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GlobalHeapIndex {
|
||||
/// Total size of this collection including header.
|
||||
pub collection_size: u64,
|
||||
/// The objects, in file order.
|
||||
pub objects: Vec<GlobalHeapObjectRef>,
|
||||
}
|
||||
|
||||
impl GlobalHeapCollection {
|
||||
/// Parse a global heap collection at the given offset in the file data.
|
||||
pub fn parse(
|
||||
@@ -64,8 +95,38 @@ impl GlobalHeapCollection {
|
||||
offset: usize,
|
||||
length_size: u8,
|
||||
) -> Result<GlobalHeapCollection, FormatError> {
|
||||
// signature(4) + version(1) + reserved(3) + collection_size(length_size)
|
||||
let header_size = 8 + length_size as usize;
|
||||
let index = Self::parse_index(file_data, offset, length_size)?;
|
||||
Ok(GlobalHeapCollection {
|
||||
collection_size: index.collection_size,
|
||||
objects: index
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| GlobalHeapObject {
|
||||
index: o.index,
|
||||
reference_count: o.reference_count,
|
||||
data: file_data[o.offset..o.offset + o.size].to_vec(),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Locate the objects of the global heap collection at `offset` without
|
||||
/// copying their data, so a caller can keep many collections indexed
|
||||
/// for the cost of their object headers.
|
||||
///
|
||||
/// The collection must lie inside `file_data`, and every object inside
|
||||
/// the collection, as libhdf5 lays them out; an object that runs past
|
||||
/// its collection is an error.
|
||||
pub fn parse_index(
|
||||
file_data: &[u8],
|
||||
offset: usize,
|
||||
length_size: u8,
|
||||
) -> Result<GlobalHeapIndex, FormatError> {
|
||||
// signature(4) + version(1) + reserved(3) + collection_size(length_size),
|
||||
// padded to a multiple of 8 as libhdf5 lays it out (`H5HG_SIZEOF_HDR`).
|
||||
// With 8-byte lengths the padding is 0; with 4-byte lengths it is 4,
|
||||
// and reading without it put every object 4 bytes early.
|
||||
let header_size = pad8(8 + length_size as usize);
|
||||
ensure_len(file_data, offset, header_size)?;
|
||||
|
||||
if file_data[offset..offset + 4] != GCOL_SIGNATURE {
|
||||
@@ -78,25 +139,25 @@ impl GlobalHeapCollection {
|
||||
}
|
||||
|
||||
let collection_size = read_length(file_data, offset + 8, length_size)?;
|
||||
let collection_size_usize =
|
||||
usize::try_from(collection_size).map_err(|_| FormatError::UnexpectedEof {
|
||||
expected: u64::MAX as usize,
|
||||
let collection_end = usize::try_from(collection_size)
|
||||
.ok()
|
||||
.and_then(|size| offset.checked_add(size))
|
||||
.ok_or(FormatError::UnexpectedEof {
|
||||
expected: usize::MAX,
|
||||
available: file_data.len(),
|
||||
})?;
|
||||
let collection_end =
|
||||
offset
|
||||
.checked_add(collection_size_usize)
|
||||
.ok_or(FormatError::UnexpectedEof {
|
||||
expected: usize::MAX,
|
||||
available: file_data.len(),
|
||||
})?;
|
||||
if collection_end > file_data.len() {
|
||||
return Err(FormatError::UnexpectedEof {
|
||||
expected: collection_end,
|
||||
available: file_data.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut pos = offset + header_size;
|
||||
let mut objects = Vec::new();
|
||||
|
||||
// Parse objects until we hit index 0 (free space) or run out of space
|
||||
while pos + 2 <= collection_end {
|
||||
ensure_len(file_data, pos, 2)?;
|
||||
let object_index = u16::from_le_bytes([file_data[pos], file_data[pos + 1]]);
|
||||
|
||||
if object_index == 0 {
|
||||
@@ -104,28 +165,39 @@ impl GlobalHeapCollection {
|
||||
break;
|
||||
}
|
||||
|
||||
// object_index(2) + reference_count(2) + reserved(4) + object_size(length_size)
|
||||
let obj_header_size = 8 + length_size as usize;
|
||||
ensure_len(file_data, pos, obj_header_size)?;
|
||||
// object_index(2) + reference_count(2) + reserved(4) +
|
||||
// object_size(length_size), padded to 8 (`H5HG_SIZEOF_OBJHDR`).
|
||||
let obj_header_size = pad8(8 + length_size as usize);
|
||||
ensure_len(&file_data[..collection_end], pos, obj_header_size)?;
|
||||
|
||||
let reference_count = u16::from_le_bytes([file_data[pos + 2], file_data[pos + 3]]);
|
||||
let object_size = read_length(file_data, pos + 8, length_size)? as usize;
|
||||
let object_size = usize::try_from(read_length(file_data, pos + 8, length_size)?)
|
||||
.map_err(|_| FormatError::Overflow("global heap object size".into()))?;
|
||||
|
||||
pos += obj_header_size;
|
||||
ensure_len(file_data, pos, object_size)?;
|
||||
let data = file_data[pos..pos + object_size].to_vec();
|
||||
if pos
|
||||
.checked_add(object_size)
|
||||
.is_none_or(|end| end > collection_end)
|
||||
{
|
||||
return Err(FormatError::VlDataError(object_overrun_msg(
|
||||
object_index,
|
||||
object_size,
|
||||
collection_size,
|
||||
)));
|
||||
}
|
||||
|
||||
objects.push(GlobalHeapObject {
|
||||
objects.push(GlobalHeapObjectRef {
|
||||
index: object_index,
|
||||
reference_count,
|
||||
data,
|
||||
offset: pos,
|
||||
size: object_size,
|
||||
});
|
||||
|
||||
// Advance past data + padding to 8-byte boundary
|
||||
pos += pad8(object_size);
|
||||
pos = pos.saturating_add(pad8(object_size));
|
||||
}
|
||||
|
||||
Ok(GlobalHeapCollection {
|
||||
Ok(GlobalHeapIndex {
|
||||
collection_size,
|
||||
objects,
|
||||
})
|
||||
@@ -149,10 +221,11 @@ mod tests {
|
||||
let ls = length_size as usize;
|
||||
|
||||
// Calculate total size
|
||||
let header_size = 8 + ls;
|
||||
// libhdf5 pads both headers to a multiple of 8.
|
||||
let header_size = pad8(8 + ls);
|
||||
let mut obj_size_total = 0usize;
|
||||
for (_, _, data) in objects {
|
||||
let obj_header = 8 + ls;
|
||||
let obj_header = pad8(8 + ls);
|
||||
obj_size_total += obj_header + pad8(data.len());
|
||||
}
|
||||
// Free space marker (2 bytes for index 0)
|
||||
@@ -170,6 +243,7 @@ mod tests {
|
||||
8 => buf.extend_from_slice(&(collection_size as u64).to_le_bytes()),
|
||||
_ => panic!("unsupported length_size"),
|
||||
}
|
||||
buf.resize(header_size, 0);
|
||||
|
||||
// Objects
|
||||
for (index, ref_count, data) in objects {
|
||||
@@ -181,6 +255,7 @@ mod tests {
|
||||
8 => buf.extend_from_slice(&(data.len() as u64).to_le_bytes()),
|
||||
_ => panic!("unsupported"),
|
||||
}
|
||||
buf.resize(buf.len() + (pad8(8 + ls) - (8 + ls)), 0);
|
||||
buf.extend_from_slice(data);
|
||||
// Pad to 8 bytes
|
||||
let padded = pad8(data.len());
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
//! `sequence_length(4 LE) + collection_address(offset_size LE) + object_index(4 LE)`.
|
||||
|
||||
#[cfg(not(feature = "std"))]
|
||||
use alloc::{string::String, vec::Vec};
|
||||
use alloc::{collections::BTreeMap, format, string::String, vec, vec::Vec};
|
||||
#[cfg(feature = "std")]
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::error::FormatError;
|
||||
use crate::global_heap::GlobalHeapCollection;
|
||||
use crate::global_heap::{GlobalHeapCollection, GlobalHeapIndex};
|
||||
|
||||
/// A parsed variable-length element reference (global heap ID).
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -109,7 +111,218 @@ fn is_undefined_address(addr: u64, offset_size: u8) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// The size of one variable-length element in a file with `offset_size`-byte
|
||||
/// addresses: a sequence length (4), a global heap collection address and an
|
||||
/// object index (4). libhdf5 computes it this way rather than trusting the
|
||||
/// datatype message (`H5T_set_loc`).
|
||||
pub fn element_size(offset_size: u8) -> usize {
|
||||
4 + offset_size as usize + 4
|
||||
}
|
||||
|
||||
/// Refuse a variable-length datatype whose stored element size is not the
|
||||
/// one this file's offset size implies. Its elements would be laid out with
|
||||
/// a stride libhdf5 does not use, so every value after the first would be
|
||||
/// read from the wrong place.
|
||||
pub fn check_element_size(stored_size: u32, offset_size: u8) -> Result<(), FormatError> {
|
||||
let expected = element_size(offset_size);
|
||||
if stored_size as usize != expected {
|
||||
return Err(FormatError::VlDataError(format!(
|
||||
"variable-length datatype stores {stored_size}-byte elements; a file with \
|
||||
{offset_size}-byte offsets uses {expected}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A collection's objects, located in the file data but not copied:
|
||||
/// `(index, offset, size)` of the first object with each index, sorted by
|
||||
/// index.
|
||||
struct CachedCollection {
|
||||
objects: Vec<(u16, usize, usize)>,
|
||||
}
|
||||
|
||||
impl CachedCollection {
|
||||
fn new(index: GlobalHeapIndex) -> Self {
|
||||
let mut objects: Vec<(u16, usize, usize)> = index
|
||||
.objects
|
||||
.iter()
|
||||
.map(|o| (o.index, o.offset, o.size))
|
||||
.collect();
|
||||
// Stable, so the first object with a repeated index is kept.
|
||||
objects.sort_by_key(|o| o.0);
|
||||
objects.dedup_by_key(|o| o.0);
|
||||
Self { objects }
|
||||
}
|
||||
|
||||
/// What this entry costs to keep, in bytes (roughly).
|
||||
fn cost(&self) -> usize {
|
||||
64 + self.objects.len() * core::mem::size_of::<(u16, usize, usize)>()
|
||||
}
|
||||
|
||||
fn get(&self, index: u32) -> Option<(usize, usize)> {
|
||||
let index = u16::try_from(index).ok()?;
|
||||
let i = self.objects.binary_search_by_key(&index, |o| o.0).ok()?;
|
||||
Some((self.objects[i].1, self.objects[i].2))
|
||||
}
|
||||
}
|
||||
|
||||
/// How many bytes of collection indexes a [`VlResolver`] keeps before it
|
||||
/// drops them and starts again. Values are never copied into the cache, so
|
||||
/// this bounds what a read retains however many collections it visits.
|
||||
const CACHE_BUDGET: usize = 32 << 20;
|
||||
|
||||
/// Resolves variable-length elements against a file's global heap, parsing
|
||||
/// each heap collection once however many elements point into it.
|
||||
///
|
||||
/// Values follow libhdf5: an element whose heap address is 0 is null (an
|
||||
/// empty string or sequence), and an element whose heap object is not
|
||||
/// exactly `length × base size` bytes is an error ("Expected global heap
|
||||
/// object size does not match"), not a truncated or padded value.
|
||||
///
|
||||
/// Memory stays bounded on hostile files: the cache holds where each
|
||||
/// object lies, not a copy of it, up to a fixed budget; and collections
|
||||
/// that overlap one another are refused (libhdf5 never writes them), so a
|
||||
/// file cannot make the resolver parse the same bytes as the objects of
|
||||
/// many collections.
|
||||
pub struct VlResolver<'a> {
|
||||
file_data: &'a [u8],
|
||||
offset_size: u8,
|
||||
length_size: u8,
|
||||
cache: BTreeMap<u64, CachedCollection>,
|
||||
cached_bytes: usize,
|
||||
budget: usize,
|
||||
/// Start → end of every collection parsed so far (kept when the cache
|
||||
/// is dropped, to check overlaps).
|
||||
extents: BTreeMap<usize, usize>,
|
||||
}
|
||||
|
||||
impl<'a> VlResolver<'a> {
|
||||
/// A resolver over `file_data` (the file from its superblock on), with
|
||||
/// the superblock's offset and length sizes.
|
||||
pub fn new(file_data: &'a [u8], offset_size: u8, length_size: u8) -> Self {
|
||||
Self {
|
||||
file_data,
|
||||
offset_size,
|
||||
length_size,
|
||||
cache: BTreeMap::new(),
|
||||
cached_bytes: 0,
|
||||
budget: CACHE_BUDGET,
|
||||
extents: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The size of one element in this file (see [`element_size`]).
|
||||
pub fn element_size(&self) -> usize {
|
||||
element_size(self.offset_size)
|
||||
}
|
||||
|
||||
/// Split `raw` into elements; its length must be a whole number of them.
|
||||
fn elements(&self, raw: &[u8]) -> Result<Vec<VlElement>, FormatError> {
|
||||
let size = self.element_size();
|
||||
if !raw.len().is_multiple_of(size) {
|
||||
return Err(FormatError::VlDataError(format!(
|
||||
"{} bytes is not a whole number of {size}-byte variable-length elements",
|
||||
raw.len()
|
||||
)));
|
||||
}
|
||||
parse_vl_references(raw, (raw.len() / size) as u64, self.offset_size)
|
||||
}
|
||||
|
||||
/// The bytes of one element: `length × base_size` bytes from the heap,
|
||||
/// or `None` for a null element.
|
||||
fn resolve(
|
||||
&mut self,
|
||||
vl: &VlElement,
|
||||
base_size: usize,
|
||||
) -> Result<Option<&'a [u8]>, FormatError> {
|
||||
let addr = vl.collection_address;
|
||||
if addr == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let data = self.object(vl)?;
|
||||
let expected = (vl.length as usize)
|
||||
.checked_mul(base_size)
|
||||
.ok_or_else(|| FormatError::Overflow("variable-length element size".into()))?;
|
||||
if data.len() != expected {
|
||||
return Err(FormatError::VlDataError(format!(
|
||||
"global heap object {} in the collection at {addr} holds {} bytes; the element \
|
||||
says {} × {base_size}",
|
||||
vl.object_index,
|
||||
data.len(),
|
||||
vl.length
|
||||
)));
|
||||
}
|
||||
Ok(Some(data))
|
||||
}
|
||||
|
||||
/// One element (the first [`element_size`](Self::element_size) bytes of
|
||||
/// `elem`) of a variable-length sequence whose base type is `base_size`
|
||||
/// bytes: its `length × base_size` bytes, or `None` for a null element
|
||||
/// (heap address 0).
|
||||
pub fn element(
|
||||
&mut self,
|
||||
elem: &[u8],
|
||||
base_size: usize,
|
||||
) -> Result<Option<&'a [u8]>, FormatError> {
|
||||
let vl = parse_vl_references(elem, 1, self.offset_size)?;
|
||||
self.resolve(&vl[0], base_size)
|
||||
}
|
||||
|
||||
/// One variable-length string element: its bytes up to the first NUL,
|
||||
/// or `None` for a null element (h5dump prints it as `NULL`, h5py
|
||||
/// returns it as empty).
|
||||
pub fn string_element(&mut self, elem: &[u8]) -> Result<Option<&'a [u8]>, FormatError> {
|
||||
Ok(self.element(elem, 1)?.map(cut_at_nul))
|
||||
}
|
||||
|
||||
/// The strings of the variable-length string elements in `raw`, as
|
||||
/// bytes. A string ends at its first NUL, as libhdf5 returns it (it
|
||||
/// converts each to a C string); a null element is empty.
|
||||
pub fn string_bytes(&mut self, raw: &[u8]) -> Result<Vec<Vec<u8>>, FormatError> {
|
||||
self.elements(raw)?
|
||||
.iter()
|
||||
.map(|vl| Ok(self.resolve(vl, 1)?.map(cut_at_nul).unwrap_or(&[]).to_vec()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The strings of the variable-length string elements in `raw`, decoded
|
||||
/// as UTF-8 with invalid sequences replaced by U+FFFD (see
|
||||
/// [`string_bytes`](Self::string_bytes) for the exact bytes).
|
||||
pub fn strings(&mut self, raw: &[u8]) -> Result<Vec<String>, FormatError> {
|
||||
Ok(self
|
||||
.string_bytes(raw)?
|
||||
.into_iter()
|
||||
.map(|b| match String::from_utf8(b) {
|
||||
Ok(s) => s,
|
||||
Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The sequences of the variable-length sequence elements in `raw`, each
|
||||
/// as its `length × base_size` bytes in the base type's encoding.
|
||||
pub fn sequences(&mut self, raw: &[u8], base_size: usize) -> Result<Vec<Vec<u8>>, FormatError> {
|
||||
if base_size == 0 {
|
||||
return Err(FormatError::VlDataError(
|
||||
"variable-length sequence of a zero-size base type".into(),
|
||||
));
|
||||
}
|
||||
self.elements(raw)?
|
||||
.iter()
|
||||
.map(|vl| Ok(self.resolve(vl, base_size)?.unwrap_or(&[]).to_vec()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// A string's bytes up to its first NUL.
|
||||
fn cut_at_nul(s: &[u8]) -> &[u8] {
|
||||
&s[..s.iter().position(|&b| b == 0).unwrap_or(s.len())]
|
||||
}
|
||||
|
||||
/// Resolve VL strings from raw data by looking up each element in the global heap.
|
||||
///
|
||||
/// Reads the first `num_elements` elements of `raw`. Strings end at their
|
||||
/// first NUL and invalid UTF-8 is replaced, as in [`VlResolver::strings`].
|
||||
pub fn read_vl_strings(
|
||||
file_data: &[u8],
|
||||
raw_data: &[u8],
|
||||
@@ -117,35 +330,23 @@ pub fn read_vl_strings(
|
||||
offset_size: u8,
|
||||
length_size: u8,
|
||||
) -> Result<Vec<String>, FormatError> {
|
||||
let refs = parse_vl_references(raw_data, num_elements, offset_size)?;
|
||||
let mut result = Vec::with_capacity(refs.len());
|
||||
let raw = first_elements(raw_data, num_elements, offset_size)?;
|
||||
VlResolver::new(file_data, offset_size, length_size).strings(raw)
|
||||
}
|
||||
|
||||
for vl in &refs {
|
||||
if vl.length == 0 && is_undefined_address(vl.collection_address, offset_size) {
|
||||
result.push(String::new());
|
||||
continue;
|
||||
}
|
||||
if vl.length == 0 && vl.collection_address == 0 {
|
||||
result.push(String::new());
|
||||
continue;
|
||||
}
|
||||
|
||||
let coll =
|
||||
GlobalHeapCollection::parse(file_data, vl.collection_address as usize, length_size)?;
|
||||
let obj = coll.get_object(vl.object_index as u16).ok_or(
|
||||
FormatError::GlobalHeapObjectNotFound {
|
||||
collection_address: vl.collection_address,
|
||||
index: vl.object_index as u16,
|
||||
},
|
||||
)?;
|
||||
|
||||
// The object data is the raw string bytes
|
||||
let len = (vl.length as usize).min(obj.data.len());
|
||||
let s = String::from_utf8_lossy(&obj.data[..len]).into_owned();
|
||||
result.push(s);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
/// The first `num_elements` elements of `raw`, or an error if it is shorter.
|
||||
fn first_elements(raw: &[u8], num_elements: u64, offset_size: u8) -> Result<&[u8], FormatError> {
|
||||
let total = usize::try_from(num_elements)
|
||||
.ok()
|
||||
.and_then(|n| n.checked_mul(element_size(offset_size)))
|
||||
.ok_or(FormatError::UnexpectedEof {
|
||||
expected: usize::MAX,
|
||||
available: raw.len(),
|
||||
})?;
|
||||
raw.get(..total).ok_or(FormatError::UnexpectedEof {
|
||||
expected: total,
|
||||
available: raw.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve VL sequences from raw data, returning each element's bytes.
|
||||
@@ -153,7 +354,9 @@ pub fn read_vl_strings(
|
||||
/// Each element is the sequence's full encoding — element count × base type
|
||||
/// size bytes, in the base type's byte order — so a sequence of `i32` yields
|
||||
/// four bytes per value. Decode it with the base type (e.g.
|
||||
/// [`crate::data_read::read_as_i64`]).
|
||||
/// [`crate::data_read::read_as_i64`]). This does not know the base type, so
|
||||
/// it returns each heap object whole; [`VlResolver::sequences`] also checks
|
||||
/// the object's size against the element's length.
|
||||
pub fn read_vl_bytes(
|
||||
file_data: &[u8],
|
||||
raw_data: &[u8],
|
||||
@@ -162,35 +365,97 @@ pub fn read_vl_bytes(
|
||||
length_size: u8,
|
||||
) -> Result<Vec<Vec<u8>>, FormatError> {
|
||||
let refs = parse_vl_references(raw_data, num_elements, offset_size)?;
|
||||
let mut resolver = VlResolver::new(file_data, offset_size, length_size);
|
||||
let mut result = Vec::with_capacity(refs.len());
|
||||
|
||||
for vl in &refs {
|
||||
if vl.length == 0
|
||||
&& (is_undefined_address(vl.collection_address, offset_size)
|
||||
|| vl.collection_address == 0)
|
||||
{
|
||||
// A heap address of 0 is a null element, as in VlResolver.
|
||||
if vl.collection_address == 0 {
|
||||
result.push(Vec::new());
|
||||
continue;
|
||||
}
|
||||
|
||||
let coll =
|
||||
GlobalHeapCollection::parse(file_data, vl.collection_address as usize, length_size)?;
|
||||
let obj = coll.get_object(vl.object_index as u16).ok_or(
|
||||
FormatError::GlobalHeapObjectNotFound {
|
||||
collection_address: vl.collection_address,
|
||||
index: vl.object_index as u16,
|
||||
},
|
||||
)?;
|
||||
|
||||
// The heap object holds the whole sequence. `vl.length` counts
|
||||
// elements, not bytes, so it is only the byte length when the base
|
||||
// type is one byte wide.
|
||||
result.push(obj.data.clone());
|
||||
let obj = resolver.object(vl)?;
|
||||
result.push(obj.to_vec());
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
impl<'a> VlResolver<'a> {
|
||||
/// The heap object `vl` points to, whatever its size; its collection is
|
||||
/// parsed on first use.
|
||||
fn object(&mut self, vl: &VlElement) -> Result<&'a [u8], FormatError> {
|
||||
let addr = vl.collection_address;
|
||||
// libhdf5 writes a null element with address 0, never the undefined
|
||||
// address, and fails to read one ("addr undefined") even when its
|
||||
// length is 0; we returned an empty value.
|
||||
if is_undefined_address(addr, self.offset_size) {
|
||||
return Err(FormatError::VlDataError(format!(
|
||||
"variable-length element (length {}) has the undefined global heap address",
|
||||
vl.length
|
||||
)));
|
||||
}
|
||||
if !self.cache.contains_key(&addr) {
|
||||
let offset = usize::try_from(addr).map_err(|_| FormatError::UnexpectedEof {
|
||||
expected: usize::MAX,
|
||||
available: self.file_data.len(),
|
||||
})?;
|
||||
let index =
|
||||
GlobalHeapCollection::parse_index(self.file_data, offset, self.length_size)?;
|
||||
// parse_index checked that the collection lies in the file.
|
||||
let end = offset + index.collection_size as usize;
|
||||
self.check_overlap(offset, end)?;
|
||||
let coll = CachedCollection::new(index);
|
||||
if self.cached_bytes.saturating_add(coll.cost()) > self.budget {
|
||||
self.cache.clear();
|
||||
self.cached_bytes = 0;
|
||||
}
|
||||
self.cached_bytes += coll.cost();
|
||||
self.cache.insert(addr, coll);
|
||||
}
|
||||
let (start, size) = self.cache[&addr].get(vl.object_index).ok_or(
|
||||
FormatError::GlobalHeapObjectNotFound {
|
||||
collection_address: addr,
|
||||
index: vl.object_index as u16,
|
||||
},
|
||||
)?;
|
||||
Ok(&self.file_data[start..start + size])
|
||||
}
|
||||
|
||||
/// Record the collection at `start..end`, refusing one that overlaps a
|
||||
/// collection already read. libhdf5 allocates each collection its own
|
||||
/// block; overlapping ones only come from a crafted file, where they let
|
||||
/// every byte be parsed again as the objects of each collection.
|
||||
fn check_overlap(&mut self, start: usize, end: usize) -> Result<(), FormatError> {
|
||||
if let Some(&known) = self.extents.get(&start) {
|
||||
return if known == end {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(FormatError::VlDataError(format!(
|
||||
"global heap collection at {start} changed size"
|
||||
)))
|
||||
};
|
||||
}
|
||||
let before = self.extents.range(..start).next_back();
|
||||
let after = self.extents.range(start..).next();
|
||||
let clash = match (before, after) {
|
||||
(Some((&s, &e)), _) if e > start => Some(s),
|
||||
(_, Some((&s, _))) if s < end => Some(s),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(other) = clash {
|
||||
return Err(FormatError::VlDataError(format!(
|
||||
"global heap collection at {start} overlaps the one at {other}"
|
||||
)));
|
||||
}
|
||||
self.extents.insert(start, end);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -285,16 +550,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_vl_element_empty_string() {
|
||||
// length=0, address=undefined
|
||||
let mut raw = Vec::new();
|
||||
raw.extend_from_slice(&0u32.to_le_bytes()); // length=0
|
||||
raw.extend_from_slice(&u64::MAX.to_le_bytes()); // undefined address
|
||||
raw.extend_from_slice(&0u32.to_le_bytes()); // index
|
||||
|
||||
let file_data = vec![0u8; 16];
|
||||
let strings = read_vl_strings(&file_data, &raw, 1, 8, 8).unwrap();
|
||||
assert_eq!(strings, vec![""]);
|
||||
fn an_undefined_heap_address_is_an_error_even_at_length_0() {
|
||||
// libhdf5 fails the read ("addr undefined"); h5py and libhdf5 write
|
||||
// a null element with address 0. We returned "".
|
||||
let mut file_data = vec![0u8; 256];
|
||||
build_gcol_at(&mut file_data, 64, &[(1, b"x")]);
|
||||
for (os, undef) in [(8u8, u64::MAX), (4, 0xFFFF_FFFF)] {
|
||||
for length in [0, 1] {
|
||||
let mut raw = element(1, 64, 1, os);
|
||||
raw.extend(element(length, undef, 1, os));
|
||||
let mut r = VlResolver::new(&file_data, os, 8);
|
||||
let e = r.string_bytes(&raw).unwrap_err().to_string();
|
||||
assert!(e.contains("undefined"), "{e}");
|
||||
assert!(r.sequences(&raw, 1).is_err());
|
||||
assert!(r.string_element(&raw[raw.len() / 2..]).is_err());
|
||||
let n = 2;
|
||||
assert!(read_vl_strings(&file_data, &raw, n, os, 8).is_err());
|
||||
assert!(read_vl_bytes(&file_data, &raw, n, os, 8).is_err());
|
||||
// The defined element alone still reads.
|
||||
assert_eq!(r.strings(&raw[..raw.len() / 2]).unwrap(), ["x"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -333,6 +609,126 @@ mod tests {
|
||||
assert_eq!(bytes, vec![vec![0xDE, 0xAD], vec![0xBE, 0xEF, 0xCA]]);
|
||||
}
|
||||
|
||||
fn element(length: u32, addr: u64, index: u32, offset_size: u8) -> Vec<u8> {
|
||||
let mut raw = length.to_le_bytes().to_vec();
|
||||
raw.extend_from_slice(&addr.to_le_bytes()[..offset_size as usize]);
|
||||
raw.extend_from_slice(&index.to_le_bytes());
|
||||
raw
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_end_at_the_first_nul() {
|
||||
// libhdf5 hands each VL string over as a C string, so h5py sees
|
||||
// "a\0b" as "a"; we used to return the NUL and what followed.
|
||||
let mut file_data = vec![0u8; 512];
|
||||
build_gcol_at(&mut file_data, 64, &[(1, b"a\0b"), (2, b"cd")]);
|
||||
let mut raw = element(3, 64, 1, 8);
|
||||
raw.extend(element(2, 64, 2, 8));
|
||||
let mut r = VlResolver::new(&file_data, 8, 8);
|
||||
assert_eq!(
|
||||
r.string_bytes(&raw).unwrap(),
|
||||
vec![b"a".to_vec(), b"cd".to_vec()]
|
||||
);
|
||||
assert_eq!(
|
||||
read_vl_strings(&file_data, &raw, 2, 8, 8).unwrap(),
|
||||
["a", "cd"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_heap_object_of_the_wrong_size_is_an_error() {
|
||||
// libhdf5: "Expected global heap object size does not match". We
|
||||
// used to return the object cut to the element's length.
|
||||
let mut file_data = vec![0u8; 512];
|
||||
build_gcol_at(&mut file_data, 64, &[(1, b"cdefgh"), (2, &[1, 0, 0, 0])]);
|
||||
let mut r = VlResolver::new(&file_data, 8, 8);
|
||||
assert!(r.string_bytes(&element(3, 64, 1, 8)).is_err());
|
||||
assert!(r.string_bytes(&element(9, 64, 1, 8)).is_err());
|
||||
assert!(read_vl_strings(&file_data, &element(3, 64, 1, 8), 1, 8, 8).is_err());
|
||||
// A sequence of one i32 is 4 bytes; of two, 8.
|
||||
assert_eq!(
|
||||
r.sequences(&element(1, 64, 2, 8), 4).unwrap(),
|
||||
vec![vec![1, 0, 0, 0]]
|
||||
);
|
||||
assert!(r.sequences(&element(2, 64, 2, 8), 4).is_err());
|
||||
assert!(r.sequences(&element(1, 64, 2, 8), 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn address_zero_is_null_whatever_the_length() {
|
||||
// libhdf5 treats a heap address of 0 as a null element.
|
||||
let file_data = vec![0u8; 64];
|
||||
let mut r = VlResolver::new(&file_data, 8, 8);
|
||||
assert_eq!(
|
||||
r.string_bytes(&element(5, 0, 1, 8)).unwrap(),
|
||||
vec![Vec::<u8>::new()]
|
||||
);
|
||||
assert_eq!(
|
||||
r.sequences(&element(5, 0, 1, 8), 4).unwrap(),
|
||||
vec![Vec::<u8>::new()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn four_byte_offsets_use_twelve_byte_elements() {
|
||||
let mut file_data = vec![0u8; 512];
|
||||
build_gcol_at(&mut file_data, 64, &[(1, b"one"), (2, b""), (3, b"three")]);
|
||||
let mut raw = element(3, 64, 1, 4);
|
||||
raw.extend(element(0, 64, 2, 4));
|
||||
raw.extend(element(5, 64, 3, 4));
|
||||
assert_eq!(raw.len(), 36);
|
||||
let mut r = VlResolver::new(&file_data, 4, 8);
|
||||
assert_eq!(r.element_size(), 12);
|
||||
assert_eq!(r.strings(&raw).unwrap(), ["one", "", "three"]);
|
||||
// Not a whole number of elements.
|
||||
assert!(r.strings(&raw[..30]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cache_stays_within_its_budget_and_rereads_what_it_dropped() {
|
||||
// Twenty collections of three objects each; a budget that holds
|
||||
// about two of them. Reading every element twice must still return
|
||||
// the right strings after the cache is dropped.
|
||||
let mut file_data = vec![0u8; 64];
|
||||
let mut raw = Vec::new();
|
||||
for c in 0..20u64 {
|
||||
let at = file_data.len();
|
||||
let names: Vec<String> = (0..3).map(|i| format!("c{c}o{i}")).collect();
|
||||
let objs: Vec<(u16, &[u8])> = names
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, n)| (i as u16 + 1, n.as_bytes()))
|
||||
.collect();
|
||||
build_gcol_at(&mut file_data, at, &objs);
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
raw.extend(element(n.len() as u32, at as u64, i as u32 + 1, 8));
|
||||
}
|
||||
}
|
||||
raw.extend(raw.clone());
|
||||
let mut r = VlResolver::new(&file_data, 8, 8);
|
||||
let one = CachedCollection {
|
||||
objects: vec![(0, 0, 0); 3],
|
||||
}
|
||||
.cost();
|
||||
r.budget = 2 * one + 1;
|
||||
let want: Vec<String> = (0..2)
|
||||
.flat_map(|_| (0..20).flat_map(|c| (0..3).map(move |i| format!("c{c}o{i}"))))
|
||||
.collect();
|
||||
for (k, chunk) in raw.chunks(16).enumerate() {
|
||||
assert_eq!(r.strings(chunk).unwrap(), [want[k].clone()]);
|
||||
assert!(r.cached_bytes <= r.budget);
|
||||
assert!(r.cache.len() <= 2);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn element_size_is_checked_against_the_offset_size() {
|
||||
assert!(check_element_size(16, 8).is_ok());
|
||||
assert!(check_element_size(12, 4).is_ok());
|
||||
assert!(check_element_size(16, 4).is_err());
|
||||
assert!(check_element_size(524_304, 8).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_vl_references_truncated_error() {
|
||||
let raw = vec![0u8; 10]; // too short for 1 element with offset_size=8
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Crafted files cannot make variable-length reads retain memory, or take
|
||||
//! time, out of proportion to the file.
|
||||
//!
|
||||
//! `VlResolver` used to keep an owned copy of every object of every
|
||||
//! collection it parsed, for the whole read. A file whose global heap
|
||||
//! collections nest inside each other's object data — each element
|
||||
//! pointing at a different one — then made retained memory O(K × file
|
||||
//! size): a 744 KB file took 1.58 GB. The same nesting, with every
|
||||
//! collection's object chain jumping to one shared run of tiny objects,
|
||||
//! made the parse time O(K × M) as well. libhdf5 never writes overlapping
|
||||
//! collections; they are now refused, and the cache holds only where
|
||||
//! objects lie.
|
||||
//!
|
||||
//! Peak heap use is measured with a counting global allocator, so the
|
||||
//! cases run one after another in a single test.
|
||||
|
||||
use std::alloc::{GlobalAlloc, Layout, System};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use clawhdf5_format::vl_data::VlResolver;
|
||||
|
||||
struct Counting;
|
||||
|
||||
static CURRENT: AtomicUsize = AtomicUsize::new(0);
|
||||
static PEAK: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
unsafe impl GlobalAlloc for Counting {
|
||||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||||
let p = unsafe { System.alloc(layout) };
|
||||
if !p.is_null() {
|
||||
let now = CURRENT.fetch_add(layout.size(), Ordering::Relaxed) + layout.size();
|
||||
PEAK.fetch_max(now, Ordering::Relaxed);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||||
unsafe { System.dealloc(ptr, layout) };
|
||||
CURRENT.fetch_sub(layout.size(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[global_allocator]
|
||||
static ALLOC: Counting = Counting;
|
||||
|
||||
/// Bytes allocated at the peak of `f`, above what was live when it started.
|
||||
fn peak_during<T>(f: impl FnOnce() -> T) -> (T, usize) {
|
||||
let base = CURRENT.load(Ordering::Relaxed);
|
||||
PEAK.store(base, Ordering::Relaxed);
|
||||
let out = f();
|
||||
(out, PEAK.load(Ordering::Relaxed) - base)
|
||||
}
|
||||
|
||||
fn put_header(file: &mut [u8], at: usize, size: u64) {
|
||||
file[at..at + 4].copy_from_slice(b"GCOL");
|
||||
file[at + 4] = 1;
|
||||
file[at + 8..at + 16].copy_from_slice(&size.to_le_bytes());
|
||||
}
|
||||
|
||||
fn put_object(file: &mut [u8], at: usize, index: u16, size: u64) {
|
||||
file[at..at + 2].copy_from_slice(&index.to_le_bytes());
|
||||
file[at + 2..at + 4].copy_from_slice(&1u16.to_le_bytes());
|
||||
file[at + 8..at + 16].copy_from_slice(&size.to_le_bytes());
|
||||
}
|
||||
|
||||
fn element(length: u32, addr: u64, index: u32) -> Vec<u8> {
|
||||
let mut e = length.to_le_bytes().to_vec();
|
||||
e.extend_from_slice(&addr.to_le_bytes());
|
||||
e.extend_from_slice(&index.to_le_bytes());
|
||||
e
|
||||
}
|
||||
|
||||
/// K collections 32 bytes apart, each running to the end of the file with
|
||||
/// one object covering the rest of it (and so every later collection).
|
||||
/// Element i is that object of collection i.
|
||||
fn nested(k: usize) -> (Vec<u8>, Vec<u8>) {
|
||||
let base = 64;
|
||||
let end = base + 32 * k + 64;
|
||||
let mut file = vec![0u8; end];
|
||||
let mut raw = Vec::new();
|
||||
for i in 0..k {
|
||||
let at = base + 32 * i;
|
||||
put_header(&mut file, at, (end - at) as u64);
|
||||
let obj = (end - at - 32) as u64;
|
||||
put_object(&mut file, at + 16, 1, obj);
|
||||
raw.extend(element(obj as u32, at as u64, 1));
|
||||
}
|
||||
(file, raw)
|
||||
}
|
||||
|
||||
/// K collections 32 bytes apart, each with a first object that jumps over
|
||||
/// the later collections to one shared run of M empty objects, so parsing
|
||||
/// every collection walks all M.
|
||||
fn shared_tail(k: usize, m: usize) -> (Vec<u8>, Vec<u8>) {
|
||||
let base = 64;
|
||||
let tail = base + 32 * k + 32;
|
||||
let end = tail + 16 * m + 16;
|
||||
let mut file = vec![0u8; end];
|
||||
let mut raw = Vec::new();
|
||||
for i in 0..k {
|
||||
let at = base + 32 * i;
|
||||
put_header(&mut file, at, (end - at) as u64);
|
||||
let jump = (tail - at - 32) as u64;
|
||||
put_object(&mut file, at + 16, 1, jump);
|
||||
raw.extend(element(jump as u32, at as u64, 1));
|
||||
}
|
||||
for j in 0..m {
|
||||
put_object(&mut file, tail + 16 * j, (j % 65_000 + 2) as u16, 0);
|
||||
}
|
||||
(file, raw)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_collections_are_refused_in_bounded_memory_and_time() {
|
||||
for (name, (file, raw)) in [
|
||||
("nested", nested(2000)),
|
||||
("shared tail", shared_tail(500, 10_000)),
|
||||
] {
|
||||
let start = Instant::now();
|
||||
let (result, peak) = peak_during(|| {
|
||||
let mut r = VlResolver::new(&file, 8, 8);
|
||||
(r.string_bytes(&raw), r.sequences(&raw, 1).map(|s| s.len()))
|
||||
});
|
||||
let took = start.elapsed();
|
||||
// libhdf5 never writes overlapping collections, and refuses these
|
||||
// files; so do we, rather than returning what they claim.
|
||||
let (strings, sequences) = result;
|
||||
let e = strings.expect_err(name).to_string();
|
||||
assert!(e.contains("overlaps"), "{name}: {e}");
|
||||
assert!(sequences.is_err(), "{name}");
|
||||
// Measured before the fix: 129 MB ("nested", 64 KB file) and 350 MB
|
||||
// ("shared tail", 176 KB file) live at the peak; after, 97 KB and
|
||||
// 0.9 MB.
|
||||
assert!(
|
||||
peak < 4 * file.len() + (1 << 20),
|
||||
"{name}: peak {peak} bytes for a {}-byte file",
|
||||
file.len()
|
||||
);
|
||||
assert!(took < Duration::from_secs(5), "{name}: took {took:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Collections that do not overlap still read, however many elements point
|
||||
/// into them, and the first object of a collection is returned for its
|
||||
/// index (as before).
|
||||
#[test]
|
||||
fn separate_collections_still_read() {
|
||||
let mut file = vec![0u8; 64 + 3 * 64];
|
||||
let mut raw = Vec::new();
|
||||
for i in 0..3usize {
|
||||
let at = 64 + 64 * i;
|
||||
put_header(&mut file, at, 64);
|
||||
put_object(&mut file, at + 16, 1, 3);
|
||||
file[at + 32..at + 35].copy_from_slice(format!("s{i}!").as_bytes());
|
||||
raw.extend(element(3, at as u64, 1));
|
||||
}
|
||||
raw.extend(element(3, 64, 1));
|
||||
let mut r = VlResolver::new(&file, 8, 8);
|
||||
assert_eq!(r.strings(&raw).unwrap(), ["s0!", "s1!", "s2!", "s0!"]);
|
||||
}
|
||||
Reference in New Issue
Block a user