- F1: materialize_plan_array (validate_bytes' packed path) now carries the zero-progress array guard the reader and legacy walker already had; validate_bytes no longer accepts an empty buffer against a stride-0 empty-struct-element array that SequentialReader rejects. Cross-consumer agreement test added (review #007 probe transcript). - F2: MAX_LENGTH = 2^26 cap on the maxLength annotation — the N2 dual-layer pattern (clean Schema parse error naming value+maximum, meta-schema "maximum": 67108864 so the published contract matches). Also closes the silent usize-overflow drop in parse_max_length. - docs/reviews/007-coverage-audit.md records the full audit: per-file numbers, all classifications, and the N3a dead-surface list deferred to the pre-release review. Verification: 477 lib + 78 integration tests green, clippy -D warnings clean, wasm32-unknown-unknown build green.
1596 lines
64 KiB
Rust
1596 lines
64 KiB
Rust
//! Aligned static `OffsetMap` — Mode 2 of the two layout modes (ADR-002).
|
||
//!
|
||
//! Fields have fixed positions with natural alignment padding.
|
||
//! Variable-length fields get a 4-byte length prefix at a known offset;
|
||
//! the variable data is not included in the static layout. Used for
|
||
//! mmap-friendly formats (metatensor, safetensors).
|
||
//!
|
||
//! The offset computation is a recursive walk of the BAST typed tree
|
||
//! ([`crate::bast::BastDoc`]). Nested structs propagate field path
|
||
//! prefixes (producing dotted paths like `"header.version"`). Alignment
|
||
//! padding is inserted before each field to satisfy the field's alignment
|
||
//! requirement (natural alignment by default, overridable via the
|
||
//! `"align"` annotation).
|
||
|
||
use crate::bast::{
|
||
BastArray, BastDefKind, BastDoc, BastField, BastStruct, BastType,
|
||
};
|
||
use crate::error::AlkTypeError;
|
||
use crate::schema::{AlkTypeKind, Endian, VariableEncoding, MAX_ARRAY_BYTES};
|
||
use std::collections::BTreeMap;
|
||
|
||
/// A byte range within a buffer.
|
||
///
|
||
/// Produced by [`OffsetMap::compute`] for each field in a schema. The
|
||
/// range is half-open: `start..end`. `end - start` is the field's byte
|
||
/// size in the static layout (for variable-length fields, this is the
|
||
/// size of the length prefix, the `{offset, length}` pair, or the
|
||
/// `maxLength` reservation — not the variable data itself).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct ByteRange {
|
||
/// Inclusive start byte offset.
|
||
pub start: usize,
|
||
/// Exclusive end byte offset.
|
||
pub end: usize,
|
||
}
|
||
|
||
impl ByteRange {
|
||
/// Byte length of the range (`end - start`).
|
||
pub fn len(&self) -> usize {
|
||
self.end - self.start
|
||
}
|
||
|
||
/// True if the range covers zero bytes.
|
||
pub fn is_empty(&self) -> bool {
|
||
self.end == self.start
|
||
}
|
||
}
|
||
|
||
/// The resolved leaf metadata needed to read or write a field's bytes:
|
||
/// the field's kind, its variable-length encoding, and its effective
|
||
/// endianness (field override, else the enclosing struct/union default,
|
||
/// propagated the same way the aligned materializer propagates it — a
|
||
/// nested container inherits the *referring field's* effective endian).
|
||
///
|
||
/// Computed at [`OffsetMap::compute`] time so `read_field`/`write_field`
|
||
/// don't re-walk the BAST tree per access (ADR-012 §2b).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct LeafMeta {
|
||
/// The leaf's type kind.
|
||
pub kind: AlkTypeKind,
|
||
/// The variable-length encoding strategy (meaningful for
|
||
/// `String`/`Bytes`/`Record` leaves).
|
||
pub encoding: VariableEncoding,
|
||
/// The effective endianness for reads/writes at this leaf.
|
||
pub endian: Endian,
|
||
}
|
||
|
||
/// A field's offset-map entry: its byte range plus its [`LeafMeta`].
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct OffsetEntry {
|
||
/// The field's byte range in the static layout.
|
||
pub range: ByteRange,
|
||
/// The resolved leaf metadata (kind, encoding, effective endian).
|
||
pub meta: LeafMeta,
|
||
}
|
||
|
||
impl OffsetEntry {
|
||
/// The entry's start byte offset.
|
||
pub fn start(&self) -> usize {
|
||
self.range.start
|
||
}
|
||
|
||
/// The entry's exclusive end byte offset.
|
||
pub fn end(&self) -> usize {
|
||
self.range.end
|
||
}
|
||
}
|
||
|
||
/// A flat table of `(field_path, OffsetEntry)` pairs computed from a
|
||
/// BAST document.
|
||
///
|
||
/// Fields have fixed positions with natural alignment padding.
|
||
/// Used for mmap-friendly formats (metatensor, safetensors) where random
|
||
/// access by field path is required — the consumer can read field N
|
||
/// without reading fields `0..N-1` first.
|
||
///
|
||
/// Construct via [`OffsetMap::compute`]. Variable-length fields appear
|
||
/// in the table as their fixed-position portion only (length prefix,
|
||
/// `{offset, length}` pair, or `maxLength` reservation); the variable
|
||
/// data lives outside the static layout.
|
||
///
|
||
/// `PartialEq`/`Eq`/`Hash`: two equal maps produce identical
|
||
/// read/write behavior over identical buffers (the compute is a pure
|
||
/// function of the document).
|
||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||
pub struct OffsetMap {
|
||
fields: Vec<(String, OffsetEntry)>,
|
||
index: BTreeMap<String, usize>,
|
||
total_size: usize,
|
||
}
|
||
|
||
impl OffsetMap {
|
||
/// Compute the offset map from a BAST document.
|
||
///
|
||
/// Walks the BAST typed tree recursively, computing byte positions
|
||
/// for each field based on type sizes, field order, and alignment.
|
||
/// The root type must be a struct.
|
||
///
|
||
/// # Errors
|
||
///
|
||
/// Returns [`AlkTypeError::Schema`] if the root type is not a struct,
|
||
/// or if the reference graph nests deeper than the walk depth cap
|
||
/// (128) or contains a `$ref` cycle (review #006 H2 — the compute
|
||
/// walk's struct recursion is unguarded, so cyclic input must be
|
||
/// rejected before the walk, not during it).
|
||
/// Returns [`AlkTypeError::Offset`] for unsupported type combinations
|
||
/// encountered during the walk (e.g. unions, which are rejected in
|
||
/// aligned mode per ADR-008).
|
||
pub fn compute(doc: &BastDoc) -> Result<Self, AlkTypeError> {
|
||
let root_def = doc.root_def();
|
||
let struct_node = match root_def.kind() {
|
||
BastDefKind::Struct(s) => s,
|
||
other => {
|
||
return Err(AlkTypeError::Schema(format!(
|
||
"OffsetMap::compute requires a struct at the root, got {kind}",
|
||
kind = other.alk_kind()
|
||
)));
|
||
}
|
||
};
|
||
crate::walk_guard::check_ref_graph(doc)?;
|
||
let mut ctx = ComputeCtx {
|
||
doc,
|
||
fields: Vec::new(),
|
||
offset: 0,
|
||
};
|
||
let struct_default_align = struct_node.align().unwrap_or(1).max(1);
|
||
let endian = struct_node.endian();
|
||
let (total, _align) = ctx.compute_struct(struct_node, "", struct_default_align, endian)?;
|
||
let index = Self::build_index(&ctx.fields);
|
||
Ok(Self {
|
||
fields: ctx.fields,
|
||
index,
|
||
total_size: total,
|
||
})
|
||
}
|
||
|
||
/// Look up a field's entry by dotted path (e.g., `"header.version"`).
|
||
///
|
||
/// Returns `None` if no field with the given path was recorded. For
|
||
/// TUnion byte-offset discriminators, the discriminator is recorded
|
||
/// under the synthetic path `"__discriminator"` (qualified by the
|
||
/// union field's path, e.g. `"payload.__discriminator"`).
|
||
pub fn get(&self, field_path: &str) -> Option<&OffsetEntry> {
|
||
let idx = *self.index.get(field_path)?;
|
||
self.fields.get(idx).map(|(_, entry)| entry)
|
||
}
|
||
|
||
/// The total size of the struct in bytes (including trailing alignment padding).
|
||
pub fn total_size(&self) -> usize {
|
||
self.total_size
|
||
}
|
||
|
||
/// Iterate over all `(field_path, OffsetEntry)` pairs in insertion order.
|
||
///
|
||
/// Field order matches the BAST `fields` array order. Nested struct
|
||
/// fields appear after their parent's path prefix.
|
||
pub fn iter(&self) -> impl Iterator<Item = (&str, &OffsetEntry)> {
|
||
self.fields.iter().map(|(path, entry)| (path.as_str(), entry))
|
||
}
|
||
|
||
/// A stable-within-version hash of the map (ADR-012 §1/§4).
|
||
///
|
||
/// Two maps with equal fingerprints (equal hashes) produce identical
|
||
/// read/write behavior over identical buffers. Cross-version
|
||
/// stability is a non-goal (ADR-012). Uses `DefaultHasher` (std, no
|
||
/// new dep); the fingerprint is not hot.
|
||
pub fn fingerprint(&self) -> u64 {
|
||
use std::hash::{Hash, Hasher};
|
||
let mut h = std::hash::DefaultHasher::new();
|
||
self.hash(&mut h);
|
||
h.finish()
|
||
}
|
||
|
||
/// Build the path→index lookup table over the insertion-ordered
|
||
/// `fields` vec. First occurrence wins on duplicate paths (matching
|
||
/// the linear-scan `find` this index replaced — `BastStruct::parse`
|
||
/// does not reject duplicate field names, so the semantic must be
|
||
/// preserved, review #006 L4).
|
||
fn build_index(fields: &[(String, OffsetEntry)]) -> BTreeMap<String, usize> {
|
||
let mut index = BTreeMap::new();
|
||
for (i, (path, _)) in fields.iter().enumerate() {
|
||
index.entry(path.clone()).or_insert(i);
|
||
}
|
||
index
|
||
}
|
||
}
|
||
|
||
/// Mutable context threaded through the recursive offset computation.
|
||
///
|
||
/// Carries the running `offset`, the accumulating `fields` vec, and a
|
||
/// reference to the BAST doc for `$ref` resolution. Grouping these
|
||
/// keeps the recursive helper signatures small.
|
||
struct ComputeCtx<'d> {
|
||
doc: &'d BastDoc,
|
||
fields: Vec<(String, OffsetEntry)>,
|
||
offset: usize,
|
||
}
|
||
|
||
/// Result of laying out a single field: its alignment.
|
||
struct FieldLayout {
|
||
align: usize,
|
||
}
|
||
|
||
impl<'d> ComputeCtx<'d> {
|
||
/// Recurse into a `BastStruct`, appending `(field_path, ByteRange)`
|
||
/// pairs to `self.fields` and advancing `self.offset`.
|
||
///
|
||
/// Returns `(total_size, alignment)` where `total_size` includes
|
||
/// trailing alignment padding and `alignment` is the struct's
|
||
/// effective alignment (its own `align` annotation, or the max of its
|
||
/// fields' alignments).
|
||
///
|
||
/// `prefix` is the dotted path prefix for nested fields (empty at the
|
||
/// top level). `parent_struct_align` is the default alignment a field
|
||
/// inherits when it specifies neither its own `align` annotation nor
|
||
/// a natural alignment larger than the default.
|
||
fn compute_struct(
|
||
&mut self,
|
||
struct_node: &BastStruct,
|
||
prefix: &str,
|
||
parent_struct_align: usize,
|
||
endian: Endian,
|
||
) -> Result<(usize, usize), AlkTypeError> {
|
||
let struct_default_align = struct_node.align().unwrap_or(parent_struct_align).max(1);
|
||
let mut max_align: usize = 1;
|
||
let struct_start = self.offset;
|
||
let fields = struct_node.fields();
|
||
let field_count = fields.len();
|
||
for (i, field) in fields.iter().enumerate() {
|
||
let field_path = if prefix.is_empty() {
|
||
field.name().to_string()
|
||
} else {
|
||
format!("{prefix}.{}", field.name())
|
||
};
|
||
if i < field_count - 1 {
|
||
if let Some(kind) = field_variable_kind(field) {
|
||
let encoding = field.encoding();
|
||
let max_length = field.max_length();
|
||
let is_inline_length_prefixed =
|
||
encoding == VariableEncoding::LengthPrefixed && max_length.is_none();
|
||
if is_inline_length_prefixed {
|
||
let remedy = if kind == AlkTypeKind::Record {
|
||
// For records both annotated remedies are
|
||
// dead ends: `maxLength` is rejected at
|
||
// parse (review #006 N3) and
|
||
// `offset-indirect` is rejected right
|
||
// below (review #006 M5) — moving to the
|
||
// last position is the only fix.
|
||
"move this field to the last position in the struct (for a \
|
||
record field, neither `maxLength` nor `offset-indirect` \
|
||
is available: `maxLength` is rejected at parse — review \
|
||
#006 N3 — and `offset-indirect` is rejected for records \
|
||
in aligned mode — review #006 M5)"
|
||
} else {
|
||
"Use `maxLength` (fixed-size reservation) or \
|
||
`\"encoding\": \"offset-indirect\"`, or move this \
|
||
field to the last position in the struct"
|
||
};
|
||
return Err(AlkTypeError::Offset {
|
||
field_path: field_path.clone(),
|
||
reason: format!(
|
||
"non-final inline length-prefixed variable field \
|
||
({kind}) in aligned mode: the variable data would \
|
||
clobber subsequent fields. {remedy}. (ADR-006)"
|
||
),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
let layout = self.compute_field(field, &field_path, struct_default_align, endian)?;
|
||
if layout.align > max_align {
|
||
max_align = layout.align;
|
||
}
|
||
}
|
||
|
||
let effective_align = struct_node.align().unwrap_or(max_align).max(1);
|
||
align_up(&mut self.offset, effective_align);
|
||
let total = self.offset - struct_start;
|
||
Ok((total, effective_align))
|
||
}
|
||
|
||
/// Compute the layout for a single field, advancing `self.offset`
|
||
/// and appending any field paths to `self.fields`.
|
||
fn compute_field(
|
||
&mut self,
|
||
field: &BastField,
|
||
field_path: &str,
|
||
struct_default_align: usize,
|
||
container_endian: Endian,
|
||
) -> Result<FieldLayout, AlkTypeError> {
|
||
let ty = field.ty();
|
||
let resolved = self.doc.resolve_typeref(ty)?;
|
||
let field_endian = field.effective_endian(container_endian);
|
||
match &resolved {
|
||
BastType::Struct(s) => self.compute_struct_field(s, field, field_path, struct_default_align, field_endian),
|
||
BastType::Union(_) => Err(AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: "TUnion is not supported in aligned static mode (ADR-008). \
|
||
Unions are the protocol dispatch pattern — use packed sequential \
|
||
mode (LayoutMode::Packed) for TUnion fields, or restructure as \
|
||
a struct with an explicit discriminator field."
|
||
.to_string(),
|
||
}),
|
||
BastType::Array(a) => self.compute_array_field(a, field, field_path, struct_default_align, field_endian),
|
||
BastType::Record(_) => {
|
||
if field.encoding() == VariableEncoding::OffsetIndirect {
|
||
return Err(AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: "record field with `encoding: offset-indirect` is not supported \
|
||
in aligned mode: the materializer walks the record's inline \
|
||
count-prefixed form from the entry start, so an offset-indirect \
|
||
record would read from the wrong wire shape. Use the default \
|
||
inline length-prefixing, or packed mode."
|
||
.to_string(),
|
||
});
|
||
}
|
||
self.compute_variable_field(field, field_path, struct_default_align, resolved.alk_kind(), field_endian)
|
||
}
|
||
BastType::Primitive(k) if k.is_variable_length() => {
|
||
self.compute_variable_field(field, field_path, struct_default_align, *k, field_endian)
|
||
}
|
||
BastType::Primitive(k) => {
|
||
self.compute_fixed_field(*k, field, field_path, struct_default_align, field_endian)
|
||
}
|
||
BastType::Enum(_) => self.compute_fixed_field(
|
||
AlkTypeKind::Enum,
|
||
field,
|
||
field_path,
|
||
struct_default_align,
|
||
field_endian,
|
||
),
|
||
BastType::Ref(_) => Err(AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: "internal: resolve_typeref returned a Ref".to_string(),
|
||
}),
|
||
}
|
||
}
|
||
|
||
/// Compute the layout for a fixed-size primitive field.
|
||
fn compute_fixed_field(
|
||
&mut self,
|
||
kind: AlkTypeKind,
|
||
field: &BastField,
|
||
field_path: &str,
|
||
struct_default_align: usize,
|
||
endian: Endian,
|
||
) -> Result<FieldLayout, AlkTypeError> {
|
||
let size = kind.type_size().ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("type_size returned None for fixed kind {kind}"),
|
||
})?;
|
||
let natural = kind.natural_alignment();
|
||
let align = field_alignment(field, struct_default_align, natural);
|
||
align_up(&mut self.offset, align);
|
||
let start = self.offset;
|
||
self.offset = start
|
||
.checked_add(size)
|
||
.ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("offset {start} + size {size} overflows usize"),
|
||
})?;
|
||
self.push(field_path, start, start + size, kind, field.encoding(), endian);
|
||
Ok(FieldLayout { align })
|
||
}
|
||
|
||
/// Compute the layout for a nested struct field.
|
||
///
|
||
/// Probes the nested struct's layout at a temporary offset of 0 to
|
||
/// determine its total size and alignment, aligns the parent offset,
|
||
/// then shifts the nested fields to their final positions.
|
||
fn compute_struct_field(
|
||
&mut self,
|
||
struct_node: &BastStruct,
|
||
field: &BastField,
|
||
field_path: &str,
|
||
struct_default_align: usize,
|
||
endian: Endian,
|
||
) -> Result<FieldLayout, AlkTypeError> {
|
||
let inner_parent_align = field.align().unwrap_or(struct_default_align);
|
||
let mut probe = ComputeCtx {
|
||
doc: self.doc,
|
||
fields: Vec::new(),
|
||
offset: 0,
|
||
};
|
||
let (inner_total, inner_align) =
|
||
probe.compute_struct(struct_node, field_path, inner_parent_align, endian)?;
|
||
|
||
let natural = inner_align;
|
||
let align = field_alignment(field, struct_default_align, natural);
|
||
align_up(&mut self.offset, align);
|
||
let struct_start = self.offset;
|
||
for (path, entry) in probe.fields {
|
||
let range = ByteRange {
|
||
start: struct_start + entry.range.start,
|
||
end: struct_start + entry.range.end,
|
||
};
|
||
self.fields.push((
|
||
path,
|
||
OffsetEntry {
|
||
range,
|
||
meta: entry.meta,
|
||
},
|
||
));
|
||
}
|
||
self.offset = struct_start
|
||
.checked_add(inner_total)
|
||
.ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("struct start {struct_start} + inner total {inner_total} overflows usize"),
|
||
})?;
|
||
Ok(FieldLayout { align })
|
||
}
|
||
|
||
/// Compute the layout for a `BastArray` field.
|
||
fn compute_array_field(
|
||
&mut self,
|
||
array: &BastArray,
|
||
field: &BastField,
|
||
field_path: &str,
|
||
struct_default_align: usize,
|
||
endian: Endian,
|
||
) -> Result<FieldLayout, AlkTypeError> {
|
||
let element_ty = array.element();
|
||
let resolved_elem = self.doc.resolve_typeref(element_ty)?;
|
||
let elem_kind = resolved_elem.alk_kind();
|
||
if !elem_kind.is_fixed_size() {
|
||
return Err(AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!(
|
||
"array of variable-length element kind {elem_kind} is not supported (OQ-001)"
|
||
),
|
||
});
|
||
}
|
||
|
||
let elem_size = elem_kind.type_size().ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("element kind {elem_kind} has no fixed size"),
|
||
})?;
|
||
let elem_natural = elem_kind.natural_alignment();
|
||
// Composites (struct/union/array/record) never reach here — the
|
||
// OQ-001 rejection above refuses every non-fixed-size element
|
||
// kind — so the element alignment is the struct default vs the
|
||
// fixed kind's natural alignment (review #006 M4 item 4: the
|
||
// former composite arms of this lookup were dead).
|
||
let elem_align = struct_default_align.max(elem_natural).max(1);
|
||
let stride = round_up(elem_size, elem_align);
|
||
let count = array.count();
|
||
let array_bytes = count
|
||
.checked_mul(stride)
|
||
.ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("array size {count} × stride {stride} overflows usize"),
|
||
})?;
|
||
if array_bytes > MAX_ARRAY_BYTES {
|
||
return Err(AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!(
|
||
"array size {count} × stride {stride} = {array_bytes} bytes exceeds the \
|
||
compile-time limit of {MAX_ARRAY_BYTES} bytes"
|
||
),
|
||
});
|
||
}
|
||
|
||
let array_align = field_alignment(field, struct_default_align, elem_align);
|
||
|
||
align_up(&mut self.offset, array_align);
|
||
let start = self.offset;
|
||
let elem_encoding = field.encoding();
|
||
let elem_endian = field_endian_for_element(&resolved_elem, endian);
|
||
for i in 0..count {
|
||
let elem_start = start
|
||
.checked_add(
|
||
i.checked_mul(stride)
|
||
.ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("element index {i} × stride {stride} overflows usize"),
|
||
})?,
|
||
)
|
||
.ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("element offset {start} + {i}×{stride} overflows usize"),
|
||
})?;
|
||
let elem_end = elem_start
|
||
.checked_add(elem_size)
|
||
.ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("element end {elem_start} + {elem_size} overflows usize"),
|
||
})?;
|
||
let elem_path = format!("{field_path}[{i}]");
|
||
self.push(&elem_path, elem_start, elem_end, elem_kind, elem_encoding, elem_endian);
|
||
}
|
||
let array_size = count
|
||
.checked_mul(stride)
|
||
.ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("array size {count} × stride {stride} overflows usize"),
|
||
})?;
|
||
self.offset = start
|
||
.checked_add(array_size)
|
||
.ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("offset {start} + array size {array_size} overflows usize"),
|
||
})?;
|
||
Ok(FieldLayout { align: array_align })
|
||
}
|
||
|
||
/// Compute the layout for a variable-length field (String/Bytes/Record).
|
||
///
|
||
/// In aligned static mode, three strategies are supported:
|
||
/// - `maxLength` reservation: `maxLength` bytes at a fixed offset.
|
||
/// - `offset-indirect` encoding: an 8-byte `{offset: u32, length: u32}` pair.
|
||
/// - inline length-prefixing (default): a 4-byte length prefix.
|
||
fn compute_variable_field(
|
||
&mut self,
|
||
field: &BastField,
|
||
field_path: &str,
|
||
struct_default_align: usize,
|
||
kind: AlkTypeKind,
|
||
endian: Endian,
|
||
) -> Result<FieldLayout, AlkTypeError> {
|
||
let encoding = field.encoding();
|
||
let max_length = field.max_length();
|
||
|
||
let (size, natural) = match (max_length, encoding) {
|
||
(Some(max_len), _) => (max_len, 1),
|
||
(None, VariableEncoding::OffsetIndirect) => (8, 4),
|
||
(None, VariableEncoding::LengthPrefixed) => (4, 4),
|
||
};
|
||
|
||
let align = field_alignment(field, struct_default_align, natural);
|
||
align_up(&mut self.offset, align);
|
||
let start = self.offset;
|
||
self.offset = start
|
||
.checked_add(size)
|
||
.ok_or_else(|| AlkTypeError::Offset {
|
||
field_path: field_path.to_string(),
|
||
reason: format!("offset {start} + size {size} overflows usize"),
|
||
})?;
|
||
self.push(field_path, start, start + size, kind, encoding, endian);
|
||
Ok(FieldLayout { align })
|
||
}
|
||
|
||
/// Push an `OffsetEntry` onto the fields vec.
|
||
fn push(&mut self, path: &str, start: usize, end: usize, kind: AlkTypeKind, encoding: VariableEncoding, endian: Endian) {
|
||
self.fields.push((
|
||
path.to_string(),
|
||
OffsetEntry {
|
||
range: ByteRange { start, end },
|
||
meta: LeafMeta {
|
||
kind,
|
||
encoding,
|
||
endian,
|
||
},
|
||
},
|
||
));
|
||
}
|
||
}
|
||
|
||
/// If the field's type is a variable-length kind, return its kind.
|
||
/// Matches primitive variable-length kinds (String/Bytes) *and*
|
||
/// `Record` — whose inline length-prefixed form has the same
|
||
/// 4-byte-prefix-then-variable-data shape and the same clobbering
|
||
/// hazard the ADR-006 check exists for (review #006 M1: records
|
||
/// previously slipped past the check).
|
||
fn field_variable_kind(field: &BastField) -> Option<AlkTypeKind> {
|
||
match field.ty() {
|
||
BastType::Primitive(k) if k.is_variable_length() => Some(*k),
|
||
BastType::Record(_) => Some(AlkTypeKind::Record),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The effective endianness for an array element's reads/writes. The
|
||
/// element TypeRef doesn't carry a field-level override (BAST field
|
||
/// annotations live on the field, not the element), so the referring
|
||
/// field's effective endian applies — the same propagation the aligned
|
||
/// materializer uses. Only fixed-size element kinds reach here (the
|
||
/// OQ-001 rejection upstream refuses composites), so the former
|
||
/// `Struct`/`Union` composite arms were dead (review #006 M4 item 4).
|
||
fn field_endian_for_element(elem_ty: &BastType, field_endian: Endian) -> Endian {
|
||
match elem_ty {
|
||
BastType::Enum(_) | BastType::Array(_) | BastType::Record(_) => field_endian,
|
||
BastType::Primitive(_) | BastType::Ref(_) => field_endian,
|
||
BastType::Struct(_) | BastType::Union(_) => field_endian,
|
||
}
|
||
}
|
||
|
||
/// Resolve the field's alignment: field-level `align` annotation,
|
||
/// then the struct default, then the natural alignment.
|
||
fn field_alignment(field: &BastField, struct_default_align: usize, natural: usize) -> usize {
|
||
if let Some(a) = field.align() {
|
||
return a.max(1);
|
||
}
|
||
struct_default_align.max(natural).max(1)
|
||
}
|
||
|
||
/// Round `offset` up to the next multiple of `align`. No-op if `align <= 1`.
|
||
fn align_up(offset: &mut usize, align: usize) {
|
||
if align <= 1 {
|
||
return;
|
||
}
|
||
let rem = *offset % align;
|
||
if rem != 0 {
|
||
*offset += align - rem;
|
||
}
|
||
}
|
||
|
||
/// Round `n` up to the next multiple of `align`.
|
||
fn round_up(n: usize, align: usize) -> usize {
|
||
if align <= 1 {
|
||
return n;
|
||
}
|
||
let rem = n % align;
|
||
if rem == 0 {
|
||
n
|
||
} else {
|
||
n + align - rem
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serde_json::json;
|
||
|
||
fn map(root: &serde_json::Value, name: &str) -> OffsetMap {
|
||
let doc = BastDoc::new(root, name).expect("bast doc");
|
||
OffsetMap::compute(&doc).expect("offset map computation")
|
||
}
|
||
|
||
#[test]
|
||
fn simple_fixed_fields_natural_alignment() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "flag", "kind": "uint8" },
|
||
{ "name": "id", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("flag"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 1 }, meta: LeafMeta { kind: AlkTypeKind::Uint8, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("id"), Some(&OffsetEntry { range: ByteRange { start: 4, end: 8 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.total_size(), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn u8_then_u32_three_bytes_padding() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "a", "kind": "uint8" },
|
||
{ "name": "b", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("a"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 1 }, meta: LeafMeta { kind: AlkTypeKind::Uint8, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("b"), Some(&OffsetEntry { range: ByteRange { start: 4, end: 8 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
}
|
||
|
||
#[test]
|
||
fn nested_struct_dotted_paths() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{
|
||
"name": "header",
|
||
"kind": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "magic", "kind": "uint32" },
|
||
{ "name": "version", "kind": "uint8" }
|
||
]
|
||
}
|
||
},
|
||
{ "name": "body", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("header.magic"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 4 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(
|
||
m.get("header.version"),
|
||
Some(&OffsetEntry { range: ByteRange { start: 4, end: 5 }, meta: LeafMeta { kind: AlkTypeKind::Uint8, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } })
|
||
);
|
||
assert_eq!(m.get("body"), Some(&OffsetEntry { range: ByteRange { start: 8, end: 12 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.total_size(), 12);
|
||
}
|
||
|
||
#[test]
|
||
fn array_fixed_count_element_offsets() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "vals", "kind": { "kind": "array", "element": "uint32", "count": 3 } }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("vals[0]"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 4 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("vals[1]"), Some(&OffsetEntry { range: ByteRange { start: 4, end: 8 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("vals[2]"), Some(&OffsetEntry { range: ByteRange { start: 8, end: 12 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.total_size(), 12);
|
||
}
|
||
|
||
// ----- H1: array caps bound untrusted schemas at compute time ------
|
||
|
||
#[test]
|
||
fn n2_struct_align_above_cap_rejected_at_parse() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"align": 4097u64,
|
||
"fields": [ { "name": "v", "kind": "uint8" } ]
|
||
}
|
||
}
|
||
});
|
||
let err = BastDoc::new(&root, "S").unwrap_err();
|
||
match err {
|
||
AlkTypeError::Schema(reason) => {
|
||
assert!(reason.contains("align"), "reason: {reason}");
|
||
assert!(reason.contains("maximum"), "reason: {reason}");
|
||
}
|
||
other => panic!("expected Schema error, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn n2_field_align_above_cap_rejected_at_parse() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "v", "kind": "uint8", "align": 8192 }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let err = BastDoc::new(&root, "S").unwrap_err();
|
||
assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn n2_align_at_cap_accepted() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"align": 4096u64,
|
||
"fields": [ { "name": "v", "kind": "uint8" } ]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.total_size(), 4096);
|
||
}
|
||
|
||
#[test]
|
||
fn h1_array_count_above_cap_rejected_at_parse() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "vals", "kind": { "kind": "array", "element": "uint8", "count": 2000000000 } }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let err = BastDoc::new(&root, "S").unwrap_err();
|
||
match err {
|
||
AlkTypeError::Schema(reason) => {
|
||
assert!(reason.contains("compile-time limit"), "reason: {reason}");
|
||
}
|
||
other => panic!("expected Schema error, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// ----- F2 (review #007): unbounded maxLength — the N2 pattern -------
|
||
|
||
#[test]
|
||
fn f2_max_length_above_cap_rejected_at_parse() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "blob", "kind": "bytes", "maxLength": 1099511627776u64 }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let err = BastDoc::new(&root, "S").unwrap_err();
|
||
match err {
|
||
AlkTypeError::Schema(reason) => {
|
||
assert!(reason.contains("maxLength"), "reason: {reason}");
|
||
assert!(reason.contains("maximum"), "reason: {reason}");
|
||
}
|
||
other => panic!("expected Schema error, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn f2_max_length_string_above_cap_rejected_at_parse() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "name", "kind": "string", "maxLength": 67108865u64 }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let err = BastDoc::new(&root, "S").unwrap_err();
|
||
assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn f2_max_length_at_cap_accepted() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"endian": "little",
|
||
"fields": [
|
||
{ "name": "blob", "kind": "bytes", "maxLength": 67108864u64 }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.total_size(), 67108864);
|
||
}
|
||
|
||
#[test]
|
||
fn f2_max_length_u64_max_rejected_not_silently_dropped() {
|
||
// The old parse silently returned None for values it could not
|
||
// handle; a u64::MAX-scale maxLength must be a clean Schema
|
||
// error (the cap arm on 64-bit, the usize-overflow arm on
|
||
// 32-bit), never a silent layout change.
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "blob", "kind": "bytes", "maxLength": 18446744073709551615u64 }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let err = BastDoc::new(&root, "S").unwrap_err();
|
||
match err {
|
||
AlkTypeError::Schema(reason) => {
|
||
assert!(reason.contains("maxLength"), "reason: {reason}");
|
||
}
|
||
other => panic!("expected Schema error, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn f2_meta_schema_rejects_max_length_above_cap() {
|
||
// The published contract matches the parser (N2 dual-layer
|
||
// pattern).
|
||
let doc = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "blob", "kind": "bytes", "maxLength": 67108865u64 }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
assert!(
|
||
crate::bast_meta::validate_bast_doc(&doc).is_err(),
|
||
"meta-schema must reject maxLength above the cap"
|
||
);
|
||
let ok = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "blob", "kind": "bytes", "maxLength": 67108864u64 }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
assert!(
|
||
crate::bast_meta::validate_bast_doc(&ok).is_ok(),
|
||
"meta-schema must accept maxLength at the cap"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn h1_array_bytes_above_cap_rejected_at_compute() {
|
||
// 65536 elements × stride 4096 (a u8 array in a struct with the
|
||
// maximum legal align, N2) = 2^28 > 2^26 — hits the byte cap
|
||
// while staying under the element cap. (A struct/array element
|
||
// would be rejected earlier as variable-length, OQ-001, so the
|
||
// reachable way over the byte cap is a large stride, not a huge
|
||
// element.)
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"align": 4096u64,
|
||
"fields": [
|
||
{ "name": "vals", "kind": { "kind": "array", "element": "uint8", "count": 65536 } }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "S").expect("bast doc (under element cap)");
|
||
let err = OffsetMap::compute(&doc).unwrap_err();
|
||
match err {
|
||
AlkTypeError::Offset { field_path, reason } => {
|
||
assert_eq!(field_path, "vals");
|
||
assert!(reason.contains("exceeds the compile-time limit"), "reason: {reason}");
|
||
}
|
||
other => panic!("expected Offset error, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn variable_string_length_prefix_at_known_offset() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "id", "kind": "uint32" },
|
||
{ "name": "name", "kind": "string" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("id"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 4 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("name"), Some(&OffsetEntry { range: ByteRange { start: 4, end: 8 }, meta: LeafMeta { kind: AlkTypeKind::String, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.total_size(), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn variable_string_max_length_reservation() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "id", "kind": "uint32" },
|
||
{ "name": "name", "kind": "string", "maxLength": 256 }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("id"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 4 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("name"), Some(&OffsetEntry { range: ByteRange { start: 4, end: 260 }, meta: LeafMeta { kind: AlkTypeKind::String, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.total_size(), 260);
|
||
}
|
||
|
||
#[test]
|
||
fn variable_string_offset_indirect_eight_bytes() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "id", "kind": "uint32" },
|
||
{ "name": "blob", "kind": "string", "encoding": "offset-indirect" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("id"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 4 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("blob"), Some(&OffsetEntry { range: ByteRange { start: 4, end: 12 }, meta: LeafMeta { kind: AlkTypeKind::String, encoding: VariableEncoding::OffsetIndirect, endian: Endian::Little } }));
|
||
assert_eq!(m.total_size(), 12);
|
||
}
|
||
|
||
#[test]
|
||
fn union_byte_discriminator_rejected_in_aligned_mode() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{
|
||
"name": "payload",
|
||
"kind": { "$ref": "#/$defs/Packet" }
|
||
}
|
||
]
|
||
},
|
||
"Packet": {
|
||
"kind": "union",
|
||
"discriminator": { "kind": "byte", "offset": 0, "type": "uint8" },
|
||
"mapping": {
|
||
"5": { "$ref": "#/$defs/Read" },
|
||
"6": { "$ref": "#/$defs/Write" }
|
||
}
|
||
},
|
||
"Read": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "handle", "kind": "uint32" },
|
||
{ "name": "length", "kind": "uint32" }
|
||
]
|
||
},
|
||
"Write": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "handle", "kind": "uint32" },
|
||
{ "name": "length", "kind": "uint32" },
|
||
{ "name": "data", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "S").expect("doc");
|
||
let err = OffsetMap::compute(&doc).unwrap_err();
|
||
assert!(matches!(err, AlkTypeError::Offset { .. }), "got {err:?}");
|
||
let reason = match err {
|
||
AlkTypeError::Offset { reason, .. } => reason,
|
||
_ => unreachable!(),
|
||
};
|
||
assert!(reason.contains("ADR-008"), "reason: {reason}");
|
||
}
|
||
|
||
#[test]
|
||
fn h2_cyclic_ref_rejected_at_compute_not_stack_overflow() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": { "kind": "struct", "fields": [
|
||
{ "name": "me", "kind": { "$ref": "#/$defs/S" } }
|
||
] }
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "S").expect("cyclic doc parses");
|
||
let err = OffsetMap::compute(&doc).unwrap_err();
|
||
match err {
|
||
AlkTypeError::Schema(reason) => {
|
||
assert!(reason.contains("cyclic"), "reason: {reason}");
|
||
}
|
||
other => panic!("expected Schema error, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn h2_two_def_cycle_rejected_at_compute() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"A": { "kind": "struct", "fields": [
|
||
{ "name": "next", "kind": { "$ref": "#/$defs/B" } }
|
||
] },
|
||
"B": { "kind": "struct", "fields": [
|
||
{ "name": "back", "kind": { "$ref": "#/$defs/A" } }
|
||
] }
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "A").expect("cyclic doc parses");
|
||
let err = OffsetMap::compute(&doc).unwrap_err();
|
||
assert!(matches!(err, AlkTypeError::Schema(_)), "got {err:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn h2_cycle_through_ref_field_of_nested_struct_rejected() {
|
||
// The cycle sits behind an inline struct + array hop: the walk
|
||
// guard must see through composite carriers, not just top-level
|
||
// fields.
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": { "kind": "struct", "fields": [
|
||
{ "name": "inner", "kind": { "kind": "struct", "fields": [
|
||
{ "name": "items", "kind": {
|
||
"kind": "array", "element": { "$ref": "#/$defs/S" }, "count": 1
|
||
} }
|
||
] } }
|
||
] }
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "S").expect("cyclic doc parses");
|
||
let err = OffsetMap::compute(&doc).unwrap_err();
|
||
assert!(
|
||
matches!(err, AlkTypeError::Schema(_)),
|
||
"expected Schema error, got {err:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn h2_diamond_refs_still_compute() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": { "kind": "struct", "fields": [
|
||
{ "name": "a", "kind": { "$ref": "#/$defs/Point" } },
|
||
{ "name": "b", "kind": { "$ref": "#/$defs/Point" } }
|
||
] },
|
||
"Point": { "kind": "struct", "fields": [
|
||
{ "name": "x", "kind": "uint16" }
|
||
] }
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "S").expect("doc");
|
||
let m = OffsetMap::compute(&doc).expect("diamond refs compute");
|
||
assert_eq!(m.get("a.x").map(|e| e.range), Some(ByteRange { start: 0, end: 2 }));
|
||
assert_eq!(m.get("b.x").map(|e| e.range), Some(ByteRange { start: 2, end: 4 }));
|
||
}
|
||
|
||
// ----- M4 item 4: array-element endian propagation + OQ-001 gate ----
|
||
|
||
#[test]
|
||
fn m4_array_of_struct_element_rejected_oq001() {
|
||
// The gate that makes field_endian_for_element's composite arms
|
||
// dead: a struct element kind is variable-length, so it hits the
|
||
// OQ-001 rejection before the endian lookup.
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": { "kind": "struct", "fields": [
|
||
{ "name": "points", "kind": { "kind": "array", "element": { "$ref": "#/$defs/Point" }, "count": 2 } }
|
||
] },
|
||
"Point": { "kind": "struct", "fields": [ { "name": "x", "kind": "uint16" } ] }
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "S").expect("doc");
|
||
let err = OffsetMap::compute(&doc).unwrap_err();
|
||
match err {
|
||
AlkTypeError::Offset { field_path, reason } => {
|
||
assert_eq!(field_path, "points");
|
||
assert!(reason.contains("OQ-001"), "reason: {reason}");
|
||
}
|
||
other => panic!("expected Offset, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn m4_array_element_endian_inherits_referring_field_not_element_own() {
|
||
// Phase-5 parity rule on the aligned path: the element's effective
|
||
// endian is the referring field's, not the element struct's own
|
||
// annotation (the pre-M4-cleanup code consulted the element
|
||
// struct's `endian` in a dead arm — dead because composites are
|
||
// rejected by OQ-001 upstream; this test pins the reachable
|
||
// propagation for fixed elements).
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"endian": "big",
|
||
"fields": [
|
||
{
|
||
"name": "vals",
|
||
"kind": { "kind": "array", "element": "uint32", "count": 2 },
|
||
"endian": "little"
|
||
}
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
let e0 = m.get("vals[0]").expect("vals[0]");
|
||
assert_eq!(e0.meta.endian, Endian::Little, "field-level endian override propagates to elements");
|
||
let root_be = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"endian": "big",
|
||
"fields": [
|
||
{ "name": "vals", "kind": { "kind": "array", "element": "uint32", "count": 2 } }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m_be = map(&root_be, "S");
|
||
assert_eq!(m_be.get("vals[0]").expect("vals[0]").meta.endian, Endian::Big, "struct default propagates to elements");
|
||
}
|
||
|
||
#[test]
|
||
fn non_final_record_rejected_in_aligned_mode() {
|
||
// M1: a Record field's inline length-prefixed form has the same
|
||
// 4-byte-prefix-then-variable-data shape as a string's, so a
|
||
// non-final record field must hit the ADR-006 rejection too
|
||
// (previously it computed silently corrupt offsets).
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "counts", "kind": { "kind": "record", "values": "uint32" } },
|
||
{ "name": "id", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "S").expect("doc");
|
||
let err = OffsetMap::compute(&doc).unwrap_err();
|
||
match err {
|
||
AlkTypeError::Offset { field_path, reason } => {
|
||
assert_eq!(field_path, "counts");
|
||
assert!(reason.contains("ADR-006"), "reason: {reason}");
|
||
assert!(reason.contains("record"), "reason: {reason}");
|
||
}
|
||
other => panic!("expected Offset, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn final_inline_record_allowed_in_aligned_mode() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "id", "kind": "uint32" },
|
||
{ "name": "counts", "kind": { "kind": "record", "values": "uint32" } }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("id"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 4 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("counts"), Some(&OffsetEntry { range: ByteRange { start: 4, end: 8 }, meta: LeafMeta { kind: AlkTypeKind::Record, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.total_size(), 8);
|
||
}
|
||
|
||
// ----- M5: record offset-indirect rejected in aligned mode; record
|
||
// maxLength now rejected earlier, at parse (N3) — see the n3_ family
|
||
// in bast.rs
|
||
|
||
#[test]
|
||
fn m5_record_offset_indirect_rejected_in_aligned_mode() {
|
||
// Same walk-shape mismatch: the materializer reads the inline
|
||
// count-prefixed form, not the {offset, length} pair the map
|
||
// would have recorded.
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "counts", "kind": { "kind": "record", "values": "uint16" }, "encoding": "offset-indirect" },
|
||
{ "name": "id", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "S").expect("doc parses");
|
||
let err = OffsetMap::compute(&doc).unwrap_err();
|
||
match err {
|
||
AlkTypeError::Offset { field_path, reason } => {
|
||
assert_eq!(field_path, "counts");
|
||
assert!(reason.contains("offset-indirect"), "reason: {reason}");
|
||
assert!(reason.contains("record"), "reason: {reason}");
|
||
}
|
||
other => panic!("expected Offset, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn non_final_inline_string_rejected_in_aligned_mode() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "name", "kind": "string" },
|
||
{ "name": "id", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "S").expect("doc");
|
||
let err = OffsetMap::compute(&doc).unwrap_err();
|
||
match err {
|
||
AlkTypeError::Offset { field_path, reason } => {
|
||
assert_eq!(field_path, "name");
|
||
assert!(reason.contains("ADR-006"), "reason: {reason}");
|
||
}
|
||
other => panic!("expected Offset, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn final_inline_string_allowed_in_aligned_mode() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "id", "kind": "uint32" },
|
||
{ "name": "name", "kind": "string" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("id"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 4 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("name"), Some(&OffsetEntry { range: ByteRange { start: 4, end: 8 }, meta: LeafMeta { kind: AlkTypeKind::String, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
}
|
||
|
||
#[test]
|
||
fn non_final_maxlength_string_allowed_in_aligned_mode() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "name", "kind": "string", "maxLength": 256 },
|
||
{ "name": "id", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("name"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 256 }, meta: LeafMeta { kind: AlkTypeKind::String, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("id"), Some(&OffsetEntry { range: ByteRange { start: 256, end: 260 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
}
|
||
|
||
#[test]
|
||
fn non_final_offset_indirect_string_allowed_in_aligned_mode() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "blob", "kind": "string", "encoding": "offset-indirect" },
|
||
{ "name": "id", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("blob"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 8 }, meta: LeafMeta { kind: AlkTypeKind::String, encoding: VariableEncoding::OffsetIndirect, endian: Endian::Little } }));
|
||
assert_eq!(m.get("id"), Some(&OffsetEntry { range: ByteRange { start: 8, end: 12 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
}
|
||
|
||
#[test]
|
||
fn struct_level_align_rounds_up_total() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"align": 16,
|
||
"fields": [
|
||
{ "name": "flag", "kind": "uint8" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("flag"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 1 }, meta: LeafMeta { kind: AlkTypeKind::Uint8, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.total_size(), 16);
|
||
}
|
||
|
||
#[test]
|
||
fn field_level_align_overrides_struct_default() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"align": 1,
|
||
"fields": [
|
||
{ "name": "tag", "kind": "uint8" },
|
||
{ "name": "flag", "kind": "uint8", "align": 16 },
|
||
{ "name": "id", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("tag"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 1 }, meta: LeafMeta { kind: AlkTypeKind::Uint8, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("flag"), Some(&OffsetEntry { range: ByteRange { start: 16, end: 17 }, meta: LeafMeta { kind: AlkTypeKind::Uint8, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("id"), Some(&OffsetEntry { range: ByteRange { start: 20, end: 24 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.total_size(), 24);
|
||
}
|
||
|
||
#[test]
|
||
fn field_align_smaller_than_struct_default() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"align": 8,
|
||
"fields": [
|
||
{ "name": "a", "kind": "uint8" },
|
||
{ "name": "b", "kind": "uint32", "align": 1 }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.get("a"), Some(&OffsetEntry { range: ByteRange { start: 0, end: 1 }, meta: LeafMeta { kind: AlkTypeKind::Uint8, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.get("b"), Some(&OffsetEntry { range: ByteRange { start: 1, end: 5 }, meta: LeafMeta { kind: AlkTypeKind::Uint32, encoding: VariableEncoding::LengthPrefixed, endian: Endian::Little } }));
|
||
assert_eq!(m.total_size(), 8);
|
||
}
|
||
|
||
#[test]
|
||
fn iter_returns_all_paths_in_order() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "a", "kind": "uint8" },
|
||
{ "name": "b", "kind": "uint32" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
let paths: Vec<&str> = m.iter().map(|(p, _)| p).collect();
|
||
assert_eq!(paths, vec!["a", "b"]);
|
||
}
|
||
|
||
// ----- L4: path-indexed random access --------------------------------
|
||
|
||
#[test]
|
||
fn l4_get_is_indexed_random_access_over_large_nested_schema() {
|
||
// The map's doc contract promises random access by field path
|
||
// ("read field N without reading fields 0..N-1 first"); L4 made
|
||
// that true with a BTreeMap index instead of a linear scan. A
|
||
// wide nested schema exercises dotted-path lookups that arrive
|
||
// late in insertion order.
|
||
let mut fields = Vec::new();
|
||
for i in 0..40 {
|
||
fields.push(json!({
|
||
"name": format!("blk{i}"),
|
||
"kind": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "id", "kind": "uint32" },
|
||
{ "name": "tag", "kind": "uint8" },
|
||
{ "name": "val", "kind": "uint64" }
|
||
]
|
||
}
|
||
}));
|
||
}
|
||
let root = json!({
|
||
"$defs": { "S": { "kind": "struct", "fields": fields } }
|
||
});
|
||
let m = map(&root, "S");
|
||
assert_eq!(m.iter().count(), 120);
|
||
let last = m.get("blk39.val").expect("late dotted-path lookup");
|
||
assert_eq!(last.range, ByteRange { start: 39 * 16 + 8, end: 39 * 16 + 16 });
|
||
assert_eq!(last.meta.kind, AlkTypeKind::Uint64);
|
||
let mid = m.get("blk20.tag").expect("mid lookup");
|
||
assert_eq!(mid.range, ByteRange { start: 20 * 16 + 4, end: 20 * 16 + 5 });
|
||
assert_eq!(m.get("blk7.id").map(|e| e.range), Some(ByteRange { start: 7 * 16, end: 7 * 16 + 4 }));
|
||
assert_eq!(m.get("nope"), None);
|
||
assert_eq!(m.get("blk"), None);
|
||
assert_eq!(m.get("blk20"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn l4_duplicate_field_paths_first_occurrence_wins() {
|
||
// BastStruct::parse does not reject duplicate field names, so two
|
||
// same-named siblings produce two entries with the same path. The
|
||
// pre-L4 linear scan returned the first; the index preserves that
|
||
// semantic.
|
||
let root = json!({
|
||
"$defs": {
|
||
"S": {
|
||
"kind": "struct",
|
||
"fields": [
|
||
{ "name": "a", "kind": "uint8" },
|
||
{ "name": "a", "kind": "uint16" }
|
||
]
|
||
}
|
||
}
|
||
});
|
||
let m = map(&root, "S");
|
||
let first = m.get("a").expect("duplicate path resolves");
|
||
assert_eq!(first.range, ByteRange { start: 0, end: 1 });
|
||
assert_eq!(first.meta.kind, AlkTypeKind::Uint8);
|
||
assert_eq!(m.iter().count(), 2);
|
||
}
|
||
|
||
// ----- Fingerprint contract (ADR-012 §1/§4) ---------------------------
|
||
|
||
#[test]
|
||
fn fingerprint_is_equal_for_equal_maps() {
|
||
let root = json!({ "$defs": { "S": { "kind": "struct", "fields": [
|
||
{ "name": "a", "kind": "uint8" },
|
||
{ "name": "b", "kind": "uint32" }
|
||
]}}});
|
||
let m1 = map(&root, "S");
|
||
let m2 = map(&root, "S");
|
||
assert_eq!(m1, m2);
|
||
assert_eq!(m1.fingerprint(), m2.fingerprint());
|
||
}
|
||
|
||
#[test]
|
||
fn fingerprint_changes_when_a_field_changes() {
|
||
let root_a = json!({ "$defs": { "S": { "kind": "struct", "fields": [
|
||
{ "name": "a", "kind": "uint8" },
|
||
{ "name": "b", "kind": "uint32" }
|
||
]}}});
|
||
let root_b = json!({ "$defs": { "S": { "kind": "struct", "fields": [
|
||
{ "name": "a", "kind": "uint16" },
|
||
{ "name": "b", "kind": "uint32" }
|
||
]}}});
|
||
let ma = map(&root_a, "S");
|
||
let mb = map(&root_b, "S");
|
||
assert_ne!(ma, mb);
|
||
assert_ne!(ma.fingerprint(), mb.fingerprint());
|
||
}
|
||
|
||
#[test]
|
||
fn fingerprint_changes_when_field_order_changes() {
|
||
let root_a = json!({ "$defs": { "S": { "kind": "struct", "fields": [
|
||
{ "name": "a", "kind": "uint8" },
|
||
{ "name": "b", "kind": "uint8" }
|
||
]}}});
|
||
let root_b = json!({ "$defs": { "S": { "kind": "struct", "fields": [
|
||
{ "name": "b", "kind": "uint8" },
|
||
{ "name": "a", "kind": "uint8" }
|
||
]}}});
|
||
let ma = map(&root_a, "S");
|
||
let mb = map(&root_b, "S");
|
||
assert_ne!(ma, mb);
|
||
assert_ne!(ma.fingerprint(), mb.fingerprint());
|
||
}
|
||
|
||
#[test]
|
||
fn fingerprint_changes_when_endianness_changes() {
|
||
let root_a = json!({ "$defs": { "S": { "kind": "struct", "endian": "little", "fields": [
|
||
{ "name": "id", "kind": "uint32" }
|
||
]}}});
|
||
let root_b = json!({ "$defs": { "S": { "kind": "struct", "endian": "big", "fields": [
|
||
{ "name": "id", "kind": "uint32" }
|
||
]}}});
|
||
let ma = map(&root_a, "S");
|
||
let mb = map(&root_b, "S");
|
||
assert_ne!(ma, mb);
|
||
assert_ne!(ma.fingerprint(), mb.fingerprint());
|
||
}
|
||
|
||
#[test]
|
||
fn compute_rejects_non_struct_top_level() {
|
||
let root = json!({
|
||
"$defs": {
|
||
"U": {
|
||
"kind": "union",
|
||
"discriminator": { "kind": "byte", "offset": 0, "type": "uint8" },
|
||
"mapping": { "1": { "$ref": "#/$defs/A" } }
|
||
},
|
||
"A": { "kind": "struct", "fields": [] }
|
||
}
|
||
});
|
||
let doc = BastDoc::new(&root, "U").expect("doc");
|
||
let err = OffsetMap::compute(&doc).unwrap_err();
|
||
assert!(matches!(err, AlkTypeError::Schema(_)));
|
||
}
|
||
|
||
#[test]
|
||
fn byte_range_len_and_is_empty() {
|
||
let r = ByteRange { start: 4, end: 8 };
|
||
assert_eq!(r.len(), 4);
|
||
assert!(!r.is_empty());
|
||
let empty = ByteRange { start: 5, end: 5 };
|
||
assert_eq!(empty.len(), 0);
|
||
assert!(empty.is_empty());
|
||
}
|
||
} |