- fuzz/ workspace (nightly-pinned via rust-toolchain.toml, excluded from the main workspace and the published package): chunk_header and envelope_frame targets per fuzzing.md \u00a77.1 - invariant logic in fuzz/shared (stable-toolchain crate): committed corpus replay as plain cargo test (quinn CI pattern, \u00a77.2 tier 3) - envelope target adds FrameError shape-partition asserts, exact consumption accounting, structural write_frame round-trip, serde key-contract, and a trailing-byte probe (prefix counts body only) - chunk_header target adds round-trip identity, TooLarge/short error shape, is_eof, 8-byte consumption, input-never-mutated - committed seed corpora: 245 deterministic seeds via fuzz/gen_fuzz_seeds.py (truncations, len=0/MAX+1/u32::MAX, channel 0, invalid UTF-8, deep nesting); grown corpora + artifacts gitignored - fuzz/run-detached.sh: \u00a77.6 detached runner (setsid+nohup+log, fork mode, rss/malloc limits) \u2014 campaigns never share fate with a session - fuzz/json.dict; README; research doc \u00a77.7 records step-1 status Verification: cargo fuzz build clean; stable side green (cargo test 682 passed, clippy -D warnings, fmt --check incl. fuzz/shared); corpus replay 245 seeds green; detached 10-min campaigns on both targets completed with zero crashes/OOMs/timeouts
176 lines
5.7 KiB
Rust
176 lines
5.7 KiB
Rust
//! Invariants for the `EventEnvelope` length-prefixed JSON framing
|
|
//! (ADR-014): no-panic, allocation bounded by `MAX_FRAME_SIZE`, clean
|
|
//! `FrameError` classes on truncation/oversize/zero-length, consumption
|
|
//! accounting, and structural round-trip via `write_frame`.
|
|
|
|
use std::io::Cursor;
|
|
|
|
use alkcall::protocol::wire::{EventEnvelope, FrameError, FrameFramedReader, FrameFramedWriter};
|
|
|
|
pub const MAX_FRAME_SIZE: u32 = 64 * 1024 * 1024;
|
|
|
|
pub fn fuzz_envelope_frame(data: &[u8]) {
|
|
let result = read_single(data);
|
|
|
|
if let Ok(envelope) = &result {
|
|
assert_envelope_shape(envelope);
|
|
assert_write_round_trip(envelope);
|
|
}
|
|
|
|
if data.len() >= 8 {
|
|
let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
|
if len > 0
|
|
&& 4 + len <= data.len().min(MAX_FRAME_SIZE as usize + 4)
|
|
&& len < MAX_FRAME_SIZE as usize
|
|
&& data[4 + len - 1] == b'}'
|
|
{
|
|
let mut stream = data[..4 + len].to_vec();
|
|
stream.push(0x7b);
|
|
let claimed = (len as u32 + 1).to_be_bytes();
|
|
stream[..4].copy_from_slice(&claimed);
|
|
match read_single(&stream) {
|
|
Err(FrameError::Json(_)) => {}
|
|
other => panic!(
|
|
"a byte after a complete JSON body, counted in the length prefix, must surface as a Json error, got {other:?}"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_single(data: &[u8]) -> Result<EventEnvelope, FrameError> {
|
|
let mut reader = FrameFramedReader::new(Cursor::new(data.to_vec()));
|
|
let result = tokio_runtime().block_on(reader.read_frame());
|
|
match &result {
|
|
Err(FrameError::ConnectionClosed) => {
|
|
assert_truncation_shape(data);
|
|
}
|
|
Err(FrameError::InvalidFrame) => {
|
|
assert_invalid_frame_shape(data);
|
|
}
|
|
Err(FrameError::Json(_)) => {
|
|
assert_json_error_shape(data);
|
|
}
|
|
Err(FrameError::Io(e)) => {
|
|
panic!("an in-memory cursor cannot produce io errors, got {e}")
|
|
}
|
|
Ok(_) => {
|
|
assert_valid_frame_shape(data);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
fn body_len(data: &[u8]) -> Option<usize> {
|
|
if data.len() < 4 {
|
|
return None;
|
|
}
|
|
let len = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;
|
|
Some(len)
|
|
}
|
|
|
|
fn assert_truncation_shape(data: &[u8]) {
|
|
match body_len(data) {
|
|
None => {
|
|
assert!(data.len() < 4, "prefix shorter than 4 bytes");
|
|
}
|
|
Some(len) => {
|
|
assert!(
|
|
len > 0 && len <= MAX_FRAME_SIZE as usize,
|
|
"ConnectionClosed with len {len} would mean an oversize/zero length was not rejected first"
|
|
);
|
|
assert!(
|
|
data.len() < 4 + len,
|
|
"a complete body must never yield ConnectionClosed"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn assert_invalid_frame_shape(data: &[u8]) {
|
|
let len = body_len(data).expect("InvalidFrame requires a 4-byte prefix");
|
|
assert!(
|
|
len == 0 || len > MAX_FRAME_SIZE as usize,
|
|
"InvalidFrame with len {len} must not happen: 0 < len <= MAX is a valid length"
|
|
);
|
|
}
|
|
|
|
fn assert_json_error_shape(data: &[u8]) {
|
|
let len = body_len(data).expect("Json errors require a 4-byte prefix");
|
|
assert!(len > 0 && len <= MAX_FRAME_SIZE as usize);
|
|
assert!(
|
|
4 + len <= data.len(),
|
|
"a Json error requires the full body to be present"
|
|
);
|
|
}
|
|
|
|
fn assert_valid_frame_shape(data: &[u8]) {
|
|
let len = body_len(data).expect("a valid frame requires a 4-byte prefix");
|
|
assert!(len > 0 && len <= MAX_FRAME_SIZE as usize);
|
|
assert!(
|
|
data.len() >= 4 + len,
|
|
"decoding a valid frame requires the full body to be present"
|
|
);
|
|
}
|
|
|
|
fn assert_envelope_shape(envelope: &EventEnvelope) {
|
|
let json = serde_json::to_value(envelope).expect("envelope serializes");
|
|
let obj = json.as_object().expect("envelope serializes to an object");
|
|
assert_eq!(
|
|
obj.get("type").and_then(|v| v.as_str()),
|
|
Some(envelope.r#type.as_str()),
|
|
"type field round-trips through serde"
|
|
);
|
|
assert_eq!(
|
|
obj.get("id").and_then(|v| v.as_str()),
|
|
Some(envelope.id.as_str())
|
|
);
|
|
assert_eq!(obj.get("payload"), Some(&envelope.payload));
|
|
}
|
|
|
|
fn assert_write_round_trip(envelope: &EventEnvelope) {
|
|
let mut bytes = Vec::new();
|
|
{
|
|
let mut writer = FrameFramedWriter::new(&mut bytes);
|
|
tokio_runtime()
|
|
.block_on(writer.write_frame(envelope))
|
|
.expect("in-memory writer cannot fail");
|
|
}
|
|
let mut reader = FrameFramedReader::new(Cursor::new(bytes));
|
|
let decoded = tokio_runtime()
|
|
.block_on(reader.read_frame())
|
|
.expect("a written frame re-decodes");
|
|
assert_eq!(
|
|
&decoded, envelope,
|
|
"structural round-trip (serde_json key order is not preserved, compare structurally)"
|
|
);
|
|
}
|
|
|
|
fn tokio_runtime() -> tokio::runtime::Runtime {
|
|
tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
.expect("current-thread runtime builds")
|
|
}
|
|
#[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/envelope_frame");
|
|
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_envelope_frame(&data);
|
|
count += 1;
|
|
}
|
|
assert!(
|
|
count >= 32,
|
|
"committed seed corpus is present, found {count}"
|
|
);
|
|
}
|
|
}
|