Builds the browser game around the existing Rust runtime: a window into the universe, not a second simulation. Pure std, no external crates. New crates: - protocol: versioned, hashable client/server messages + hand-rolled JSON value and total parser (malformed packet -> Err, never panic). - game_runtime: authoritative match state. Resolves turns through the INDEPENDENT interpreter (runtime_under_test::native_resolve), not the reference engine; filters visibility/knowledge; records and regenerates replays. A match is a pure function of (seed, roster, ordered inputs). - web_assets/web_client: embedded browser client (arena, rune editor, knowledge panels, replay viewer) + static HTTP delivery. - server: std::net HTTP + WebSocket (hand-rolled SHA-1/base64/RFC-6455 framing), turn timer, disconnect handling, panic-proof dispatch, poison-tolerant lock. - web_tests: dependency-free WebSocket test client + Phase H gates. Trust hardening per review: - game_runtime no longer delegates to reference_runtime::execute; it runs the independent interpreter that the runtime-equivalence gate proves correct. - Protocol/socket/replay/visibility/resilience gates are merge-blocking (added to the merge_group-required job in merge-gates.yml): 1k matches/0 drift, 10k fuzz/0 panics, 100 headless socket E2E, 0 hidden-state leaks. - Rendered-browser E2E is marked EXTERNAL-BLOCKED: Playwright runs advisory-only (continue-on-error, artifacts) until CI infrastructure with a browser exists; it is treated as unsatisfied, not green. The headless 100-match gate is labeled protocol-level coverage, not rendered-browser coverage. - README documents the hand-rolled crypto/parser audit risk explicitly. Fixes an integer-overflow panic in observed-volatility inference (i64 sum / abs near i64::MIN) that could poison the server mutex. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
171 lines
6.3 KiB
Rust
171 lines
6.3 KiB
Rust
//! `web_tests` — a tiny, dependency-free WebSocket *client* used to drive the
|
|
//! real server over a real socket in integration tests. It performs the HTTP
|
|
//! upgrade, masks client frames (as RFC 6455 requires), and reads server
|
|
//! frames. This is the harness behind the Phase H web CI gates.
|
|
|
|
use std::io::{self, BufRead, BufReader, Read, Write};
|
|
use std::net::TcpStream;
|
|
use std::time::Duration;
|
|
|
|
use protocol::{ClientMessage, ServerMessage};
|
|
|
|
/// A blocking WebSocket client connection to the test server.
|
|
pub struct WsClient {
|
|
stream: TcpStream,
|
|
reader: BufReader<TcpStream>,
|
|
}
|
|
|
|
impl WsClient {
|
|
/// Connect, upgrade to WebSocket, and verify the handshake.
|
|
pub fn connect(addr: &str) -> io::Result<WsClient> {
|
|
let stream = TcpStream::connect(addr)?;
|
|
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
|
|
stream.set_nodelay(true).ok();
|
|
let mut reader = BufReader::new(stream.try_clone()?);
|
|
|
|
// A fixed client key keeps the handshake assertion deterministic.
|
|
let key = "dGhlIHNhbXBsZSBub25jZQ==";
|
|
let mut s = stream.try_clone()?;
|
|
let req = format!(
|
|
"GET /ws HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n\
|
|
Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n\
|
|
Sec-WebSocket-Version: 13\r\n\r\n"
|
|
);
|
|
s.write_all(req.as_bytes())?;
|
|
s.flush()?;
|
|
|
|
// Read the response head.
|
|
let mut status = String::new();
|
|
reader.read_line(&mut status)?;
|
|
if !status.contains("101") {
|
|
return Err(io::Error::new(io::ErrorKind::Other, format!("no upgrade: {status:?}")));
|
|
}
|
|
let mut saw_accept = false;
|
|
loop {
|
|
let mut line = String::new();
|
|
let n = reader.read_line(&mut line)?;
|
|
if n == 0 || line.trim_end().is_empty() {
|
|
break;
|
|
}
|
|
if line.to_ascii_lowercase().starts_with("sec-websocket-accept:") {
|
|
let got = line.split(':').nth(1).unwrap_or("").trim();
|
|
// Expected accept for the canonical key above.
|
|
if got == "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=" {
|
|
saw_accept = true;
|
|
}
|
|
}
|
|
}
|
|
if !saw_accept {
|
|
return Err(io::Error::new(io::ErrorKind::Other, "bad Sec-WebSocket-Accept"));
|
|
}
|
|
Ok(WsClient { stream, reader })
|
|
}
|
|
|
|
/// Send a typed client message.
|
|
pub fn send(&mut self, msg: &ClientMessage) -> io::Result<()> {
|
|
self.send_raw_text(&msg.encode())
|
|
}
|
|
|
|
/// Send arbitrary text as a masked frame (used by fuzz tests).
|
|
pub fn send_raw_text(&mut self, text: &str) -> io::Result<()> {
|
|
self.write_masked(0x1, text.as_bytes())
|
|
}
|
|
|
|
/// Send arbitrary bytes as a masked binary frame (fuzz transport).
|
|
pub fn send_raw_bytes(&mut self, bytes: &[u8]) -> io::Result<()> {
|
|
self.write_masked(0x2, bytes)
|
|
}
|
|
|
|
fn write_masked(&mut self, opcode: u8, payload: &[u8]) -> io::Result<()> {
|
|
let mask = [0x12u8, 0x34, 0x56, 0x78];
|
|
let mut frame = vec![0x80 | opcode];
|
|
let len = payload.len();
|
|
if len < 126 {
|
|
frame.push(0x80 | len as u8);
|
|
} else if len < 65536 {
|
|
frame.push(0x80 | 126);
|
|
frame.extend_from_slice(&(len as u16).to_be_bytes());
|
|
} else {
|
|
frame.push(0x80 | 127);
|
|
frame.extend_from_slice(&(len as u64).to_be_bytes());
|
|
}
|
|
frame.extend_from_slice(&mask);
|
|
for (i, &b) in payload.iter().enumerate() {
|
|
frame.push(b ^ mask[i % 4]);
|
|
}
|
|
self.stream.write_all(&frame)?;
|
|
self.stream.flush()
|
|
}
|
|
|
|
/// Read one server text frame and decode it. Skips control frames.
|
|
pub fn recv(&mut self) -> io::Result<ServerMessage> {
|
|
let text = self.recv_text()?;
|
|
ServerMessage::decode(&text)
|
|
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("decode: {e}")))
|
|
}
|
|
|
|
/// Read one server text frame (raw).
|
|
pub fn recv_text(&mut self) -> io::Result<String> {
|
|
loop {
|
|
let mut hdr = [0u8; 2];
|
|
self.reader.read_exact(&mut hdr)?;
|
|
let opcode = hdr[0] & 0x0f;
|
|
let masked = hdr[1] & 0x80 != 0;
|
|
let len7 = (hdr[1] & 0x7f) as usize;
|
|
let len = match len7 {
|
|
126 => {
|
|
let mut b = [0u8; 2];
|
|
self.reader.read_exact(&mut b)?;
|
|
u16::from_be_bytes(b) as usize
|
|
}
|
|
127 => {
|
|
let mut b = [0u8; 8];
|
|
self.reader.read_exact(&mut b)?;
|
|
u64::from_be_bytes(b) as usize
|
|
}
|
|
n => n,
|
|
};
|
|
// Server frames are not masked, but tolerate it.
|
|
let mask = if masked {
|
|
let mut m = [0u8; 4];
|
|
self.reader.read_exact(&mut m)?;
|
|
Some(m)
|
|
} else {
|
|
None
|
|
};
|
|
let mut payload = vec![0u8; len];
|
|
self.reader.read_exact(&mut payload)?;
|
|
if let Some(m) = mask {
|
|
for (i, b) in payload.iter_mut().enumerate() {
|
|
*b ^= m[i % 4];
|
|
}
|
|
}
|
|
match opcode {
|
|
0x1 | 0x2 => return Ok(String::from_utf8_lossy(&payload).into_owned()),
|
|
0x8 => return Err(io::Error::new(io::ErrorKind::ConnectionAborted, "closed")),
|
|
_ => continue, // ping/pong/continuation
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Receive until a predicate matches, returning that message. Bounded so a
|
|
/// test never hangs.
|
|
pub fn recv_until<F: Fn(&ServerMessage) -> bool>(&mut self, pred: F) -> io::Result<ServerMessage> {
|
|
for _ in 0..256 {
|
|
let m = self.recv()?;
|
|
if pred(&m) {
|
|
return Ok(m);
|
|
}
|
|
}
|
|
Err(io::Error::new(io::ErrorKind::Other, "predicate never matched"))
|
|
}
|
|
}
|
|
|
|
/// Start a fresh server instance on an ephemeral port for a test, returning the
|
|
/// address string. Each call binds a new port so tests are isolated.
|
|
pub fn spawn_test_server(turn_ms: u64) -> String {
|
|
let addr = server::serve("127.0.0.1:0", server::Config { turn_ms })
|
|
.expect("bind test server");
|
|
format!("127.0.0.1:{}", addr.port())
|
|
}
|