//! Phase H gate: protocol fuzzing. The web CI minimum is "10,000 protocol fuzz //! cases" with "0 server panics". Decoding is total, so every byte string must //! yield `Ok` or `Err` — never an unwind. A sample is also fired at a live //! server to prove a malformed packet cannot bring it down. use protocol::{json, ClientMessage}; use web_tests::{spawn_test_server, WsClient}; use world_model::Rng; /// Generate a pseudo-random byte string from a seed, with a bias toward /// JSON-ish characters so the parser's deeper paths are exercised. fn fuzz_bytes(seed: u64) -> Vec { let mut rng = Rng::derive(seed, "fuzz"); let alphabet = b"{}[]\":,0123456789tfnuelavabcdef.- \\/\n\t"; let len = rng.below(80); (0..len) .map(|_| { if rng.chance(0.85) { alphabet[rng.below(alphabet.len())] } else { rng.next_u64() as u8 } }) .collect() } #[test] fn ten_thousand_fuzz_cases_never_panic() { let mut ok = 0u64; let mut err = 0u64; for seed in 0..10_000u64 { let bytes = fuzz_bytes(seed); let text = String::from_utf8_lossy(&bytes); // Raw JSON parse must be total. let _ = json::parse(&text); // Full client-message decode must be total. match ClientMessage::decode(&text) { Ok(_) => ok += 1, Err(_) => err += 1, } } // The point is the absence of a panic; both counters are just evidence the // loop ran to completion. assert_eq!(ok + err, 10_000); } #[test] fn structured_but_invalid_messages_are_rejected_not_panicked() { let cases = [ "{}", "{\"v\":1}", "{\"v\":2,\"type\":\"Ping\",\"body\":{}}", // wrong version "{\"v\":1,\"type\":\"Nope\",\"body\":{}}", // unknown type "{\"v\":1,\"type\":\"SubmitTurn\",\"body\":{}}", // missing fields "{\"v\":1,\"type\":\"JoinMatch\",\"body\":{\"name\":5}}", // wrong type ]; for c in cases { assert!(ClientMessage::decode(c).is_err(), "should reject: {c}"); } } #[test] fn live_server_survives_malformed_packets() { let addr = spawn_test_server(30); let mut c = WsClient::connect(&addr).expect("connect"); // Fire a burst of garbage frames (kept modest so the join reply is not // starved behind a flood of rejection reports; decode breadth is covered by // the 10k case test above). for seed in 0..40u64 { let bytes = fuzz_bytes(seed); let _ = c.send_raw_bytes(&bytes); } // Also send raw garbage text. for s in ["", "{", "garbage", "{\"v\":1,\"type\":\"X\",\"body\":1}"] { let _ = c.send_raw_text(s); } // The server must still be alive and respond to a valid join. c.send(&ClientMessage::JoinMatch { name: "after-fuzz".into(), match_id: None }) .expect("send join"); let m = c .recv_until(|m| matches!(m, protocol::ServerMessage::MatchState { .. })) .expect("server still serving after fuzz"); assert!(matches!(m, protocol::ServerMessage::MatchState { .. })); }