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>
532 lines
16 KiB
Rust
532 lines
16 KiB
Rust
//! A complete hand-rolled JSON value, serializer, and parser (no external
|
|
//! crates). The orchestrator's `ci_reports::json` is write-only; the protocol
|
|
//! needs to *parse* untrusted client packets too, and parsing must be **total**
|
|
//! — any byte sequence yields `Ok` or `Err`, never a panic. That totality is
|
|
//! what lets the server treat a malformed packet as a deterministic
|
|
//! `ValidationReport` rather than a crash.
|
|
|
|
use std::collections::BTreeMap;
|
|
use std::fmt;
|
|
|
|
/// A parsed JSON value. Objects use a `BTreeMap` so key order is canonical,
|
|
/// which keeps re-serialization stable and hashable.
|
|
#[derive(Clone, PartialEq, Debug)]
|
|
pub enum Json {
|
|
Null,
|
|
Bool(bool),
|
|
/// All numbers are carried as `f64`; integer accessors round-trip exact
|
|
/// values within the safe integer range, which is all the protocol uses.
|
|
Num(f64),
|
|
Str(String),
|
|
Arr(Vec<Json>),
|
|
Obj(BTreeMap<String, Json>),
|
|
}
|
|
|
|
impl Json {
|
|
pub fn s(v: impl Into<String>) -> Json {
|
|
Json::Str(v.into())
|
|
}
|
|
pub fn i(v: i64) -> Json {
|
|
Json::Num(v as f64)
|
|
}
|
|
pub fn u(v: u64) -> Json {
|
|
Json::Num(v as f64)
|
|
}
|
|
pub fn obj(fields: Vec<(&str, Json)>) -> Json {
|
|
let mut m = BTreeMap::new();
|
|
for (k, v) in fields {
|
|
m.insert(k.to_string(), v);
|
|
}
|
|
Json::Obj(m)
|
|
}
|
|
|
|
// ---- typed accessors (all fallible, none panic) ----
|
|
|
|
pub fn get(&self, key: &str) -> Option<&Json> {
|
|
match self {
|
|
Json::Obj(m) => m.get(key),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub fn as_str(&self) -> Option<&str> {
|
|
match self {
|
|
Json::Str(s) => Some(s),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub fn as_f64(&self) -> Option<f64> {
|
|
match self {
|
|
Json::Num(n) => Some(*n),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub fn as_i64(&self) -> Option<i64> {
|
|
match self {
|
|
Json::Num(n) if n.is_finite() => Some(*n as i64),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub fn as_u64(&self) -> Option<u64> {
|
|
match self {
|
|
Json::Num(n) if n.is_finite() && *n >= 0.0 => Some(*n as u64),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub fn as_u8(&self) -> Option<u8> {
|
|
self.as_u64().and_then(|v| u8::try_from(v).ok())
|
|
}
|
|
pub fn as_bool(&self) -> Option<bool> {
|
|
match self {
|
|
Json::Bool(b) => Some(*b),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub fn as_arr(&self) -> Option<&[Json]> {
|
|
match self {
|
|
Json::Arr(a) => Some(a),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Convenience: required field accessors that produce a descriptive error.
|
|
pub fn field<'a>(&'a self, key: &str) -> Result<&'a Json, JsonError> {
|
|
self.get(key).ok_or_else(|| JsonError::Field(key.to_string()))
|
|
}
|
|
pub fn str_field(&self, key: &str) -> Result<String, JsonError> {
|
|
self.field(key)?
|
|
.as_str()
|
|
.map(|s| s.to_string())
|
|
.ok_or_else(|| JsonError::Type(key.to_string(), "string"))
|
|
}
|
|
pub fn u64_field(&self, key: &str) -> Result<u64, JsonError> {
|
|
self.field(key)?
|
|
.as_u64()
|
|
.ok_or_else(|| JsonError::Type(key.to_string(), "u64"))
|
|
}
|
|
pub fn i64_field(&self, key: &str) -> Result<i64, JsonError> {
|
|
self.field(key)?
|
|
.as_i64()
|
|
.ok_or_else(|| JsonError::Type(key.to_string(), "i64"))
|
|
}
|
|
pub fn arr_field<'a>(&'a self, key: &str) -> Result<&'a [Json], JsonError> {
|
|
self.field(key)?
|
|
.as_arr()
|
|
.ok_or_else(|| JsonError::Type(key.to_string(), "array"))
|
|
}
|
|
|
|
// ---- serialization ----
|
|
|
|
/// Compact canonical serialization (no whitespace). Deterministic because
|
|
/// object keys are stored sorted.
|
|
pub fn to_compact(&self) -> String {
|
|
let mut out = String::new();
|
|
self.write(&mut out);
|
|
out
|
|
}
|
|
|
|
fn write(&self, out: &mut String) {
|
|
match self {
|
|
Json::Null => out.push_str("null"),
|
|
Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
|
|
Json::Num(n) => {
|
|
if !n.is_finite() {
|
|
out.push_str("null");
|
|
} else if *n == n.trunc() && n.abs() < 9_007_199_254_740_992.0 {
|
|
// Exact integer: print without a decimal point.
|
|
out.push_str(&(*n as i64).to_string());
|
|
} else {
|
|
out.push_str(&format!("{}", n));
|
|
}
|
|
}
|
|
Json::Str(s) => write_str(out, s),
|
|
Json::Arr(items) => {
|
|
out.push('[');
|
|
for (i, it) in items.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push(',');
|
|
}
|
|
it.write(out);
|
|
}
|
|
out.push(']');
|
|
}
|
|
Json::Obj(m) => {
|
|
out.push('{');
|
|
for (i, (k, v)) in m.iter().enumerate() {
|
|
if i > 0 {
|
|
out.push(',');
|
|
}
|
|
write_str(out, k);
|
|
out.push(':');
|
|
v.write(out);
|
|
}
|
|
out.push('}');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn write_str(out: &mut String, s: &str) {
|
|
out.push('"');
|
|
for c in s.chars() {
|
|
match c {
|
|
'"' => out.push_str("\\\""),
|
|
'\\' => out.push_str("\\\\"),
|
|
'\n' => out.push_str("\\n"),
|
|
'\r' => out.push_str("\\r"),
|
|
'\t' => out.push_str("\\t"),
|
|
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
|
c => out.push(c),
|
|
}
|
|
}
|
|
out.push('"');
|
|
}
|
|
|
|
/// A JSON parse / shape error. Carrying a message keeps decode failures
|
|
/// diagnosable without ever unwinding.
|
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
pub enum JsonError {
|
|
Parse(String),
|
|
Field(String),
|
|
Type(String, &'static str),
|
|
}
|
|
|
|
impl fmt::Display for JsonError {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
match self {
|
|
JsonError::Parse(m) => write!(f, "json parse error: {m}"),
|
|
JsonError::Field(k) => write!(f, "missing field: {k}"),
|
|
JsonError::Type(k, t) => write!(f, "field {k} is not a {t}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::error::Error for JsonError {}
|
|
|
|
/// Parse a JSON document. Total: never panics on any input.
|
|
pub fn parse(input: &str) -> Result<Json, JsonError> {
|
|
let bytes = input.as_bytes();
|
|
let mut p = Parser { bytes, pos: 0, depth: 0 };
|
|
p.skip_ws();
|
|
let v = p.value()?;
|
|
p.skip_ws();
|
|
if p.pos != bytes.len() {
|
|
return Err(JsonError::Parse("trailing characters".into()));
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
/// Maximum nesting depth. Bounds recursion so a deeply-nested adversarial
|
|
/// packet returns `Err` instead of overflowing the stack.
|
|
const MAX_DEPTH: usize = 64;
|
|
|
|
struct Parser<'a> {
|
|
bytes: &'a [u8],
|
|
pos: usize,
|
|
depth: usize,
|
|
}
|
|
|
|
impl<'a> Parser<'a> {
|
|
fn peek(&self) -> Option<u8> {
|
|
self.bytes.get(self.pos).copied()
|
|
}
|
|
|
|
fn skip_ws(&mut self) {
|
|
while let Some(b) = self.peek() {
|
|
if b == b' ' || b == b'\t' || b == b'\n' || b == b'\r' {
|
|
self.pos += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn value(&mut self) -> Result<Json, JsonError> {
|
|
self.depth += 1;
|
|
if self.depth > MAX_DEPTH {
|
|
return Err(JsonError::Parse("max depth exceeded".into()));
|
|
}
|
|
let r = match self.peek() {
|
|
Some(b'{') => self.object(),
|
|
Some(b'[') => self.array(),
|
|
Some(b'"') => Ok(Json::Str(self.string()?)),
|
|
Some(b't') | Some(b'f') => self.boolean(),
|
|
Some(b'n') => self.null(),
|
|
Some(b'-') | Some(b'0'..=b'9') => self.number(),
|
|
Some(c) => Err(JsonError::Parse(format!("unexpected byte '{}'", c as char))),
|
|
None => Err(JsonError::Parse("unexpected end".into())),
|
|
};
|
|
self.depth -= 1;
|
|
r
|
|
}
|
|
|
|
fn expect(&mut self, b: u8) -> Result<(), JsonError> {
|
|
if self.peek() == Some(b) {
|
|
self.pos += 1;
|
|
Ok(())
|
|
} else {
|
|
Err(JsonError::Parse(format!("expected '{}'", b as char)))
|
|
}
|
|
}
|
|
|
|
fn object(&mut self) -> Result<Json, JsonError> {
|
|
self.expect(b'{')?;
|
|
let mut m = BTreeMap::new();
|
|
self.skip_ws();
|
|
if self.peek() == Some(b'}') {
|
|
self.pos += 1;
|
|
return Ok(Json::Obj(m));
|
|
}
|
|
loop {
|
|
self.skip_ws();
|
|
let key = self.string()?;
|
|
self.skip_ws();
|
|
self.expect(b':')?;
|
|
self.skip_ws();
|
|
let val = self.value()?;
|
|
m.insert(key, val);
|
|
self.skip_ws();
|
|
match self.peek() {
|
|
Some(b',') => {
|
|
self.pos += 1;
|
|
continue;
|
|
}
|
|
Some(b'}') => {
|
|
self.pos += 1;
|
|
break;
|
|
}
|
|
_ => return Err(JsonError::Parse("expected ',' or '}'".into())),
|
|
}
|
|
}
|
|
Ok(Json::Obj(m))
|
|
}
|
|
|
|
fn array(&mut self) -> Result<Json, JsonError> {
|
|
self.expect(b'[')?;
|
|
let mut a = Vec::new();
|
|
self.skip_ws();
|
|
if self.peek() == Some(b']') {
|
|
self.pos += 1;
|
|
return Ok(Json::Arr(a));
|
|
}
|
|
loop {
|
|
self.skip_ws();
|
|
a.push(self.value()?);
|
|
self.skip_ws();
|
|
match self.peek() {
|
|
Some(b',') => {
|
|
self.pos += 1;
|
|
continue;
|
|
}
|
|
Some(b']') => {
|
|
self.pos += 1;
|
|
break;
|
|
}
|
|
_ => return Err(JsonError::Parse("expected ',' or ']'".into())),
|
|
}
|
|
}
|
|
Ok(Json::Arr(a))
|
|
}
|
|
|
|
fn string(&mut self) -> Result<String, JsonError> {
|
|
self.expect(b'"')?;
|
|
let mut s = String::new();
|
|
loop {
|
|
match self.peek() {
|
|
None => return Err(JsonError::Parse("unterminated string".into())),
|
|
Some(b'"') => {
|
|
self.pos += 1;
|
|
break;
|
|
}
|
|
Some(b'\\') => {
|
|
self.pos += 1;
|
|
match self.peek() {
|
|
Some(b'"') => s.push('"'),
|
|
Some(b'\\') => s.push('\\'),
|
|
Some(b'/') => s.push('/'),
|
|
Some(b'n') => s.push('\n'),
|
|
Some(b'r') => s.push('\r'),
|
|
Some(b't') => s.push('\t'),
|
|
Some(b'b') => s.push('\u{0008}'),
|
|
Some(b'f') => s.push('\u{000c}'),
|
|
Some(b'u') => {
|
|
let cp = self.hex4()?;
|
|
// Handle surrogate pairs.
|
|
if (0xD800..=0xDBFF).contains(&cp) {
|
|
if self.peek() == Some(b'\\') {
|
|
self.pos += 1;
|
|
if self.peek() == Some(b'u') {
|
|
let lo = self.hex4()?;
|
|
if (0xDC00..=0xDFFF).contains(&lo) {
|
|
let c = 0x10000
|
|
+ ((cp - 0xD800) << 10)
|
|
+ (lo - 0xDC00);
|
|
if let Some(ch) = char::from_u32(c) {
|
|
s.push(ch);
|
|
} else {
|
|
s.push('\u{FFFD}');
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
s.push('\u{FFFD}');
|
|
} else if let Some(ch) = char::from_u32(cp) {
|
|
s.push(ch);
|
|
} else {
|
|
s.push('\u{FFFD}');
|
|
}
|
|
continue;
|
|
}
|
|
_ => return Err(JsonError::Parse("bad escape".into())),
|
|
}
|
|
self.pos += 1;
|
|
}
|
|
Some(_) => {
|
|
// Copy one UTF-8 codepoint from the source.
|
|
let start = self.pos;
|
|
let len = utf8_len(self.bytes[start]);
|
|
if start + len > self.bytes.len() {
|
|
return Err(JsonError::Parse("bad utf8".into()));
|
|
}
|
|
match std::str::from_utf8(&self.bytes[start..start + len]) {
|
|
Ok(chunk) => s.push_str(chunk),
|
|
Err(_) => return Err(JsonError::Parse("bad utf8".into())),
|
|
}
|
|
self.pos += len;
|
|
}
|
|
}
|
|
}
|
|
Ok(s)
|
|
}
|
|
|
|
fn hex4(&mut self) -> Result<u32, JsonError> {
|
|
// assumes the 'u' has been consumed
|
|
self.pos += 1;
|
|
let mut v: u32 = 0;
|
|
for _ in 0..4 {
|
|
let d = self
|
|
.peek()
|
|
.and_then(|b| (b as char).to_digit(16))
|
|
.ok_or_else(|| JsonError::Parse("bad \\u".into()))?;
|
|
v = v * 16 + d;
|
|
self.pos += 1;
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
fn boolean(&mut self) -> Result<Json, JsonError> {
|
|
if self.bytes[self.pos..].starts_with(b"true") {
|
|
self.pos += 4;
|
|
Ok(Json::Bool(true))
|
|
} else if self.bytes[self.pos..].starts_with(b"false") {
|
|
self.pos += 5;
|
|
Ok(Json::Bool(false))
|
|
} else {
|
|
Err(JsonError::Parse("bad literal".into()))
|
|
}
|
|
}
|
|
|
|
fn null(&mut self) -> Result<Json, JsonError> {
|
|
if self.bytes[self.pos..].starts_with(b"null") {
|
|
self.pos += 4;
|
|
Ok(Json::Null)
|
|
} else {
|
|
Err(JsonError::Parse("bad literal".into()))
|
|
}
|
|
}
|
|
|
|
fn number(&mut self) -> Result<Json, JsonError> {
|
|
let start = self.pos;
|
|
if self.peek() == Some(b'-') {
|
|
self.pos += 1;
|
|
}
|
|
while let Some(b'0'..=b'9') = self.peek() {
|
|
self.pos += 1;
|
|
}
|
|
if self.peek() == Some(b'.') {
|
|
self.pos += 1;
|
|
while let Some(b'0'..=b'9') = self.peek() {
|
|
self.pos += 1;
|
|
}
|
|
}
|
|
if let Some(b'e') | Some(b'E') = self.peek() {
|
|
self.pos += 1;
|
|
if let Some(b'+') | Some(b'-') = self.peek() {
|
|
self.pos += 1;
|
|
}
|
|
while let Some(b'0'..=b'9') = self.peek() {
|
|
self.pos += 1;
|
|
}
|
|
}
|
|
let slice = std::str::from_utf8(&self.bytes[start..self.pos])
|
|
.map_err(|_| JsonError::Parse("bad number".into()))?;
|
|
slice
|
|
.parse::<f64>()
|
|
.map(Json::Num)
|
|
.map_err(|_| JsonError::Parse("bad number".into()))
|
|
}
|
|
}
|
|
|
|
fn utf8_len(b: u8) -> usize {
|
|
if b < 0x80 {
|
|
1
|
|
} else if b >> 5 == 0b110 {
|
|
2
|
|
} else if b >> 4 == 0b1110 {
|
|
3
|
|
} else if b >> 3 == 0b11110 {
|
|
4
|
|
} else {
|
|
1
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn roundtrip_basic() {
|
|
let v = Json::obj(vec![
|
|
("a", Json::i(42)),
|
|
("b", Json::Arr(vec![Json::Bool(true), Json::Null, Json::s("x")])),
|
|
("c", Json::Num(1.5)),
|
|
]);
|
|
let s = v.to_compact();
|
|
let back = parse(&s).unwrap();
|
|
assert_eq!(v, back);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_never_panics_on_garbage() {
|
|
let deep = "{".repeat(1000);
|
|
let cases = [
|
|
"", "{", "[", "\"", "nul", "{\"a\":}", "[1,2,", "tru", "12.3.4",
|
|
"{\"a\"1}", "\\", "\"\\u00\"", deep.as_str(),
|
|
];
|
|
for c in cases {
|
|
// Must return without panicking; value is irrelevant.
|
|
let _ = parse(c);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn deep_nesting_is_rejected_not_overflowed() {
|
|
let deep = "[".repeat(10_000);
|
|
assert!(parse(&deep).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn integers_roundtrip_exact() {
|
|
let v = Json::i(-1234567890123);
|
|
assert_eq!(parse(&v.to_compact()).unwrap().as_i64(), Some(-1234567890123));
|
|
}
|
|
|
|
#[test]
|
|
fn escapes_roundtrip() {
|
|
let v = Json::s("line\ntab\tquote\"slash\\end");
|
|
let s = v.to_compact();
|
|
assert_eq!(parse(&s).unwrap(), v);
|
|
}
|
|
}
|