diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 398ad77..1ae3c77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,3 +40,31 @@ jobs: uses: docker://rhysd/actionlint:1.7.12 with: args: -color + + fuzz-smoke: + name: Fuzz smoke (pinned, read-only, offline) + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + env: + RUST_TEST_THREADS: "1" + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + - name: Set up Rust 1.98.1 + run: rustup toolchain install 1.98.1 --profile minimal --no-self-update + - name: Reject seed mirror drift + run: diff -r tests/fixtures/fuzz/framer-seeds crates/devtools-client/tests/fixtures/fuzz/framer-seeds + - name: TypeScript fuzz smoke (no install) + run: bun test tests/framer-fuzz-smoke.test.ts + - name: Fetch locked Rust dependencies (setup only) + run: cargo fetch --locked + - name: Rust fuzz smoke (locked, offline, single-threaded) + env: + CARGO_NET_OFFLINE: "true" + run: cargo test -p bitty-devtools-client --test framer_fuzz_smoke --locked --offline -- --test-threads=1 diff --git a/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/cjk.bin b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/cjk.bin new file mode 100644 index 0000000..1bead7a --- /dev/null +++ b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/cjk.bin @@ -0,0 +1 @@ +日本語 \ No newline at end of file diff --git a/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/combining.bin b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/combining.bin new file mode 100644 index 0000000..1f45813 --- /dev/null +++ b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/combining.bin @@ -0,0 +1 @@ +é \ No newline at end of file diff --git a/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/emoji.bin b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/emoji.bin new file mode 100644 index 0000000..3995456 --- /dev/null +++ b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/emoji.bin @@ -0,0 +1 @@ +😀 \ No newline at end of file diff --git a/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/empty.bin b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/empty.bin new file mode 100644 index 0000000..e69de29 diff --git a/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/hello.bin b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/hello.bin new file mode 100644 index 0000000..b6fc4c6 --- /dev/null +++ b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/hello.bin @@ -0,0 +1 @@ +hello \ No newline at end of file diff --git a/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/vectors.json b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/vectors.json new file mode 100644 index 0000000..9c6eeb1 --- /dev/null +++ b/crates/devtools-client/tests/fixtures/fuzz/framer-seeds/vectors.json @@ -0,0 +1,333 @@ +{ + "constants": { + "framerLimitBytes": 524296, + "maxBufferedBytes": 262152, + "maxFrameBytes": 262144, + "rc10ChunkBytes": 262144, + "rc9PayloadCapBytes": 1048576 + }, + "manifestVersion": 1, + "provenance": { + "issue": "https://github.com/bitty-terminal/bitty-devtools/issues/124", + "mode": "checked-in benign seeds only; large vectors are built in memory from sizes below, never stored", + "option": "B thin-duplicate harness, devtools-side only; no core edits, no live socket, no credentials", + "task": "CTX-0074" + }, + "vectors": [ + { + "expect": { + "disposition": "accept", + "framesHex": [] + }, + "id": "V01", + "inputHex": "", + "kind": "empty-push", + "target": "T1" + }, + { + "expect": { + "disposition": "accept", + "framesHex": [""] + }, + "id": "V02", + "inputHex": "00000000", + "kind": "zero-len-frame", + "target": "T1" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["68656c6c6f"], + "retainedAfterFirstPush": 1 + }, + "feed": { + "splits": [1] + }, + "id": "V03", + "kind": "header-split-1B", + "target": "T1", + "wireHex": "0000000568656c6c6f" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["68656c6c6f"], + "retainedAfterFirstPush": 2 + }, + "feed": { + "splits": [2] + }, + "id": "V04", + "kind": "header-split-2B", + "target": "T1", + "wireHex": "0000000568656c6c6f" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["68656c6c6f"], + "retainedAfterFirstPush": 3 + }, + "feed": { + "splits": [3] + }, + "id": "V05", + "kind": "header-split-3B", + "target": "T1", + "wireHex": "0000000568656c6c6f" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["6869"] + }, + "feed": { + "trickleBytes": 1 + }, + "id": "V06", + "kind": "trickle-1B", + "target": "T1", + "wireHex": "000000026869" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["61", "6262", "636363"] + }, + "id": "V07", + "inputHex": "000000016100000002626200000003636363", + "kind": "coalesced-3", + "target": "T1" + }, + { + "expect": { + "disposition": "accept", + "framesHex": [], + "retainedAfterFirstPush": 2 + }, + "feed": { + "splits": [2] + }, + "id": "V08", + "kind": "truncated-header-2B", + "target": "T1", + "wireHex": "0000000568656c6c6f" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["68656c6c6f4141414141414141414141"], + "retainedAfterFirstPush": 9 + }, + "feed": { + "splits": [9] + }, + "id": "V09", + "kind": "body-short-then-complete", + "target": "T1", + "wireHex": "0000001068656c6c6f4141414141414141414141" + }, + { + "expect": { + "cleared": true, + "code": "FrameTooLarge", + "disposition": "reject", + "recoversWith": "6f6b" + }, + "id": "V10", + "inputHex": "00040001", + "kind": "declared-256k-plus1", + "target": "T1" + }, + { + "expect": { + "cleared": true, + "code": "FrameTooLarge", + "disposition": "reject", + "recoversWith": "6f6b" + }, + "id": "V11", + "inputHex": "ffffffff", + "kind": "declared-u32max", + "target": "T1" + }, + { + "build": { + "fillByte": 0, + "payloadLen": 524297 + }, + "expect": { + "cleared": true, + "code": "PayloadTooLarge", + "disposition": "reject", + "recoversWith": "6f6b" + }, + "id": "V12", + "kind": "over-buffer-limit-plus1", + "target": "T1" + }, + { + "expect": { + "cleared": true, + "code": "FrameTooLarge", + "disposition": "reject", + "recoversWith": "6f6b" + }, + "id": "V13", + "inputHex": "0000000568656c6c6fffffffff", + "kind": "mixed-valid-then-oversize", + "target": "T1" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["f09f9880"] + }, + "feed": { + "splits": [6] + }, + "id": "V14", + "kind": "emoji-body-split", + "target": "T1", + "wireHex": "00000004f09f9880" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["e697a5e69cace8aa9e"] + }, + "feed": { + "splits": [5] + }, + "id": "V15", + "kind": "cjk-body-split", + "target": "T1", + "wireHex": "00000009e697a5e69cace8aa9e" + }, + { + "build": { + "fillByte": 65, + "payloadLen": 262143 + }, + "expect": { + "disposition": "accept", + "frameCount": 1, + "payloadLen": 262143 + }, + "id": "V16", + "kind": "payload-256k-minus1", + "target": "T1" + }, + { + "build": { + "fillByte": 65, + "payloadLen": 262144 + }, + "expect": { + "disposition": "accept", + "frameCount": 1, + "payloadLen": 262144 + }, + "id": "V17", + "kind": "payload-256k-exact", + "target": "T1" + }, + { + "expect": { + "chunks": ["a", "😀", "b"] + }, + "id": "C01", + "input": "a😀b", + "kind": "chunk-emoji", + "target": "T3", + "limitBytes": 4 + }, + { + "expect": { + "chunks": ["日", "本", "語"] + }, + "id": "C02", + "input": "日本語", + "kind": "chunk-cjk", + "target": "T3", + "limitBytes": 4 + }, + { + "expect": { + "chunks": ["é", "x"] + }, + "id": "C03", + "input": "éx", + "kind": "chunk-combining", + "target": "T3", + "limitBytes": 3 + }, + { + "expect": { + "disposition": "error" + }, + "id": "C04", + "input": "a😀b", + "kind": "chunk-too-narrow", + "limitBytes": 2, + "target": "T3" + }, + { + "build": { + "jsonLen": 262144 + }, + "expect": { + "chunks": 1 + }, + "id": "Q01", + "kind": "req-256k-exact", + "target": "T4" + }, + { + "build": { + "jsonLen": 262145 + }, + "expect": { + "chunks": 2 + }, + "id": "Q02", + "kind": "req-256k-plus1", + "target": "T4" + }, + { + "build": { + "jsonLen": 1048576 + }, + "expect": { + "chunks": 4 + }, + "id": "Q03", + "kind": "req-1mib-exact", + "target": "T4" + }, + { + "build": { + "jsonLen": 1048577 + }, + "expect": { + "code": "PayloadTooLarge", + "disposition": "reject" + }, + "id": "Q04", + "kind": "req-1mib-plus1", + "target": "T4" + }, + { + "expect": { + "cleared": true, + "disposition": "reject", + "pendingDropped": true + }, + "id": "Q05", + "inputHex": "ffffffff", + "kind": "inbound-failframing-model", + "note": "mock bytes only, no dial", + "target": "T4" + } + ] +} diff --git a/crates/devtools-client/tests/framer_fuzz_smoke.rs b/crates/devtools-client/tests/framer_fuzz_smoke.rs new file mode 100644 index 0000000..29452b1 --- /dev/null +++ b/crates/devtools-client/tests/framer_fuzz_smoke.rs @@ -0,0 +1,419 @@ +use bitty_devtools_client::protocol::chunk_text; +use bitty_devtools_client::transport::{ + Framer, IpcTransport, MAX_BUFFERED_BYTES, MAX_FRAME_BYTES, RC9_PAYLOAD_CAP_BYTES, + RC10_CHUNK_CEILING, StdioTransportStub, TransportError, check_payload_cap, decode_frame, + encode_frame, +}; +use std::time::Instant; + +const ORACLE_JSON: &str = include_str!("fixtures/fuzz/framer-seeds/vectors.json"); + +const LOCAL_TARGET_BUDGET_MS: u128 = 60_000; + +fn seed_dir() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("fuzz") + .join("framer-seeds") +} + +fn load_seed(name: &str) -> Vec { + std::fs::read(seed_dir().join(name)).expect("seed file must exist") +} + +fn to_hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn from_hex(hex: &str) -> Vec { + assert!(hex.len() % 2 == 0, "hex must have even length"); + (0..hex.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex")) + .collect() +} + +fn header_only(declared_len: u32) -> Vec { + declared_len.to_be_bytes().to_vec() +} + +fn expect_fail_closed_p0(bytes: &[u8], code: &str) { + let mut framer = Framer::new(); + let result = framer.push_bytes(bytes); + match code { + "FrameTooLarge" => assert!( + matches!(result, Err(TransportError::FrameTooLarge { .. })), + "expected FrameTooLarge, got {result:?}" + ), + "PayloadTooLarge" => assert!( + matches!(result, Err(TransportError::PayloadTooLarge { .. })), + "expected PayloadTooLarge, got {result:?}" + ), + other => panic!("unknown oracle code {other}"), + } + assert!(framer.is_empty()); + assert_eq!(framer.buffered_len(), 0); + let ok = framer.push_bytes(&encode_frame(b"ok").unwrap()).unwrap(); + assert_eq!(ok.len(), 1); + assert_eq!(ok[0].payload(), b"ok"); +} + +fn oracle_has(id: &str) -> bool { + ORACLE_JSON.contains(&format!("\"id\": \"{id}\"")) + || ORACLE_JSON.contains(&format!("\"id\":\"{id}\"")) +} + +fn request_json_of_len(total: usize) -> String { + let head = "{\"id\":1,\"method\":\"bitty.debug/listPlugins\",\"params\":{\"pad\":\""; + let tail = "\"},\"version\":\"1.0\"}"; + assert!(total > head.len() + tail.len()); + let pad = "a".repeat(total - head.len() - tail.len()); + let json = format!("{head}{pad}{tail}"); + assert_eq!(json.len(), total); + json +} + +fn connected_transport() -> IpcTransport { + let mut transport = IpcTransport::with_defaults( + 1000, + "/tmp/ctx-0074-fuzz-smoke-fixture.sock".to_owned(), + None, + ); + transport.connect().unwrap(); + transport +} + +#[test] +fn oracle_manifest_version_and_shared_constants() { + assert!( + ORACLE_JSON.contains("\"manifestVersion\": 1") + || ORACLE_JSON.contains("\"manifestVersion\":1") + ); + assert_eq!(MAX_FRAME_BYTES, 262_144); + assert_eq!(MAX_BUFFERED_BYTES, 262_152); + assert_eq!(MAX_BUFFERED_BYTES + MAX_FRAME_BYTES, 524_296); + assert_eq!(RC9_PAYLOAD_CAP_BYTES, 1_048_576); + assert_eq!(RC10_CHUNK_CEILING, 262_144); + for id in [ + "V01", "V02", "V03", "V04", "V05", "V06", "V07", "V08", "V09", "V10", "V11", "V12", "V13", + "V14", "V15", "V16", "V17", "C01", "C02", "C03", "C04", "Q01", "Q02", "Q03", "Q04", "Q05", + ] { + assert!(oracle_has(id), "oracle must pin vector {id}"); + } +} + +#[test] +fn seed_files_present_and_benign() { + assert!(load_seed("empty.bin").is_empty()); + assert_eq!(load_seed("hello.bin"), b"hello"); + assert_eq!(to_hex(&load_seed("emoji.bin")), "f09f9880"); + assert_eq!(to_hex(&load_seed("cjk.bin")), "e697a5e69cace8aa9e"); + assert_eq!(to_hex(&load_seed("combining.bin")), "65cc81"); +} + +#[test] +fn t1_empty_push_emits_nothing() { + let mut framer = Framer::new(); + assert!(framer.push_bytes(&[]).unwrap().is_empty()); + assert!(framer.is_empty()); +} + +#[test] +fn t1_zero_len_frame_decodes_to_empty_payload() { + let mut framer = Framer::new(); + let frames = framer.push_bytes(&from_hex("00000000")).unwrap(); + assert_eq!(frames.len(), 1); + assert!(frames[0].payload().is_empty()); + assert!(framer.is_empty()); +} + +#[test] +fn t1_header_splits_retain_then_decode() { + for at in [1usize, 2, 3] { + let wire = from_hex("0000000568656c6c6f"); + let mut framer = Framer::new(); + assert!(framer.push_bytes(&wire[..at]).unwrap().is_empty()); + assert_eq!(framer.buffered_len(), at); + let frames = framer.push_bytes(&wire[at..]).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].payload(), b"hello"); + assert!(framer.is_empty()); + } +} + +#[test] +fn t1_trickle_emits_single_frame_at_end() { + let wire = from_hex("000000026869"); + let mut framer = Framer::new(); + let mut emitted = 0usize; + for (i, byte) in wire.iter().enumerate() { + let frames = framer.push_bytes(std::slice::from_ref(byte)).unwrap(); + if i + 1 < wire.len() { + assert!(frames.is_empty()); + } else { + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].payload(), b"hi"); + } + emitted += frames.len(); + } + assert_eq!(emitted, 1); + assert!(framer.is_empty()); +} + +#[test] +fn t1_coalesced_frames_decode_in_order() { + let input = from_hex("000000016100000002626200000003636363"); + let mut framer = Framer::new(); + let frames = framer.push_bytes(&input).unwrap(); + assert_eq!(frames.len(), 3); + assert_eq!(frames[0].payload(), b"a"); + assert_eq!(frames[1].payload(), b"bb"); + assert_eq!(frames[2].payload(), b"ccc"); + assert!(framer.is_empty()); +} + +#[test] +fn t1_truncated_header_and_body_short_recover() { + let wire = from_hex("0000000568656c6c6f"); + let mut framer = Framer::new(); + assert!(framer.push_bytes(&wire[..2]).unwrap().is_empty()); + assert_eq!(framer.buffered_len(), 2); + let frames = framer.push_bytes(&wire[2..]).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].payload(), b"hello"); + + let long = from_hex("0000001068656c6c6f4141414141414141414141"); + let mut framer = Framer::new(); + assert!(framer.push_bytes(&long[..9]).unwrap().is_empty()); + assert_eq!(framer.buffered_len(), 9); + assert!(framer.buffered_len() <= MAX_BUFFERED_BYTES + MAX_FRAME_BYTES); + let frames = framer.push_bytes(&long[9..]).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].payload().len(), 16); + assert_eq!( + to_hex(frames[0].payload()), + "68656c6c6f4141414141414141414141" + ); +} + +#[test] +fn t1_oversize_declared_lengths_fail_closed() { + expect_fail_closed_p0(&from_hex("00040001"), "FrameTooLarge"); + expect_fail_closed_p0(&from_hex("ffffffff"), "FrameTooLarge"); +} + +#[test] +fn t1_over_buffer_limit_plus_one_fail_closed() { + let start = Instant::now(); + let input = vec![0u8; MAX_BUFFERED_BYTES + MAX_FRAME_BYTES + 1]; + assert_eq!(input.len(), 524_297); + expect_fail_closed_p0(&input, "PayloadTooLarge"); + assert!(start.elapsed().as_millis() < LOCAL_TARGET_BUDGET_MS); +} + +#[test] +fn t1_mixed_valid_then_oversize_recovers() { + let input = from_hex("0000000568656c6c6fffffffff"); + let mut framer = Framer::new(); + let result = framer.push_bytes(&input); + assert!( + matches!(result, Err(TransportError::FrameTooLarge { .. })), + "expected FrameTooLarge, got {result:?}" + ); + assert!(framer.is_empty()); + assert_eq!(framer.buffered_len(), 0); + let ok = framer.push_bytes(&encode_frame(b"ok").unwrap()).unwrap(); + assert_eq!(ok.len(), 1); +} + +#[test] +fn t1_large_payloads_round_trip_in_memory() { + let start = Instant::now(); + for len in [262_143usize, 262_144] { + let payload = vec![65u8; len]; + let wire = encode_frame(&payload).unwrap(); + assert_eq!(wire.len(), 4 + len); + let mut framer = Framer::new(); + let frames = framer.push_bytes(&wire).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].payload(), payload.as_slice()); + assert!(framer.is_empty()); + } + assert!(start.elapsed().as_millis() < LOCAL_TARGET_BUDGET_MS); +} + +#[test] +fn t1_multibyte_body_splits_stay_byte_identical() { + for (wire_hex, frame_hex, at) in [ + ("00000004f09f9880", "f09f9880", 6usize), + ("00000009e697a5e69cace8aa9e", "e697a5e69cace8aa9e", 5usize), + ] { + let wire = from_hex(wire_hex); + let mut framer = Framer::new(); + assert!(framer.push_bytes(&wire[..at]).unwrap().is_empty()); + let frames = framer.push_bytes(&wire[at..]).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(to_hex(frames[0].payload()), frame_hex); + } + let emoji = load_seed("emoji.bin"); + let wire = encode_frame(&emoji).unwrap(); + let mut framer = Framer::new(); + assert!(framer.push_bytes(&wire[..6]).unwrap().is_empty()); + let frames = framer.push_bytes(&wire[6..]).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].payload(), emoji.as_slice()); +} + +#[test] +fn t1_rejected_input_leaves_queues_unchanged() { + let stub = StdioTransportStub::new(64); + let before_out = stub.outgoing_len(); + let mut framer = Framer::new(); + assert!(matches!( + framer.push_bytes(&header_only(u32::MAX)), + Err(TransportError::FrameTooLarge { .. }) + )); + assert_eq!(stub.outgoing_len(), before_out); + assert_eq!(stub.dropped_outgoing(), 0); +} + +#[test] +fn t1_rejected_send_leaves_outgoing_unchanged() { + let mut transport = connected_transport(); + assert_eq!(transport.outgoing_len(), 0); + let oversized = request_json_of_len(1_048_577); + assert!(transport.send_request(&oversized, 0).is_err()); + assert_eq!(transport.outgoing_len(), 0); +} + +#[test] +fn t2_round_trip_single_shot_and_split() { + let mut payloads: Vec> = vec![ + load_seed("empty.bin"), + load_seed("hello.bin"), + load_seed("emoji.bin"), + load_seed("cjk.bin"), + load_seed("combining.bin"), + vec![65u8; 1024], + ]; + assert_eq!(payloads.len(), 6); + for payload in payloads.drain(..) { + let wire = encode_frame(&payload).unwrap(); + let (frame, consumed) = decode_frame(&wire).unwrap(); + assert_eq!(consumed, 4 + payload.len()); + assert_eq!(frame.payload(), payload.as_slice()); + for at in [1usize, 3, 7] { + if at >= wire.len() { + continue; + } + let mut framer = Framer::new(); + assert!(framer.push_bytes(&wire[..at]).unwrap().is_empty()); + let frames = framer.push_bytes(&wire[at..]).unwrap(); + assert_eq!(frames.len(), 1); + assert_eq!(frames[0].payload(), payload.as_slice()); + } + } + let hello_wire = encode_frame(&load_seed("hello.bin")).unwrap(); + assert_eq!(to_hex(&hello_wire), "0000000568656c6c6f"); + assert_eq!(to_hex(&encode_frame(&[]).unwrap()), "00000000"); +} + +#[test] +fn t3_chunk_boundary_parity_with_oracle() { + assert_eq!(chunk_text("a😀b", 4).unwrap(), vec!["a", "😀", "b"]); + assert_eq!(chunk_text("日本語", 4).unwrap(), vec!["日", "本", "語"]); + assert_eq!(chunk_text("éx", 3).unwrap(), vec!["é", "x"]); + for (text, limit, expected) in [ + ("a😀b", 4usize, vec!["a", "😀", "b"]), + ("日本語", 4, vec!["日", "本", "語"]), + ("éx", 3, vec!["é", "x"]), + ] { + let chunks = chunk_text(text, limit).unwrap(); + assert_eq!(chunks, expected); + assert_eq!(chunks.concat(), text); + for chunk in &chunks { + assert!(chunk.len() <= limit); + } + } + assert!(chunk_text("a😀b", 2).is_err()); +} + +#[test] +fn t4_chunk_counts_for_sized_requests() { + let start = Instant::now(); + for (target, chunks) in [(262_144usize, 1), (262_145, 2), (1_048_576, 4)] { + let json = request_json_of_len(target); + let mut transport = connected_transport(); + transport.send_request(&json, 0).unwrap(); + assert_eq!(transport.outgoing_len(), chunks, "target {target}"); + let mut got = Vec::new(); + for _ in 0..chunks { + let frame = transport.stub_mut().recv_outgoing().unwrap(); + assert!(frame.payload().len() <= MAX_FRAME_BYTES); + got.extend_from_slice(frame.payload()); + } + assert_eq!(got, json.as_bytes()); + } + assert!(start.elapsed().as_millis() < LOCAL_TARGET_BUDGET_MS); +} + +#[test] +fn t4_oversize_request_refused_with_payload_too_large() { + let json = request_json_of_len(1_048_577); + assert!(matches!( + check_payload_cap(json.len()), + Err(TransportError::PayloadTooLarge { .. }) + )); + let mut transport = connected_transport(); + assert!(matches!( + transport.send_request(&json, 0), + Err(TransportError::PayloadTooLarge { .. }) + )); +} + +fn inbound_on_data( + inbound: &mut Framer, + pending: &mut Vec>, + framing_failed: &mut bool, + data: &[u8], +) { + if *framing_failed { + return; + } + match inbound.push_bytes(data) { + Ok(frames) => { + for frame in &frames { + pending.push(frame.payload().to_vec()); + } + } + Err(_) => { + *framing_failed = true; + inbound.clear(); + pending.clear(); + } + } +} + +#[test] +fn t4_inbound_failframing_model_drops_pending() { + let mut inbound = Framer::new(); + let mut pending: Vec> = Vec::new(); + let mut framing_failed = false; + inbound_on_data( + &mut inbound, + &mut pending, + &mut framing_failed, + &encode_frame(b"queued").unwrap(), + ); + assert_eq!(pending.len(), 1); + inbound_on_data( + &mut inbound, + &mut pending, + &mut framing_failed, + &from_hex("ffffffff"), + ); + assert!(framing_failed); + assert!(inbound.is_empty()); + assert!(pending.is_empty()); +} diff --git a/tests/fixtures/fuzz/README.md b/tests/fixtures/fuzz/README.md new file mode 100644 index 0000000..fff3213 --- /dev/null +++ b/tests/fixtures/fuzz/README.md @@ -0,0 +1,45 @@ +# Framer fuzz-smoke corpus + +Checked-in benign seeds for CTX-0074, issue +`bitty-terminal/bitty-devtools#124`, design CTX-0070 PX-0320/PX-0321. + +## Targets + +- T1 `Framer.pushBytes` in TypeScript (`src/transport.ts`) and Rust + (`crates/devtools-client/src/transport.rs`): split, coalesced, and + adversarial-length inputs. +- T2 encode/decode round-trip in both languages. +- T3 `chunkText`/`chunk_text` boundary parity on multibyte splits. +- T4 `IpcTransport` 1 MiB chunking plus the inbound `failFraming` model on + mock bytes only. Live sockets, peer credentials, and wall-clock + rate-limit timing stay out of scope. + +## Layout + +- `framer-seeds/vectors.json`: the shared differential oracle. Both suites + assert the same accept/reject dispositions, frame bytes, chunk splits, and + `manifestVersion`. Large vectors are described by size and fill byte, then + built in memory by the tests; they are never stored on disk. +- `framer-seeds/*.bin`: tiny benign payloads (empty, hello, emoji, CJK, + combining mark) consumed by both suites. +- `crates/devtools-client/tests/fixtures/fuzz/framer-seeds/`: byte-identical + Rust-side mirror. CI rejects drift with `diff -r`; mirrors move together + behind a `manifestVersion` bump, never by silent divergence. +- Generated corpora, if any, belong under gitignored `recording/fuzz/` and + are never committed. + +## Budgets + +- Local smoke: 60 s per target, 256 MiB resident cap, single-threaded, + fully deterministic (fixed sizes and splits, no randomness). +- CI smoke: 5 min total, 1 GiB, single-threaded (`--test-threads=1`), + pinned engines (Bun 1.4.2, Rust 1.98.1), `--locked`/`--offline` with no + `bunx`, `cargo-install`, or runtime installs. + +## Core property + +P0 fail-closed with zero partial state: an oversize declaration or an +over-buffer push emits zero frames, clears the buffer (`isEmpty` true, +`buffered_len` 0), leaves queue and drop counters unchanged, and the next +valid push decodes cleanly. Truncated input emits zero frames while the tail +stays retained and bounded. diff --git a/tests/fixtures/fuzz/framer-seeds/cjk.bin b/tests/fixtures/fuzz/framer-seeds/cjk.bin new file mode 100644 index 0000000..1bead7a --- /dev/null +++ b/tests/fixtures/fuzz/framer-seeds/cjk.bin @@ -0,0 +1 @@ +日本語 \ No newline at end of file diff --git a/tests/fixtures/fuzz/framer-seeds/combining.bin b/tests/fixtures/fuzz/framer-seeds/combining.bin new file mode 100644 index 0000000..1f45813 --- /dev/null +++ b/tests/fixtures/fuzz/framer-seeds/combining.bin @@ -0,0 +1 @@ +é \ No newline at end of file diff --git a/tests/fixtures/fuzz/framer-seeds/emoji.bin b/tests/fixtures/fuzz/framer-seeds/emoji.bin new file mode 100644 index 0000000..3995456 --- /dev/null +++ b/tests/fixtures/fuzz/framer-seeds/emoji.bin @@ -0,0 +1 @@ +😀 \ No newline at end of file diff --git a/tests/fixtures/fuzz/framer-seeds/empty.bin b/tests/fixtures/fuzz/framer-seeds/empty.bin new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/fuzz/framer-seeds/hello.bin b/tests/fixtures/fuzz/framer-seeds/hello.bin new file mode 100644 index 0000000..b6fc4c6 --- /dev/null +++ b/tests/fixtures/fuzz/framer-seeds/hello.bin @@ -0,0 +1 @@ +hello \ No newline at end of file diff --git a/tests/fixtures/fuzz/framer-seeds/vectors.json b/tests/fixtures/fuzz/framer-seeds/vectors.json new file mode 100644 index 0000000..9c6eeb1 --- /dev/null +++ b/tests/fixtures/fuzz/framer-seeds/vectors.json @@ -0,0 +1,333 @@ +{ + "constants": { + "framerLimitBytes": 524296, + "maxBufferedBytes": 262152, + "maxFrameBytes": 262144, + "rc10ChunkBytes": 262144, + "rc9PayloadCapBytes": 1048576 + }, + "manifestVersion": 1, + "provenance": { + "issue": "https://github.com/bitty-terminal/bitty-devtools/issues/124", + "mode": "checked-in benign seeds only; large vectors are built in memory from sizes below, never stored", + "option": "B thin-duplicate harness, devtools-side only; no core edits, no live socket, no credentials", + "task": "CTX-0074" + }, + "vectors": [ + { + "expect": { + "disposition": "accept", + "framesHex": [] + }, + "id": "V01", + "inputHex": "", + "kind": "empty-push", + "target": "T1" + }, + { + "expect": { + "disposition": "accept", + "framesHex": [""] + }, + "id": "V02", + "inputHex": "00000000", + "kind": "zero-len-frame", + "target": "T1" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["68656c6c6f"], + "retainedAfterFirstPush": 1 + }, + "feed": { + "splits": [1] + }, + "id": "V03", + "kind": "header-split-1B", + "target": "T1", + "wireHex": "0000000568656c6c6f" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["68656c6c6f"], + "retainedAfterFirstPush": 2 + }, + "feed": { + "splits": [2] + }, + "id": "V04", + "kind": "header-split-2B", + "target": "T1", + "wireHex": "0000000568656c6c6f" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["68656c6c6f"], + "retainedAfterFirstPush": 3 + }, + "feed": { + "splits": [3] + }, + "id": "V05", + "kind": "header-split-3B", + "target": "T1", + "wireHex": "0000000568656c6c6f" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["6869"] + }, + "feed": { + "trickleBytes": 1 + }, + "id": "V06", + "kind": "trickle-1B", + "target": "T1", + "wireHex": "000000026869" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["61", "6262", "636363"] + }, + "id": "V07", + "inputHex": "000000016100000002626200000003636363", + "kind": "coalesced-3", + "target": "T1" + }, + { + "expect": { + "disposition": "accept", + "framesHex": [], + "retainedAfterFirstPush": 2 + }, + "feed": { + "splits": [2] + }, + "id": "V08", + "kind": "truncated-header-2B", + "target": "T1", + "wireHex": "0000000568656c6c6f" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["68656c6c6f4141414141414141414141"], + "retainedAfterFirstPush": 9 + }, + "feed": { + "splits": [9] + }, + "id": "V09", + "kind": "body-short-then-complete", + "target": "T1", + "wireHex": "0000001068656c6c6f4141414141414141414141" + }, + { + "expect": { + "cleared": true, + "code": "FrameTooLarge", + "disposition": "reject", + "recoversWith": "6f6b" + }, + "id": "V10", + "inputHex": "00040001", + "kind": "declared-256k-plus1", + "target": "T1" + }, + { + "expect": { + "cleared": true, + "code": "FrameTooLarge", + "disposition": "reject", + "recoversWith": "6f6b" + }, + "id": "V11", + "inputHex": "ffffffff", + "kind": "declared-u32max", + "target": "T1" + }, + { + "build": { + "fillByte": 0, + "payloadLen": 524297 + }, + "expect": { + "cleared": true, + "code": "PayloadTooLarge", + "disposition": "reject", + "recoversWith": "6f6b" + }, + "id": "V12", + "kind": "over-buffer-limit-plus1", + "target": "T1" + }, + { + "expect": { + "cleared": true, + "code": "FrameTooLarge", + "disposition": "reject", + "recoversWith": "6f6b" + }, + "id": "V13", + "inputHex": "0000000568656c6c6fffffffff", + "kind": "mixed-valid-then-oversize", + "target": "T1" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["f09f9880"] + }, + "feed": { + "splits": [6] + }, + "id": "V14", + "kind": "emoji-body-split", + "target": "T1", + "wireHex": "00000004f09f9880" + }, + { + "expect": { + "disposition": "accept", + "framesHex": ["e697a5e69cace8aa9e"] + }, + "feed": { + "splits": [5] + }, + "id": "V15", + "kind": "cjk-body-split", + "target": "T1", + "wireHex": "00000009e697a5e69cace8aa9e" + }, + { + "build": { + "fillByte": 65, + "payloadLen": 262143 + }, + "expect": { + "disposition": "accept", + "frameCount": 1, + "payloadLen": 262143 + }, + "id": "V16", + "kind": "payload-256k-minus1", + "target": "T1" + }, + { + "build": { + "fillByte": 65, + "payloadLen": 262144 + }, + "expect": { + "disposition": "accept", + "frameCount": 1, + "payloadLen": 262144 + }, + "id": "V17", + "kind": "payload-256k-exact", + "target": "T1" + }, + { + "expect": { + "chunks": ["a", "😀", "b"] + }, + "id": "C01", + "input": "a😀b", + "kind": "chunk-emoji", + "target": "T3", + "limitBytes": 4 + }, + { + "expect": { + "chunks": ["日", "本", "語"] + }, + "id": "C02", + "input": "日本語", + "kind": "chunk-cjk", + "target": "T3", + "limitBytes": 4 + }, + { + "expect": { + "chunks": ["é", "x"] + }, + "id": "C03", + "input": "éx", + "kind": "chunk-combining", + "target": "T3", + "limitBytes": 3 + }, + { + "expect": { + "disposition": "error" + }, + "id": "C04", + "input": "a😀b", + "kind": "chunk-too-narrow", + "limitBytes": 2, + "target": "T3" + }, + { + "build": { + "jsonLen": 262144 + }, + "expect": { + "chunks": 1 + }, + "id": "Q01", + "kind": "req-256k-exact", + "target": "T4" + }, + { + "build": { + "jsonLen": 262145 + }, + "expect": { + "chunks": 2 + }, + "id": "Q02", + "kind": "req-256k-plus1", + "target": "T4" + }, + { + "build": { + "jsonLen": 1048576 + }, + "expect": { + "chunks": 4 + }, + "id": "Q03", + "kind": "req-1mib-exact", + "target": "T4" + }, + { + "build": { + "jsonLen": 1048577 + }, + "expect": { + "code": "PayloadTooLarge", + "disposition": "reject" + }, + "id": "Q04", + "kind": "req-1mib-plus1", + "target": "T4" + }, + { + "expect": { + "cleared": true, + "disposition": "reject", + "pendingDropped": true + }, + "id": "Q05", + "inputHex": "ffffffff", + "kind": "inbound-failframing-model", + "note": "mock bytes only, no dial", + "target": "T4" + } + ] +} diff --git a/tests/framer-fuzz-smoke.test.ts b/tests/framer-fuzz-smoke.test.ts new file mode 100644 index 0000000..d012281 --- /dev/null +++ b/tests/framer-fuzz-smoke.test.ts @@ -0,0 +1,565 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + Framer, + IpcTransport, + StdioTransportStub, + TransportError, + checkPayloadCap, + decodeFrame, + encodeFrame, + MAX_BUFFERED_BYTES, + MAX_FRAME_BYTES, + RC9_PAYLOAD_CAP_BYTES, + RC10_CHUNK_CEILING, +} from "../src/transport.js"; +import { chunkText } from "../src/protocol.js"; + +const SEED_DIR = join(import.meta.dir, "fixtures", "fuzz", "framer-seeds"); + +const LOCAL_TARGET_BUDGET_MS = 60_000; +const LOCAL_RSS_CAP_BYTES = 256 * 1024 * 1024; +const SUITE_START_MS = Date.now(); + +type OracleVector = { + id: string; + target: string; + kind: string; + inputHex?: string; + wireHex?: string; + feed?: { splits?: number[]; trickleBytes?: number }; + build?: { payloadLen?: number; fillByte?: number; jsonLen?: number }; + input?: string; + limitBytes?: number; + note?: string; + expect: Record; +}; + +type Oracle = { + manifestVersion: number; + constants: Record; + vectors: OracleVector[]; +}; + +function loadOracle(): Oracle { + const raw = readFileSync(join(SEED_DIR, "vectors.json"), "utf8"); + return JSON.parse(raw) as Oracle; +} + +function loadSeed(name: string): Uint8Array { + return new Uint8Array(readFileSync(join(SEED_DIR, name))); +} + +function fromHex(hex: string): Uint8Array { + return new Uint8Array(Buffer.from(hex, "hex")); +} + +function toHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("hex"); +} + +function headerOnly(declaredLen: number): Uint8Array { + const out = new Uint8Array(4); + new DataView(out.buffer).setUint32(0, declaredLen, false); + return out; +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let off = 0; + for (const p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +function fillBytes(len: number, byte: number): Uint8Array { + return new Uint8Array(len).fill(byte); +} + +function splitFeed(framer: Framer, wire: Uint8Array, at: number): void { + const first = framer.pushBytes(wire.slice(0, at)); + expect(first.length).toBe(0); +} + +function codeOf(fn: () => unknown): string | null { + try { + fn(); + } catch (error) { + if (error instanceof TransportError) return error.code; + throw error; + } + return null; +} + +function expectFailClosedP0( + bytes: Uint8Array, + code: "FrameTooLarge" | "PayloadTooLarge", +): void { + const framer = new Framer(); + let frames: ReturnType | null = null; + let thrown: unknown = null; + try { + frames = framer.pushBytes(bytes); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(TransportError); + expect((thrown as TransportError).code).toBe(code); + expect(frames).toBeNull(); + expect(framer.isEmpty()).toBe(true); + expect(framer.bufferedLen()).toBe(0); + const ok = framer.pushBytes(encodeFrame(new TextEncoder().encode("ok"))); + expect(ok.length).toBe(1); + expect(new TextDecoder().decode(ok[0]!.payload)).toBe("ok"); +} + +function vectorById(oracle: Oracle, id: string): OracleVector { + const found = oracle.vectors.find((v) => v.id === id); + expect(found).toBeDefined(); + return found!; +} + +function requestOfJsonLen(total: number): { + id: number; + method: string; + params: { pad: string }; + version: string; +} { + const head = '{"id":1,"method":"bitty.debug/listPlugins","params":{"pad":"'; + const tail = '"},"version":"1.0"}'; + const padLen = total - head.length - tail.length; + expect(padLen).toBeGreaterThan(0); + return { + id: 1, + method: "bitty.debug/listPlugins", + params: { pad: "a".repeat(padLen) }, + version: "1.0", + }; +} + +function requestJsonBytes(req: { + id: number; + method: string; + params: { pad: string }; + version: string; +}): Uint8Array { + return new TextEncoder().encode(JSON.stringify(req)); +} + +describe("framer fuzz smoke oracle", () => { + test("oracle manifest version and shared constants", () => { + const oracle = loadOracle(); + expect(oracle.manifestVersion).toBe(1); + expect(oracle.constants["maxFrameBytes"]).toBe(MAX_FRAME_BYTES); + expect(oracle.constants["maxFrameBytes"]).toBe(256 * 1024); + expect(oracle.constants["maxBufferedBytes"]).toBe(MAX_BUFFERED_BYTES); + expect(oracle.constants["framerLimitBytes"]).toBe( + MAX_BUFFERED_BYTES + MAX_FRAME_BYTES, + ); + expect(oracle.constants["rc9PayloadCapBytes"]).toBe(RC9_PAYLOAD_CAP_BYTES); + expect(oracle.constants["rc9PayloadCapBytes"]).toBe(1024 * 1024); + expect(oracle.constants["rc10ChunkBytes"]).toBe(RC10_CHUNK_CEILING); + expect(MAX_FRAME_BYTES).toBe(262144); + expect(MAX_BUFFERED_BYTES).toBe(262152); + }); + + test("checked-in seed files are present and benign", () => { + expect(loadSeed("empty.bin").length).toBe(0); + expect(new TextDecoder().decode(loadSeed("hello.bin"))).toBe("hello"); + expect(toHex(loadSeed("emoji.bin"))).toBe("f09f9880"); + expect(toHex(loadSeed("cjk.bin"))).toBe("e697a5e69cace8aa9e"); + expect(toHex(loadSeed("combining.bin"))).toBe("65cc81"); + const oracle = loadOracle(); + for (const id of ["V01", "V06", "V14", "Q05", "C01", "Q03"]) { + expect(oracle.vectors.some((v) => v.id === id)).toBe(true); + } + }); +}); + +describe("T1 framer pushBytes", () => { + test("V01 empty push emits nothing and stays empty", () => { + const oracle = loadOracle(); + const input = fromHex(vectorById(oracle, "V01").inputHex!); + expect(input.length).toBe(0); + const framer = new Framer(); + expect(framer.pushBytes(input).length).toBe(0); + expect(framer.isEmpty()).toBe(true); + }); + + test("V02 zero-length frame decodes to one empty payload", () => { + const oracle = loadOracle(); + const input = fromHex(vectorById(oracle, "V02").inputHex!); + const framer = new Framer(); + const frames = framer.pushBytes(input); + expect(frames.length).toBe(1); + expect(frames[0]!.payload.length).toBe(0); + expect(framer.isEmpty()).toBe(true); + }); + + test("V03 V04 V05 1-3B header splits retain then decode hello", () => { + const oracle = loadOracle(); + for (const [id, at] of [ + ["V03", 1], + ["V04", 2], + ["V05", 3], + ] as const) { + const wire = fromHex(vectorById(oracle, id).wireHex!); + const framer = new Framer(); + splitFeed(framer, wire, at); + expect(framer.bufferedLen()).toBe(at); + const frames = framer.pushBytes(wire.slice(at)); + expect(frames.length).toBe(1); + expect(new TextDecoder().decode(frames[0]!.payload)).toBe("hello"); + expect(framer.isEmpty()).toBe(true); + } + }); + + test("V06 1-byte trickle emits exactly one frame at the end", () => { + const oracle = loadOracle(); + const wire = fromHex(vectorById(oracle, "V06").wireHex!); + const framer = new Framer(); + let emitted = 0; + for (let i = 0; i < wire.length; i += 1) { + const frames = framer.pushBytes(wire.slice(i, i + 1)); + emitted += frames.length; + if (i < wire.length - 1) expect(frames.length).toBe(0); + if (frames.length > 0) { + expect(new TextDecoder().decode(frames[0]!.payload)).toBe("hi"); + } + } + expect(emitted).toBe(1); + expect(framer.isEmpty()).toBe(true); + }); + + test("V07 coalesced frames decode in order with empty tail", () => { + const oracle = loadOracle(); + const input = fromHex(vectorById(oracle, "V07").inputHex!); + const framer = new Framer(); + const frames = framer.pushBytes(input); + expect(frames.map((f) => new TextDecoder().decode(f.payload))).toEqual([ + "a", + "bb", + "ccc", + ]); + expect(framer.isEmpty()).toBe(true); + }); + + test("V08 truncated 2B header retains then decodes", () => { + const oracle = loadOracle(); + const wire = fromHex(vectorById(oracle, "V08").wireHex!); + const framer = new Framer(); + splitFeed(framer, wire, 2); + expect(framer.bufferedLen()).toBe(2); + const frames = framer.pushBytes(wire.slice(2)); + expect(frames.length).toBe(1); + expect(new TextDecoder().decode(frames[0]!.payload)).toBe("hello"); + }); + + test("V09 body-short retains bounded tail then completes", () => { + const oracle = loadOracle(); + const wire = fromHex(vectorById(oracle, "V09").wireHex!); + const framer = new Framer(); + splitFeed(framer, wire, 9); + expect(framer.bufferedLen()).toBe(9); + expect(framer.bufferedLen()).toBeLessThanOrEqual( + MAX_BUFFERED_BYTES + MAX_FRAME_BYTES, + ); + const frames = framer.pushBytes(wire.slice(9)); + expect(frames.length).toBe(1); + expect(frames[0]!.payload.length).toBe(16); + expect(toHex(frames[0]!.payload)).toBe("68656c6c6f4141414141414141414141"); + }); + + test("V10 declared 256KiB+1 header-only fails closed P0", () => { + const oracle = loadOracle(); + const input = fromHex(vectorById(oracle, "V10").inputHex!); + expect(input.length).toBe(4); + expectFailClosedP0(input, "FrameTooLarge"); + }); + + test("V11 declared u32max fails closed P0 without allocation", () => { + const oracle = loadOracle(); + const input = fromHex(vectorById(oracle, "V11").inputHex!); + expect(input.length).toBe(4); + expectFailClosedP0(input, "FrameTooLarge"); + }); + + test( + "V12 over-buffer limit+1 fails closed P0", + () => { + const oracle = loadOracle(); + const build = vectorById(oracle, "V12").build!; + const input = fillBytes(build["payloadLen"]!, build["fillByte"]!); + expect(input.length).toBe(MAX_BUFFERED_BYTES + MAX_FRAME_BYTES + 1); + expectFailClosedP0(input, "PayloadTooLarge"); + }, + LOCAL_TARGET_BUDGET_MS, + ); + + test("V13 mixed valid then oversize emits zero frames and recovers", () => { + const oracle = loadOracle(); + const input = fromHex(vectorById(oracle, "V13").inputHex!); + const framer = new Framer(); + let frames: ReturnType | null = null; + let thrown: unknown = null; + try { + frames = framer.pushBytes(input); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(TransportError); + expect((thrown as TransportError).code).toBe("FrameTooLarge"); + expect(frames).toBeNull(); + expect(framer.isEmpty()).toBe(true); + expect(framer.bufferedLen()).toBe(0); + const ok = framer.pushBytes(encodeFrame(new TextEncoder().encode("ok"))); + expect(ok.length).toBe(1); + }); + + test( + "V16 V17 256KiB-1 and exact payloads round-trip in memory", + () => { + const oracle = loadOracle(); + for (const id of ["V16", "V17"]) { + const build = vectorById(oracle, id).build!; + const payload = fillBytes(build["payloadLen"]!, build["fillByte"]!); + const wire = encodeFrame(payload); + expect(wire.length).toBe(4 + payload.length); + const framer = new Framer(); + const frames = framer.pushBytes(wire); + expect(frames.length).toBe(1); + expect(frames[0]!.payload).toEqual(payload); + expect(framer.isEmpty()).toBe(true); + } + }, + LOCAL_TARGET_BUDGET_MS, + ); + + test("V14 V15 emoji and CJK body splits stay byte-identical", () => { + const oracle = loadOracle(); + for (const [id, at] of [ + ["V14", 6], + ["V15", 5], + ] as const) { + const vec = vectorById(oracle, id); + const wire = fromHex(vec.wireHex!); + const framer = new Framer(); + splitFeed(framer, wire, at); + const frames = framer.pushBytes(wire.slice(at)); + expect(frames.length).toBe(1); + expect(toHex(frames[0]!.payload)).toBe( + (vec.expect["framesHex"] as string[])[0], + ); + } + const emojiPayload = loadSeed("emoji.bin"); + const emojiWire = encodeFrame(emojiPayload); + const framer = new Framer(); + splitFeed(framer, emojiWire, 6); + const frames = framer.pushBytes(emojiWire.slice(6)); + expect(frames.length).toBe(1); + expect(frames[0]!.payload).toEqual(emojiPayload); + }); + + test("rejected input leaves queue and drop counters unchanged", () => { + const stub = new StdioTransportStub(); + const beforeOut = stub.outgoingLen(); + const beforeDrop = stub.droppedCount(); + expect(codeOf(() => new Framer().pushBytes(headerOnly(0xffffffff)))).toBe( + "FrameTooLarge", + ); + expect(stub.outgoingLen()).toBe(beforeOut); + expect(stub.droppedCount()).toBe(beforeDrop); + }); + + test("rejected send leaves transport outgoing length unchanged", () => { + const transport = new IpcTransport({ + runtimeUid: 1000, + socketPath: "/tmp/ctx-0074-fuzz-smoke-fixture.sock", + }); + transport.connect(); + expect(transport.outgoingLen()).toBe(0); + const req = requestOfJsonLen(1024 * 1024 + 1); + expect(() => transport.sendRequest(req, 0)).toThrow(); + expect(transport.outgoingLen()).toBe(0); + }); +}); + +describe("T2 encode decode round-trip", () => { + test("fixed payloads survive single-shot and split delivery", () => { + const payloads: Uint8Array[] = [ + loadSeed("empty.bin"), + loadSeed("hello.bin"), + loadSeed("emoji.bin"), + loadSeed("cjk.bin"), + loadSeed("combining.bin"), + fillBytes(1024, 0x41), + ]; + for (const payload of payloads) { + const wire = encodeFrame(payload); + const { frame, consumed } = decodeFrame(wire); + expect(consumed).toBe(4 + payload.length); + expect(frame.payload).toEqual(payload); + for (const at of [1, 3, 7]) { + if (at >= wire.length) continue; + const framer = new Framer(); + splitFeed(framer, wire, at); + const frames = framer.pushBytes(wire.slice(at)); + expect(frames.length).toBe(1); + expect(frames[0]!.payload).toEqual(payload); + } + } + }); + + test("oracle fixed frames match encoder output", () => { + const oracle = loadOracle(); + const helloWire = encodeFrame(loadSeed("hello.bin")); + expect(toHex(helloWire)).toBe(vectorById(oracle, "V03").wireHex); + expect(toHex(encodeFrame(loadSeed("empty.bin")))).toBe( + vectorById(oracle, "V02").inputHex, + ); + }); +}); + +describe("T3 chunkText boundary parity", () => { + test("C01 C02 C03 fixed chunking matches the shared oracle", () => { + const oracle = loadOracle(); + for (const id of ["C01", "C02", "C03"]) { + const vec = vectorById(oracle, id); + const chunks = chunkText(vec.input!, vec.limitBytes!); + expect(chunks).toEqual(vec.expect["chunks"]); + expect(chunks.join("")).toBe(vec.input); + const encoder = new TextEncoder(); + for (const chunk of chunks) { + expect(encoder.encode(chunk).length).toBeLessThanOrEqual( + vec.limitBytes!, + ); + } + } + }); + + test("C04 too-narrow limit for emoji fails in both twins", () => { + const oracle = loadOracle(); + const vec = vectorById(oracle, "C04"); + expect(vec.expect["disposition"]).toBe("error"); + expect(() => chunkText(vec.input!, vec.limitBytes!)).toThrow( + "cannot fit the next Unicode scalar", + ); + }); +}); + +describe("T4 chunking and inbound framing model", () => { + test("Q01 256KiB exact request is a single chunk", () => { + const oracle = loadOracle(); + const target = vectorById(oracle, "Q01").build!["jsonLen"]!; + const transport = new IpcTransport({ + runtimeUid: 1000, + socketPath: "/tmp/ctx-0074-fuzz-smoke-fixture.sock", + }); + const req = requestOfJsonLen(target); + expect(requestJsonBytes(req).length).toBe(target); + const chunks = transport.encodeRequest(req); + expect(chunks.length).toBe(1); + const { frame, consumed } = decodeFrame(chunks[0]!); + expect(consumed).toBe(chunks[0]!.length); + expect(frame.payload.length).toBe(target); + }); + + test("Q02 256KiB+1 request splits into two bounded chunks", () => { + const oracle = loadOracle(); + const target = vectorById(oracle, "Q02").build!["jsonLen"]!; + const transport = new IpcTransport({ + runtimeUid: 1000, + socketPath: "/tmp/ctx-0074-fuzz-smoke-fixture.sock", + }); + const req = requestOfJsonLen(target); + expect(requestJsonBytes(req).length).toBe(target); + const chunks = transport.encodeRequest(req); + expect(chunks.length).toBe(2); + const parts: Uint8Array[] = []; + for (const chunk of chunks) { + const { frame } = decodeFrame(chunk); + expect(frame.payload.length).toBeLessThanOrEqual(MAX_FRAME_BYTES); + parts.push(frame.payload); + } + expect(concatBytes(...parts)).toEqual(requestJsonBytes(req)); + }); + + test( + "Q03 1MiB exact request splits into four bounded chunks", + () => { + const oracle = loadOracle(); + const target = vectorById(oracle, "Q03").build!["jsonLen"]!; + const transport = new IpcTransport({ + runtimeUid: 1000, + socketPath: "/tmp/ctx-0074-fuzz-smoke-fixture.sock", + }); + const req = requestOfJsonLen(target); + expect(requestJsonBytes(req).length).toBe(target); + const chunks = transport.encodeRequest(req); + expect(chunks.length).toBe(4); + const parts: Uint8Array[] = []; + for (const chunk of chunks) { + const { frame } = decodeFrame(chunk); + expect(frame.payload.length).toBeLessThanOrEqual(MAX_FRAME_BYTES); + parts.push(frame.payload); + } + expect(concatBytes(...parts)).toEqual(requestJsonBytes(req)); + }, + LOCAL_TARGET_BUDGET_MS, + ); + + test("Q04 1MiB+1 request is refused with PayloadTooLarge", () => { + const oracle = loadOracle(); + const target = vectorById(oracle, "Q04").build!["jsonLen"]!; + const transport = new IpcTransport({ + runtimeUid: 1000, + socketPath: "/tmp/ctx-0074-fuzz-smoke-fixture.sock", + }); + const req = requestOfJsonLen(target); + const bytes = requestJsonBytes(req); + expect(bytes.length).toBe(target); + expect(() => transport.encodeRequest(req)).toThrow(); + expect(codeOf(() => checkPayloadCap(bytes.length))).toBe("PayloadTooLarge"); + }); + + test("Q05 inbound failFraming model drops pending on mock bytes only", () => { + const oracle = loadOracle(); + const input = fromHex(vectorById(oracle, "Q05").inputHex!); + const inbound = new Framer(); + const pending: Uint8Array[] = []; + let framingFailed = false; + const onData = (data: Uint8Array): void => { + if (framingFailed) return; + try { + for (const frame of inbound.pushBytes(data)) { + pending.push(frame.payload.slice()); + } + } catch { + framingFailed = true; + inbound.clear(); + pending.length = 0; + } + }; + const good = encodeFrame(new TextEncoder().encode("queued")); + onData(good); + expect(pending.length).toBe(1); + onData(input); + expect(framingFailed).toBe(true); + expect(inbound.isEmpty()).toBe(true); + expect(pending.length).toBe(0); + }); +}); + +describe("fuzz smoke budgets", () => { + test("suite completes within the 60s local target budget", () => { + expect(Date.now() - SUITE_START_MS).toBeLessThan(LOCAL_TARGET_BUDGET_MS); + }); + + test("resident set stays within the 256MiB local cap", () => { + expect(process.memoryUsage.rss()).toBeLessThan(LOCAL_RSS_CAP_BYTES); + }); +});