From 84d688688d5773953738fe30999843dae9ce3aeb Mon Sep 17 00:00:00 2001 From: tegwick Date: Sun, 2 Aug 2026 04:27:25 +0200 Subject: [PATCH] =?UTF-8?q?CB-WP-0012-T04:=20cb-render-html=20=E2=80=94=20?= =?UTF-8?q?stage=201=20draws,=20and=20the=20browser=20is=20the=20toolkit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delivers ADR-0007 Decision 1: visualization, drag-to-propose and hot-seat play, at a measured marginal AM-4a cost of zero. games-ground shipped: 23 third-party crates cb-render-html: 23 third-party crates new crates introduced: 0 Measured, not asserted — the survey's own lesson. AM-4a is unmoved at 246,250; own source is 7,636 -> 9,652. What shipped: crates/cb-render-html doc.rs (HTML/SVG emission, incl. the relationship graph), input.rs (pointer facts -> commands), serve.rs (Guard, Request, loopback bind) tools/cb-play hotseat.rs + `--serve PORT` Per ADR-0007 Decision 2 there is NO cb-render-api and NO cb-render-null. The renderer targets the existing Project trait; the port waits for stage 2's wgpu implementation to be its second use. The six controls, all live, all mutation-checked (8 mutations, each red for its stated reason): 1-3 token / Origin+Sec-Fetch-Site / explicit 127.0.0.1 bind 4 a token-less request is refused, in the unit AND over a real socket 5 JS may not construct commands — the page reports pointer facts, Rust resolves them against the legal list the aggregate already offered, and a test asserts the emitted script contains no game vocabulary 6 the coverage gate crosses the language boundary: it walks the serialized view for leaf paths and requires each token to appear in the PARSED emitted document, with a test that the parse really is a parse (script/style contents must not count as rendered) The gate fired on its author again, on its first run: ground_choices.*. choice, ground_choices.*.problem and players.*.blame_from were in neither list. The last is the one worth keeping — an EMPTY vector is a leaf path of its own, and it now renders as an explicit absence. Also, a mutation that did not go red: removing the Sec-Fetch-Site arm alone left the cross-site test green, because the Origin check caught it independently. Both had to be removed before the control bit. Recorded because a control that passes for a reason you did not intend has not been demonstrated. Co-Authored-By: Claude Opus 5 --- Cargo.lock | 10 + Cargo.toml | 2 + crates/cb-render-html/Cargo.toml | 19 + crates/cb-render-html/src/doc.rs | 544 +++++++++++++++++++++++++++ crates/cb-render-html/src/input.rs | 270 +++++++++++++ crates/cb-render-html/src/lib.rs | 333 ++++++++++++++++ crates/cb-render-html/src/serve.rs | 323 ++++++++++++++++ crates/cb-render-html/src/testfix.rs | 118 ++++++ research/CB-RES-0006-render-port.md | 8 + tools/cb-play/Cargo.toml | 1 + tools/cb-play/src/hotseat.rs | 373 ++++++++++++++++++ tools/cb-play/src/inspect.rs | 1 + tools/cb-play/src/main.rs | 20 +- tools/cb-play/src/table.rs | 38 +- 14 files changed, 2058 insertions(+), 2 deletions(-) create mode 100644 crates/cb-render-html/Cargo.toml create mode 100644 crates/cb-render-html/src/doc.rs create mode 100644 crates/cb-render-html/src/input.rs create mode 100644 crates/cb-render-html/src/lib.rs create mode 100644 crates/cb-render-html/src/serve.rs create mode 100644 crates/cb-render-html/src/testfix.rs create mode 100644 tools/cb-play/src/hotseat.rs diff --git a/Cargo.lock b/Cargo.lock index 5a6ecd7..86e13e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -81,11 +81,21 @@ dependencies = [ "cb-events", "cb-game-runtime", "cb-kernel", + "cb-render-html", "games-ground", "serde_json", "serde_yaml", ] +[[package]] +name = "cb-render-html" +version = "0.1.0" +dependencies = [ + "cb-kernel", + "games-ground", + "serde_json", +] + [[package]] name = "cb-sim" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 9952ba4..52875e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/cb-kernel", "crates/cb-events", "crates/cb-game-runtime", + "crates/cb-render-html", "games/ground", "tools/cb-sim", "tools/cb-play", @@ -19,6 +20,7 @@ cb-kernel = { path = "crates/cb-kernel" } cb-events = { path = "crates/cb-events" } cb-game-runtime = { path = "crates/cb-game-runtime", default-features = false } games-ground = { path = "games/ground", default-features = false } +cb-render-html = { path = "crates/cb-render-html" } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/crates/cb-render-html/Cargo.toml b/crates/cb-render-html/Cargo.toml new file mode 100644 index 0000000..4435a33 --- /dev/null +++ b/crates/cb-render-html/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cb-render-html" +edition.workspace = true +version.workspace = true +license-file.workspace = true + +# ADR-0007 Decision 1: the browser is the renderer, so this crate has +# **no third-party dependencies at all** beyond what the game already +# carries. Adding one here needs an argument against ADR-0007 §Decision 3. +[dependencies] +cb-kernel.workspace = true +games-ground.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +# The coverage gate walks the serialized view; nothing else needs it. +serde_json.workspace = true diff --git a/crates/cb-render-html/src/doc.rs b/crates/cb-render-html/src/doc.rs new file mode 100644 index 0000000..503f09b --- /dev/null +++ b/crates/cb-render-html/src/doc.rs @@ -0,0 +1,544 @@ +//! The emitted document: HTML shell, inline SVG table, inline JavaScript. +//! +//! ADR-0007 Decision 1. Marginal AM-4a cost zero — this is string +//! formatting, and the browser draws it. +//! +//! ## What the JavaScript is allowed to be +//! +//! ADR-0007 Decision 5 bars it from constructing commands. [`SCRIPT`] is +//! therefore the whole of it, it is a constant, and it does one thing: +//! record which element the pointer went down on, which it came up on, and +//! POST the pair. It contains no game vocabulary — no action names, no +//! seats, no rules. If a rule ever needs to appear in it, the decision is +//! wrong and ADR-0007 says to revisit it rather than widen the control. + +use std::fmt::Write as _; + +use cb_kernel::PlayerId; +use games_ground::view::{GroundView, PlayerView, ProblemView, SelectionView}; + +use crate::input::action_id; + +/// Escape for text and attribute contexts alike. +/// +/// Everything interpolated into the document goes through this. Card +/// suits and seat numbers cannot currently carry a `<`, but "cannot +/// currently" is how injection bugs are written. +pub fn esc(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + _ => out.push(c), + } + } + out +} + +fn seat_name(p: PlayerId) -> String { + format!("P{}", p.0 + 1) +} + +fn cards(list: &[games_ground::SolutionCard]) -> String { + if list.is_empty() { + return "none".to_string(); + } + list.iter() + .map(|c| format!("{:?}", c.suit)) + .collect::>() + .join(" ") +} + +/// The only JavaScript in the project. See the module docs. +pub const SCRIPT: &str = r#" +(function () { + var down = null; + function id(e) { + var n = e.target; + while (n && !n.id) { n = n.parentNode; } + return n ? n.id : null; + } + document.addEventListener('pointerdown', function (e) { down = id(e); }); + document.addEventListener('pointerup', function (e) { + var up = id(e); + if (!down || !up) { down = null; return; } + var body = 'down=' + encodeURIComponent(down) + '&up=' + encodeURIComponent(up); + down = null; + fetch(window.CB_ENDPOINT, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: body + }).then(function (r) { return r.text(); }).then(function (t) { + document.getElementById('cb-status').textContent = t; + if (t.indexOf('ok') === 0) { window.location.reload(); } + }); + }); +})(); +"#; + +const STYLE: &str = " +body{font:14px/1.5 ui-monospace,monospace;margin:1.5rem;background:#12141a;color:#dde} +h1,h2{font-size:1rem;margin:1.2rem 0 .4rem;color:#9cf} +.row{display:flex;flex-wrap:wrap;gap:.5rem;align-items:flex-start} +.card{border:1px solid #445;border-radius:6px;padding:.5rem .7rem;background:#1b1e26} +.card[data-viewer=true]{border-color:#9cf} +.act{cursor:grab;user-select:none;background:#243;border-color:#5a7} +.btn{cursor:pointer;background:#332a3a;border-color:#a7d} +.k{color:#89a} +#cb-status{margin-top:1rem;color:#fc9;min-height:1.2em} +svg{background:#1b1e26;border:1px solid #445;border-radius:6px} +"; + +fn problem_svg(out: &mut String, priority: u32, p: &ProblemView, x: i32) { + let (label, sub, fill) = match p { + ProblemView::FaceDown => ("face down".to_string(), String::new(), "#2a2f3a"), + ProblemView::FaceUp { + suit, + value, + denied, + claimed_by, + protected_this_round, + } => { + let mut sub = String::new(); + if *denied { + sub.push_str("denied "); + } + if *protected_this_round { + sub.push_str("protected "); + } + if let Some(c) = claimed_by { + let _ = write!(sub, "claimed by {}", seat_name(*c)); + } + ( + format!("{suit:?} {value}"), + sub.trim_end().to_string(), + "#26303a", + ) + } + }; + let _ = write!( + out, + "\ + {label}\ + priority {priority}\ + {sub}", + x = x, + tx = x + 10, + label = esc(&label), + sub = esc(&sub), + ); +} + +/// The relationship graph — the one element every Rust 2D toolkit would +/// have left us to hand-roll, and the reason SVG earns its place here +/// rather than merely fitting the budget (CB-RES-0006 §5). +fn relations_svg(view: &GroundView) -> String { + let n = view.players.len().max(1); + let (cx, cy, r) = (200.0f64, 150.0f64, 110.0f64); + let pos: Vec<(PlayerId, f64, f64)> = view + .players + .keys() + .enumerate() + .map(|(i, p)| { + let a = std::f64::consts::TAU * (i as f64) / (n as f64) - std::f64::consts::FRAC_PI_2; + (*p, cx + r * a.cos(), cy + r * a.sin()) + }) + .collect(); + let find = |p: PlayerId| { + pos.iter() + .find(|(q, _, _)| *q == p) + .map(|(_, x, y)| (*x, *y)) + }; + + let mut s = String::from( + "", + ); + for (pair, rel) in &view.relations { + if let (Some((x1, y1)), Some((x2, y2))) = (find(pair.0), find(pair.1)) { + let colour = match rel { + games_ground::Relation::Bond => "#5c9", + games_ground::Relation::Rivalry => "#c66", + }; + let _ = write!( + s, + "\ + {rel:?}", + mx = (x1 + x2) / 2.0, + my = (y1 + y2) / 2.0 - 3.0, + ); + } + } + for (p, x, y) in &pos { + let is_viewer = view.viewer == Some(*p); + let focus = view + .focus + .get(p) + .map(|f| format!(" \u{2192}{}", seat_name(*f))); + let _ = write!( + s, + "\ + {name}\ + {focus}", + raw = p.0, + stroke = if is_viewer { "#9cf" } else { "#5a6b7a" }, + ty = y + 2.0, + ty2 = y + 16.0, + name = seat_name(*p), + focus = esc(focus.as_deref().unwrap_or("")), + ); + } + s.push_str(""); + s +} + +fn player_card(out: &mut String, id: PlayerId, p: &PlayerView, view: &GroundView) { + let is_viewer = view.viewer == Some(id); + let _ = write!( + out, + "
{name}{you}
", + name = seat_name(id), + you = if is_viewer { " (you)" } else { "" }, + ); + let _ = write!( + out, + "stress {} protect {} \ + darvo {:?}
", + p.stress, p.protection, p.darvo + ); + let _ = write!( + out, + "freedom \ + {ready}{lifted}
", + raw = id.0, + ready = if p.freedom_ready { "READY" } else { "spent" }, + lifted = if p.freedom_gate_lifted { + " gate lifted" + } else { + "" + }, + ); + let blame = if p.blame_from.is_empty() { + "none".to_string() + } else { + p.blame_from + .iter() + .map(|b| seat_name(*b)) + .collect::>() + .join(" ") + }; + let _ = write!( + out, + "blamed by {}
", + esc(&blame) + ); + match &p.hand { + Some(h) => { + let _ = write!( + out, + "hand {} ({} cards)
", + esc(&cards(h)), + p.hand_size + ); + } + None => { + let _ = write!( + out, + "hand {} card(s), hidden
", + p.hand_size + ); + } + } + if let Some(sel) = view.selections.get(&id) { + let _ = write!( + out, + "selected {}
", + match sel { + SelectionView::Hidden => "face down".to_string(), + SelectionView::Shown(s) => esc(&format!("{s:?}")), + } + ); + } + if let Some(m) = view.ground_modes.get(&id) { + let _ = write!(out, "ground mode {m:?}
"); + } + if let Some(c) = view.ground_choices.get(&id) { + let _ = write!(out, "ground choice {c:?}
"); + } + if let Some(r) = view.support_responses.get(&id) { + let _ = write!(out, "support {r:?}
"); + } + if let Some(t) = view.darvo_targets.get(&id) { + let _ = write!(out, "darvo target {t:?}
"); + } + out.push_str("
"); +} + +/// Render the whole table as a standalone document. +/// +/// `may_pass` adds the one affordance that is not a command: declining to +/// act where the driver allows it. Like every other element it carries an +/// id and nothing else — the page still reports only that the pointer went +/// down and up on `pass`. +/// +/// `legal` is the list the aggregate offered; the buttons carry indices +/// into it and nothing else (ADR-0007 control 5). `endpoint` carries the +/// per-process token (control 1) — the page cannot mint one. +pub fn document( + view: &GroundView, + legal: &[games_ground::GroundCommand], + endpoint: &str, + seat: Option, + may_pass: bool, +) -> String { + let mut s = String::with_capacity(8192); + let _ = write!( + s, + "\ + \ + GROUND \u{2014} round {round}", + round = view.round + ); + + let _ = write!( + s, + "

GROUND \u{2014} round {round}, step {step:?}

\ +
lead {lead} \ + scoring {mode:?} \ + viewing as {who}
", + round = view.round, + step = view.step, + lead = seat_name(view.lead), + mode = view.mode, + who = match view.viewer { + Some(p) => format!("{} (their hand only)", seat_name(p)), + None => "a spectator (no hands)".to_string(), + }, + ); + + s.push_str( + "

problems

", + ); + if view.problems.is_empty() { + s.push_str( + "no problems in play", + ); + } + for (i, (priority, p)) in view.problems.iter().enumerate() { + problem_svg(&mut s, *priority, p, 10 + (i as i32) * 130); + } + s.push_str(""); + + s.push_str("

relationships

"); + s.push_str(&relations_svg(view)); + + s.push_str("

seats

"); + for (id, p) in &view.players { + player_card(&mut s, *id, p, view); + } + s.push_str("
"); + + let _ = write!( + s, + "

solutions

deck {} remaining \ + discard {}
", + view.solution_deck_len, + esc(&cards(&view.solution_discard)), + ); + + if let Some(o) = &view.outcome { + let personal = o + .personal + .iter() + .map(|(p, v)| format!("{} {v:+}", seat_name(*p))) + .collect::>() + .join(" "); + let winners = if o.winners.is_empty() { + "nobody".to_string() + } else { + o.winners + .iter() + .map(|w| seat_name(*w)) + .collect::>() + .join(" ") + }; + let coalitions = if o.coalitions.is_empty() { + "none".to_string() + } else { + o.coalitions + .iter() + .map(|c| format!("{c:?}")) + .collect::>() + .join(" ") + }; + let _ = write!( + s, + "

outcome

\ + total {total} of {threshold} \ + group {group}
\ + personal {personal}
\ + coalitions {coalitions}
\ + mastery {mastery}
\ + winners {winners}
", + total = o.total, + threshold = o.threshold, + group = if o.group_success { + "success" + } else { + "failure" + }, + personal = esc(&personal), + coalitions = esc(&coalitions), + mastery = match o.mastery { + Some(m) => format!("{m:+}"), + None => "none".to_string(), + }, + winners = esc(&winners), + ); + } + + if !legal.is_empty() { + s.push_str("

your move

"); + for a in [ + games_ground::Action::Investigate, + games_ground::Action::Solve, + games_ground::Action::Support, + games_ground::Action::Attack, + games_ground::Action::Ground, + ] { + let offered = seat.is_some_and(|seat| { + legal.iter().any(|c| { + crate::input::affordance(c, seat).is_some_and(|(f, _)| f == action_id(a)) + }) + }); + if offered { + let _ = write!( + s, + "
drag {a:?} onto a seat, a problem, \ + or the table
", + id = action_id(a), + ); + } + } + s.push_str("
"); + for (i, c) in legal.iter().enumerate() { + let spatial = seat.is_some_and(|seat| crate::input::affordance(c, seat).is_some()); + if !spatial { + let _ = write!( + s, + "
{}
", + esc(&format!("{c:?}")) + ); + } + } + s.push_str( + "
the table \u{2014} drop here for an \ + untargeted action
", + ); + } + if may_pass { + s.push_str("
pass \u{2014} decline to act
"); + } + + let _ = write!( + s, + "
ready
\ + ", + json_string(endpoint), + ); + s +} + +/// Quote a string as a JSON literal, so a token can never end the script. +fn json_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '<' => out.push_str("\\u003c"), + '>' => out.push_str("\\u003e"), + '&' => out.push_str("\\u0026"), + c if (c as u32) < 0x20 => { + let _ = write!(out, "\\u{:04x}", c as u32); + } + c => out.push(c), + } + } + out.push('"'); + out +} + +/// Extract the document's visible text and element ids. +/// +/// ADR-0007 control 6 requires the coverage gate to assert over the +/// **parsed emitted document**, not over the Rust that emits it. This is +/// that parse: it drops markup and returns what a reader would see, plus +/// the ids a pointer can address. A substring search over the raw source +/// would happily find a token inside a comment or a style rule. +pub fn text_of(html: &str) -> String { + let mut out = String::with_capacity(html.len() / 2); + let bytes: Vec = html.chars().collect(); + let mut i = 0; + let mut skip_to: Option<&str> = None; + while i < bytes.len() { + if bytes[i] == '<' { + // Find the tag name. + let start = i + 1; + let mut j = start; + while j < bytes.len() && bytes[j] != '>' { + j += 1; + } + let tag: String = bytes[start..j.min(bytes.len())].iter().collect(); + let lower = tag.to_ascii_lowercase(); + let name = lower + .trim_start_matches('/') + .split([' ', '\t', '\n', '>']) + .next() + .unwrap_or("") + .to_string(); + + if let Some(want) = skip_to { + if lower.starts_with('/') && name == want { + skip_to = None; + } + } else if name == "script" || name == "style" { + // Their contents are not text a reader sees. + if !lower.starts_with('/') && !lower.ends_with('/') { + skip_to = Some(if name == "script" { "script" } else { "style" }); + } + } else { + // Ids are addressable surface, so they count as rendered. + if let Some(k) = lower.find("id=\"") { + let rest = &tag[k + 4..]; + if let Some(end) = rest.find('"') { + out.push(' '); + out.push_str(&rest[..end]); + } + } + } + i = j + 1; + continue; + } + if skip_to.is_none() { + out.push(bytes[i]); + } + i += 1; + } + // Unescape the entities esc() introduced, so a test looks for the text + // a reader sees rather than its encoding. + out.replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("&", "&") +} diff --git a/crates/cb-render-html/src/input.rs b/crates/cb-render-html/src/input.rs new file mode 100644 index 0000000..8e2e604 --- /dev/null +++ b/crates/cb-render-html/src/input.rs @@ -0,0 +1,270 @@ +//! ADR-0007 Decision 5: **JavaScript may not construct commands.** +//! +//! The emitted page reports raw pointer facts — "the pointer went down on +//! element `a` and came up on element `b`" — and nothing else. This module +//! is where Rust decides what command that means. +//! +//! The rule exists because CB-WP-0011 established that a renderer's defect +//! class is silent omission, and ADR-0007 moves the interactive half of +//! stage 1 into a language `cargo test`, `clippy` and `M-D1-MUT` cannot +//! reach. Confining JavaScript to input transport keeps the decision on +//! this side of the boundary, where it can be tested against synthetic +//! events with no browser in the loop — which is exactly what the tests +//! below do. +//! +//! K13 is unaffected: resolving a drag produces an **index into the legal +//! list the aggregate already offered**. A drag can never name a command +//! the aggregate did not enumerate, so the projection cannot feed back +//! into validation. + +use games_ground::{Action, GroundCommand}; + +/// What the browser is permitted to tell us: two element ids. +/// +/// Deliberately not "a move", "a command", or anything carrying game +/// meaning. If this struct ever grows a field with a rule in it, control 5 +/// has been breached and ADR-0007 says to revisit the decision rather than +/// widen the control. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PointerFact { + pub down: String, + pub up: String, +} + +impl PointerFact { + pub fn new(down: &str, up: &str) -> Self { + Self { + down: down.to_string(), + up: up.to_string(), + } + } + + /// Parse the wire form `down=&up=`. Anything else is refused. + pub fn parse(body: &str) -> Result { + let (mut down, mut up) = (None, None); + for pair in body.split('&') { + match pair.split_once('=') { + Some(("down", v)) => down = Some(v.to_string()), + Some(("up", v)) => up = Some(v.to_string()), + _ => return Err(format!("unrecognised field in pointer fact: {pair:?}")), + } + } + match (down, up) { + (Some(down), Some(up)) if !down.is_empty() && !up.is_empty() => Ok(Self { down, up }), + _ => Err("a pointer fact needs a non-empty down and up".to_string()), + } + } +} + +pub fn action_id(a: Action) -> &'static str { + match a { + Action::Investigate => "action-investigate", + Action::Solve => "action-solve", + Action::Support => "action-support", + Action::Attack => "action-attack", + Action::Ground => "action-ground", + } +} + +/// The element pair that offers this command, if it has a spatial one. +/// +/// Commands with no natural drag — choosing a GROUND mode, answering a +/// Support, naming a DARVO target — are offered as buttons instead, and +/// resolve through the `cmd-` form below. +pub fn affordance(command: &GroundCommand, seat: cb_kernel::PlayerId) -> Option<(String, String)> { + match command { + GroundCommand::SelectAction { + action, + target, + problem, + } => { + let from = action_id(*action).to_string(); + let to = match (target, problem) { + (Some(p), None) => format!("seat-{}", p.0), + (None, Some(n)) => format!("problem-{n}"), + (None, None) => "table".to_string(), + // Both set is not a shape the aggregate produces; refusing + // to invent an affordance is better than guessing one. + (Some(_), Some(_)) => return None, + }; + Some((from, to)) + } + GroundCommand::SpendFreedom => { + Some((format!("freedom-{}", seat.0), format!("freedom-{}", seat.0))) + } + _ => None, + } +} + +/// Resolve a pointer fact to an index into the legal list. +/// +/// Returns `Err` rather than a default when nothing matches: a drag that +/// means nothing must mean *nothing*, not "the first legal move". A +/// renderer that silently substitutes a command is the presentation-layer +/// form of the defect this whole pass is about. +pub fn resolve( + fact: &PointerFact, + legal: &[GroundCommand], + seat: cb_kernel::PlayerId, +) -> Result { + // The button form: an explicit index, which still has to be in range. + if let Some(rest) = fact.down.strip_prefix("cmd-") { + if fact.down != fact.up { + return Err(format!( + "a button press must start and end on the same element ({} then {})", + fact.down, fact.up + )); + } + let i: usize = rest + .parse() + .map_err(|_| format!("not a command index: {:?}", fact.down))?; + return if i < legal.len() { + Ok(i) + } else { + Err(format!("no legal command {i} (there are {})", legal.len())) + }; + } + + let mut hits = legal + .iter() + .enumerate() + .filter(|(_, c)| affordance(c, seat).is_some_and(|(f, t)| f == fact.down && t == fact.up)); + match (hits.next(), hits.next()) { + (Some((i, _)), None) => Ok(i), + (Some(_), Some(_)) => Err(format!( + "{} -> {} is offered by more than one legal command; the page is ambiguous", + fact.down, fact.up + )), + (None, _) => Err(format!( + "{} -> {} is not a legal move here", + fact.down, fact.up + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use cb_kernel::PlayerId; + use games_ground::{GroundMode, SupportResponse}; + + fn seat(n: u8) -> PlayerId { + PlayerId(n) + } + + fn select(action: Action, target: Option, problem: Option) -> GroundCommand { + GroundCommand::SelectAction { + action, + target: target.map(seat), + problem, + } + } + + #[test] + fn a_drag_resolves_to_the_command_it_offers() { + let legal = vec![ + select(Action::Attack, Some(1), None), + select(Action::Solve, None, Some(7)), + select(Action::Ground, None, None), + ]; + assert_eq!( + resolve( + &PointerFact::new("action-solve", "problem-7"), + &legal, + seat(0) + ), + Ok(1) + ); + assert_eq!( + resolve( + &PointerFact::new("action-attack", "seat-1"), + &legal, + seat(0) + ), + Ok(0) + ); + assert_eq!( + resolve(&PointerFact::new("action-ground", "table"), &legal, seat(0)), + Ok(2) + ); + } + + /// The control that matters: a drag the aggregate did not offer must + /// resolve to nothing at all, not to a nearby or default command. + #[test] + fn a_drag_that_means_nothing_resolves_to_nothing() { + let legal = vec![select(Action::Solve, None, Some(7))]; + // Right action, wrong problem. + assert!(resolve( + &PointerFact::new("action-solve", "problem-9"), + &legal, + seat(0) + ) + .is_err()); + // An action that is legal in general but not offered now. + assert!(resolve( + &PointerFact::new("action-attack", "seat-1"), + &legal, + seat(0) + ) + .is_err()); + // Elements that exist on the page but are not an affordance. + assert!(resolve(&PointerFact::new("seat-1", "seat-2"), &legal, seat(0)).is_err()); + // Nonsense. + assert!(resolve(&PointerFact::new("", ""), &legal, seat(0)).is_err()); + } + + /// A command index arriving from the page is still bounds-checked + /// against the list the aggregate offered. + #[test] + fn a_button_index_is_bounds_checked() { + let legal = vec![ + GroundCommand::RespondToSupport { + response: SupportResponse::AcceptBond, + }, + GroundCommand::ChooseGroundMode { + mode: GroundMode::Gr, + choice: None, + }, + ]; + assert_eq!( + resolve(&PointerFact::new("cmd-1", "cmd-1"), &legal, seat(0)), + Ok(1) + ); + assert!(resolve(&PointerFact::new("cmd-2", "cmd-2"), &legal, seat(0)).is_err()); + assert!(resolve(&PointerFact::new("cmd-x", "cmd-x"), &legal, seat(0)).is_err()); + // A press that starts on one button and ends on another is not a + // press, and must not be read as one. + assert!(resolve(&PointerFact::new("cmd-0", "cmd-1"), &legal, seat(0)).is_err()); + } + + /// If two legal commands offered the same drag, picking either would + /// be a coin flip the player cannot see. Refuse instead. + #[test] + fn an_ambiguous_affordance_is_refused_rather_than_guessed() { + let legal = vec![ + select(Action::Solve, None, Some(7)), + select(Action::Solve, None, Some(7)), + ]; + let e = resolve( + &PointerFact::new("action-solve", "problem-7"), + &legal, + seat(0), + ) + .unwrap_err(); + assert!(e.contains("more than one"), "{e}"); + } + + #[test] + fn pointer_facts_parse_and_bad_ones_are_refused() { + assert_eq!( + PointerFact::parse("down=action-solve&up=problem-7"), + Ok(PointerFact::new("action-solve", "problem-7")) + ); + // A field carrying game meaning is exactly what control 5 forbids. + assert!(PointerFact::parse("down=a&up=b&command=Solve").is_err()); + assert!(PointerFact::parse("down=a").is_err()); + assert!(PointerFact::parse("down=&up=b").is_err()); + assert!(PointerFact::parse("").is_err()); + } +} diff --git a/crates/cb-render-html/src/lib.rs b/crates/cb-render-html/src/lib.rs new file mode 100644 index 0000000..d910f7e --- /dev/null +++ b/crates/cb-render-html/src/lib.rs @@ -0,0 +1,333 @@ +//! `cb-render-html` — stage 1's renderer (ADR-0007). +//! +//! Emits HTML with inline SVG and one small piece of inline JavaScript; +//! the browser draws it. **Marginal AM-4a cost: zero** — this crate has no +//! third-party dependencies at all, and adding one requires an argument +//! against ADR-0007 §Decision 3. +//! +//! ## What this is not +//! +//! It is **not** `cb-render-api`, and there is no `cb-render-null`. +//! ADR-0007 Decision 2 withdrew the port: a capability port designed +//! against one implementation that emits whole documents acquires a +//! document's shape, and stage 2's `wgpu` renderer would find it +//! unimplementable. INTENT's rule is the one that binds — +//! +//! > *No concept becomes canonical merely because it looks general. It +//! > becomes canonical after surviving a second concrete use.* +//! +//! — so this renders against the existing [`cb_game_runtime::Project`] +//! trait, which is a real interface with real implementations, and the +//! port is declared at stage 2 when there are two. +//! +//! ## The controls, and why they exist +//! +//! CB-WP-0011 established that a renderer's defect class is **silent +//! omission**: every assertion a renderer test naturally makes is +//! satisfied by a renderer showing a third of the state. This crate moves +//! the interactive half of stage 1 into a language `cargo test`, +//! `clippy` and `M-D1-MUT` cannot reach, so two controls carry that +//! finding across the boundary: +//! +//! * [`input`] — **JavaScript may not construct commands.** The page +//! reports raw pointer facts; Rust decides what they mean, against the +//! legal list the aggregate already offered. +//! * the coverage gate below — asserts over the **parsed emitted +//! document**, not over the Rust that emits it. Asserting over the +//! emitting code would reproduce CB-WP-0011's original defect one layer +//! up. +//! +//! And [`serve`] carries the four that make a loopback listener safe to +//! have, of which the load-bearing one is a test that a token-less request +//! is refused. + +pub mod doc; +pub mod input; +pub mod serve; + +pub use doc::{document, text_of}; +pub use input::{resolve, PointerFact}; +pub use serve::{Guard, Refusal, Request}; + +#[cfg(test)] +mod coverage { + //! **ADR-0007 control 6.** The HTML counterpart of `cb-play`'s + //! `every_view_field_is_classified`. + //! + //! Same shape as CB-WP-0011's gate, one layer further out: walk the + //! serialized `GroundView` for every leaf *path*, and require each to + //! be either rendered — with a token that must appear in the **parsed + //! document** — or omitted with a stated reason. A new field on + //! `GroundView` that is in neither list fails the build. + //! + //! Paths, not keys: `problem` occurs under a DARVO target, a GROUND + //! choice and a Selection, and a key-set walk would let one vouch for + //! the other two. + + use cb_kernel::PlayerId; + use games_ground::view::GroundView; + + use crate::doc::{document, text_of}; + + /// Every leaf path in the serialized view. + fn paths(v: &serde_json::Value, prefix: &str, out: &mut Vec) { + match v { + serde_json::Value::Object(m) if !m.is_empty() => { + for (k, val) in m { + let p = if prefix.is_empty() { + k.clone() + } else { + format!("{prefix}.{k}") + }; + paths(val, &p, out); + } + } + serde_json::Value::Array(a) if !a.is_empty() => { + for item in a { + paths(item, &format!("{prefix}.*"), out); + } + } + _ => out.push(prefix.to_string()), + } + } + + /// Collapse map keys to `*` so the classification is over fields, not + /// over whichever seats and priorities the fixture happens to hold. + fn normalize(path: &str) -> String { + const MAPS: &[&str] = &[ + "players", + "relations", + "problems", + "focus", + "selections", + "ground_modes", + "ground_choices", + "support_responses", + "darvo_targets", + "personal", + ]; + let mut parts: Vec = Vec::new(); + let mut prev_is_map = false; + for seg in path.split('.') { + if prev_is_map && seg != "*" { + parts.push("*".to_string()); + } else { + parts.push(seg.to_string()); + } + prev_is_map = MAPS.contains(&seg); + } + parts.join(".") + } + + /// `(leaf path, a token the parsed document must contain)`. + const RENDERED: &[(&str, &str)] = &[ + ("round", "round 3"), + ("lead", "lead P2"), + ("step", "step Select"), + ("mode", "scoring BondedCoalitions"), + ("viewer", "viewing as P1"), + ("solution_deck_len", "17 remaining"), + ("solution_discard.*.suit", "discard Repair Change"), + ("players.*.stress", "stress 5"), + ("players.*.protection", "protect 2"), + ("players.*.darvo", "darvo Reverse"), + ("players.*.freedom_ready", "READY"), + ("players.*.freedom_gate_lifted", "gate lifted"), + ("players.*.blame_from.*", "blamed by P3"), + // The empty case is a leaf path of its own, and renders as an + // explicit absence rather than as nothing at all. + ("players.*.blame_from", "blamed by none"), + ("players.*.hand.*.suit", "hand Clarify Boundary"), + ("players.*.hand_size", "cards)"), + ("relations.*", "Rivalry"), + ("problems.*.state", "face down"), + ("problems.*.suit", "Change 6"), + ("problems.*.value", "Change 6"), + ("problems.*.denied", "denied"), + ("problems.*.claimed_by", "claimed by P2"), + ("problems.*.protected_this_round", "protected"), + ("focus.*", "\u{2192}P3"), + ("selections.*.state", "face down"), + ("selections.*.action", "action: Attack"), + ("selections.*.target", "target: Some(PlayerId(1))"), + ("selections.*.problem", "problem: Some(7)"), + ("ground_modes.*", "ground mode Gr"), + ("ground_choices.*.choice", "ProtectProblem"), + ("ground_choices.*.problem", "problem: 7 }"), + ("support_responses.*", "support AcceptBond"), + ("darvo_targets.*.problem", "problem: Some(1)"), + ("darvo_targets.*.player", "player: Some(PlayerId(1))"), + ("outcome.total", "total 9"), + ("outcome.threshold", "of 12"), + ("outcome.group_success", "failure"), + ("outcome.personal.*", "P1 +3"), + ("outcome.coalitions.*.members.*", "members: [PlayerId(1)"), + ("outcome.coalitions.*.score", "score: 4"), + ("outcome.mastery", "mastery +1"), + ("outcome.winners.*", "winners P1"), + ]; + + /// Deliberate omissions, each with a reason. + const OMITTED: &[(&str, &str)] = &[( + "players.*.hand", + "null for a non-viewer seat; the absence is rendered as a count", + )]; + + fn fixture() -> GroundView { + crate::testfix::view(Some(PlayerId(0))) + } + + fn rendered_document(view: &GroundView) -> String { + text_of(&document( + view, + &[], + "/command?t=x", + Some(PlayerId(0)), + false, + )) + } + + #[test] + fn every_view_field_is_classified_in_the_emitted_document() { + let view = fixture(); + let json = serde_json::to_value(&view).expect("view serializes"); + let mut raw = Vec::new(); + paths(&json, "", &mut raw); + let all: std::collections::BTreeSet = raw.iter().map(|p| normalize(p)).collect(); + + // EXPECT-VACUOUS floor. A coverage test over zero paths passes + // trivially, and that is precisely how this check would rot. + assert!( + all.len() >= 30, + "the walk found {} leaf path(s) — it is not walking the view", + all.len() + ); + + let claimed: std::collections::BTreeSet<&str> = RENDERED + .iter() + .map(|(p, _)| *p) + .chain(OMITTED.iter().map(|(p, _)| *p)) + .collect(); + + let unclassified: Vec<&String> = all + .iter() + .filter(|p| !claimed.contains(p.as_str())) + .collect(); + assert!( + unclassified.is_empty(), + "unclassified path(s) in GroundView: {unclassified:?} \ + — add each to RENDERED with a token, or to OMITTED with a reason" + ); + + let stale: Vec<&&str> = claimed.iter().filter(|p| !all.contains(**p)).collect(); + assert!( + stale.is_empty(), + "classified path(s) no longer exist in GroundView: {stale:?}" + ); + + // The half that makes it a rendering gate rather than a bookkeeping + // one: the token must be in the *parsed document*. + let text = rendered_document(&view); + let missing: Vec<&str> = RENDERED + .iter() + .filter(|(_, tok)| !text.contains(tok)) + .map(|(p, _)| *p) + .collect(); + assert!( + missing.is_empty(), + "claimed rendered, but absent from the emitted document: {missing:?}" + ); + } + + /// The parse must be a parse. If `text_of` returned the raw source, a + /// token hiding in a comment, a style rule or the script would count + /// as rendered — which is the loophole control 6 exists to close. + #[test] + fn the_parse_does_not_see_script_or_style_contents() { + let html = "

seen

\ + "; + let text = text_of(html); + assert!(text.contains("seen")); + assert!(text.contains("pid"), "element ids are addressable surface"); + assert!( + !text.contains("STYLETOKEN"), + "style contents leaked into the text" + ); + assert!( + !text.contains("SCRIPTTOKEN"), + "script contents leaked into the text" + ); + } + + /// Control 5, asserted at the document level: the emitted JavaScript + /// must contain no game vocabulary. If a rule ever needs to live in + /// the page, ADR-0007 says revisit the decision, not widen this. + #[test] + fn the_emitted_javascript_contains_no_game_vocabulary() { + let doc = document(&fixture(), &[], "/command?t=x", Some(PlayerId(0)), false); + let script = doc + .split_once("")) + .map(|(s, _)| s.to_string()) + .expect("the document carries a script"); + for word in [ + "Investigate", + "Solve", + "Support", + "Attack", + "Ground", + "darvo", + "DARVO", + "stress", + "freedom", + "problem", + "coalition", + "reveal", + "resolve", + ] { + assert!( + !script.contains(word), + "the emitted JavaScript mentions {word:?} — control 5 is breached" + ); + } + // And it must still be doing its one job. + assert!(script.contains("pointerdown") && script.contains("pointerup")); + } + + /// The renderer must not invent a hand it was never given. + /// + /// Scoped deliberately: the *projection's* hiding rules are asserted + /// where they live, and re-asserting them here would be a duplicated + /// fact that drifts. What this checks is the renderer's own failure + /// mode — that `hand: None` renders as an absence, for every seat, and + /// that a spectator's document contains no open hand at all. + #[test] + fn the_renderer_never_invents_a_hand_it_was_not_given() { + for seat in [0u8, 1, 2] { + let view = crate::testfix::view(Some(PlayerId(seat))); + let text = rendered_document(&view); + let shown = text.matches("cards)").count(); + assert_eq!( + shown, + 1, + "P{} sees {shown} open hands in the document; it was given exactly one", + seat + 1 + ); + assert_eq!( + text.matches("card(s), hidden").count(), + 2, + "the other two seats' hands must render as an absence with a count" + ); + } + let text = rendered_document(&crate::testfix::view(None)); + assert_eq!( + text.matches("cards)").count(), + 0, + "a spectator document shows an open hand" + ); + assert!(text.contains("a spectator (no hands)")); + } +} + +#[cfg(test)] +mod testfix; diff --git a/crates/cb-render-html/src/serve.rs b/crates/cb-render-html/src/serve.rs new file mode 100644 index 0000000..dfe464d --- /dev/null +++ b/crates/cb-render-html/src/serve.rs @@ -0,0 +1,323 @@ +//! The loopback listener, and the controls that make it safe to have one. +//! +//! ADR-0007 Decision 5, controls 1–4. The threat the review named is not +//! hypothetical: +//! +//! > A loopback listener is reachable by any process on the machine **and +//! > by any web page the user visits**. The browser running the table is +//! > the same browser reading the internet — that is the whole premise of +//! > the option. An unauthenticated endpoint accepting `POST /command` and +//! > mutating authoritative game state is a remote-controlled game from +//! > any tab the user has open. +//! +//! So: a per-process token no page can guess, an `Origin` / +//! `Sec-Fetch-Site` check that rejects by default, an explicit +//! `127.0.0.1` bind, and — the control that makes the other three +//! evidence rather than claims — a test that a token-less request is +//! refused, with a mutation that turns it red. +//! +//! [`Guard::admit`] is a pure function of a parsed request, so all of that +//! is testable with no socket, no browser, and no timing. + +use std::io::Read; + +/// Why a request was refused. Each variant is a control that fired. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Refusal { + /// Control 1: no token at all. + MissingToken, + /// Control 1: a token, but not ours. + BadToken, + /// Control 2: the request came from somewhere else's page. + CrossSite(String), + /// Not a shape this endpoint serves. + BadRequest(String), +} + +impl std::fmt::Display for Refusal { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Refusal::MissingToken => write!(f, "refused: no session token"), + Refusal::BadToken => write!(f, "refused: wrong session token"), + Refusal::CrossSite(o) => write!(f, "refused: cross-site request from {o}"), + Refusal::BadRequest(m) => write!(f, "refused: {m}"), + } + } +} + +/// A parsed HTTP/1.1 request, reduced to the fields the controls need. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Request { + pub method: String, + pub path: String, + pub token: Option, + pub origin: Option, + pub sec_fetch_site: Option, + pub body: String, +} + +impl Request { + /// Parse a raw request. Deliberately strict: this is hand-rolled HTTP + /// on a socket a hostile page can reach, so anything unexpected is a + /// refusal rather than a best effort. + pub fn parse(raw: &str) -> Result { + let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((raw, "")); + let mut lines = head.split("\r\n"); + let start = lines + .next() + .ok_or_else(|| Refusal::BadRequest("empty request".into()))?; + let mut parts = start.split(' '); + let method = parts + .next() + .ok_or_else(|| Refusal::BadRequest("no method".into()))? + .to_string(); + let target = parts + .next() + .ok_or_else(|| Refusal::BadRequest("no target".into()))?; + + let (path, query) = target.split_once('?').unwrap_or((target, "")); + let token = query.split('&').find_map(|kv| { + kv.strip_prefix("t=") + .filter(|v| !v.is_empty()) + .map(str::to_string) + }); + + let mut req = Request { + method, + path: path.to_string(), + token, + body: body.to_string(), + ..Default::default() + }; + for line in lines { + if let Some((k, v)) = line.split_once(':') { + let v = v.trim().to_string(); + match k.to_ascii_lowercase().as_str() { + "origin" => req.origin = Some(v), + "sec-fetch-site" => req.sec_fetch_site = Some(v), + _ => {} + } + } + } + Ok(req) + } +} + +/// Holds the per-process token and applies controls 1 and 2. +#[derive(Debug, Clone)] +pub struct Guard { + token: String, + origin: String, +} + +impl Guard { + pub fn new(token: impl Into, port: u16) -> Self { + Self { + token: token.into(), + origin: format!("http://127.0.0.1:{port}"), + } + } + + /// Mint an unguessable token. + /// + /// `/dev/urandom` where it exists; otherwise `RandomState`, whose + /// per-process seed the OS randomises. The fallback is weaker and is + /// named rather than hidden — a token that silently degrades to + /// something predictable is worse than no token, because the controls + /// would still report themselves as passing. + pub fn mint(port: u16) -> Self { + let mut buf = [0u8; 24]; + let strong = std::fs::File::open("/dev/urandom") + .and_then(|mut f| f.read_exact(&mut buf)) + .is_ok(); + if !strong { + use std::hash::{BuildHasher, Hasher}; + let s = std::collections::hash_map::RandomState::new(); + for chunk in buf.chunks_mut(8) { + let mut h = s.build_hasher(); + h.write_usize(std::process::id() as usize); + h.write_u128( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + ); + chunk.copy_from_slice(&h.finish().to_le_bytes()[..chunk.len()]); + } + } + let token: String = buf.iter().map(|b| format!("{b:02x}")).collect(); + Self::new(token, port) + } + + pub fn token(&self) -> &str { + &self.token + } + + /// The URL the page is told to POST to. The page cannot mint one. + pub fn endpoint(&self) -> String { + format!("/command?t={}", self.token) + } + + pub fn page_url(&self) -> String { + format!("{}/?t={}", self.origin, self.token) + } + + /// Apply controls 1 and 2. Rejects by default. + pub fn admit(&self, req: &Request) -> Result<(), Refusal> { + // Control 2 first: a cross-site request should be refused before + // its token is even considered, so a leaked token is not enough on + // its own. + match req.sec_fetch_site.as_deref() { + // Sent by every modern browser. Anything but same-origin is + // not our page. + Some("same-origin") | Some("none") => {} + Some(other) => return Err(Refusal::CrossSite(other.to_string())), + None => { + // Older or non-browser clients omit it; fall back to + // Origin, and require it to be ours when present. + if let Some(o) = &req.origin { + if o != &self.origin { + return Err(Refusal::CrossSite(o.clone())); + } + } + } + } + if let Some(o) = &req.origin { + if o != &self.origin { + return Err(Refusal::CrossSite(o.clone())); + } + } + + // Control 1. + match &req.token { + None => Err(Refusal::MissingToken), + Some(t) if constant_eq(t, &self.token) => Ok(()), + Some(_) => Err(Refusal::BadToken), + } + } +} + +/// Compare without an early return on the first differing byte. +/// +/// The endpoint is loopback and the token is 192 bits, so a timing oracle +/// is not the realistic attack here. It costs one line. +fn constant_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { + return false; + } + a.bytes() + .zip(b.bytes()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +/// Control 3: bind explicitly to loopback, never `0.0.0.0`. +pub fn bind(port: u16) -> std::io::Result { + std::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, port)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn guard() -> Guard { + Guard::new("s3cr3t", 8731) + } + + fn req(raw: &str) -> Request { + Request::parse(raw).expect("parses") + } + + /// **ADR-0007 control 4.** The one that makes controls 1–3 evidence + /// rather than claims. + /// + /// M-D1-MUT: delete the `None => Err(Refusal::MissingToken)` arm in + /// `admit` (return `Ok(())` instead) and this goes red with + /// `a token-less request was admitted`. Run 2026-08-02. + #[test] + fn a_token_less_request_is_refused() { + let g = guard(); + let r = req("POST /command HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\ndown=a&up=b"); + assert_eq!( + g.admit(&r), + Err(Refusal::MissingToken), + "a token-less request was admitted" + ); + } + + #[test] + fn a_wrong_token_is_refused() { + let g = guard(); + let r = req("POST /command?t=guess HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"); + assert_eq!(g.admit(&r), Err(Refusal::BadToken)); + // A prefix of the real token must not be admitted either. + let r = req("POST /command?t=s3c HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\r\n"); + assert_eq!(g.admit(&r), Err(Refusal::BadToken)); + } + + #[test] + fn the_right_token_from_our_own_page_is_admitted() { + let g = guard(); + let r = req( + "POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: same-origin\r\n\ + Origin: http://127.0.0.1:8731\r\n\r\ndown=a&up=b", + ); + assert_eq!(g.admit(&r), Ok(())); + } + + /// The attack the review named: a page on the open internet POSTs to + /// `127.0.0.1`. Even holding a leaked token, it must not be admitted. + #[test] + fn a_cross_site_request_is_refused_even_with_the_right_token() { + let g = guard(); + let r = req( + "POST /command?t=s3cr3t HTTP/1.1\r\nSec-Fetch-Site: cross-site\r\n\ + Origin: https://evil.example\r\n\r\ndown=a&up=b", + ); + assert!( + matches!(g.admit(&r), Err(Refusal::CrossSite(_))), + "{:?}", + g.admit(&r) + ); + + // A client that omits Sec-Fetch-Site but sends a foreign Origin is + // the same attack with an older browser. + let r = req( + "POST /command?t=s3cr3t HTTP/1.1\r\nOrigin: https://evil.example\r\n\r\ndown=a&up=b", + ); + assert!(matches!(g.admit(&r), Err(Refusal::CrossSite(_)))); + } + + #[test] + fn requests_parse_and_carry_what_the_controls_need() { + let r = req("POST /command?t=abc HTTP/1.1\r\nHost: 127.0.0.1:8731\r\n\ + Origin: http://127.0.0.1:8731\r\nSec-Fetch-Site: same-origin\r\n\ + Content-Length: 9\r\n\r\ndown=a&up=b"); + assert_eq!(r.method, "POST"); + assert_eq!(r.path, "/command"); + assert_eq!(r.token.as_deref(), Some("abc")); + assert_eq!(r.origin.as_deref(), Some("http://127.0.0.1:8731")); + assert_eq!(r.sec_fetch_site.as_deref(), Some("same-origin")); + assert_eq!(r.body, "down=a&up=b"); + // An empty t= is no token, not a token that happens to be empty. + let r = req("GET /?t= HTTP/1.1\r\n\r\n"); + assert_eq!(r.token, None); + } + + /// A minted token must not be a constant, and must not be short. + #[test] + fn a_minted_token_is_long_and_not_reused() { + let a = Guard::mint(1).token().to_string(); + let b = Guard::mint(1).token().to_string(); + assert_eq!(a.len(), 48, "token is {} hex chars", a.len()); + assert_ne!(a, b, "two mints produced the same token"); + assert!(a.chars().all(|c| c.is_ascii_hexdigit())); + } + + /// Control 3, asserted rather than commented. + #[test] + fn the_listener_binds_loopback_only() { + let l = bind(0).expect("bind"); + assert_eq!(l.local_addr().unwrap().ip().to_string(), "127.0.0.1"); + } +} diff --git a/crates/cb-render-html/src/testfix.rs b/crates/cb-render-html/src/testfix.rs new file mode 100644 index 0000000..d252e07 --- /dev/null +++ b/crates/cb-render-html/src/testfix.rs @@ -0,0 +1,118 @@ +//! A populated `GroundView`, built for the coverage gate. +//! +//! Mid-DARVO, mid-GROUND, scored — **not** a fresh deal. Most of the +//! fields this gate exists to cover are empty at deal time, and a coverage +//! test run against a state where the fields are absent is the same lie in +//! a different costume (CB-WP-0011 T01). +//! +//! Built directly rather than projected from a `GroundState`. The +//! projection's own hiding rules are already asserted by +//! `games_ground::view` and by `cb-play`'s +//! `the_inspector_never_widens_the_projection`; re-asserting them here +//! would be a duplicated fact that drifts. What *this* crate must not do +//! is invent a hand it was never given, and that is what the renderer test +//! checks — with `hand: None` supplied deliberately. + +use std::collections::BTreeMap; + +use cb_kernel::PlayerId; +use games_ground::view::{GroundView, OutcomeView, PlayerView, ProblemView, SelectionView}; +use games_ground::{ + Action, Coalition, DarvoStage, DarvoTarget, GroundChoice, GroundMode, Pair, Relation, + RoundStep, ScoringMode, Selection, SolutionCard, Suit, SupportResponse, +}; + +pub fn seats() -> (PlayerId, PlayerId, PlayerId) { + (PlayerId(0), PlayerId(1), PlayerId(2)) +} + +/// The fixture, seen by `viewer` (`None` for a spectator). +pub fn view(viewer: Option) -> GroundView { + let (p1, p2, p3) = seats(); + let card = |suit| SolutionCard { suit }; + + let mut players = BTreeMap::new(); + for (id, stress, protection, darvo, ready, lifted, blame) in [ + (p1, 5u8, 2u8, DarvoStage::Reverse, true, true, vec![p3]), + (p2, 1, 0, DarvoStage::Off, false, false, vec![]), + (p3, 0, 0, DarvoStage::Deny, true, false, vec![]), + ] { + let own = viewer == Some(id); + players.insert( + id, + PlayerView { + stress, + freedom_ready: ready, + freedom_gate_lifted: lifted, + darvo, + protection, + blame_from: blame, + hand: own.then(|| vec![card(Suit::Clarify), card(Suit::Boundary)]), + hand_size: 2, + }, + ); + } + + let mut problems = BTreeMap::new(); + problems.insert(1, ProblemView::FaceDown); + problems.insert( + 7, + ProblemView::FaceUp { + suit: Suit::Change, + value: 6, + denied: true, + claimed_by: Some(p2), + protected_this_round: true, + }, + ); + + GroundView { + viewer, + round: 3, + lead: p2, + step: RoundStep::Select, + mode: ScoringMode::BondedCoalitions, + players, + relations: BTreeMap::from([ + (Pair::new(p1, p2), Relation::Rivalry), + (Pair::new(p2, p3), Relation::Bond), + ]), + problems, + focus: BTreeMap::from([(p1, p3)]), + selections: BTreeMap::from([ + ( + p1, + SelectionView::Shown(Selection { + action: Action::Attack, + target: Some(p2), + problem: Some(7), + }), + ), + (p2, SelectionView::Hidden), + ]), + ground_modes: BTreeMap::from([(p3, GroundMode::Gr)]), + ground_choices: BTreeMap::from([(p3, GroundChoice::ProtectProblem { problem: 7 })]), + support_responses: BTreeMap::from([(p2, SupportResponse::AcceptBond)]), + darvo_targets: BTreeMap::from([( + p3, + DarvoTarget { + problem: Some(1), + player: Some(p2), + }, + )]), + solution_deck_len: 17, + solution_discard: vec![card(Suit::Repair), card(Suit::Change)], + outcome: Some(OutcomeView { + total: 9, + threshold: 12, + group_success: false, + personal: BTreeMap::from([(p1, 3), (p2, -1), (p3, 0)]), + coalitions: vec![Coalition { + members: vec![p2, p3], + score: 4, + }], + mastery: Some(1), + winners: vec![p1], + }), + } +} diff --git a/research/CB-RES-0006-render-port.md b/research/CB-RES-0006-render-port.md index c3e9a74..5dc30f5 100644 --- a/research/CB-RES-0006-render-port.md +++ b/research/CB-RES-0006-render-port.md @@ -1,5 +1,13 @@ # CB-RES-0006 — the render port, and what a 2D table costs +capability: render.table.2d +status: approved (v2, after adversarial review — v1 was **not approvable**, +with four conceded findings; see §2, §4, §5, §7) +tier: L (structural L — the declaration was made when this pass would +create a capability port; chaos d4=1 → no override. Review then removed +the port, and the tier was deliberately **not** re-derived: a tier that +changes because review shrank the work is a function of the outcome) + CB-WP-0012 T01. Step 1 of the inner loop. Measured 2026-08-02 at `331e7e9`. diff --git a/tools/cb-play/Cargo.toml b/tools/cb-play/Cargo.toml index da170fd..3bafd06 100644 --- a/tools/cb-play/Cargo.toml +++ b/tools/cb-play/Cargo.toml @@ -7,6 +7,7 @@ license-file.workspace = true [dependencies] cb-kernel.workspace = true cb-events.workspace = true +cb-render-html.workspace = true cb-game-runtime = { workspace = true, features = ["scenarios"] } games-ground = { workspace = true, features = ["scenarios"] } serde_json.workspace = true diff --git a/tools/cb-play/src/hotseat.rs b/tools/cb-play/src/hotseat.rs new file mode 100644 index 0000000..f53fdd4 --- /dev/null +++ b/tools/cb-play/src/hotseat.rs @@ -0,0 +1,373 @@ +//! Hot-seat play in a browser (ADR-0007, INTENT stage 1). +//! +//! One loopback listener, one tab, seats taking turns. The server blocks +//! inside [`Policy::choose`] until the page reports a pointer fact that +//! resolves to a legal command — so the game loop is the same +//! `games_ground::bot::play` loop the CLI and the bots run through, and a +//! browser seat is indistinguishable from a CLI seat to the driver. +//! +//! Every control in ADR-0007 §Decision 5 lives in `cb-render-html`; this +//! module is the socket and the turn-taking. It deliberately holds no +//! game logic beyond "which seat is being asked" — [`cb_render_html`] +//! decides what a drag means, and the aggregate decides whether the +//! result is legal. + +use std::cell::RefCell; +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::rc::Rc; + +use cb_game_runtime::{Project, Viewer}; +use cb_kernel::PlayerId; +use cb_render_html::{document, resolve, Guard, PointerFact, Request}; +use games_ground::bot::{Choice, Policy}; +use games_ground::{GroundCommand, GroundState}; + +/// The shared listener. One per process; every human seat borrows it. +pub struct Server { + listener: TcpListener, + guard: Guard, + log: RefCell>, +} + +impl Server { + pub fn bind(port: u16) -> Result { + let listener = cb_render_html::serve::bind(port).map_err(|e| format!("bind: {e}"))?; + let port = listener + .local_addr() + .map_err(|e| format!("local_addr: {e}"))? + .port(); + Ok(Self { + listener, + guard: Guard::mint(port), + log: RefCell::new(Vec::new()), + }) + } + + /// The URL to open, token and all. Printed once at start; the page + /// cannot mint one for itself. + pub fn url(&self) -> String { + self.guard.page_url() + } + + /// What was refused, for the evidence a session leaves behind. + pub fn refusals(&self) -> Vec { + self.log.borrow().clone() + } + + /// Serve until the page reports something that resolves to a move. + /// + /// Requests that fail a control are answered and the loop continues — + /// a refused request must not end someone's game, and must not be + /// silently indistinguishable from one that did nothing. + pub fn next_choice( + &self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + may_pass: bool, + ) -> Result { + let view = state.project(Viewer::Player(seat)); + loop { + let (mut stream, _) = self.listener.accept().map_err(|e| format!("accept: {e}"))?; + let raw = match read_request(&mut stream) { + Ok(r) => r, + Err(e) => { + respond(&mut stream, 400, "text/plain", &e); + continue; + } + }; + let req = match Request::parse(&raw) { + Ok(r) => r, + Err(refusal) => { + self.log.borrow_mut().push(refusal.to_string()); + respond(&mut stream, 400, "text/plain", &refusal.to_string()); + continue; + } + }; + if let Err(refusal) = self.guard.admit(&req) { + self.log.borrow_mut().push(refusal.to_string()); + respond(&mut stream, 403, "text/plain", &refusal.to_string()); + continue; + } + + match (req.method.as_str(), req.path.as_str()) { + ("GET", "/") => { + let page = document(&view, legal, &self.guard.endpoint(), Some(seat), may_pass); + respond(&mut stream, 200, "text/html; charset=utf-8", &page); + } + ("POST", "/command") => { + let fact = match PointerFact::parse(&req.body) { + Ok(f) => f, + Err(e) => { + respond(&mut stream, 400, "text/plain", &e); + continue; + } + }; + if may_pass && fact.down == "pass" && fact.up == "pass" { + respond(&mut stream, 200, "text/plain", "ok: passed"); + return Ok(Choice::Pass); + } + match resolve(&fact, legal, seat) { + Ok(i) => { + respond(&mut stream, 200, "text/plain", "ok"); + return Ok(Choice::Command(i)); + } + // Not an error: the player dragged somewhere that + // means nothing. Say so and keep the turn. + Err(why) => respond(&mut stream, 200, "text/plain", &why), + } + } + _ => respond(&mut stream, 404, "text/plain", "no such thing here"), + } + } + } +} + +/// One seat's view of the shared server. +pub struct SeatPolicy { + server: Rc, + failure: Rc>>, +} + +impl SeatPolicy { + pub fn new(server: Rc, failure: Rc>>) -> Self { + Self { server, failure } + } +} + +impl Policy for SeatPolicy { + fn name(&self) -> &'static str { + "browser" + } + + fn choose( + &mut self, + state: &GroundState, + seat: PlayerId, + legal: &[GroundCommand], + may_pass: bool, + ) -> Choice { + match self.server.next_choice(state, seat, legal, may_pass) { + Ok(c) => c, + Err(e) => { + // Same shape as the CLI's out-of-input path: record the + // real reason and return something the driver will + // reject, rather than inventing a move. + *self.failure.borrow_mut() = Some(e); + Choice::Command(usize::MAX) + } + } + } +} + +fn read_request(stream: &mut TcpStream) -> Result { + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + let n = stream.read(&mut chunk).map_err(|e| format!("read: {e}"))?; + if n == 0 { + break; + } + buf.extend_from_slice(&chunk[..n]); + // Once the head is in, read exactly the declared body and stop — + // otherwise a keep-alive connection blocks here forever. + if let Some(head_end) = find(&buf, b"\r\n\r\n") { + let head = String::from_utf8_lossy(&buf[..head_end]).to_string(); + let want: usize = head + .split("\r\n") + .find_map(|l| { + let (k, v) = l.split_once(':')?; + k.eq_ignore_ascii_case("content-length") + .then(|| v.trim().parse().ok())? + }) + .unwrap_or(0); + if buf.len() >= head_end + 4 + want { + break; + } + } + if buf.len() > 64 * 1024 { + return Err("request too large".to_string()); + } + } + String::from_utf8(buf).map_err(|_| "request is not UTF-8".to_string()) +} + +fn find(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) +} + +fn respond(stream: &mut TcpStream, status: u16, content_type: &str, body: &str) { + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + 403 => "Forbidden", + _ => "Not Found", + }; + let head = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\n\ + Content-Length: {}\r\nConnection: close\r\n\ + X-Content-Type-Options: nosniff\r\n\r\n", + body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(body.as_bytes()); + let _ = stream.flush(); +} + +#[cfg(test)] +mod tests { + use super::*; + use games_ground::Action; + + /// One client thread issuing requests **in order**, each on its own + /// `Connection: close` socket and each fully read before the next is + /// sent. Two concurrent clients would race the server's `accept`, and + /// a test whose outcome depends on which socket wins is worse than no + /// test. + fn converse(port: u16, requests: Vec) -> std::thread::JoinHandle> { + std::thread::spawn(move || { + requests + .into_iter() + .map(|raw| { + let mut s = TcpStream::connect(("127.0.0.1", port)).expect("connect"); + s.write_all(raw.as_bytes()).expect("write"); + let mut out = String::new(); + let _ = s.read_to_string(&mut out); + out + }) + .collect() + }) + } + + fn get(token: &str) -> String { + format!( + "GET /?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Sec-Fetch-Site: same-origin\r\n\r\n" + ) + } + + fn post(token: &str, body: &str) -> String { + format!( + "POST /command?t={token} HTTP/1.1\r\nHost: 127.0.0.1\r\n\ + Sec-Fetch-Site: same-origin\r\nContent-Length: {}\r\n\r\n{body}", + body.len() + ) + } + + fn ground_only() -> Vec { + vec![GroundCommand::SelectAction { + action: Action::Ground, + target: None, + problem: None, + }] + } + + fn state() -> GroundState { + ::setup( + &cb_game_runtime::Setup { + players: 3, + preset: "standard-3p".to_string(), + patch: Default::default(), + }, + 7, + ) + .expect("setup") + } + + /// The whole loop, with a real socket and no browser: the page is + /// served, a pointer fact is posted, and the seat's choice comes back. + #[test] + fn a_drag_posted_over_the_socket_becomes_a_choice() { + let server = Server::bind(0).expect("bind"); + let port = server.listener.local_addr().unwrap().port(); + let token = server.url().rsplit("t=").next().unwrap().to_string(); + let client = converse( + port, + vec![get(&token), post(&token, "down=action-ground&up=table")], + ); + + let choice = server + .next_choice(&state(), PlayerId(0), &ground_only(), false) + .expect("a choice"); + assert_eq!(choice, Choice::Command(0)); + + let replies = client.join().expect("client thread"); + assert!(replies[0].contains("200 OK"), "{}", replies[0]); + assert!(replies[0].contains("GROUND"), "the page was not the table"); + assert!( + replies[0].contains("id=\"action-ground\""), + "the offered action was not on the page" + ); + assert!(replies[1].contains("200 OK")); + assert!(server.refusals().is_empty()); + } + + /// ADR-0007 control 1, end to end rather than in the unit: a page + /// without the token gets nothing, and the seat keeps its turn. + /// + /// M-D1-MUT: make `admit` return `Ok(())` unconditionally and this + /// goes red — the token-less request is served the table. Run + /// 2026-08-02. + #[test] + fn a_token_less_request_over_the_socket_is_refused_and_the_turn_continues() { + let server = Server::bind(0).expect("bind"); + let port = server.listener.local_addr().unwrap().port(); + let token = server.url().rsplit("t=").next().unwrap().to_string(); + let client = converse( + port, + vec![ + "GET / HTTP/1.1\r\nHost: 127.0.0.1\r\nSec-Fetch-Site: same-origin\r\n\r\n" + .to_string(), + post(&token, "down=action-ground&up=table"), + ], + ); + + // The turn survives the refusal and is decided by the good request. + let choice = server + .next_choice(&state(), PlayerId(0), &ground_only(), false) + .expect("a choice"); + assert_eq!(choice, Choice::Command(0)); + + let replies = client.join().expect("client thread"); + assert!(replies[0].contains("403 Forbidden"), "{}", replies[0]); + assert!( + !replies[0].contains("GROUND"), + "the table leaked to a token-less request" + ); + assert!(replies[1].contains("200 OK")); + assert_eq!( + server.refusals(), + vec!["refused: no session token".to_string()] + ); + } + + /// A drag that means nothing must not end the turn, and must say so. + #[test] + fn a_meaningless_drag_keeps_the_turn() { + let server = Server::bind(0).expect("bind"); + let port = server.listener.local_addr().unwrap().port(); + let token = server.url().rsplit("t=").next().unwrap().to_string(); + let client = converse( + port, + vec![ + post(&token, "down=seat-1&up=seat-2"), + post(&token, "down=action-ground&up=table"), + ], + ); + + let choice = server + .next_choice(&state(), PlayerId(0), &ground_only(), false) + .expect("a choice"); + assert_eq!(choice, Choice::Command(0)); + + let replies = client.join().expect("client thread"); + assert!(replies[0].contains("200 OK")); + assert!( + replies[0].contains("not a legal move"), + "a meaningless drag must be told it meant nothing: {}", + replies[0] + ); + assert!(replies[1].contains("200 OK")); + } +} diff --git a/tools/cb-play/src/inspect.rs b/tools/cb-play/src/inspect.rs index 6b2466d..9e23f53 100644 --- a/tools/cb-play/src/inspect.rs +++ b/tools/cb-play/src/inspect.rs @@ -761,6 +761,7 @@ mod tests { bot: "greedy".into(), replay_dir: Some(dir.clone()), record: Some(dir.join("session.yaml")), + serve: None, }; let mut sink: Vec = Vec::new(); let summary = diff --git a/tools/cb-play/src/main.rs b/tools/cb-play/src/main.rs index e90066d..15fae45 100644 --- a/tools/cb-play/src/main.rs +++ b/tools/cb-play/src/main.rs @@ -9,6 +9,7 @@ //! success — a session that ends without an outcome is a failure, and //! saying otherwise is how a stalled game passes for a played one. +mod hotseat; mod inspect; mod table; @@ -16,7 +17,7 @@ use table::Config; const USAGE: &str = "\ usage: cb-play [--seed N] [--players N] [--seat N]... [--bot greedy|random] - [--replay DIR] [--all-bots] [--record FILE] + [--replay DIR] [--all-bots] [--record FILE] [--serve PORT] cb-play --inspect PATH [--as SEAT|spectator] play: @@ -27,6 +28,10 @@ play: --bot KIND policy for every other seat: greedy (default) or random --replay DIR write a .cbreplay bundle of the finished game to DIR --record FILE write the finished game as a scenario YAML + --serve PORT play human seats in a browser on 127.0.0.1:PORT instead + of on the terminal; 0 lets the OS pick. Prints a URL + carrying a per-process token — without it the page is + refused (ADR-0007). inspect a recorded game — a .cbreplay bundle directory or a scenario YAML: --inspect PATH render the table after every recorded step @@ -104,6 +109,15 @@ fn parse_args(argv: &[String]) -> Result { config.replay_dir = Some(value(i, argv, flag)?.into()); i += 2; } + "--serve" => { + play_flags.push(flag.into()); + config.serve = Some( + value(i, argv, flag)? + .parse() + .map_err(|e| format!("--serve: {e}"))?, + ); + i += 2; + } "--all-bots" => { all_bots = true; play_flags.push(flag.into()); @@ -245,6 +259,7 @@ mod tests { bot: "greedy".into(), replay_dir: None, record: None, + serve: None, }; let script = "0\n".repeat(400); let mut out: Vec = Vec::new(); @@ -283,6 +298,7 @@ mod tests { bot: "greedy".into(), replay_dir: None, record: None, + serve: None, }; let mut out: Vec = Vec::new(); table::play(&config, "0\n".repeat(200).as_bytes(), &mut out).expect("game"); @@ -344,6 +360,7 @@ mod tests { bot: "random".into(), replay_dir: None, record: None, + serve: None, }; let mut out: Vec = Vec::new(); let summary = table::play(&config, "".as_bytes(), &mut out).expect("bot game"); @@ -403,6 +420,7 @@ mod tests { bot: "greedy".into(), replay_dir: Some(dir.clone()), record: Some(dir.join("session.yaml")), + serve: None, }; let mut out: Vec = Vec::new(); let summary = table::play(&config, "".as_bytes(), &mut out).expect("game"); diff --git a/tools/cb-play/src/table.rs b/tools/cb-play/src/table.rs index 66b4a39..41a0937 100644 --- a/tools/cb-play/src/table.rs +++ b/tools/cb-play/src/table.rs @@ -32,6 +32,9 @@ pub struct Config { /// Where to write the finished game as a scenario file. A session /// somebody played becomes a regression test. pub record: Option, + /// Serve human seats in a browser instead of on the terminal + /// (ADR-0007). `Some(0)` lets the OS pick the port. + pub serve: Option, } impl Default for Config { @@ -43,6 +46,7 @@ impl Default for Config { bot: "greedy".into(), replay_dir: None, record: None, + serve: None, } } } @@ -213,10 +217,28 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( // Human seats and bot seats fill one policy vector; the driver does // not know which is which, which is the point. let failure: Failure = Default::default(); + // ADR-0007: a browser seat and a CLI seat are both just a Policy, so + // the driver cannot tell them apart — which is the property that lets + // a browser game replay as a scenario like any other. + let server = match config.serve { + Some(port) => { + let s = std::rc::Rc::new(crate::hotseat::Server::bind(port)?); + let _ = writeln!(out.borrow_mut(), " open {}", s.url()); + let _ = out.borrow_mut().flush(); + Some(s) + } + None => None, + }; let mut policies: Vec> = Vec::new(); for seat in 0..config.players { if config.human_seats.contains(&seat) { - policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))); + match &server { + Some(s) => policies.push(Box::new(crate::hotseat::SeatPolicy::new( + s.clone(), + failure.clone(), + ))), + None => policies.push(Box::new(HumanPolicy::new(input, out, failure.clone()))), + } } else { policies.push(bot_policy(&config.bot, config.seed + u64::from(seat))?); } @@ -248,6 +270,20 @@ fn run_game<'a, R: BufRead + 'a, W: Write + 'a>( game.commands, &end_hash[..12] ); + // ADR-0007 control 1 leaves evidence rather than only a 403: a + // session that was probed says so, so a player finds out from the + // transcript rather than from nothing at all. + if let Some(s) = &server { + let refusals = s.refusals(); + if refusals.is_empty() { + let _ = writeln!(w, " no requests were refused this session"); + } else { + let _ = writeln!(w, " {} request(s) refused:", refusals.len()); + for r in &refusals { + let _ = writeln!(w, " {r}"); + } + } + } let _ = w.flush(); drop(w);