fuzz: wave 1 — infra + bast_compile/data_access targets, seeds, corpus-replay gate
- fuzz/ workspace (nightly-pinned subtree, own [workspace]), copied
from the alkhttp/alkcall pattern: thin fuzz_target wrappers,
stable-toolchain shared crate holding the invariant logic, detached
runner, seed generator, json.dict, README
- bast_compile: AlkTypeEngine::compile both modes over attacker BAST
JSON; meta-schema gate ordering, always-Result, fixed-size leaf
metadata partition, json_schema lane
- data_access: the hand-rolled decode core over raw bytes at
attacker-chosen offsets; bool strictness, UTF-8 discipline, bounds
partitions, indirect {offset,length} pair contract, write-side
no-touch-on-failure + write/read round trips
- 136 committed seeds (38 + 98) via fuzz/gen_fuzz_seeds.py
- root Cargo.toml: explicit [workspace] exclude=[fuzz]; publish
exclude gains fuzz/
- AGENTS.md verification checklist gains the corpus-replay gate
- .gitignore: fuzz artifacts + grown-corpus pattern
Verification: cargo test 569 pass; clippy -D warnings clean; corpus
replay 4/4 green (136 seeds); cargo fuzz build clean (nightly
confined to fuzz/)
This commit is contained in:
1 parent
8d779e7672
commit
ed41d77e72
152 files changed
+2287
-2
No files matched your search
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "alktype-fuzz-shared"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
alktype = { path = "../.." }
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
|
||||
[dependencies.arbitrary]
|
||||
version = "1"
|
||||
features = ["derive"]
|
||||
@@ -0,0 +1,268 @@
|
||||
//! Invariants for `bast_compile` — the whole schema side, driven
|
||||
//! through `AlkTypeEngine::compile` (both layout modes): no-panic on
|
||||
//! any JSON document, always-`Result` compile, the meta-schema gate
|
||||
//! ordering, the parse-time caps (align / arrays / maxLength / depth /
|
||||
//! cycles), packed↔aligned gate agreement, and the endianness round
|
||||
//! trip (`endian()` equals the root struct's declaration).
|
||||
|
||||
use alktype::{AlkTypeEngine, AlkTypeError, AlkTypeKind, LayoutMode};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The root struct name a well-formed harness schema uses. The corpus
|
||||
/// and the fuzzer both reach it through `"root"` in the input when
|
||||
/// present; the default matches the seed generator's root name.
|
||||
pub const ROOT_NAME: &str = "S";
|
||||
|
||||
pub fn fuzz_bast_compile(data: &[u8]) {
|
||||
// Parse the input as JSON. Anything serde_json rejects is not an
|
||||
// alktype surface (serde's own parser is upstream-fuzzed); the
|
||||
// lane still asserts the engine never panics on the raw bytes.
|
||||
let doc: Value = match serde_json::from_slice(data) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// The root name travels in a top-level "root" key when compile
|
||||
// should have a chance to succeed (the meta-schema has
|
||||
// `additionalProperties` at the document level? No — "$defs" is
|
||||
// the only declared property, so "root" rides alongside it. The
|
||||
// meta-schema does not set additionalProperties: false at the top
|
||||
// level, so this is legal.) Fallback: "S".
|
||||
let root_name = doc.get("root").and_then(Value::as_str).unwrap_or(ROOT_NAME);
|
||||
|
||||
// Drive the full compile fan-out in both modes: bast_meta gate →
|
||||
// BastDoc parse → ValidationPlan → layout walks → validator. The
|
||||
// invariant is: always a Result, never a panic, and every rejection
|
||||
// is a clean AlkTypeError.
|
||||
let packed = AlkTypeEngine::compile(&doc, root_name, LayoutMode::Packed, None);
|
||||
let aligned = AlkTypeEngine::compile(&doc, root_name, LayoutMode::Aligned, None);
|
||||
|
||||
// Gate-layer agreement: the parse layers (bast_meta, BastDoc,
|
||||
// ValidationPlan, walk guards) are mode-independent. If one mode
|
||||
// accepted and the other failed, the failure must be attributable
|
||||
// to the mode-specific layout walk, not the shared parse gate.
|
||||
// Encoded as: parse-stage failures must occur in both modes. The
|
||||
// engine does not expose which layer failed, so the weaker but
|
||||
// sound probe is: if either mode succeeded, the other must not
|
||||
// have failed with a meta-schema-class rejection. Probed by
|
||||
// validate_bast_doc agreement instead (below).
|
||||
let meta_ok = alktype::bast_meta::validate_bast_doc(&doc).is_ok();
|
||||
|
||||
if let Ok(engine) = &packed {
|
||||
assert!(
|
||||
meta_ok,
|
||||
"a successful compile implies the meta-schema gate passed"
|
||||
);
|
||||
assert_packed_engine_shape(engine);
|
||||
}
|
||||
if let Ok(engine) = &aligned {
|
||||
assert!(
|
||||
meta_ok,
|
||||
"a successful compile implies the meta-schema gate passed"
|
||||
);
|
||||
assert_aligned_engine_shape(engine);
|
||||
}
|
||||
|
||||
// The meta-schema gate ordering: validate_bast_doc failing must
|
||||
// imply compile failed in both modes (a structurally malformed doc
|
||||
// can never compile).
|
||||
if !meta_ok {
|
||||
assert!(
|
||||
packed.is_err() && aligned.is_err(),
|
||||
"a doc failing the meta-schema gate must fail compile in both modes"
|
||||
);
|
||||
for err in [packed.unwrap_err(), aligned.unwrap_err()] {
|
||||
assert_clean_error(&err);
|
||||
}
|
||||
}
|
||||
|
||||
// The json_schema lane: build_validator over the second attacker
|
||||
// shape (a sibling "json_schema" document key when present; else
|
||||
// the doc itself). Compilation must be Result; a successful engine
|
||||
// must accept its own happy-path instance or reject it with a
|
||||
// Validation error — never panic, never panic inside jsonschema.
|
||||
if meta_ok {
|
||||
let schema_obj = doc.get("json_schema").cloned().unwrap_or_else(|| {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": true
|
||||
})
|
||||
});
|
||||
match AlkTypeEngine::compile(&doc, root_name, LayoutMode::Packed, Some(&schema_obj)) {
|
||||
Ok(engine) => {
|
||||
let instance = serde_json::json!({});
|
||||
match engine.validate_json(&instance) {
|
||||
Ok(()) | Err(AlkTypeError::Validation(_)) => {}
|
||||
Err(other) => panic!(
|
||||
"validate_json against a fresh empty object must be \
|
||||
Ok or Validation, got {other:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
Err(err) => assert_clean_error(&err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A compiled packed engine exposes a read plan whose compiled forms
|
||||
/// are consistent with the root name and endianness contract.
|
||||
fn assert_packed_engine_shape(engine: &AlkTypeEngine) {
|
||||
assert!(matches!(engine.mode(), LayoutMode::Packed));
|
||||
assert!(engine.offset_map().is_none());
|
||||
engine.sequential_reader(); // the factory must never panic (ADR-007)
|
||||
}
|
||||
|
||||
/// A compiled aligned engine exposes an offset map; every recorded
|
||||
/// leaf entry's byte range sits inside the reported total size, and
|
||||
/// leaf metadata matches the declared kind classes.
|
||||
fn assert_aligned_engine_shape(engine: &AlkTypeEngine) {
|
||||
assert!(matches!(engine.mode(), LayoutMode::Aligned));
|
||||
let map = engine
|
||||
.offset_map()
|
||||
.expect("an aligned-mode engine always carries an OffsetMap");
|
||||
let total = map.total_size();
|
||||
for (_path, entry) in map.iter() {
|
||||
let range = entry.range;
|
||||
assert!(
|
||||
range.end <= total,
|
||||
"offset entry [{}..{}) exceeds total_size {total}",
|
||||
range.start,
|
||||
range.end
|
||||
);
|
||||
assert_leaf_meta(&entry.meta.kind);
|
||||
}
|
||||
}
|
||||
|
||||
/// Leaf metadata kind-class sanity (the 18 kinds partition into
|
||||
/// fixed-size / variable / composite at the plan level).
|
||||
fn assert_leaf_meta(kind: &AlkTypeKind) {
|
||||
if kind.is_fixed_size() {
|
||||
assert!(
|
||||
kind.type_size().is_some(),
|
||||
"a fixed-size kind always carries a byte size"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
kind.type_size().is_none(),
|
||||
"a non-fixed-size kind never carries a byte size"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every rejection is one of the documented variants with a
|
||||
/// well-formed payload (non-empty message / path).
|
||||
pub fn assert_clean_error(err: &AlkTypeError) {
|
||||
match err {
|
||||
AlkTypeError::Schema(msg) => assert!(!msg.is_empty(), "Schema error carries a message"),
|
||||
AlkTypeError::Offset { field_path, reason } => {
|
||||
assert!(
|
||||
!reason.is_empty(),
|
||||
"Offset error carries a reason (path {field_path:?})"
|
||||
);
|
||||
}
|
||||
AlkTypeError::Access { field_path, reason } => {
|
||||
assert!(
|
||||
!reason.is_empty(),
|
||||
"Access error carries a reason (path {field_path:?})"
|
||||
);
|
||||
}
|
||||
AlkTypeError::Validation(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[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/bast_compile");
|
||||
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_bast_compile(&data);
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 30,
|
||||
"committed seed corpus is present, found {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_bast_compile_edges() {
|
||||
// Minimal valid doc, both modes succeed.
|
||||
let minimal = serde_json::json!({
|
||||
"$defs": { "S": { "kind": "struct", "fields": [ { "name": "x", "kind": "uint8" } ] } }
|
||||
})
|
||||
.to_string();
|
||||
fuzz_bast_compile(minimal.as_bytes());
|
||||
|
||||
// Every primitive kind once.
|
||||
let all_kinds = serde_json::json!({
|
||||
"$defs": { "S": { "kind": "struct", "fields": [
|
||||
{ "name": "a", "kind": "int8" },
|
||||
{ "name": "b", "kind": "int16" },
|
||||
{ "name": "c", "kind": "int32" },
|
||||
{ "name": "d", "kind": "int64" },
|
||||
{ "name": "e", "kind": "uint8" },
|
||||
{ "name": "f", "kind": "uint16" },
|
||||
{ "name": "g", "kind": "uint32" },
|
||||
{ "name": "h", "kind": "uint64" },
|
||||
{ "name": "i", "kind": "float32" },
|
||||
{ "name": "j", "kind": "float64" },
|
||||
{ "name": "k", "kind": "bool" },
|
||||
{ "name": "l", "kind": "string" },
|
||||
{ "name": "m", "kind": "bytes" }
|
||||
] } }
|
||||
})
|
||||
.to_string();
|
||||
fuzz_bast_compile(all_kinds.as_bytes());
|
||||
|
||||
// Composite kinds: nested struct, array, record, enum, union.
|
||||
let composites = serde_json::json!({
|
||||
"$defs": {
|
||||
"S": { "kind": "struct", "fields": [
|
||||
{ "name": "child", "kind": { "$ref": "#/$defs/Nested" } },
|
||||
{ "name": "arr", "kind": { "kind": "array", "element": "uint32", "count": 3 } },
|
||||
{ "name": "rec", "kind": { "kind": "record", "values": "string" } },
|
||||
{ "name": "en", "kind": { "$ref": "#/$defs/E" } },
|
||||
{ "name": "un", "kind": { "$ref": "#/$defs/U" } }
|
||||
] },
|
||||
"Nested": { "kind": "struct", "fields": [ { "name": "y", "kind": "int16" } ] },
|
||||
"E": { "kind": "enum", "values": ["a", "b"] },
|
||||
"U": { "kind": "union",
|
||||
"discriminator": { "kind": "byte", "offset": 0, "type": "uint8" },
|
||||
"mapping": { "0": "uint8", "1": "uint16" } }
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
fuzz_bast_compile(composites.as_bytes());
|
||||
|
||||
// Reject classes: align over cap, count over cap, maxLength
|
||||
// over cap, unknown kind, root not a struct, $ref cycle,
|
||||
// $ref to missing def, missing $defs, non-object doc.
|
||||
for hostile in [
|
||||
serde_json::json!({ "$defs": { "S": { "kind": "struct", "align": 4097,
|
||||
"fields": [] } } }),
|
||||
serde_json::json!({ "$defs": { "S": { "kind": "struct",
|
||||
"fields": [ { "name": "a", "kind": { "kind": "array",
|
||||
"element": "uint8", "count": 65537 } } ] } } }),
|
||||
serde_json::json!({ "$defs": { "S": { "kind": "struct",
|
||||
"fields": [ { "name": "s", "kind": "string", "maxLength": 67108865 } ] } } }),
|
||||
serde_json::json!({ "$defs": { "S": { "kind": "struct",
|
||||
"fields": [ { "name": "a", "kind": "nonsense" } ] } } }),
|
||||
serde_json::json!({ "$defs": { "S": { "kind": "uint8",
|
||||
"fields": [] } } }),
|
||||
serde_json::json!({ "$defs": { "S": { "kind": "struct",
|
||||
"fields": [ { "name": "a", "kind": { "$ref": "#/$defs/S" } } ] } } }),
|
||||
serde_json::json!({ "$defs": {} }),
|
||||
serde_json::json!([1, 2, 3]),
|
||||
] {
|
||||
fuzz_bast_compile(hostile.to_string().as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
//! Invariants for the hand-rolled decode core (`src/data_access.rs`):
|
||||
//! no-panic for any (buffer, offset, endian) triple, the bool
|
||||
//! strictness contract (0x00/0x01 only), invalid-UTF-8 rejection in
|
||||
//! strings, the bounds partition (Ok implies the field sits fully
|
||||
//! inside the buffer; error implies an arithmetic overflow or an
|
||||
//! out-of-bounds window with the field path named), and the
|
||||
//! offset-indirect `{data_offset, data_length}` pair partition (the
|
||||
//! widest attacker-influenced values in the crate).
|
||||
|
||||
use alktype::data_access;
|
||||
use alktype::schema::Endian;
|
||||
|
||||
const ALL: [Endian; 2] = [Endian::Little, Endian::Big];
|
||||
const PATH: &str = "f";
|
||||
|
||||
/// Offset for a probe run: the fuzzer picks among the interesting
|
||||
/// regions via the first two bytes of the input (kept out of the
|
||||
/// payload bytes the decoders see, so the same buffer body is probed
|
||||
/// at the interesting boundary offsets).
|
||||
fn probe_offset(region: u8, salt: u8, buf_len: usize) -> usize {
|
||||
match region % 8 {
|
||||
0 => 0,
|
||||
1 => buf_len / 2,
|
||||
2 => buf_len.saturating_sub(1),
|
||||
3 => buf_len,
|
||||
4 => buf_len.saturating_add(1),
|
||||
5 => usize::try_from(u32::from(salt)).unwrap_or(usize::MAX),
|
||||
6 => usize::MAX.saturating_sub(salt as usize),
|
||||
_ => salt as usize,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fuzz_data_access(data: &[u8]) {
|
||||
// The buffer bodies the decode functions see: the raw input (the
|
||||
// attacker's literal shape) and a fixed padded variant so
|
||||
// boundary-offset probes have room in both directions.
|
||||
let mut padded = Vec::with_capacity(data.len() + 32);
|
||||
padded.extend_from_slice(data);
|
||||
padded.extend_from_slice(&[0u8; 32]);
|
||||
|
||||
let region = data.first().copied().unwrap_or(0);
|
||||
let salt = data.get(1).copied().unwrap_or(0);
|
||||
|
||||
for endian in ALL {
|
||||
// Fixed-width kinds, both endians, at a chosen offset.
|
||||
fixed_width(data, probe_offset(region, salt, data.len()), endian);
|
||||
fixed_width(&padded, probe_offset(region, salt, padded.len()), endian);
|
||||
// Variable-length lanes: length-prefixed and indirect pairs.
|
||||
variable(data, probe_offset(region ^ 1, salt, data.len()), endian);
|
||||
variable(
|
||||
&padded,
|
||||
probe_offset(region ^ 1, salt, padded.len()),
|
||||
endian,
|
||||
);
|
||||
}
|
||||
|
||||
// Endianness agreement: Little and Big decodes of the same bytes
|
||||
// agree up to byte swaps for the multi-width primitives — probed
|
||||
// structurally via the write side below.
|
||||
write_side(data);
|
||||
bool_strictness(data);
|
||||
enum_lane(data);
|
||||
utf8_discipline(data);
|
||||
}
|
||||
|
||||
/// Every fixed-width read: no-panic; Ok implies exactly `size` bytes
|
||||
/// fit at the offset (the bounds partition).
|
||||
fn fixed_width(buf: &[u8], off: usize, endian: Endian) {
|
||||
let _ = data_access::read_i8(buf, off, PATH);
|
||||
let _ = data_access::read_u8(buf, off, PATH);
|
||||
let _ = data_access::read_bool(buf, off, PATH);
|
||||
let _ = data_access::read_i16(buf, off, PATH, endian);
|
||||
let _ = data_access::read_i32(buf, off, PATH, endian);
|
||||
let _ = data_access::read_i64(buf, off, PATH, endian);
|
||||
let _ = data_access::read_u16(buf, off, PATH, endian);
|
||||
let _ = data_access::read_u32(buf, off, PATH, endian);
|
||||
let _ = data_access::read_u64(buf, off, PATH, endian);
|
||||
let _ = data_access::read_f32(buf, off, PATH, endian);
|
||||
let _ = data_access::read_f64(buf, off, PATH, endian);
|
||||
let _ = data_access::read_enum(buf, off, PATH, endian);
|
||||
}
|
||||
|
||||
/// Length-prefixed and offset-indirect reads/write probes with the
|
||||
/// contract partitions asserted.
|
||||
fn variable(buf: &[u8], off: usize, endian: Endian) {
|
||||
// Length-prefixed: Ok implies data_start = off + 4 and
|
||||
// data_end = data_start + len satisfy data_end <= buf.len().
|
||||
if let Ok(bytes) = data_access::read_bytes(buf, off, PATH, endian) {
|
||||
let len_bytes: [u8; 4] = buf[off..off + 4]
|
||||
.try_into()
|
||||
.expect("read_bytes Ok implies the 4-byte prefix is in bounds");
|
||||
let len = match endian {
|
||||
Endian::Little => u32::from_le_bytes(len_bytes),
|
||||
Endian::Big => u32::from_be_bytes(len_bytes),
|
||||
} as usize;
|
||||
assert_eq!(
|
||||
bytes.len(),
|
||||
len,
|
||||
"read_bytes returns exactly the declared length"
|
||||
);
|
||||
assert!(
|
||||
off + 4 + len <= buf.len(),
|
||||
"read_bytes Ok implies the window sits inside the buffer"
|
||||
);
|
||||
}
|
||||
if let Ok(s) = data_access::read_string(buf, off, PATH, endian) {
|
||||
// UTF-8 discipline: a successful read_string is valid UTF-8 by
|
||||
// construction; decode a copy to confirm no lossy path exists.
|
||||
assert!(
|
||||
std::str::from_utf8(s.as_bytes()).is_ok(),
|
||||
"read_string Ok implies valid UTF-8"
|
||||
);
|
||||
}
|
||||
// Indirect pair: the widest attacker-controlled values. Ok implies
|
||||
// data_offset + data_length <= buf.len() and the returned slice is
|
||||
// exactly the declared window.
|
||||
if let Ok(pair) = data_access::read_bytes_indirect(buf, off, PATH, endian) {
|
||||
let (doff, dlen) = indirect_pair(buf, off, endian);
|
||||
assert_eq!(pair.len(), dlen, "indirect slice is the declared length");
|
||||
assert!(
|
||||
doff + dlen <= buf.len(),
|
||||
"indirect Ok implies {doff} + {dlen} <= {}",
|
||||
buf.len()
|
||||
);
|
||||
}
|
||||
if let Ok(s) = data_access::read_string_indirect(buf, off, PATH, endian) {
|
||||
assert!(
|
||||
std::str::from_utf8(s.as_bytes()).is_ok(),
|
||||
"indirect string Ok implies valid UTF-8"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn indirect_pair(buf: &[u8], off: usize, endian: Endian) -> (usize, usize) {
|
||||
let take = |o: usize| -> u32 {
|
||||
let w: [u8; 4] = buf[o..o + 4].try_into().expect("pair words in bounds");
|
||||
match endian {
|
||||
Endian::Little => u32::from_le_bytes(w),
|
||||
Endian::Big => u32::from_be_bytes(w),
|
||||
}
|
||||
};
|
||||
// Only called after read_bytes_indirect returned Ok, which implies
|
||||
// both words decoded fine at off and off+4.
|
||||
(take(off) as usize, take(off + 4) as usize)
|
||||
}
|
||||
|
||||
/// The write side: every failed write leaves the buffer untouched
|
||||
/// (byte-equal snapshot); every successful write_string round-trips
|
||||
/// through read_string; write_bytes_indirect / read_bytes_indirect
|
||||
/// are one contract across the pair.
|
||||
fn write_side(data: &[u8]) {
|
||||
for endian in ALL {
|
||||
// Length-prefixed round trip into an exact-size buffer.
|
||||
let payload: Vec<u8> = data.iter().copied().take(64).collect();
|
||||
let mut buf = vec![0u8; 4 + payload.len()];
|
||||
let written =
|
||||
data_access::write_bytes(&mut buf, 0, &payload, PATH, endian).expect("buffer sized");
|
||||
assert_eq!(written, 4 + payload.len(), "write_bytes returns 4 + len");
|
||||
let back = data_access::read_bytes(&buf, 0, PATH, endian)
|
||||
.expect("a written prefix always reads back");
|
||||
assert_eq!(back, payload, "write_bytes → read_bytes round trip");
|
||||
|
||||
// Failed writes never touch the buffer: snapshot, attempt an
|
||||
// out-of-bounds write at trailing offsets, compare.
|
||||
for off in [buf.len().saturating_sub(1), buf.len(), buf.len() + 16] {
|
||||
let mut probe = buf.clone();
|
||||
let before = probe.clone();
|
||||
let res = data_access::write_bytes(&mut probe, off, &payload, PATH, endian);
|
||||
if res.is_err() {
|
||||
assert_eq!(
|
||||
probe, before,
|
||||
"a failed write_bytes leaves the buffer byte-identical"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Indirect pair contract: write the pair at 0 with the data at
|
||||
// 8; the read side honors exactly the absolute window.
|
||||
let mut indirect_buf = vec![0u8; 8 + payload.len()];
|
||||
let n = data_access::write_bytes_indirect(&mut indirect_buf, 0, 8, &payload, PATH, endian)
|
||||
.expect("indirect pair fits the buffer");
|
||||
assert_eq!(n, 8, "write_bytes_indirect returns 8 (the pair width)");
|
||||
let back = data_access::read_bytes_indirect(&indirect_buf, 0, PATH, endian)
|
||||
.expect("a written indirect pair always reads back");
|
||||
assert_eq!(back, payload, "indirect write → read round trip");
|
||||
|
||||
// An indirect write whose data window overflows the buffer
|
||||
// fails — and the pair region may be written (the documented
|
||||
// behavior: the pair is written before the data-region bounds
|
||||
// check), but nothing beyond the declared pair+data region is
|
||||
// touched. Assert the post-state partition on failure: bytes
|
||||
// beyond the buffer bounds are unreachable (no panic) and the
|
||||
// failure is an Access error naming the path.
|
||||
let mut probe = vec![0u8; 8];
|
||||
let res = data_access::write_bytes_indirect(&mut probe, 0, 4, &payload, PATH, endian);
|
||||
if payload.len() > 4 {
|
||||
assert!(res.is_err(), "data window over the buffer end fails");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The bool strictness contract: exactly 0x00/0x01 decode; every other
|
||||
/// byte (0x02..=0xFF) rejects with Access regardless of offset
|
||||
/// context. Also: read_bool at an out-of-bounds offset never returns
|
||||
/// Ok.
|
||||
fn bool_strictness(data: &[u8]) {
|
||||
for b in [
|
||||
0x00u8,
|
||||
0x01,
|
||||
0x02,
|
||||
0x7F,
|
||||
0xFF,
|
||||
data.first().copied().unwrap_or(0x02),
|
||||
] {
|
||||
let buf = [b];
|
||||
match data_access::read_bool(&buf, 0, PATH) {
|
||||
Ok(v) => {
|
||||
assert!(
|
||||
b == 0x00 || b == 0x01,
|
||||
"only 0x00/0x01 decode as bool, got 0x{b:02X}"
|
||||
);
|
||||
assert_eq!(v, b == 0x01);
|
||||
}
|
||||
Err(e) => {
|
||||
assert!(
|
||||
b != 0x00 && b != 0x01,
|
||||
"0x00/0x01 must decode, got error {e:?} for 0x{b:02X}"
|
||||
);
|
||||
assert_clean(&e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Enum reads are u32 reads (the index mapping is the caller's job).
|
||||
fn enum_lane(data: &[u8]) {
|
||||
for endian in ALL {
|
||||
if let Ok(v) = data_access::read_enum(data, 0, PATH, endian) {
|
||||
let expect = match endian {
|
||||
Endian::Little => {
|
||||
u32::from_le_bytes(data[..4].try_into().expect("enum Ok implies 4 bytes"))
|
||||
}
|
||||
Endian::Big => {
|
||||
u32::from_be_bytes(data[..4].try_into().expect("enum Ok implies 4 bytes"))
|
||||
}
|
||||
};
|
||||
assert_eq!(v, expect, "read_enum is a u32 read under `endian`");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// UTF-8 discipline: read_string over invalid UTF-8 errors with
|
||||
/// Access; the error names the field path.
|
||||
fn utf8_discipline(data: &[u8]) {
|
||||
if data.len() >= 5 {
|
||||
let mut hostile = data.to_vec();
|
||||
// Force the byte after a zero length prefix into invalid-UTF-8
|
||||
// territory regardless of the input's shape.
|
||||
hostile[4] = 0xFF;
|
||||
if let Err(e) = data_access::read_string(&hostile, 0, PATH, Endian::Little) {
|
||||
assert_clean(&e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_clean(e: &alktype::AlkTypeError) {
|
||||
match e {
|
||||
alktype::AlkTypeError::Access { field_path, .. } => {
|
||||
assert!(!field_path.is_empty(), "Access errors carry the field path");
|
||||
}
|
||||
alktype::AlkTypeError::Schema(_)
|
||||
| alktype::AlkTypeError::Offset { .. }
|
||||
| alktype::AlkTypeError::Validation(_) => {
|
||||
panic!("data_access failures must be Access errors, 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/data_access");
|
||||
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_data_access(&data);
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 25,
|
||||
"committed seed corpus is present, found {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_decode_edges() {
|
||||
// Minimal valid encodings per width / endianness.
|
||||
fuzz_data_access(&[0x01]); // bool true, i8
|
||||
fuzz_data_access(&[0x00]);
|
||||
fuzz_data_access(&0x1234u16.to_le_bytes());
|
||||
fuzz_data_access(&0x1234u16.to_be_bytes());
|
||||
fuzz_data_access(&0x11223344u32.to_le_bytes());
|
||||
fuzz_data_access(&0x11223344u32.to_be_bytes());
|
||||
fuzz_data_access(&0x1122334455667788u64.to_le_bytes());
|
||||
fuzz_data_access(&0x1122334455667788u64.to_be_bytes());
|
||||
fuzz_data_access(&1.5f32.to_le_bytes());
|
||||
fuzz_data_access(&2.5f64.to_be_bytes());
|
||||
|
||||
// Length-prefixed shape: [len: u32 LE][payload].
|
||||
let mut lp = 3u32.to_le_bytes().to_vec();
|
||||
lp.extend_from_slice(b"abc");
|
||||
fuzz_data_access(&lp);
|
||||
|
||||
// Truncations at every prefix.
|
||||
for n in 1..lp.len() {
|
||||
fuzz_data_access(&lp[..n]);
|
||||
}
|
||||
|
||||
// len = 0, MAX_LENGTH-ish, u32::MAX.
|
||||
fuzz_data_access(&0u32.to_le_bytes());
|
||||
fuzz_data_access(&(1u32 << 26).to_le_bytes());
|
||||
fuzz_data_access(&u32::MAX.to_le_bytes());
|
||||
|
||||
// Indirect pair shapes: {0,0}, {len,0}, {0,len}, {big,0},
|
||||
// {u32::MAX, u32::MAX}, self-referential.
|
||||
for (doff, dlen) in [
|
||||
(0u32, 0u32),
|
||||
(8, 0),
|
||||
(0, 8),
|
||||
(u32::MAX, 0),
|
||||
(u32::MAX, u32::MAX),
|
||||
(4, 4),
|
||||
(12, 4),
|
||||
] {
|
||||
let mut buf = Vec::new();
|
||||
buf.extend_from_slice(&doff.to_le_bytes());
|
||||
buf.extend_from_slice(&dlen.to_le_bytes());
|
||||
fuzz_data_access(&buf);
|
||||
}
|
||||
|
||||
// Invalid UTF-8 string.
|
||||
let mut bad = 3u32.to_le_bytes().to_vec();
|
||||
bad.extend_from_slice(&[0xFF, 0xFE, 0xFD]);
|
||||
fuzz_data_access(&bad);
|
||||
|
||||
// Bool strictness bytes.
|
||||
fuzz_data_access(&[0x02]);
|
||||
fuzz_data_access(&[0xFF]);
|
||||
|
||||
// NaN/Inf byte shapes.
|
||||
fuzz_data_access(&f32::NAN.to_le_bytes());
|
||||
fuzz_data_access(&f64::INFINITY.to_be_bytes());
|
||||
|
||||
// Empty / pure noise.
|
||||
fuzz_data_access(&[]);
|
||||
fuzz_data_access(&[0xAA; 64]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//! Fuzz invariant logic for alktype's wave-1 targets. Kept out of the
|
||||
//! fuzz-target binaries so the corpus replay unit tests in the release
|
||||
//! verification checklist can exercise the same invariant checks
|
||||
//! against every committed corpus entry (the quinn CI pattern) without
|
||||
//! a nightly toolchain.
|
||||
|
||||
pub mod bast_compile;
|
||||
pub mod data_access;
|
||||
Reference in new issue
Block a user