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:
glm-5.3-flash committed 2026-09-27 21:14:35 +00:00
1 parent 7ae0315f19
commit 80a37e94ab
1454 files changed
+2026 -3

No files matched your search

+10
View File
@@ -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 }
+79
View File
@@ -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}"
);
}
}
+175
View File
@@ -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}"
);
}
}
+7
View File
@@ -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;