fuzz: wave 2 — stateful read_opseq + layout_build targets, seeds, one engine fix
Targets 3-4 of docs/plans/fuzzing.md, per the sibling layout:
- fuzz/shared/src/read_opseq.rs — SequentialReader op sequences
(Next/NextBorrowed/Field/Reset/End, Arbitrary-derived) over hostile
buffers under the fixed packed schema menu. Invariants: cursor
discipline (failed read leaves position untouched, state replay
deterministic), None sticky at plan end, plan-order full walks with
a spin bound, read_field leaves a usable reader, ADR-007 reader
independence (shared Arc, isolated cursors), and the plan §6-1
record-count ≥4-verified-bytes bound encoded as an explicit End-op
assertion.
- fuzz/shared/src/layout_build.rs — LayoutBuilder::build with
adversarial var_sizes over a five-schema menu (string/bytes, nested
struct, byte-disc union, record+array, fixed control). Invariants:
Offset-class failures only, position disjointness + total-size
bounds, variable fields record their 4-byte prefix, failed writes
leave the buffer byte-identical, write→read pair round trip.
- derive_var_sizes discovers the synthetic keys ('p.__discriminator')
the builder actually wants by parsing the quoted key from the
Offset reason.
- 73 committed seeds (58 read_opseq + 15 layout_build) hand-encoded
against the pinned arbitrary 1.4.2 derive layout (4-byte LE
multiply-shift variant selectors, keep-going vec elements,
take-rest last field) and pinned by decode_lands_on_the_intended_variants
replay tests; gen_fuzz_seeds.py mirrors the encoders.
- Engine fix (finding W2-1): plan_read_array returned Ok for a
fixed-stride array whose count*stride window extended past the
buffer — the struct/union arms bounds-check, the array arm did not;
a truncated array deferred the failure to the next field (wrong
path) or masked it entirely as an Ok walk. Now an Access error
naming the array, regression test in sequential_reader.rs.
- Packed-mode 'encoding: offset-indirect' pinned as the documented
inline-length-prefix no-op (finding W2-2, bast-format.md Default
strategy selection); open design question recorded as plan §6-7.
Verification: fuzz corpus replay 19/19; main crate 570 tests incl.
the new regression; clippy -D warnings clean (crate + shared); wasm
build clean; cargo fuzz build clean (nightly confined to fuzz/).
Smoke campaigns (10 min detached each): read_opseq 52.1k execs exit 0
empty artifacts, layout_build 42.4k execs exit 0 empty artifacts; no
crash/oom/timeout on any fork job.
This commit is contained in:
1 parent
752e36b526
commit
16b9023f60
83 files changed
+1574
-17
No files matched your search
@@ -0,0 +1,564 @@
|
||||
//! Invariants for the packed `LayoutBuilder` (wave 2, target 4):
|
||||
//! no-panic across adversarial `var_sizes` (empty, zero, huge,
|
||||
//! mismatched with declarations), failed-write buffer-untouched /
|
||||
//! documented-partial contracts, position disjointness + bounds for
|
||||
//! the reported total size on a successful build, and the
|
||||
//! write-side/read-side pair contract for offset-indirect fields
|
||||
//! (`read_*_indirect` bounds partition holds for everything the write
|
||||
//! side can emit).
|
||||
|
||||
use alktype::data_access;
|
||||
use alktype::layout_builder::FieldPosition;
|
||||
use alktype::schema::{AlkTypeKind, Endian};
|
||||
use alktype::{LayoutMode, PackedLayout};
|
||||
use alktype::{AlkTypeEngine, AlkTypeError};
|
||||
use arbitrary::Arbitrary;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The schema menu: one packed struct per variable-width shape plus a
|
||||
/// fixed-size control. The fuzzer picks the schema lane by byte and
|
||||
/// the `var_sizes` / write values from the payload.
|
||||
const MENUS: [&str; 5] = [
|
||||
// 0: one string + one bytes + tail (the classic variable pair).
|
||||
r##"{"$defs": {"S": {"kind": "struct", "endian": "little", "fields": [
|
||||
{ "name": "s", "kind": "string" },
|
||||
{ "name": "b", "kind": "bytes" },
|
||||
{ "name": "tail", "kind": "uint8" }]}}}"##,
|
||||
// 1: nested struct with a variable field inside.
|
||||
r##"{"$defs": {"S": {"kind": "struct", "fields": [
|
||||
{ "name": "hdr", "kind": {"kind": "struct", "fields": [
|
||||
{ "name": "id", "kind": "uint8" },
|
||||
{ "name": "name", "kind": "string" }]}},
|
||||
{ "name": "tail", "kind": "uint32" }]}}}"##,
|
||||
// 2: union with byte discriminator whose variant has a variable
|
||||
// field (paths shift with the discriminator value).
|
||||
r##"{"$defs": {"S": {"kind": "struct", "fields": [
|
||||
{ "name": "p", "kind": {"$ref": "#/$defs/Packet"} }]},
|
||||
"Packet": {"kind": "union",
|
||||
"discriminator": {"kind": "byte", "offset": 0, "type": "uint8"},
|
||||
"mapping": {"5": {"$ref": "#/$defs/Read"}}},
|
||||
"Read": {"kind": "struct", "fields": [
|
||||
{ "name": "handle", "kind": "uint32" },
|
||||
{ "name": "path", "kind": "string" }]}}}"##,
|
||||
// 3: record (variable) + array control.
|
||||
r##"{"$defs": {"S": {"kind": "struct", "fields": [
|
||||
{ "name": "counts", "kind": {"kind": "record", "values": "uint32" } },
|
||||
{ "name": "vals", "kind": {"kind": "array", "element": "uint16", "count": 2 } }]}}}"##,
|
||||
// 4: fixed-size only — var_sizes must be ignorable here.
|
||||
r##"{"$defs": {"S": {"kind": "struct", "endian": "big", "fields": [
|
||||
{ "name": "x", "kind": "uint32" },
|
||||
{ "name": "y", "kind": "uint8" }]}}}"##,
|
||||
];
|
||||
|
||||
#[derive(Debug, Arbitrary, Clone, Copy)]
|
||||
pub enum SizeKind {
|
||||
Zero,
|
||||
One,
|
||||
Small(u8),
|
||||
Huge,
|
||||
Overflowing,
|
||||
FromInput(u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, Arbitrary)]
|
||||
pub struct LayoutInput {
|
||||
menu: u8,
|
||||
size_kind: SizeKind,
|
||||
drive_writes: bool,
|
||||
roundtrip_probe: bool,
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn fuzz_layout_build(data: &[u8]) {
|
||||
let Ok(input) = LayoutInput::arbitrary_take_rest(arbitrary::Unstructured::new(data)) else {
|
||||
return;
|
||||
};
|
||||
let menu = &MENUS[(input.menu as usize) % MENUS.len()];
|
||||
let Ok(doc) = serde_json::from_str::<serde_json::Value>(menu) else {
|
||||
return;
|
||||
};
|
||||
let Ok(engine) = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None) else {
|
||||
// The menu schemas are all valid; a meta-gate failure on one is
|
||||
// a menu bug, but the harness treats compile as Result anyway.
|
||||
return;
|
||||
};
|
||||
let Some(builder) = engine.layout_builder() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let var_sizes = derive_var_sizes(builder, &input.size_kind, &input.payload);
|
||||
let layout = builder.build(&var_sizes);
|
||||
match layout {
|
||||
Ok(l) => {
|
||||
assert_position_partition(&l);
|
||||
if input.drive_writes {
|
||||
drive_writes(&l, &input.payload);
|
||||
}
|
||||
if input.roundtrip_probe {
|
||||
write_then_read_roundtrip(&doc, &l, &input.payload);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Packed-mode build failures are Offset-class by contract
|
||||
// (missing var size, unknown discriminator, overflow).
|
||||
match e {
|
||||
AlkTypeError::Offset { field_path, .. } => {
|
||||
assert!(
|
||||
!field_path.is_empty(),
|
||||
"Offset errors carry the field path"
|
||||
);
|
||||
}
|
||||
other => panic!(
|
||||
"packed build with adversarial var_sizes must fail with Offset, got {other:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `var_sizes` map keyed by the schema's actual
|
||||
/// variable-length field paths (discovered from a reference build's
|
||||
/// error paths — the builder names them on failure), with values from
|
||||
/// the size_kind selector. Missing keys are the mismatched case.
|
||||
fn derive_var_sizes(
|
||||
builder: &alktype::LayoutBuilder,
|
||||
kind: &SizeKind,
|
||||
payload: &[u8],
|
||||
) -> HashMap<String, usize> {
|
||||
let value = match kind {
|
||||
SizeKind::Zero => 0usize,
|
||||
SizeKind::One => 1,
|
||||
SizeKind::Small(b) => *b as usize,
|
||||
SizeKind::Huge => 1 << 20,
|
||||
SizeKind::Overflowing => usize::MAX - 2,
|
||||
SizeKind::FromInput(b) => payload.iter().fold(0usize, |a, x| {
|
||||
(a.wrapping_mul(31).wrapping_add(*x as usize))
|
||||
.min(if *b & 0x80 != 0 { usize::MAX } else { *b as usize * 97 })
|
||||
}),
|
||||
};
|
||||
|
||||
// Discover every variable-length path by walking a probe build
|
||||
// that fails on the first missing size, then supplying it and
|
||||
// repeating until the build succeeds or the error stops asking.
|
||||
// The error names the *field* (`p`) but the missing key is the
|
||||
// synthetic `p.__discriminator` / `p.__variant` — quoted in the
|
||||
// reason; parse it out, falling back to the documented suffix per
|
||||
// failure class.
|
||||
let mut map = HashMap::new();
|
||||
for _ in 0..16 {
|
||||
match builder.build(&map) {
|
||||
Ok(_) => break,
|
||||
Err(AlkTypeError::Offset { field_path, reason }) => {
|
||||
let key = reason
|
||||
.split('\'')
|
||||
.nth(1)
|
||||
.map(str::to_string)
|
||||
.unwrap_or(field_path);
|
||||
map.insert(key, value);
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// A successful layout's positions are disjoint and in-bounds for the
|
||||
/// reported total size. Discriminator entries (`__discriminator`) sit
|
||||
/// inside the union region and may share bytes with the union start —
|
||||
/// they are asserted in-bounds, and disjointness is asserted over the
|
||||
/// non-synthetic leaf positions only (the synthetic disc entry
|
||||
/// documents the discriminator word's home; variant fields start right
|
||||
/// after it, so they are disjoint from it by construction).
|
||||
fn assert_position_partition(l: &PackedLayout) {
|
||||
let total = l.total_size();
|
||||
let mut occupied: Vec<(usize, usize, String)> = Vec::new();
|
||||
for (path, pos) in l.iter() {
|
||||
assert!(
|
||||
pos.offset.checked_add(pos.size).is_some()
|
||||
&& pos.offset + pos.size <= total,
|
||||
"field {path} window [{}, {}) exceeds total_size {total}",
|
||||
pos.offset,
|
||||
pos.offset + pos.size
|
||||
);
|
||||
match pos.kind {
|
||||
// The length-prefix size 4 is contract for variable kinds.
|
||||
AlkTypeKind::String | AlkTypeKind::Bytes | AlkTypeKind::Record => {
|
||||
assert_eq!(pos.size, 4, "variable field {path} records its prefix size");
|
||||
}
|
||||
k => {
|
||||
assert_eq!(
|
||||
k.type_size(),
|
||||
Some(pos.size),
|
||||
"fixed field {path} size matches the kind's width"
|
||||
);
|
||||
}
|
||||
}
|
||||
if path.ends_with("__discriminator") {
|
||||
continue;
|
||||
}
|
||||
for (o_start, o_end, o_path) in &occupied {
|
||||
assert!(
|
||||
pos.offset >= *o_end || pos.offset + pos.size <= *o_start,
|
||||
"field {path} [{}, {}) overlaps {o_path} [{o_start}, {o_end})",
|
||||
pos.offset,
|
||||
pos.offset + pos.size
|
||||
);
|
||||
}
|
||||
occupied.push((pos.offset, pos.offset + pos.size, path.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Write every field's declared value at its position (using the
|
||||
/// engine-free data_access lane with the layout's own metadata). The
|
||||
/// contract pinned: a failed fixed/length-prefixed write leaves the
|
||||
/// buffer byte-equal to its pre-call snapshot; a failed
|
||||
/// write_bytes_indirect leaves the declared pair region written
|
||||
/// (documented partial) but never trips a panic, and every failed
|
||||
/// write is an Access error naming the path.
|
||||
fn drive_writes(l: &PackedLayout, payload: &[u8]) {
|
||||
let endian = endian_of(l);
|
||||
let mut buf = vec![0u8; l.total_size()];
|
||||
for (path, pos) in l.iter() {
|
||||
if path.ends_with("__discriminator") {
|
||||
// Discriminator words are written by the value lane below.
|
||||
continue;
|
||||
}
|
||||
let byte = payload.first().copied().unwrap_or(0x41);
|
||||
match pos.kind {
|
||||
AlkTypeKind::String => {
|
||||
let value = "abcdefgh";
|
||||
let before = buf.clone();
|
||||
match data_access::write_string(&mut buf, pos.offset, value, path, endian) {
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
assert_eq!(
|
||||
buf, before,
|
||||
"failed write_string leaves the buffer untouched"
|
||||
);
|
||||
assert!(matches!(e, AlkTypeError::Access { .. }), "got {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
AlkTypeKind::Bytes | AlkTypeKind::Record => {
|
||||
// Write the data inline after the prefix position: the
|
||||
// packed variable contract. A payload-sized write into
|
||||
// the recorded 4-byte window fails cleanly.
|
||||
let before = buf.clone();
|
||||
match data_access::write_bytes(&mut buf, pos.offset, &[byte; 8], path, endian) {
|
||||
Ok(_) => {
|
||||
// The prefix + data fit: reading back the
|
||||
// declared length yields the declared bytes.
|
||||
let back =
|
||||
data_access::read_bytes(&buf, pos.offset, path, endian).expect(
|
||||
"a successful write_bytes prefix always reads back",
|
||||
);
|
||||
assert_eq!(back, [byte; 8]);
|
||||
}
|
||||
Err(e) => {
|
||||
assert_eq!(
|
||||
buf, before,
|
||||
"failed write_bytes leaves the buffer untouched"
|
||||
);
|
||||
assert!(matches!(e, AlkTypeError::Access { .. }), "got {e:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
k => {
|
||||
// Fixed-width kind at a recorded offset: the value lane.
|
||||
let before = buf.clone();
|
||||
let res = write_fixed(&mut buf, pos, byte, path, endian);
|
||||
let _ = k;
|
||||
if res.is_err() {
|
||||
assert_eq!(buf, before, "failed fixed write leaves the buffer untouched");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn endian_of(_l: &PackedLayout) -> Endian {
|
||||
// The packed layout itself does not store endianness; the seeds'
|
||||
// menus declare it, the roundtrip probe compiles its own engine.
|
||||
Endian::Little
|
||||
}
|
||||
|
||||
fn write_fixed(
|
||||
buf: &mut [u8],
|
||||
pos: &FieldPosition,
|
||||
byte: u8,
|
||||
path: &str,
|
||||
endian: Endian,
|
||||
) -> Result<(), AlkTypeError> {
|
||||
use alktype::data_access as da;
|
||||
match pos.kind {
|
||||
AlkTypeKind::Int8 => da::write_i8(buf, pos.offset, byte as i8, path),
|
||||
AlkTypeKind::Uint8 => da::write_u8(buf, pos.offset, byte, path),
|
||||
AlkTypeKind::Boolean => da::write_bool(buf, pos.offset, byte & 1 == 1, path),
|
||||
AlkTypeKind::Int16 => da::write_i16(buf, pos.offset, byte as i16, path, endian),
|
||||
AlkTypeKind::Uint16 => da::write_u16(buf, pos.offset, byte as u16, path, endian),
|
||||
AlkTypeKind::Int32 => da::write_i32(buf, pos.offset, byte as i32, path, endian),
|
||||
AlkTypeKind::Uint32 | AlkTypeKind::Enum => {
|
||||
da::write_u32(buf, pos.offset, byte as u32, path, endian)
|
||||
}
|
||||
AlkTypeKind::Int64 => da::write_i64(buf, pos.offset, byte as i64, path, endian),
|
||||
AlkTypeKind::Uint64 => da::write_u64(buf, pos.offset, byte as u64, path, endian),
|
||||
AlkTypeKind::Float32 => da::write_f32(buf, pos.offset, byte as f32, path, endian),
|
||||
AlkTypeKind::Float64 => da::write_f64(buf, pos.offset, byte as f64, path, endian),
|
||||
other => panic!("write_fixed over a non-fixed kind {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The write-then-read pair fixture (top-level string only — the
|
||||
/// sequential walk surfaces nested structs as `FieldValue::Struct`
|
||||
/// descriptors and never descends, so a nested `hdr.name` can never be
|
||||
/// round-tripped through `read_next`): build the layout, write a
|
||||
/// length-prefixed value at the recorded position into a correctly
|
||||
/// sized buffer, then read it back through the sequential walk — the
|
||||
/// write side and read side of the format are one contract.
|
||||
fn write_then_read_roundtrip(doc: &serde_json::Value, l: &PackedLayout, payload: &[u8]) {
|
||||
let Some(s_pos) = l.get("s") else {
|
||||
return;
|
||||
};
|
||||
let Ok(engine) = AlkTypeEngine::compile(doc, "S", LayoutMode::Packed, None) else {
|
||||
return;
|
||||
};
|
||||
let value = payload
|
||||
.iter()
|
||||
.take(4)
|
||||
.map(|b| std::char::from_u32(0x40 + u32::from(b % 26)).unwrap_or('a'))
|
||||
.collect::<String>();
|
||||
let mut buf = vec![0u8; l.total_size()];
|
||||
data_access::write_string(&mut buf, s_pos.offset, &value, "s", Endian::Little)
|
||||
.expect("roundtrip buffer is sized from the same layout");
|
||||
let mut reader = engine.sequential_reader().expect("packed reader");
|
||||
let mut steps = 0usize;
|
||||
loop {
|
||||
steps += 1;
|
||||
if steps > buf.len() + 2 {
|
||||
panic!("roundtrip walk exceeded its bound");
|
||||
}
|
||||
match reader.read_next(&buf) {
|
||||
Ok(Some((name, alktype::FieldValue::String(s)))) if name == "s" => {
|
||||
assert_eq!(s, value, "the written value reads back verbatim");
|
||||
return;
|
||||
}
|
||||
Ok(Some(_)) => continue,
|
||||
Ok(None) => panic!("the roundtrip walk ended before reaching the string"),
|
||||
Err(e) => panic!("the roundtrip walk over a builder-written buffer failed: {e:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod corpus_replay {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn committed_corpus_replays_through_the_invariants() {
|
||||
let corpus =
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/layout_build");
|
||||
let mut count = 0usize;
|
||||
for entry in std::fs::read_dir(&corpus).expect("corpus directory is committed") {
|
||||
let path = entry.expect("corpus entry readable").path();
|
||||
let data = std::fs::read(&path).expect("corpus entry readable");
|
||||
fuzz_layout_build(&data);
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 10,
|
||||
"committed seed corpus is present, found {count}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Byte encodings against the pinned arbitrary 1.4.2 derive layout:
|
||||
/// field order { menu: u8, size_kind: SizeKind, drive_writes: bool,
|
||||
/// roundtrip_probe: bool, payload: Vec<u8> } with payload
|
||||
/// take-rest. SizeKind has 6 variants.
|
||||
mod enc {
|
||||
pub fn variant_u32(k: u64, count: u64) -> [u8; 4] {
|
||||
let mut v = (k << 32).div_ceil(count);
|
||||
while (v.wrapping_mul(count) >> 32) != k {
|
||||
v += 1;
|
||||
}
|
||||
u32::try_from(v).expect("k < count always admits a < 2^32 v").to_le_bytes()
|
||||
}
|
||||
|
||||
pub const SIZE_ZERO: u64 = 0;
|
||||
pub const SIZE_SMALL: u64 = 2;
|
||||
pub const SIZE_HUGE: u64 = 3;
|
||||
pub const SIZE_OVERFLOW: u64 = 4;
|
||||
pub const SIZE_FROM_INPUT: u64 = 5;
|
||||
|
||||
pub fn size_kind(tag: u64, payload_byte: u8) -> Vec<u8> {
|
||||
let mut v = variant_u32(tag, 6).to_vec();
|
||||
if tag == SIZE_SMALL || tag == SIZE_FROM_INPUT {
|
||||
v.push(payload_byte);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
pub fn layout_seed(
|
||||
menu: u8,
|
||||
size: Vec<u8>,
|
||||
payload: &[u8],
|
||||
drive: bool,
|
||||
rt: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut v = vec![menu];
|
||||
v.extend_from_slice(&size);
|
||||
v.push(u8::from(drive));
|
||||
v.push(u8::from(rt));
|
||||
for b in payload {
|
||||
v.push(0x01);
|
||||
v.push(*b);
|
||||
}
|
||||
v.push(0x00);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_lands_on_the_intended_variants() {
|
||||
fn decode(data: &[u8]) -> (usize, String, bool, bool, usize) {
|
||||
let input =
|
||||
LayoutInput::arbitrary_take_rest(arbitrary::Unstructured::new(data))
|
||||
.expect("the encoding decodes");
|
||||
let size = match input.size_kind {
|
||||
SizeKind::Zero => "Zero".to_string(),
|
||||
SizeKind::One => "One".to_string(),
|
||||
SizeKind::Small(n) => format!("S{n}"),
|
||||
SizeKind::Huge => "Huge".to_string(),
|
||||
SizeKind::Overflowing => "Ovf".to_string(),
|
||||
SizeKind::FromInput(n) => format!("I{n}"),
|
||||
};
|
||||
(
|
||||
input.menu as usize,
|
||||
size,
|
||||
input.drive_writes,
|
||||
input.roundtrip_probe,
|
||||
input.payload.len(),
|
||||
)
|
||||
}
|
||||
|
||||
// bool is `byte & 1 == 1` — any odd byte is true, even false.
|
||||
let seed =
|
||||
enc::layout_seed(3, enc::size_kind(enc::SIZE_SMALL, 10), b"wxyz", true, false);
|
||||
assert_eq!(decode(&seed), (3, "S10".to_string(), true, false, 4));
|
||||
assert_eq!(
|
||||
decode(&enc::layout_seed(
|
||||
4,
|
||||
enc::size_kind(enc::SIZE_ZERO, 0),
|
||||
&[],
|
||||
false,
|
||||
true
|
||||
)),
|
||||
(4, "Zero".to_string(), false, true, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&enc::layout_seed(
|
||||
2,
|
||||
enc::size_kind(enc::SIZE_OVERFLOW, 0),
|
||||
&[],
|
||||
true,
|
||||
true
|
||||
)),
|
||||
(2, "Ovf".to_string(), true, true, 0)
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&enc::layout_seed(
|
||||
0,
|
||||
enc::size_kind(enc::SIZE_FROM_INPUT, 0x80),
|
||||
&[9],
|
||||
true,
|
||||
false
|
||||
)),
|
||||
(0, "I128".to_string(), true, false, 1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spec_example_u8_u32_string_total_19_holds() {
|
||||
// The builder's own doc example, exercised through the fuzz
|
||||
// entry's build path: s=10, b=0 → tail at 18, total 19.
|
||||
let doc: serde_json::Value =
|
||||
serde_json::from_str(MENUS[0]).expect("menu 0 parses");
|
||||
let engine =
|
||||
AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None).expect("menu compiles");
|
||||
let mut vs = HashMap::new();
|
||||
vs.insert("s".to_string(), 10usize);
|
||||
vs.insert("b".to_string(), 0usize);
|
||||
let l = engine
|
||||
.layout_builder()
|
||||
.expect("packed builder")
|
||||
.build(&vs)
|
||||
.expect("builds");
|
||||
assert_eq!(l.get("s").expect("s").offset, 0);
|
||||
assert_eq!(l.get("b").expect("b").offset, 14);
|
||||
assert_eq!(l.get("tail").expect("tail").offset, 18);
|
||||
assert_eq!(l.total_size(), 19);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_writes_untouched_and_roundtrip_holds() {
|
||||
// The write contract menu through the fuzz entry: sizes that
|
||||
// fit, sizes that overflow, and the roundtrip probe.
|
||||
fuzz_layout_build(&enc::layout_seed(
|
||||
0,
|
||||
enc::size_kind(enc::SIZE_SMALL, 7),
|
||||
b"x",
|
||||
true,
|
||||
true,
|
||||
));
|
||||
fuzz_layout_build(&enc::layout_seed(0, enc::size_kind(enc::SIZE_HUGE, 0), b"", true, true));
|
||||
fuzz_layout_build(&enc::layout_seed(
|
||||
2,
|
||||
enc::size_kind(enc::SIZE_SMALL, 9),
|
||||
b"y",
|
||||
true,
|
||||
true,
|
||||
));
|
||||
fuzz_layout_build(&enc::layout_seed(3, enc::size_kind(enc::SIZE_ZERO, 0), b"", true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_and_record_overflow_failures_are_offset_class() {
|
||||
// The union menu (2) with an overflowing size must fail with
|
||||
// Offset naming the path (derive_var_sizes supplies
|
||||
// __discriminator then hits overflow); record menu with huge.
|
||||
fuzz_layout_build(&enc::layout_seed(
|
||||
2,
|
||||
enc::size_kind(enc::SIZE_OVERFLOW, 0),
|
||||
b"",
|
||||
false,
|
||||
false,
|
||||
));
|
||||
fuzz_layout_build(&enc::layout_seed(
|
||||
3,
|
||||
enc::size_kind(enc::SIZE_HUGE, 0),
|
||||
b"",
|
||||
false,
|
||||
false,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn union_menu_reaches_success_paths_with_the_discovered_key_form() {
|
||||
// The discovery loop parses the synthetic key
|
||||
// ('p.__discriminator') out of the reason quoting — with a
|
||||
// small size the union layout builds and the position
|
||||
// partition holds over the variant's fields.
|
||||
fuzz_layout_build(&enc::layout_seed(
|
||||
2,
|
||||
enc::size_kind(enc::SIZE_SMALL, 8),
|
||||
b"",
|
||||
true,
|
||||
false,
|
||||
));
|
||||
fuzz_layout_build(&enc::layout_seed(
|
||||
2,
|
||||
enc::size_kind(enc::SIZE_ZERO, 0),
|
||||
b"",
|
||||
true,
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,5 @@
|
||||
|
||||
pub mod bast_compile;
|
||||
pub mod data_access;
|
||||
pub mod layout_build;
|
||||
pub mod read_opseq;
|
||||
@@ -0,0 +1,603 @@
|
||||
//! Invariants for the stateful `SequentialReader` (wave 2, target 3):
|
||||
//! no-panic over any op interleaving and any buffer, cursor discipline
|
||||
//! (a failed read leaves the cursor untouched; `reset` restores the
|
||||
//! exact initial state; the cursor never exceeds the buffer), plan-order
|
||||
//! reads with `None` exactly at plan end, `read_field` leaves a usable
|
||||
//! reader on every outcome, the wire-controlled record-count spin bound
|
||||
//! (≥ 4 verified bytes per inner iteration), and ADR-007's
|
||||
//! reader-independence contract.
|
||||
|
||||
use alktype::{AlkTypeEngine, AlkTypeError, LayoutMode};
|
||||
use arbitrary::Arbitrary;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The fixed schema menu. Every shape a hostile op sequence can
|
||||
/// interleave against: fixed-width leaves, a variable-length field
|
||||
/// sandwiched between tails, nested structs, fixed-stride arrays,
|
||||
/// records (the count-loop spin candidate), and a byte-discriminator
|
||||
/// TUnion.
|
||||
const DOC: &str = r##"{
|
||||
"$defs": {
|
||||
"S": {
|
||||
"kind": "struct",
|
||||
"endian": "little",
|
||||
"fields": [
|
||||
{ "name": "a", "kind": "uint8" },
|
||||
{ "name": "n", "kind": "uint32" },
|
||||
{ "name": "s", "kind": "string" },
|
||||
{ "name": "tail", "kind": "uint8" },
|
||||
{ "name": "nested", "kind": { "$ref": "#/$defs/Inner" } },
|
||||
{ "name": "arr", "kind": { "kind": "array", "element": "uint16", "count": 2 } },
|
||||
{ "name": "rec", "kind": { "kind": "record", "values": "uint32" } },
|
||||
{ "name": "un", "kind": { "$ref": "#/$defs/U" } }
|
||||
]
|
||||
},
|
||||
"Inner": { "kind": "struct", "fields": [
|
||||
{ "name": "x", "kind": "int16" },
|
||||
{ "name": "y", "kind": "bool" }
|
||||
]},
|
||||
"U": {
|
||||
"kind": "union",
|
||||
"discriminator": { "kind": "byte", "offset": 0, "type": "uint8" },
|
||||
"mapping": {
|
||||
"0": { "$ref": "#/$defs/Small" },
|
||||
"1": { "$ref": "#/$defs/Wide" }
|
||||
}
|
||||
},
|
||||
"Small": { "kind": "struct", "fields": [{ "name": "v", "kind": "uint8" }] },
|
||||
"Wide": { "kind": "struct", "fields": [
|
||||
{ "name": "w", "kind": "uint64" },
|
||||
{ "name": "blob", "kind": "bytes" }
|
||||
]}
|
||||
}
|
||||
}"##;
|
||||
|
||||
/// Field paths a `Field` op may target: every declared field (including
|
||||
/// a dotted nested path) plus junk that must error with `Schema`,
|
||||
/// never panic. The op's u8 payload picks `byte % 10`.
|
||||
const FIELD_PATHS: [&str; 10] = [
|
||||
"a", "n", "s", "tail", "nested", "arr", "rec", "un", "nested.x", "__nope__",
|
||||
];
|
||||
|
||||
#[derive(Debug, Arbitrary)]
|
||||
pub enum Op {
|
||||
Next,
|
||||
NextBorrowed,
|
||||
Field(u8),
|
||||
Reset,
|
||||
End,
|
||||
}
|
||||
|
||||
/// Post-decode transform of the hostile bytes.
|
||||
#[derive(Debug, Arbitrary, Clone, Copy)]
|
||||
pub enum Shape {
|
||||
Raw,
|
||||
Empty,
|
||||
Truncate(u8),
|
||||
Padded,
|
||||
}
|
||||
|
||||
/// The typed fuzz input. `bytes` carries the hostile buffer body;
|
||||
/// `shape` transforms it; `ops` (the take-rest field) drives the
|
||||
/// stateful sequence.
|
||||
#[derive(Debug, Arbitrary)]
|
||||
pub struct OpSeq {
|
||||
bytes: Vec<u8>,
|
||||
shape: Shape,
|
||||
ops: Vec<Op>,
|
||||
}
|
||||
|
||||
pub fn fuzz_read_opseq(data: &[u8]) {
|
||||
let Ok(seq) = OpSeq::arbitrary_take_rest(arbitrary::Unstructured::new(data)) else {
|
||||
return;
|
||||
};
|
||||
let Ok(doc) = serde_json::from_str::<serde_json::Value>(DOC) else {
|
||||
return;
|
||||
};
|
||||
let Ok(engine) = AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let buffer = shape_buffer(&seq.shape, &seq.bytes);
|
||||
run_ops(&engine, &buffer, &seq.ops);
|
||||
reader_independence(&engine, &buffer);
|
||||
}
|
||||
|
||||
fn shape_buffer(shape: &Shape, bytes: &[u8]) -> Vec<u8> {
|
||||
match shape {
|
||||
Shape::Raw => bytes.to_vec(),
|
||||
Shape::Empty => Vec::new(),
|
||||
Shape::Truncate(n) => bytes[..bytes.len().min(*n as usize)].to_vec(),
|
||||
Shape::Padded => {
|
||||
let mut v = bytes.to_vec();
|
||||
v.extend_from_slice(&[0u8; 64]);
|
||||
v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The spin bound for one full walk: every successful `read_next`
|
||||
/// advances the field index, and every record/variable-length inner
|
||||
/// iteration verifies ≥ 4 bytes, so a completed walk is bounded by
|
||||
/// `field_count` outer steps regardless of buffer length. `buffer_len`
|
||||
/// is added headroom so the bound also fires if a zero-cost loop
|
||||
/// appears anywhere.
|
||||
fn walk_bound(buffer_len: usize, field_count: usize) -> usize {
|
||||
buffer_len.saturating_add(field_count).saturating_add(2)
|
||||
}
|
||||
|
||||
fn run_ops(engine: &AlkTypeEngine, buffer: &[u8], ops: &[Op]) {
|
||||
let Some(mut reader) = engine.sequential_reader() else {
|
||||
return;
|
||||
};
|
||||
let field_count = reader.plan().fields().len();
|
||||
|
||||
for op in ops {
|
||||
match op {
|
||||
Op::Next => {
|
||||
let before = reader.position();
|
||||
match reader.read_next(buffer) {
|
||||
Ok(Some(_)) => {
|
||||
assert!(
|
||||
reader.position() <= buffer.len(),
|
||||
"cursor {} exceeds buffer len {}",
|
||||
reader.position(),
|
||||
buffer.len()
|
||||
);
|
||||
}
|
||||
Ok(None) => {
|
||||
assert!(
|
||||
matches!(reader.read_next(buffer), Ok(None)),
|
||||
"None must be sticky at plan end"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
assert_eq!(
|
||||
reader.position(),
|
||||
before,
|
||||
"a failed read_next leaves the position unchanged"
|
||||
);
|
||||
let msg = format!("{e:?}");
|
||||
assert_clean(&e);
|
||||
let e2 = reader.read_next(buffer).unwrap_err();
|
||||
assert_eq!(
|
||||
msg,
|
||||
format!("{e2:?}"),
|
||||
"the same cursor state + buffer must reproduce the error"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Op::NextBorrowed => {
|
||||
match reader.read_next_borrowed(buffer) {
|
||||
Ok(Some(_)) => {
|
||||
assert!(reader.position() <= buffer.len());
|
||||
}
|
||||
Ok(None) => {
|
||||
assert!(
|
||||
matches!(reader.read_next_borrowed(buffer), Ok(None)),
|
||||
"None must be sticky at plan end (borrowed)"
|
||||
);
|
||||
}
|
||||
Err(e) => assert_clean(&e),
|
||||
}
|
||||
}
|
||||
Op::Field(choice) => {
|
||||
let path = FIELD_PATHS[(*choice as usize) % FIELD_PATHS.len()];
|
||||
match reader.read_field(buffer, path) {
|
||||
Ok(_) => {
|
||||
assert!(reader.position() <= buffer.len());
|
||||
}
|
||||
Err(e) => match e {
|
||||
AlkTypeError::Schema(_) => {}
|
||||
other => assert_clean(&other),
|
||||
},
|
||||
}
|
||||
reader.reset();
|
||||
assert_eq!(reader.position(), 0);
|
||||
match reader.read_next(buffer) {
|
||||
Ok(Some(_)) | Ok(None) => {}
|
||||
Err(e) => {
|
||||
assert_clean(&e);
|
||||
reader.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
Op::Reset => {
|
||||
reader.reset();
|
||||
assert_eq!(reader.position(), 0, "reset zeroes the position");
|
||||
let first = reader.plan().fields().first().map(|f| f.name().to_string());
|
||||
if let Ok(Some((name, _))) = reader.read_next(buffer) {
|
||||
assert_eq!(Some(name), first, "reset re-reads field 0");
|
||||
}
|
||||
}
|
||||
Op::End => {
|
||||
let mut steps = 0usize;
|
||||
let mut names = Vec::new();
|
||||
let Some(mut probe) = engine.sequential_reader() else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
steps += 1;
|
||||
if steps > walk_bound(buffer.len(), field_count) {
|
||||
panic!(
|
||||
"walk exceeded the spin bound at step {steps} over a \
|
||||
{len}-byte buffer with {fields} fields — a \
|
||||
wire-controlled count is looping",
|
||||
len = buffer.len(),
|
||||
fields = field_count
|
||||
);
|
||||
}
|
||||
match probe.read_next(buffer) {
|
||||
Ok(Some((name, _))) => names.push(name),
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
assert_clean(&e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if walk_completes(engine, buffer, field_count) && names.len() == field_count {
|
||||
let expected: Vec<String> =
|
||||
probe.plan().fields().iter().map(|f| f.name().to_string()).collect();
|
||||
assert_eq!(
|
||||
names, expected,
|
||||
"a completed walk reports fields exactly in plan order"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn walk_completes(engine: &AlkTypeEngine, buffer: &[u8], field_count: usize) -> bool {
|
||||
let Some(mut probe) = engine.sequential_reader() else {
|
||||
return false;
|
||||
};
|
||||
let mut steps = 0usize;
|
||||
loop {
|
||||
steps += 1;
|
||||
if steps > walk_bound(buffer.len(), field_count) {
|
||||
panic!("walk_completes exceeded the spin bound");
|
||||
}
|
||||
match probe.read_next(buffer) {
|
||||
Ok(Some(_)) => continue,
|
||||
Ok(None) => return true,
|
||||
Err(_) => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Two engine-issued readers over the same plan and buffer never share
|
||||
/// cursor state (ADR-007): identical initial state ⇒ identical first
|
||||
/// read; interleaved reads and resets never move the other's cursor.
|
||||
fn reader_independence(engine: &AlkTypeEngine, buffer: &[u8]) {
|
||||
let Some(mut r1) = engine.sequential_reader() else {
|
||||
return;
|
||||
};
|
||||
let Some(mut r2) = engine.sequential_reader() else {
|
||||
return;
|
||||
};
|
||||
assert!(
|
||||
Arc::ptr_eq(r1.plan(), r2.plan()),
|
||||
"engine-issued readers share the compiled plan Arc"
|
||||
);
|
||||
|
||||
let first1 =
|
||||
r1.read_next_borrowed(buffer).map(|o| o.map(|(n, _)| n.to_string()));
|
||||
let first2 =
|
||||
r2.read_next_borrowed(buffer).map(|o| o.map(|(n, _)| n.to_string()));
|
||||
if let (Ok(a), Ok(b)) = (&first1, &first2) {
|
||||
assert_eq!(a, b, "independent readers in identical state agree");
|
||||
}
|
||||
|
||||
let pos = r1.position();
|
||||
let _ = r2.read_next(buffer);
|
||||
let _ = r2.read_next(buffer);
|
||||
assert_eq!(r1.position(), pos, "r2's reads never move r1's cursor");
|
||||
r2.reset();
|
||||
assert_eq!(r1.position(), pos, "r2's reset never moves r1's cursor");
|
||||
|
||||
let field_count = r1.plan().fields().len();
|
||||
let mut steps = 0usize;
|
||||
loop {
|
||||
steps += 1;
|
||||
if steps > walk_bound(buffer.len(), field_count) {
|
||||
panic!("r1's interleaved walk exceeded the spin bound");
|
||||
}
|
||||
match r1.read_next(buffer) {
|
||||
Ok(Some(_)) => continue,
|
||||
Ok(None) | Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_clean(e: &AlkTypeError) {
|
||||
match e {
|
||||
AlkTypeError::Access { field_path, .. } => {
|
||||
assert!(!field_path.is_empty(), "Access errors carry the field path");
|
||||
}
|
||||
AlkTypeError::Schema(_) => {}
|
||||
AlkTypeError::Offset { .. } | AlkTypeError::Validation(_) => {
|
||||
panic!("sequential reads must never error with Offset/Validation, got {e:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod corpus_replay {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn committed_corpus_replays_through_the_invariants() {
|
||||
let corpus =
|
||||
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/read_opseq");
|
||||
let mut count = 0usize;
|
||||
for entry in std::fs::read_dir(&corpus).expect("corpus directory is committed") {
|
||||
let path = entry.expect("corpus entry readable").path();
|
||||
let data = std::fs::read(&path).expect("corpus entry readable");
|
||||
fuzz_read_opseq(&data);
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 10,
|
||||
"committed seed corpus is present, found {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fixed_menu_compiles() {
|
||||
let doc: serde_json::Value = serde_json::from_str(DOC).expect("menu doc is valid JSON");
|
||||
AlkTypeEngine::compile(&doc, "S", LayoutMode::Packed, None)
|
||||
.expect("the fixed menu schema compiles");
|
||||
}
|
||||
|
||||
/// Byte encodings against the pinned arbitrary 1.4.2 derive layout:
|
||||
/// field order { bytes: Vec<u8>, shape: Shape, ops: Vec<Op> } with
|
||||
/// ops the take-rest field. Enum variants consume a 4-byte LE u32
|
||||
/// `v` and select variant `(v × count) >> 32`. Vec elements are
|
||||
/// keep-going byte (odd = one more element) + element bytes. bools
|
||||
/// are not involved. The Rust side mirrors this in `enc` below;
|
||||
/// the Python seed generator mirrors both.
|
||||
mod enc {
|
||||
pub fn variant_u32(k: u64, count: u64) -> [u8; 4] {
|
||||
let mut v = (k << 32).div_ceil(count);
|
||||
while (v.wrapping_mul(count) >> 32) != k {
|
||||
v += 1;
|
||||
}
|
||||
u32::try_from(v).expect("variant selector < 2^32 for k < count").to_le_bytes()
|
||||
}
|
||||
|
||||
pub const SHAPE_RAW: u64 = 0;
|
||||
pub const SHAPE_EMPTY: u64 = 1;
|
||||
pub const SHAPE_TRUNCATE: u64 = 2;
|
||||
pub const SHAPE_PADDED: u64 = 3;
|
||||
|
||||
pub const OP_NEXT: u64 = 0;
|
||||
pub const OP_NEXT_BORROWED: u64 = 1;
|
||||
pub const OP_FIELD: u64 = 2;
|
||||
pub const OP_RESET: u64 = 3;
|
||||
pub const OP_END: u64 = 4;
|
||||
|
||||
pub fn bytes_field(bytes: &[u8]) -> Vec<u8> {
|
||||
let mut v = Vec::new();
|
||||
for b in bytes {
|
||||
v.push(0x01);
|
||||
v.push(*b);
|
||||
}
|
||||
v.push(0x00);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn op(tag: u64) -> Vec<u8> {
|
||||
variant_u32(tag, 5).to_vec()
|
||||
}
|
||||
|
||||
pub fn field_op(choice: u8) -> Vec<u8> {
|
||||
let mut v = op(OP_FIELD);
|
||||
v.push(choice);
|
||||
v
|
||||
}
|
||||
|
||||
/// shape_field with no variant payload; Truncate carries a u8.
|
||||
pub fn shape_field(tag: u64) -> Vec<u8> {
|
||||
variant_u32(tag, 4).to_vec()
|
||||
}
|
||||
|
||||
pub fn shape_truncate(n: u8) -> Vec<u8> {
|
||||
let mut v = variant_u32(SHAPE_TRUNCATE, 4).to_vec();
|
||||
v.push(n);
|
||||
v
|
||||
}
|
||||
|
||||
pub fn opseq(bytes: &[u8], shape: Vec<u8>, ops: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut v = bytes_field(bytes);
|
||||
v.extend_from_slice(&shape);
|
||||
for o in ops {
|
||||
v.push(0x01);
|
||||
v.extend_from_slice(o);
|
||||
}
|
||||
v.push(0x00);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// A buffer covering every field of the menu schema: u8 | u32 |
|
||||
/// string | u8 | Inner(i16, bool) | u16[2] | record<u32> ×2 |
|
||||
/// union(disc=0 → Small { u8 }).
|
||||
fn host_buffer() -> Vec<u8> {
|
||||
let mut buf = Vec::new();
|
||||
buf.push(0x11); // a
|
||||
buf.extend_from_slice(&7u32.to_le_bytes()); // n
|
||||
buf.extend_from_slice(&5u32.to_le_bytes()); // s len
|
||||
buf.extend_from_slice(b"hello"); // s
|
||||
buf.push(0x22); // tail
|
||||
buf.extend_from_slice(&(-2i16).to_le_bytes()); // nested.x
|
||||
buf.push(0x01); // nested.y
|
||||
buf.extend_from_slice(&0xBEEFu16.to_le_bytes()); // arr[0]
|
||||
buf.extend_from_slice(&0xCAFEu16.to_le_bytes()); // arr[1]
|
||||
buf.extend_from_slice(&2u32.to_le_bytes()); // rec count
|
||||
buf.extend_from_slice(&1u32.to_le_bytes()); // rec[0].key len
|
||||
buf.push(b'k'); // rec[0].key
|
||||
buf.extend_from_slice(&0x0102_0304u32.to_le_bytes()); // rec[0].value
|
||||
buf.extend_from_slice(&1u32.to_le_bytes()); // rec[1].key len
|
||||
buf.push(b'j'); // rec[1].key
|
||||
buf.extend_from_slice(&0x0A0B_0C0Du32.to_le_bytes()); // rec[1].value
|
||||
buf.push(0x00); // un disc → "0"
|
||||
buf.push(0x33); // Small.v
|
||||
buf
|
||||
}
|
||||
|
||||
fn all_next_ops(n: usize) -> Vec<Vec<u8>> {
|
||||
let mut ops = vec![enc::op(enc::OP_NEXT); n];
|
||||
ops.push(enc::op(enc::OP_END));
|
||||
ops
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_lands_on_the_intended_variants() {
|
||||
// The seed-decode pin: every hand-encoding helper must decode
|
||||
// (through the real Arbitrary derive) to exactly the variant it
|
||||
// intends, so the committed seeds carry the menu they claim.
|
||||
use arbitrary::Arbitrary;
|
||||
|
||||
fn decode(data: &[u8]) -> (String, String, usize) {
|
||||
let seq = OpSeq::arbitrary_take_rest(arbitrary::Unstructured::new(data))
|
||||
.expect("the encoding decodes");
|
||||
let ops = seq
|
||||
.ops
|
||||
.iter()
|
||||
.map(|o| match o {
|
||||
Op::Next => "N".to_string(),
|
||||
Op::NextBorrowed => "B".to_string(),
|
||||
Op::Field(c) => format!("F{c}"),
|
||||
Op::Reset => "R".to_string(),
|
||||
Op::End => "E".to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
let shape = match seq.shape {
|
||||
Shape::Raw => "Raw".to_string(),
|
||||
Shape::Empty => "Empty".to_string(),
|
||||
Shape::Truncate(n) => format!("T{n}"),
|
||||
Shape::Padded => "P".to_string(),
|
||||
};
|
||||
(ops, shape, seq.bytes.len())
|
||||
}
|
||||
|
||||
let full = enc::opseq(
|
||||
&[0xAA, 0xBB],
|
||||
enc::shape_field(enc::SHAPE_PADDED),
|
||||
&[
|
||||
enc::op(enc::OP_NEXT),
|
||||
enc::op(enc::OP_NEXT_BORROWED),
|
||||
enc::field_op(3),
|
||||
enc::op(enc::OP_RESET),
|
||||
enc::op(enc::OP_END),
|
||||
],
|
||||
);
|
||||
assert_eq!(decode(&full), ("NB".to_string() + "F3" + "RE", "P".to_string(), 2));
|
||||
assert_eq!(
|
||||
decode(&enc::opseq(&[], enc::shape_field(enc::SHAPE_RAW), &[enc::field_op(9)]))
|
||||
.0,
|
||||
"F9"
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&enc::opseq(&[], enc::shape_truncate(42), &[])).1,
|
||||
"T42"
|
||||
);
|
||||
assert_eq!(
|
||||
decode(&enc::opseq(&[], enc::shape_field(enc::SHAPE_EMPTY), &[])).1,
|
||||
"Empty"
|
||||
);
|
||||
// Exhausted input with a keep-going byte but no element bytes:
|
||||
// the u32 selector zero-fills → variant 0 (Next). This is
|
||||
// exactly the boundary the fuzzer explores; pin it so a
|
||||
// derive-layout change surfaces here.
|
||||
let mut cut = enc::opseq(&[], enc::shape_field(enc::SHAPE_RAW), &[enc::op(enc::OP_END)]);
|
||||
cut.truncate(cut.len() - 5); // stop byte + 4-byte op selector
|
||||
let (ops, shape, n) = decode(&cut);
|
||||
assert_eq!((ops.as_str(), shape.as_str(), n), ("N", "Raw", 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_walk_over_a_complete_buffer() {
|
||||
let buf = host_buffer();
|
||||
fuzz_read_opseq(&enc::opseq(&buf, enc::shape_field(enc::SHAPE_RAW), &all_next_ops(10)));
|
||||
// Padded shape: the same semantic walk over 64 zero tails.
|
||||
fuzz_read_opseq(&enc::opseq(&buf, enc::shape_field(enc::SHAPE_PADDED), &all_next_ops(10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_mid_walk_then_walk_again() {
|
||||
let buf = host_buffer();
|
||||
let ops = [
|
||||
enc::op(enc::OP_NEXT),
|
||||
enc::op(enc::OP_NEXT),
|
||||
enc::op(enc::OP_NEXT),
|
||||
enc::op(enc::OP_RESET),
|
||||
enc::op(enc::OP_END),
|
||||
];
|
||||
fuzz_read_opseq(&enc::opseq(&buf, enc::shape_field(enc::SHAPE_RAW), &ops));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_read_then_reset() {
|
||||
// Truncated at 12 bytes: reads fail mid-walk; every failure
|
||||
// must leave a usable reader (the op loop resets and replays).
|
||||
let ops = [
|
||||
enc::op(enc::OP_END),
|
||||
enc::op(enc::OP_RESET),
|
||||
enc::op(enc::OP_END),
|
||||
enc::field_op(2),
|
||||
enc::op(enc::OP_END),
|
||||
];
|
||||
fuzz_read_opseq(&enc::opseq(&host_buffer(), enc::shape_truncate(12), &ops));
|
||||
// Empty buffer: nothing succeeds; the walk bound still holds.
|
||||
fuzz_read_opseq(&enc::opseq(&host_buffer(), enc::shape_field(enc::SHAPE_EMPTY), &ops));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn junk_and_real_field_paths() {
|
||||
let buf = host_buffer();
|
||||
fuzz_read_opseq(&enc::opseq(&buf, enc::shape_field(enc::SHAPE_RAW), &[enc::field_op(9)]));
|
||||
fuzz_read_opseq(&enc::opseq(&buf, enc::shape_field(enc::SHAPE_RAW), &[enc::field_op(8)]));
|
||||
fuzz_read_opseq(&enc::opseq(&buf, enc::shape_field(enc::SHAPE_RAW), &[enc::field_op(6)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_record_count_fails_fast_with_access() {
|
||||
// The record-count spin candidate (plan §6 item 1): the count
|
||||
// far above any iteration budget. Every iteration must verify
|
||||
// ≥ 4 bytes, so the walk fails with Access inside the bound —
|
||||
// asserted per the plan, not just no-panic. A regression to a
|
||||
// zero-width inner loop trips the End-op spin-bound panic
|
||||
// and/or cargo's test timeout.
|
||||
let rec_at = 1 + 4 + 4 + 5 + 1 + 2 + 1 + 4; // a, n, s(4+5), tail, x, y, arr
|
||||
let mut hostile = host_buffer();
|
||||
hostile[rec_at..rec_at + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
|
||||
hostile.truncate(rec_at + 4);
|
||||
fuzz_read_opseq(&enc::opseq(&hostile, enc::shape_field(enc::SHAPE_RAW), &all_next_ops(10)));
|
||||
|
||||
// Zero-length keys with a large count: 8 verified bytes per
|
||||
// iteration (key prefix + value) — still bounded.
|
||||
let mut zero_keys = host_buffer();
|
||||
zero_keys[rec_at..rec_at + 4].copy_from_slice(&0x0001_0000u32.to_le_bytes());
|
||||
for b in &mut zero_keys[rec_at + 4..] {
|
||||
*b = 0x00;
|
||||
}
|
||||
fuzz_read_opseq(&enc::opseq(&zero_keys, enc::shape_field(enc::SHAPE_RAW), &all_next_ops(10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_at_every_prefix_stays_clean() {
|
||||
let buf = host_buffer();
|
||||
for n in 1..buf.len() {
|
||||
fuzz_read_opseq(&enc::opseq(
|
||||
&buf,
|
||||
enc::shape_truncate(u8::try_from(n).expect("fixture < 256")),
|
||||
&all_next_ops(10),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user