feat(fuzz): session_opseq target - stateful op-sequence campaign against the session pump
Target 4 from the fuzzing plan (the alkcall-bug-class hunt): a #[derive(Arbitrary)] op sequence drives the producer session pump (drive_session_pre_negotiated) over a duplex pair against a fuzz-local mock backend. - fuzz/shared/src/session_opseq.rs: op vocabulary (client writes incl. invalid-type/oversize-length barrier probes, backend production/EOF, exit resolve/fail/drop, control messages, bounded reads, yields), kill-guard exit future (ADR-005 observability), delivery model, and the invariants: kill-on-Drop (kill_fired == !exit_resolved), exit chunk is last with first-issued code -1 on wait-failure (ADR-004), lossless per-stream FIFO + single stdout sentinel, stdin prefix-losslessness (post-shutdown chunks never delivered), control dispatch bounds, take-once allocation, teardown termination. - Harness-model correction caught by a 60s smoke run before the campaign (the alkcall §7.7 pattern, third time): a failed client write can mean the session completed and dropped the server duplex half while the write was in flight - the harness now drains and requires the exit chunk on any write/shutdown error (premature close without it is a finding). No adapter code changed. - 12 committed seeds via the deterministic generator, hand-encoded against the arbitrary 1.4.x derive layout; the encoding is pinned by a seed-decode test. Existing chunk_frame/negotiation_frame/ control_json seed corpora are byte-identical to before. - fuzz/.gitignore: the per-target seed negations never matched (corpus/* excludes the parent dir; git cannot re-include beneath an excluded dir). Fixed with the !corpus/*/ + corpus/*/* + !corpus/*/seed-* recipe - new seed files were silently ignored until now. - Campaign: 10 min detached via run-detached.sh, exited 0, artifact dir empty - 42.3k execs across 33 fork jobs, 0 crash/oom/timeout, cov 3934 -> 4075 edges. Grown corpus excised per the corpus policy. Verification: corpus replay green on stable (9 tests), cargo test 113 + --all-features 156, clippy stable/wasm/fuzz-shared, fmt, doc, publish dry-run, wasm check - all pass.
This commit is contained in:
1 parent
e72e1fc4c7
commit
fb7cfe3851
22 files changed
+1549
-11
No files matched your search
+4
-4
@@ -1,7 +1,7 @@
|
||||
target
|
||||
corpus/*
|
||||
!corpus/chunk_frame/seed-*
|
||||
!corpus/negotiation_frame/seed-*
|
||||
!corpus/control_json/seed-*
|
||||
!corpus/*/
|
||||
corpus/*/*
|
||||
!corpus/*/seed-*
|
||||
artifacts
|
||||
coverage
|
||||
coverage
|
||||
Generated
+18
@@ -78,9 +78,13 @@ name = "alktty-fuzz-shared"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"alktty",
|
||||
"arbitrary",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -94,6 +98,9 @@ name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
@@ -181,6 +188,17 @@ version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
|
||||
@@ -35,4 +35,11 @@ test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "session_opseq"
|
||||
path = "fuzz_targets/session_opseq.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[workspace]
|
||||
+14
-1
@@ -20,6 +20,7 @@ operating rules live in `docs/plans/fuzzing.md` (adopted from alkcall's
|
||||
| `chunk_frame` | `ChunkReader`/`ChunkWriter` (5-byte chunk codec, ADR-001) |
|
||||
| `negotiation_frame` | `NegotiationReader`/`NegotiationWriter` + `NegotiateRequest` + `error_response_bytes` + the cross-codec peek-disambiguation seam |
|
||||
| `control_json` | `ControlMessage::from_slice`/`to_json` + `signal_from_name` |
|
||||
| `session_opseq` | the producer session pump (`drive_session_pre_negotiated`) + a fuzz-local mock backend — the stateful op-sequence target (ADR-004/ADR-005 invariants; the alkcall-bug-class hunt) |
|
||||
|
||||
## Running a campaign — always detached
|
||||
|
||||
@@ -57,4 +58,16 @@ required by cargo-fuzz); the crate itself stays on stable at MSRV 1.88.
|
||||
toolchain file (rustup resolves per directory).
|
||||
|
||||
Run campaigns before releases and after touching `src/wire.rs`,
|
||||
`src/negotiation.rs`, `src/control.rs`, the adapter, or the session pump.
|
||||
`src/negotiation.rs`, `src/control.rs`, the adapter, or the session pump.
|
||||
|
||||
## session_opseq notes
|
||||
|
||||
The op-sequence target is inherently slower than the byte-format targets
|
||||
(each input drives a full tokio session with bounded reads and yields —
|
||||
tens of exec/s per worker, not millions). That is expected: the value is
|
||||
state-space exploration, not throughput. It uses `tokio::time::timeout`
|
||||
internally, so the nightly target needs the `--features time` tokio
|
||||
surface already present via the shared crate's deps — no extra flags.
|
||||
Its invariants (kill-on-Drop, exit-chunk-is-last, sentinel/FIFO
|
||||
semantics) are the ones unit tests assert individually; the fuzzer
|
||||
explores interleavings no example test can enumerate.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
|
||||
fuzz_target!(
|
||||
|seq: alktty_fuzz_shared::session_opseq::SessionSequence| {
|
||||
alktty_fuzz_shared::session_opseq::fuzz_session_opseq(&seq);
|
||||
}
|
||||
);
|
||||
@@ -241,10 +241,277 @@ def control_json_seeds():
|
||||
return seeds
|
||||
|
||||
|
||||
# session_opseq seeds are arbitrary-encoded `SessionSequence` inputs, so
|
||||
# they are hand-encoded against the `arbitrary` 1.4.x derive layout the
|
||||
# same way alkcall's manager_routing seeds are: each element of a Vec is
|
||||
# gated by a keep-going byte (odd = another element follows; the final
|
||||
# gated read before data exhaustion yields true via zero-fill), enum
|
||||
# variant tags are 4-byte LE u32s with `(tag * 12) >> 32` selecting by
|
||||
# declaration order, and fields follow in declaration order
|
||||
# little-endian, zero-filled on short data. The encoding is pinned by
|
||||
# the seed-decode test in fuzz/shared/src/session_opseq.rs.
|
||||
SESSION_OP_VARIANTS = [
|
||||
"ClientWrite",
|
||||
"ClientCloseWrite",
|
||||
"Stdout",
|
||||
"StdoutEof",
|
||||
"Stderr",
|
||||
"StderrEof",
|
||||
"Exit",
|
||||
"ExitFail",
|
||||
"DropExitTx",
|
||||
"CtrlIn",
|
||||
"ClientReadAll",
|
||||
"Yield",
|
||||
]
|
||||
|
||||
|
||||
def _le32(v):
|
||||
return struct.pack("<I", v & 0xFFFFFFFF)
|
||||
|
||||
|
||||
class OpSeqEncoder:
|
||||
def __init__(self, stderr_enabled):
|
||||
self.buf = bytearray()
|
||||
self.buf.append(1 if stderr_enabled else 0)
|
||||
|
||||
def op(self, name):
|
||||
idx = SESSION_OP_VARIANTS.index(name)
|
||||
self.buf += _le32((idx << 32) // 12)
|
||||
|
||||
def u8(self, v):
|
||||
self.buf.append((v if isinstance(v, int) else v[0]) & 0xFF)
|
||||
|
||||
def u16(self, v):
|
||||
self.buf += struct.pack("<H", v & 0xFFFF)
|
||||
|
||||
def i16(self, v):
|
||||
self.buf += struct.pack("<h", v)
|
||||
|
||||
def end(self):
|
||||
self.buf.append(0)
|
||||
return bytes(self.buf)
|
||||
|
||||
|
||||
def session_opseq_seeds():
|
||||
seeds = []
|
||||
n = 0
|
||||
|
||||
def add(blob):
|
||||
nonlocal n
|
||||
seeds.append((f"seed-{n:03d}", blob))
|
||||
n += 1
|
||||
|
||||
add(
|
||||
bytes(
|
||||
[
|
||||
0x00, 0x01, 0xAB, 0xAA, 0xAA, 0x2A, 0x10, 0x00, 0x61, 0x01,
|
||||
0xAB, 0xAA, 0xAA, 0xEA, 0x04,
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=True)
|
||||
e.op("Stdout")
|
||||
e.u16(32)
|
||||
e.u8(b"a")
|
||||
e.op("Stderr")
|
||||
e.u16(8)
|
||||
e.u8(b"e")
|
||||
e.op("Yield")
|
||||
e.u8(8)
|
||||
add(e.end())
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=True)
|
||||
e.op("ClientWrite")
|
||||
e.u8(0)
|
||||
e.u16(3)
|
||||
e.u8(b"x")
|
||||
e.op("ClientWrite")
|
||||
e.u8(0)
|
||||
e.u16(0)
|
||||
e.u8(0)
|
||||
e.op("ClientWrite")
|
||||
e.u8(0)
|
||||
e.u16(1)
|
||||
e.u8(b"y")
|
||||
e.op("CtrlIn")
|
||||
e.u8(2)
|
||||
e.u16(0)
|
||||
e.u16(0)
|
||||
e.op("Stdout")
|
||||
e.u16(4)
|
||||
e.u8(b"o")
|
||||
e.op("StdoutEof")
|
||||
e.op("Exit")
|
||||
e.i16(3)
|
||||
e.op("ClientReadAll")
|
||||
e.u8(63)
|
||||
add(e.end())
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=True)
|
||||
e.op("Exit")
|
||||
e.i16(7)
|
||||
e.op("Exit")
|
||||
e.i16(43)
|
||||
e.op("Stdout")
|
||||
e.u16(4)
|
||||
e.u8(b"o")
|
||||
e.op("StdoutEof")
|
||||
e.op("Stderr")
|
||||
e.u16(4)
|
||||
e.u8(b"e")
|
||||
e.op("StderrEof")
|
||||
e.op("ClientReadAll")
|
||||
e.u8(63)
|
||||
add(e.end())
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=False)
|
||||
e.op("ExitFail")
|
||||
e.op("Stdout")
|
||||
e.u16(4)
|
||||
e.u8(b"o")
|
||||
e.op("StdoutEof")
|
||||
e.op("ClientReadAll")
|
||||
e.u8(63)
|
||||
add(e.end())
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=True)
|
||||
e.op("ClientWrite")
|
||||
e.u8(200)
|
||||
e.u16(0)
|
||||
e.u8(0)
|
||||
e.op("Stdout")
|
||||
e.u16(4)
|
||||
e.u8(b"o")
|
||||
e.op("ClientWrite")
|
||||
e.u8(5)
|
||||
e.u16(0)
|
||||
e.u8(0)
|
||||
e.op("StdoutEof")
|
||||
e.op("ClientReadAll")
|
||||
e.u8(63)
|
||||
e.op("Exit")
|
||||
e.i16(-1)
|
||||
add(e.end())
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=False)
|
||||
for cols, rows in [(80, 24), (0, 0), (65535, 65535), (120, 40)]:
|
||||
e.op("CtrlIn")
|
||||
e.u8(0)
|
||||
e.u16(cols)
|
||||
e.u16(rows)
|
||||
e.op("CtrlIn")
|
||||
e.u8(1)
|
||||
e.u16(0)
|
||||
e.u16(0)
|
||||
e.op("ClientReadAll")
|
||||
e.u8(4)
|
||||
e.op("Exit")
|
||||
e.i16(0)
|
||||
e.op("ClientReadAll")
|
||||
e.u8(63)
|
||||
add(e.end())
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=False)
|
||||
e.op("CtrlIn")
|
||||
e.u8(3)
|
||||
e.u16(99)
|
||||
e.u16(0)
|
||||
e.op("CtrlIn")
|
||||
e.u8(4)
|
||||
e.u16(0)
|
||||
e.u16(0)
|
||||
e.op("ClientWrite")
|
||||
e.u8(4)
|
||||
e.u16(2)
|
||||
e.u8(b"z")
|
||||
e.op("Stdout")
|
||||
e.u16(4)
|
||||
e.u8(b"o")
|
||||
e.op("StdoutEof")
|
||||
e.op("Exit")
|
||||
e.i16(5)
|
||||
e.op("ClientReadAll")
|
||||
e.u8(63)
|
||||
add(e.end())
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=True)
|
||||
e.op("ClientWrite")
|
||||
e.u8(0)
|
||||
e.u16(2)
|
||||
e.u8(b"w")
|
||||
e.op("ClientCloseWrite")
|
||||
e.op("Stdout")
|
||||
e.u16(4)
|
||||
e.u8(b"o")
|
||||
e.op("StdoutEof")
|
||||
e.op("Stderr")
|
||||
e.u16(4)
|
||||
e.u8(b"e")
|
||||
e.op("StderrEof")
|
||||
e.op("Exit")
|
||||
e.i16(0)
|
||||
e.op("ClientReadAll")
|
||||
e.u8(63)
|
||||
add(e.end())
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=False)
|
||||
e.op("DropExitTx")
|
||||
e.op("Stdout")
|
||||
e.u16(4)
|
||||
e.u8(b"o")
|
||||
e.op("StdoutEof")
|
||||
e.op("ClientReadAll")
|
||||
e.u8(63)
|
||||
add(e.end())
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=True)
|
||||
e.op("Stderr")
|
||||
e.u16(4)
|
||||
e.u8(b"e")
|
||||
e.op("StderrEof")
|
||||
e.op("Stdout")
|
||||
e.u16(4)
|
||||
e.u8(b"o")
|
||||
e.op("Exit")
|
||||
e.i16(9)
|
||||
e.op("StdoutEof")
|
||||
e.op("ClientReadAll")
|
||||
e.u8(63)
|
||||
add(e.end())
|
||||
|
||||
e = OpSeqEncoder(stderr_enabled=False)
|
||||
e.op("ClientWrite")
|
||||
e.u8(0)
|
||||
e.u16(2)
|
||||
e.u8(b"a")
|
||||
e.op("ClientWrite")
|
||||
e.u8(1)
|
||||
e.u16(2)
|
||||
e.u8(b"b")
|
||||
e.op("ClientWrite")
|
||||
e.u8(0)
|
||||
e.u16(2)
|
||||
e.u8(b"c")
|
||||
e.op("Exit")
|
||||
e.i16(1)
|
||||
e.op("Stdout")
|
||||
e.u16(4)
|
||||
e.u8(b"o")
|
||||
e.op("StdoutEof")
|
||||
e.op("ClientReadAll")
|
||||
e.u8(63)
|
||||
add(e.end())
|
||||
|
||||
return seeds
|
||||
|
||||
|
||||
def main():
|
||||
write_seeds("chunk_frame", chunk_frame_seeds())
|
||||
write_seeds("negotiation_frame", negotiation_frame_seeds())
|
||||
write_seeds("control_json", control_json_seeds())
|
||||
write_seeds("session_opseq", session_opseq_seeds())
|
||||
print("seeds written")
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,14 @@ edition = "2021"
|
||||
alktty = { path = "../.." }
|
||||
serde_json = "1"
|
||||
bytes = "1"
|
||||
tokio = { version = "1", features = ["rt"], default-features = false }
|
||||
futures-core = "0.3"
|
||||
tokio = { version = "1", features = ["rt", "macros", "io-util", "sync", "time"], default-features = false }
|
||||
tokio-stream = { version = "0.1", default-features = false }
|
||||
async-trait = "0.1"
|
||||
|
||||
[dependencies.arbitrary]
|
||||
version = "1"
|
||||
features = ["derive"]
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { version = "1", features = ["preserve_order"] }
|
||||
@@ -6,3 +6,4 @@
|
||||
pub mod chunk_frame;
|
||||
pub mod control_json;
|
||||
pub mod negotiation_frame;
|
||||
pub mod session_opseq;
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user