fuzz-target-payload-codec: the §3.1 target — all five invariants in shared, thin fuzz_target wrapper, 97 committed seeds + gitignore negation + README row; placeholder bin replaced; serde (derive) added to shared
This commit is contained in:
1 parent
21ab6fe564
commit
1f6cc0970a
108 files changed
+583
-24
No files matched your search
@@ -6,6 +6,7 @@ edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
alkstore = { path = "../../alkstore" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[dependencies.arbitrary]
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
//! CI pattern) without a nightly toolchain.
|
||||
|
||||
pub mod arbitrary_value;
|
||||
pub mod payload_codec;
|
||||
@@ -0,0 +1,295 @@
|
||||
//! Invariant logic for the payload codec target (ADR-020 §4 and
|
||||
//! ADR-023 §1, `alkstore/src/payload.rs`): no-panic on any byte
|
||||
//! sequence, the typed-failure partition (every decode failure is
|
||||
//! `Error::Codec`), deep-input loudness at serde_json's recursion
|
||||
//! limit, the `Value` round-trip with serde_json's canonicalization
|
||||
//! pinned-not-regressed, and the typed round-trip for a
|
||||
//! representative payload set.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use alkstore::{encode_payload, payload_as, Error};
|
||||
use arbitrary::{Arbitrary, Unstructured};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::arbitrary_value::JsonValue;
|
||||
|
||||
/// serde_json's default recursion limit — the depth at which decode
|
||||
/// must fail loud and typed.
|
||||
const RECURSION_LIMIT: usize = 128;
|
||||
|
||||
/// The representative typed payload for the `payload_as` side: a
|
||||
/// struct in the shape consumers actually construct, covering
|
||||
/// string/int/vec/option/map fields.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
struct TestJobPayload {
|
||||
queue: String,
|
||||
attempt: i64,
|
||||
tags: Vec<String>,
|
||||
note: Option<String>,
|
||||
meta: BTreeMap<String, Value>,
|
||||
}
|
||||
|
||||
fn test_payload_batteries() -> Vec<TestJobPayload> {
|
||||
vec![
|
||||
TestJobPayload {
|
||||
queue: "orders".to_owned(),
|
||||
attempt: 3,
|
||||
tags: vec!["alpha".to_owned(), "beta".to_owned()],
|
||||
note: Some("queued by the bridge".to_owned()),
|
||||
meta: BTreeMap::from([
|
||||
("source".to_owned(), json!("outbox")),
|
||||
("count".to_owned(), json!(7)),
|
||||
]),
|
||||
},
|
||||
TestJobPayload {
|
||||
queue: String::new(),
|
||||
attempt: i64::MIN,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
meta: BTreeMap::new(),
|
||||
},
|
||||
TestJobPayload {
|
||||
queue: "unicode-é-😀".to_owned(),
|
||||
attempt: i64::MAX,
|
||||
tags: vec!["tab\tquote\"slash\\/".to_owned()],
|
||||
note: Some(String::new()),
|
||||
meta: BTreeMap::from([("nested".to_owned(), json!([1, null, true]))]),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn fuzz_payload_codec(data: &[u8]) {
|
||||
// The baseline: no panic on any byte sequence, and every decode
|
||||
// failure across the input space is the typed `Error::Codec` —
|
||||
// over `Value` and over representative typed targets alike.
|
||||
assert_decode_partition::<Value>(data);
|
||||
assert_decode_partition::<TestJobPayload>(data);
|
||||
assert_decode_partition::<String>(data);
|
||||
assert_decode_partition::<Vec<Value>>(data);
|
||||
|
||||
// A successfully-decoded `Value` re-encodes byte-stably: the Value
|
||||
// round-trip closes over the raw input, with serde_json's
|
||||
// canonicalization (duplicate-key last-write-wins, number-to-NormalizedNumber,
|
||||
// key ordering) pinned — not re-derived, just not regressed.
|
||||
if let Ok(v) = payload_as::<Value>(data) {
|
||||
let bytes = encode_payload(&v).expect("a decoded `Value` must re-encode");
|
||||
let back: Value = payload_as(&bytes).expect("the encoded `Value` must decode");
|
||||
assert_eq!(back, v, "the `Value` round-trip must close over raw input");
|
||||
}
|
||||
|
||||
assert_deep_input_loudness();
|
||||
assert_empty_byte_store_loudness();
|
||||
assert_value_round_trip_battery();
|
||||
assert_typed_round_trip_battery();
|
||||
assert_arbitrary_value_round_trip(data);
|
||||
}
|
||||
|
||||
fn assert_decode_partition<T: serde::de::DeserializeOwned>(data: &[u8]) {
|
||||
match payload_as::<T>(data) {
|
||||
Ok(_) | Err(Error::Codec(_)) => {}
|
||||
Err(other) => panic!("the decode failure must be `Error::Codec`, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deep inputs decode loud and typed: past serde_json's recursion
|
||||
/// limit, deserialization fails with `Error::Codec` — never a stack
|
||||
/// overflow, never an untyped panic. The encode side handles deep
|
||||
/// (but sub-limit) data as the normal round-trip.
|
||||
fn assert_deep_input_loudness() {
|
||||
for depth in [RECURSION_LIMIT * 4, 4096] {
|
||||
let deep_bytes = nested_array_bytes(depth);
|
||||
match payload_as::<Value>(&deep_bytes) {
|
||||
Err(Error::Codec(_)) => {}
|
||||
Err(other) => {
|
||||
panic!("deep deserialization failure must be `Error::Codec`, got {other:?}")
|
||||
}
|
||||
Ok(_) => panic!("nesting at depth {depth} must hit serde_json's recursion limit"),
|
||||
}
|
||||
match payload_as::<TestJobPayload>(&deep_bytes) {
|
||||
Err(Error::Codec(_)) => {}
|
||||
Err(other) => {
|
||||
panic!("deep typed deserialization must be `Error::Codec`, got {other:?}")
|
||||
}
|
||||
Ok(_) => panic!("deep bytes must not decode to the typed target at depth {depth}"),
|
||||
}
|
||||
let deep_value = nest_value(Value::Null, RECURSION_LIMIT + 1);
|
||||
let deep_encoded =
|
||||
encode_payload(&deep_value).expect("a sub-limit-depth `Value` must encode");
|
||||
match payload_as::<Value>(&deep_encoded) {
|
||||
Err(Error::Codec(_)) => {}
|
||||
Err(other) => {
|
||||
panic!("the stored deep form must decode as typed `Codec`, got {other:?}")
|
||||
}
|
||||
Ok(_) => {
|
||||
panic!("stored bytes nested past the decode limit must decode loud, not silent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let shallow_bytes = nested_array_bytes(8);
|
||||
let shallow_value = nest_value(Value::from(0u8), 8);
|
||||
let shallow_encoded = encode_payload(&shallow_value).expect("a shallow `Value` must encode");
|
||||
let back: Value = payload_as(&shallow_encoded).expect("the shallow form must decode");
|
||||
let shallow_parsed: Value = payload_as(&shallow_bytes).expect("the shallow doc must decode");
|
||||
assert_eq!(back, shallow_value, "the shallow `Value` round-trip holds");
|
||||
assert_eq!(shallow_parsed, shallow_value, "nesting shape is preserved");
|
||||
}
|
||||
|
||||
fn assert_empty_byte_store_loudness() {
|
||||
match payload_as::<Value>(b"") {
|
||||
Err(Error::Codec(_)) => {}
|
||||
Err(other) => {
|
||||
panic!("the empty-byte-store failure must be `Codec`, got {other:?}")
|
||||
}
|
||||
Ok(_) => panic!("the empty-byte store must never decode to a silent `Null` (ADR-023 §1)"),
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_value_round_trip_battery() {
|
||||
for v in value_battery() {
|
||||
let bytes = encode_payload(&v).expect(
|
||||
"encode_payload is only type-theoretically fallible; `Value` variants all serialize",
|
||||
);
|
||||
let back: Value = payload_as(&bytes).expect("the encoded battery `Value` must decode");
|
||||
assert_eq!(back, v, "the `Value` round-trip must be exact");
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_typed_round_trip_battery() {
|
||||
for payload in test_payload_batteries() {
|
||||
assert_typed_round_trip(&payload);
|
||||
}
|
||||
assert_typed_round_trip(&String::from("hello"));
|
||||
assert_typed_round_trip(&String::new());
|
||||
assert_typed_round_trip(&String::from("unicode é 😀"));
|
||||
assert_typed_round_trip(&42i64);
|
||||
assert_typed_round_trip(&i64::MAX);
|
||||
assert_typed_round_trip(&i64::MIN);
|
||||
assert_typed_round_trip(&true);
|
||||
assert_typed_round_trip(&());
|
||||
assert_typed_round_trip(&Option::<String>::None);
|
||||
assert_typed_round_trip(&Some(String::from("some")));
|
||||
assert_typed_round_trip(&vec![1i64, -2, 3]);
|
||||
assert_typed_round_trip(&Vec::<Value>::new());
|
||||
assert_typed_round_trip(&BTreeMap::from([
|
||||
(String::from("a"), String::from("1")),
|
||||
(String::from("b"), String::from("2")),
|
||||
]));
|
||||
}
|
||||
|
||||
fn assert_typed_round_trip<T>(payload: &T)
|
||||
where
|
||||
T: serde::Serialize + serde::de::DeserializeOwned + PartialEq + std::fmt::Debug,
|
||||
{
|
||||
let value =
|
||||
serde_json::to_value(payload).expect("the battery payload must serialize to a `Value`");
|
||||
let bytes = encode_payload(&value).expect("the battery `Value` must encode");
|
||||
let back: T = match payload_as(&bytes) {
|
||||
Ok(payload) => payload,
|
||||
Err(other) => panic!("the typed round-trip decode holds, got {other:?}"),
|
||||
};
|
||||
assert_eq!(&back, payload, "the typed round-trip must be exact");
|
||||
}
|
||||
|
||||
fn assert_arbitrary_value_round_trip(data: &[u8]) {
|
||||
let mut u = Unstructured::new(data);
|
||||
for _ in 0..2 {
|
||||
let Ok(jv) = JsonValue::arbitrary(&mut u) else {
|
||||
return;
|
||||
};
|
||||
let v: Value = jv.into();
|
||||
let bytes = encode_payload(&v).expect("any arbitrary-derived `Value` must encode");
|
||||
let back: Value = payload_as(&bytes).expect("the encoded arbitrary `Value` must decode");
|
||||
assert_eq!(
|
||||
back, v,
|
||||
"the `Value` round-trip must hold for every arbitrary `Value`"
|
||||
);
|
||||
match payload_as::<TestJobPayload>(&bytes) {
|
||||
Ok(typed) => {
|
||||
let retrip = serde_json::to_value(&typed)
|
||||
.expect("a decoded typed payload must re-serialize");
|
||||
let bytes2 =
|
||||
encode_payload(&retrip).expect("the re-serialized typed payload must encode");
|
||||
let back: TestJobPayload =
|
||||
payload_as(&bytes2).expect("the typed re-encoded form must decode");
|
||||
assert_eq!(back, typed, "the typed round-trip is stable");
|
||||
}
|
||||
Err(Error::Codec(_)) => {}
|
||||
Err(other) => {
|
||||
panic!("the typed decode of arbitrary `Value` bytes must be `Codec`, got {other:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn value_battery() -> Vec<Value> {
|
||||
vec![
|
||||
Value::Null,
|
||||
json!(true),
|
||||
json!(false),
|
||||
json!(0),
|
||||
json!(-1),
|
||||
json!(i64::MIN),
|
||||
json!(i64::MAX),
|
||||
Value::from(u64::MAX),
|
||||
json!(1.5),
|
||||
json!(-2.25),
|
||||
json!(1e10),
|
||||
json!(""),
|
||||
json!("hello world"),
|
||||
json!("unicode é 😀"),
|
||||
json!("tab\tnewline\nquote\"slash\\/"),
|
||||
json!("null"),
|
||||
Value::Array(vec![]),
|
||||
json!([1, "two", null, true, {"three": 3}, []]),
|
||||
Value::Object(Map::new()),
|
||||
json!({"a": 1, "nested": {"deep": [true, null, 1.5]}}),
|
||||
json!({"unicode-é-key": "value"}),
|
||||
]
|
||||
}
|
||||
|
||||
fn nested_array_bytes(depth: usize) -> Vec<u8> {
|
||||
let mut s = String::with_capacity(depth * 2 + 1);
|
||||
s.push_str(&"[".repeat(depth));
|
||||
s.push('0');
|
||||
s.push_str(&"]".repeat(depth));
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
fn nest_value(inner: Value, depth: usize) -> Value {
|
||||
let mut v = inner;
|
||||
for _ in 0..depth {
|
||||
v = Value::Array(vec![v]);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod corpus_replay {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn committed_corpus_replays_through_the_invariants() {
|
||||
let corpus = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../corpus/payload_codec");
|
||||
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_payload_codec(&data);
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 64,
|
||||
"committed seed corpus is present, found {count}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invariants_hold_without_corpus() {
|
||||
fuzz_payload_codec(b"");
|
||||
fuzz_payload_codec(b"null");
|
||||
fuzz_payload_codec(&nested_array_bytes(RECURSION_LIMIT + 1));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user