feat(fuzz): cargo-fuzz workspace, chunk_header + envelope_frame targets (fuzzing.md step 1)
- 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
This commit is contained in:
1 parent
7ae0315f19
commit
80a37e94ab
1454 files changed
+2026
-3
No files matched your search
@@ -0,0 +1,10 @@
|
||||
[package]
|
||||
name = "alkcall-fuzz-shared"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
alkcall = { path = "../.." }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["rt"], default-features = false }
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Invariants for the channels 8-byte chunk header (ADR-034): no-panic,
|
||||
//! consumption accounting (8 bytes), round-trip identity, and
|
||||
//! `TooLarge` iff `length > MAX_CHUNK_LEN`.
|
||||
|
||||
use alkcall::channels::wire::{parse_header, write_header, ChunkError, CHUNK_HEADER_LEN};
|
||||
|
||||
pub const MAX_CHUNK_LEN: u32 = 16 * 1024 * 1024;
|
||||
|
||||
pub fn fuzz_chunk_header(data: &[u8]) {
|
||||
match parse_header(data) {
|
||||
Err(ChunkError::HeaderTooShort { need, have }) => {
|
||||
assert_eq!(need, CHUNK_HEADER_LEN);
|
||||
assert_eq!(have, data.len());
|
||||
assert!(data.len() < CHUNK_HEADER_LEN);
|
||||
assert_header_bytes_unchanged(data);
|
||||
}
|
||||
Err(ChunkError::TooLarge { length, max }) => {
|
||||
assert_eq!(max, MAX_CHUNK_LEN);
|
||||
assert!(length > MAX_CHUNK_LEN);
|
||||
assert_eq!(
|
||||
length,
|
||||
u32::from_be_bytes([data[4], data[5], data[6], data[7]])
|
||||
);
|
||||
assert!(data.len() >= CHUNK_HEADER_LEN);
|
||||
assert_header_bytes_unchanged(data);
|
||||
}
|
||||
Ok(header) => {
|
||||
assert!(data.len() >= CHUNK_HEADER_LEN);
|
||||
assert!(header.length <= MAX_CHUNK_LEN);
|
||||
assert_eq!(header.is_eof(), header.length == 0);
|
||||
|
||||
let mut out = [0u8; CHUNK_HEADER_LEN];
|
||||
write_header(header.channel_id, header.length, &mut out)
|
||||
.expect("8-byte out buffer cannot fail");
|
||||
assert_eq!(&out, &data[..CHUNK_HEADER_LEN]);
|
||||
|
||||
let mut resized = [0u8; 4];
|
||||
match write_header(header.channel_id, header.length, &mut resized) {
|
||||
Err(ChunkError::HeaderTooShort { need, have }) => {
|
||||
assert_eq!(need, CHUNK_HEADER_LEN);
|
||||
assert_eq!(have, 4);
|
||||
}
|
||||
other => panic!("expected HeaderTooShort, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_header_bytes_unchanged(data: &[u8]) {
|
||||
let mut buf = [0u8; CHUNK_HEADER_LEN];
|
||||
let n = data.len().min(CHUNK_HEADER_LEN);
|
||||
buf[..n].copy_from_slice(&data[..n]);
|
||||
assert_eq!(
|
||||
&buf[..n],
|
||||
&data[..n],
|
||||
"parse_header must not mutate its input"
|
||||
);
|
||||
}
|
||||
#[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/chunk_header");
|
||||
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_chunk_header(&data);
|
||||
count += 1;
|
||||
}
|
||||
assert!(
|
||||
count >= 16,
|
||||
"committed seed corpus is present, found {count}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Invariant logic shared by alkcall's fuzz targets. Kept out of the
|
||||
//! fuzz-target binaries so the corpus replay unit tests in normal CI can
|
||||
//! exercise the same invariant checks against every committed corpus
|
||||
//! entry (the quinn CI pattern) without a nightly toolchain.
|
||||
|
||||
pub mod chunk_header;
|
||||
pub mod envelope_frame;
|
||||
Reference in new issue
Block a user