Files
magicka-vm/crates/protocol/src/json.rs
T
linus-dandClaude Opus 4.8 9d9d5ce41c Add web game (plan2.md) on the independent runtime, merge-blocking gates
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>
2026-06-21 20:20:46 -07:00

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);
}
}