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>
343 lines
11 KiB
Rust
343 lines
11 KiB
Rust
//! Minimal WebSocket (RFC 6455) support over `std::net`, no external crates.
|
|
//! Implements just what the game needs: the upgrade handshake (SHA1 + base64),
|
|
//! masked client-frame reading with fragment reassembly, and unmasked
|
|
//! server-frame writing. All reads are length-checked so a hostile frame
|
|
//! returns an `Err`, never a panic or unbounded allocation.
|
|
|
|
use std::io::{self, Read, Write};
|
|
|
|
const WS_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
/// Reject any single message larger than this (defensive bound).
|
|
pub const MAX_MESSAGE: usize = 1 << 20; // 1 MiB
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// SHA-1 (FIPS 180-1). Used only for the handshake accept key.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn sha1(data: &[u8]) -> [u8; 20] {
|
|
let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];
|
|
let ml = (data.len() as u64) * 8;
|
|
let mut msg = data.to_vec();
|
|
msg.push(0x80);
|
|
while msg.len() % 64 != 56 {
|
|
msg.push(0);
|
|
}
|
|
msg.extend_from_slice(&ml.to_be_bytes());
|
|
|
|
for chunk in msg.chunks_exact(64) {
|
|
let mut w = [0u32; 80];
|
|
for (i, wi) in w.iter_mut().enumerate().take(16) {
|
|
*wi = u32::from_be_bytes([
|
|
chunk[i * 4],
|
|
chunk[i * 4 + 1],
|
|
chunk[i * 4 + 2],
|
|
chunk[i * 4 + 3],
|
|
]);
|
|
}
|
|
for i in 16..80 {
|
|
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
|
|
}
|
|
let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]);
|
|
for (i, &wi) in w.iter().enumerate() {
|
|
let (f, k) = match i {
|
|
0..=19 => ((b & c) | ((!b) & d), 0x5A827999u32),
|
|
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
|
|
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
|
|
_ => (b ^ c ^ d, 0xCA62C1D6),
|
|
};
|
|
let tmp = a
|
|
.rotate_left(5)
|
|
.wrapping_add(f)
|
|
.wrapping_add(e)
|
|
.wrapping_add(k)
|
|
.wrapping_add(wi);
|
|
e = d;
|
|
d = c;
|
|
c = b.rotate_left(30);
|
|
b = a;
|
|
a = tmp;
|
|
}
|
|
h[0] = h[0].wrapping_add(a);
|
|
h[1] = h[1].wrapping_add(b);
|
|
h[2] = h[2].wrapping_add(c);
|
|
h[3] = h[3].wrapping_add(d);
|
|
h[4] = h[4].wrapping_add(e);
|
|
}
|
|
|
|
let mut out = [0u8; 20];
|
|
for (i, hi) in h.iter().enumerate() {
|
|
out[i * 4..i * 4 + 4].copy_from_slice(&hi.to_be_bytes());
|
|
}
|
|
out
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// base64 (standard alphabet).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn base64(data: &[u8]) -> String {
|
|
const ALPHABET: &[u8; 64] =
|
|
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut out = String::new();
|
|
for chunk in data.chunks(3) {
|
|
let b = [
|
|
chunk[0],
|
|
*chunk.get(1).unwrap_or(&0),
|
|
*chunk.get(2).unwrap_or(&0),
|
|
];
|
|
let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32);
|
|
out.push(ALPHABET[((n >> 18) & 63) as usize] as char);
|
|
out.push(ALPHABET[((n >> 12) & 63) as usize] as char);
|
|
if chunk.len() > 1 {
|
|
out.push(ALPHABET[((n >> 6) & 63) as usize] as char);
|
|
} else {
|
|
out.push('=');
|
|
}
|
|
if chunk.len() > 2 {
|
|
out.push(ALPHABET[(n & 63) as usize] as char);
|
|
} else {
|
|
out.push('=');
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Compute the `Sec-WebSocket-Accept` value for a client key.
|
|
pub fn accept_key(client_key: &str) -> String {
|
|
let mut concat = client_key.to_string();
|
|
concat.push_str(WS_GUID);
|
|
base64(&sha1(concat.as_bytes()))
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Frames.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Opcode {
|
|
Continuation,
|
|
Text,
|
|
Binary,
|
|
Close,
|
|
Ping,
|
|
Pong,
|
|
}
|
|
|
|
impl Opcode {
|
|
fn from_u8(v: u8) -> Option<Opcode> {
|
|
Some(match v {
|
|
0x0 => Opcode::Continuation,
|
|
0x1 => Opcode::Text,
|
|
0x2 => Opcode::Binary,
|
|
0x8 => Opcode::Close,
|
|
0x9 => Opcode::Ping,
|
|
0xA => Opcode::Pong,
|
|
_ => return None,
|
|
})
|
|
}
|
|
}
|
|
|
|
struct Frame {
|
|
fin: bool,
|
|
opcode: Opcode,
|
|
payload: Vec<u8>,
|
|
}
|
|
|
|
fn read_frame<R: Read>(r: &mut R) -> io::Result<Frame> {
|
|
let mut hdr = [0u8; 2];
|
|
r.read_exact(&mut hdr)?;
|
|
let fin = hdr[0] & 0x80 != 0;
|
|
let opcode = Opcode::from_u8(hdr[0] & 0x0f)
|
|
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "bad opcode"))?;
|
|
let masked = hdr[1] & 0x80 != 0;
|
|
let len7 = (hdr[1] & 0x7f) as usize;
|
|
let len = match len7 {
|
|
126 => {
|
|
let mut b = [0u8; 2];
|
|
r.read_exact(&mut b)?;
|
|
u16::from_be_bytes(b) as usize
|
|
}
|
|
127 => {
|
|
let mut b = [0u8; 8];
|
|
r.read_exact(&mut b)?;
|
|
u64::from_be_bytes(b) as usize
|
|
}
|
|
n => n,
|
|
};
|
|
if len > MAX_MESSAGE {
|
|
return Err(io::Error::new(io::ErrorKind::InvalidData, "frame too large"));
|
|
}
|
|
// Per RFC, client frames MUST be masked.
|
|
let mask = if masked {
|
|
let mut m = [0u8; 4];
|
|
r.read_exact(&mut m)?;
|
|
Some(m)
|
|
} else {
|
|
None
|
|
};
|
|
let mut payload = vec![0u8; len];
|
|
r.read_exact(&mut payload)?;
|
|
if let Some(m) = mask {
|
|
for (i, b) in payload.iter_mut().enumerate() {
|
|
*b ^= m[i % 4];
|
|
}
|
|
}
|
|
Ok(Frame { fin, opcode, payload })
|
|
}
|
|
|
|
/// A complete application message read from the socket. Control frames are
|
|
/// surfaced rather than answered inline so that *all* socket writes can be
|
|
/// funneled through a single writer (avoiding interleaved frames when a server
|
|
/// is both broadcasting and answering pings).
|
|
pub enum Message {
|
|
Text(String),
|
|
/// A ping with its payload; the caller must reply with a pong.
|
|
Ping(Vec<u8>),
|
|
/// A pong (informational).
|
|
Pong,
|
|
/// The peer requested close.
|
|
Close,
|
|
}
|
|
|
|
/// Read one full WebSocket message, reassembling fragments. Returns `Ok(None)`
|
|
/// on a clean EOF. Reads only — never writes to the socket.
|
|
pub fn read_message<R: Read>(stream: &mut R) -> io::Result<Option<Message>> {
|
|
let mut buf: Vec<u8> = Vec::new();
|
|
let mut msg_op: Option<Opcode> = None;
|
|
loop {
|
|
let frame = match read_frame(stream) {
|
|
Ok(f) => f,
|
|
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => return Ok(None),
|
|
Err(e) => return Err(e),
|
|
};
|
|
match frame.opcode {
|
|
Opcode::Close => return Ok(Some(Message::Close)),
|
|
Opcode::Ping => return Ok(Some(Message::Ping(frame.payload))),
|
|
Opcode::Pong => return Ok(Some(Message::Pong)),
|
|
Opcode::Text | Opcode::Binary => {
|
|
if msg_op.is_some() {
|
|
return Err(io::Error::new(io::ErrorKind::InvalidData, "interleaved frame"));
|
|
}
|
|
msg_op = Some(frame.opcode);
|
|
buf.extend_from_slice(&frame.payload);
|
|
}
|
|
Opcode::Continuation => {
|
|
if msg_op.is_none() {
|
|
return Err(io::Error::new(io::ErrorKind::InvalidData, "stray continuation"));
|
|
}
|
|
buf.extend_from_slice(&frame.payload);
|
|
}
|
|
}
|
|
if buf.len() > MAX_MESSAGE {
|
|
return Err(io::Error::new(io::ErrorKind::InvalidData, "message too large"));
|
|
}
|
|
if frame.fin {
|
|
// We only surface text to the application; binary is decoded lossily.
|
|
let s = String::from_utf8_lossy(&buf).into_owned();
|
|
return Ok(Some(Message::Text(s)));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_frame<W: Write>(w: &mut W, opcode: Opcode, payload: &[u8]) -> io::Result<()> {
|
|
let op = match opcode {
|
|
Opcode::Continuation => 0x0,
|
|
Opcode::Text => 0x1,
|
|
Opcode::Binary => 0x2,
|
|
Opcode::Close => 0x8,
|
|
Opcode::Ping => 0x9,
|
|
Opcode::Pong => 0xA,
|
|
};
|
|
let mut frame = vec![0x80 | op];
|
|
let len = payload.len();
|
|
if len < 126 {
|
|
frame.push(len as u8);
|
|
} else if len < 65536 {
|
|
frame.push(126);
|
|
frame.extend_from_slice(&(len as u16).to_be_bytes());
|
|
} else {
|
|
frame.push(127);
|
|
frame.extend_from_slice(&(len as u64).to_be_bytes());
|
|
}
|
|
frame.extend_from_slice(payload);
|
|
w.write_all(&frame)?;
|
|
w.flush()
|
|
}
|
|
|
|
/// Send a text message (server frames are never masked).
|
|
pub fn write_text<W: Write>(w: &mut W, text: &str) -> io::Result<()> {
|
|
write_frame(w, Opcode::Text, text.as_bytes())
|
|
}
|
|
|
|
/// Send a pong frame echoing a ping payload.
|
|
pub fn write_pong<W: Write>(w: &mut W, payload: &[u8]) -> io::Result<()> {
|
|
write_frame(w, Opcode::Pong, payload)
|
|
}
|
|
|
|
/// Send a close frame.
|
|
pub fn write_close<W: Write>(w: &mut W) -> io::Result<()> {
|
|
write_frame(w, Opcode::Close, &[])
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn rfc_example_accept_key() {
|
|
// The canonical example from RFC 6455 section 1.3.
|
|
assert_eq!(
|
|
accept_key("dGhlIHNhbXBsZSBub25jZQ=="),
|
|
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn sha1_known_vector() {
|
|
// "abc" -> a9993e364706816aba3e25717850c26c9cd0d89d
|
|
let d = sha1(b"abc");
|
|
let hex: String = d.iter().map(|b| format!("{b:02x}")).collect();
|
|
assert_eq!(hex, "a9993e364706816aba3e25717850c26c9cd0d89d");
|
|
}
|
|
|
|
#[test]
|
|
fn base64_roundtrip_lengths() {
|
|
assert_eq!(base64(b""), "");
|
|
assert_eq!(base64(b"f"), "Zg==");
|
|
assert_eq!(base64(b"fo"), "Zm8=");
|
|
assert_eq!(base64(b"foo"), "Zm9v");
|
|
assert_eq!(base64(b"foobar"), "Zm9vYmFy");
|
|
}
|
|
|
|
#[test]
|
|
fn masked_text_frame_roundtrips_through_reader() {
|
|
use std::io::Cursor;
|
|
// Build a masked client text frame for "hi".
|
|
let payload = b"hi";
|
|
let mask = [0x01, 0x02, 0x03, 0x04];
|
|
let mut frame = vec![0x81, 0x80 | payload.len() as u8];
|
|
frame.extend_from_slice(&mask);
|
|
for (i, &b) in payload.iter().enumerate() {
|
|
frame.push(b ^ mask[i % 4]);
|
|
}
|
|
// Cursor implements Read+Write (write goes nowhere useful but pong path
|
|
// is not exercised here).
|
|
let mut cur = Cursor::new(frame);
|
|
match read_message(&mut cur).unwrap() {
|
|
Some(Message::Text(s)) => assert_eq!(s, "hi"),
|
|
_ => panic!("expected text"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn oversized_frame_is_rejected() {
|
|
use std::io::Cursor;
|
|
// Declares a 127-length (8-byte) payload of u64::MAX — must error, not OOM.
|
|
let mut frame = vec![0x81, 0x80 | 127];
|
|
frame.extend_from_slice(&u64::MAX.to_be_bytes());
|
|
frame.extend_from_slice(&[0, 0, 0, 0]); // partial mask
|
|
let mut cur = Cursor::new(frame);
|
|
assert!(read_message(&mut cur).is_err());
|
|
}
|
|
}
|