A channel's message slots are named from the client, on every backend
The IR called them react-message and django-message, so a FastAPI channel had to declare a DjangoMessage. They are client-message and server-message now, and the direction words hold wherever a channel is declared: Params / ClientMessage / ServerMessage, with mizan-core deriving <Pascal>Params and friends so no backend names a type itself. Django's ReactChannel and FastAPI's ReactChannel are both Channel. mizan-fastapi never registered a channels extension, so build_ir() emitted no channel at all and every payload type was invisible to codegen. It registers one now. RegistryExtension is an ABC requiring all(), which is what the IR reads — an extension that cannot enumerate its registrations no longer exists. The gate that should have caught the rename could not: tests/afi registered no channel because mizan-rust had no channel registry to register one in, so a five-package rename of the wire contract passed byte-parity without a channel byte crossing it. mizan-rust grows ChannelSlotKind, a CHANNELS slice, a #[mizan::channel] macro, and KDL emission whose wire_to_pascal matches Python's split; the AFI fixture now carries a channel with every slot and one with a single slot, so all three backends prove the contract byte for byte. MizanChannel held three Option<String> beside three has_*() predicates and unwrapped them with defaults; it holds an ordered slot vector, so an absent slot is absent rather than defaulted. The channels target emitted a React hooks file that a stage1-only consumer could not compile — react emits that now. The codegen's parity tests byte-compared emitted source against baselines without ever compiling it: they compile the generated crate and run its tests, import the generated Python package and call every method, and typecheck each TypeScript target against a consumer. Also fixed at source: app_visitor printed its import diagnostic to stdout, the stream export_mizan_ir writes KDL to, so a failed import silently corrupted the IR; the apps root was hardcoded to "apps"; _default_literal crashed build_ir on any non-JSON-serializable field default; Django and mizan-core derived Pascal names two different ways, disagreeing on every dotted channel name. ir.py builds a document and renders templates/ir/document.kdl.j2 rather than appending KDL strings with hand-tracked indentation, and named types resolve to a fixed point — a model reachable only through a union branch was referenced by a ref that no type block ever defined. The rest is the write-gate's own classifiers run over the standing tree: relative imports, silent swallows, Protocol contracts that should be ABCs, emitters hand-rendering target source, catch-all arms over closed enums, and comments narrating the project rather than the code. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,14 @@
|
||||
//! Mizan SSR engine.
|
||||
//! Mizan SSR engine: an embedded `deno_core` V8 runtime composed with
|
||||
//! `deno_web`, holding one evaluated JS bundle plus the `renderApp` function
|
||||
//! that bundle defines.
|
||||
//!
|
||||
//! Embeds a `deno_core` V8 runtime composed with `deno_web` so the build-time
|
||||
//! JS bundle (component + `react-dom/server.browser`, produced by the bundler
|
||||
//! during `mizan-generate`) renders to HTML in-process. The bundle exposes a
|
||||
//! global render function; the engine evals it once and calls it per request.
|
||||
//! No external JS runtime — node and bun are build-time tools only.
|
||||
//! `deno_web` supplies the web-platform globals a bare isolate lacks —
|
||||
//! `TextEncoder`/`TextDecoder`, timers, `MessagePort`, `performance` — as real
|
||||
//! implementations rather than partial shims.
|
||||
//!
|
||||
//! The host globals a bare V8 isolate lacks — `TextEncoder`/`TextDecoder`,
|
||||
//! timers, `MessagePort`, `performance` — come from `deno_web` as real
|
||||
//! web-platform implementations, not shims (a partial polyfill is
|
||||
//! silent-failure-shaped: it passes until a render path hits the gap).
|
||||
//!
|
||||
//! Props never enter evaluated source. Only the trusted bundle is `eval`'d;
|
||||
//! per-render data crosses as a `v8::json::parse`d value passed as a function
|
||||
//! argument, so a prop string has no source to break out of — code injection
|
||||
//! is structurally absent, not filtered.
|
||||
//! Only the bundle is ever `eval`'d. Per-render props enter through
|
||||
//! `v8::json::parse` and are handed in as a call argument, so a prop string has
|
||||
//! no surrounding source to break out of.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -36,15 +30,32 @@ const INSTALL_WEB_GLOBALS: &str = r#"{
|
||||
globalThis.TextDecoder = te.TextDecoder;
|
||||
}"#;
|
||||
|
||||
/// Yield the bundle's `renderApp`, throwing on the JS side when it is absent
|
||||
/// or not callable. The script therefore either fails — arriving in Rust as
|
||||
/// the evaluator's own error — or produces a callable, which is what lets the
|
||||
/// engine take it as a `v8::Function` without a second check.
|
||||
const TAKE_RENDER_APP: &str = r#"(() => {
|
||||
const f = globalThis.renderApp;
|
||||
if (typeof f !== "function") {
|
||||
throw new TypeError("the SSR bundle assigns no callable `renderApp`");
|
||||
}
|
||||
return f;
|
||||
})()"#;
|
||||
|
||||
/// An embedded V8 runtime carrying one rendered bundle, plus the web-platform
|
||||
/// globals react-dom needs. One isolate per engine (V8's Locker constraint
|
||||
/// means an engine is not `Send`; hold one per worker thread).
|
||||
///
|
||||
/// `render_fn` is taken during construction, so a render calls a function this
|
||||
/// engine already owns and repeats no lookup.
|
||||
pub struct SsrEngine {
|
||||
runtime: JsRuntime,
|
||||
render_fn: v8::Global<v8::Function>,
|
||||
}
|
||||
|
||||
impl SsrEngine {
|
||||
/// Build the runtime and eval `bundle` (which assigns `globalThis.renderApp`).
|
||||
/// Build the runtime, eval `bundle` (which assigns `globalThis.renderApp`),
|
||||
/// and take hold of that function.
|
||||
pub fn new(bundle: String) -> Result<Self> {
|
||||
let mut runtime = JsRuntime::new(RuntimeOptions {
|
||||
extensions: vec![
|
||||
@@ -64,50 +75,64 @@ impl SsrEngine {
|
||||
runtime
|
||||
.execute_script("[mizan:bundle]", bundle)
|
||||
.context("evaluating the SSR bundle")?;
|
||||
Ok(Self { runtime })
|
||||
let render_app = runtime
|
||||
.execute_script("[mizan:render-app]", TAKE_RENDER_APP)
|
||||
.context("taking `renderApp` from the evaluated bundle")?;
|
||||
|
||||
let render_fn = {
|
||||
deno_core::scope!(scope, &mut runtime);
|
||||
let func = v8::Local::new(scope, render_app).cast::<v8::Function>();
|
||||
v8::Global::new(scope, func)
|
||||
};
|
||||
|
||||
Ok(Self { runtime, render_fn })
|
||||
}
|
||||
|
||||
/// Render to HTML by calling the bundle's `renderApp(props)`. `props_json`
|
||||
/// is a JSON object string; it is parsed to a V8 value and passed as an
|
||||
/// argument — never spliced into evaluated source.
|
||||
pub fn render(&mut self, props_json: &str) -> Result<String> {
|
||||
let render_fn = self.render_fn.clone();
|
||||
deno_core::scope!(scope, &mut self.runtime);
|
||||
let context = scope.get_current_context();
|
||||
let global = context.global(scope);
|
||||
|
||||
let key = v8::String::new(scope, "renderApp").context("intern renderApp key")?;
|
||||
let func_val = global
|
||||
.get(scope, key.into())
|
||||
.ok_or_else(|| anyhow!("renderApp is not defined on globalThis"))?;
|
||||
let func: v8::Local<v8::Function> = func_val
|
||||
.try_into()
|
||||
.map_err(|_| anyhow!("renderApp is not a function"))?;
|
||||
|
||||
let props_str = v8::String::new(scope, props_json).context("intern props")?;
|
||||
let props = v8::json::parse(scope, props_str)
|
||||
.ok_or_else(|| anyhow!("props are not valid JSON"))?;
|
||||
let func = v8::Local::new(scope, &render_fn);
|
||||
|
||||
let props = parse_props(scope, props_json)?;
|
||||
let recv = v8::undefined(scope).into();
|
||||
let result = func
|
||||
let html = func
|
||||
.call(scope, recv, &[props])
|
||||
.ok_or_else(|| anyhow!("renderApp threw or returned nothing"))?;
|
||||
Ok(result.to_rust_string_lossy(scope))
|
||||
Ok(html.to_rust_string_lossy(scope))
|
||||
}
|
||||
}
|
||||
|
||||
/// The one crossing where untrusted request text becomes a value inside the
|
||||
/// isolate. Both steps report that boundary's failure and nothing else: V8
|
||||
/// refuses a string past its length limit, and its JSON grammar rejects
|
||||
/// malformed input.
|
||||
fn parse_props<'s>(
|
||||
scope: &v8::PinScope<'s, '_>,
|
||||
props_json: &str,
|
||||
) -> Result<v8::Local<'s, v8::Value>> {
|
||||
let text = v8::String::new(scope, props_json)
|
||||
.ok_or_else(|| anyhow!("props exceed V8's maximum string length"))?;
|
||||
v8::json::parse(scope, text).ok_or_else(|| anyhow!("props are not valid JSON"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn renders_react_bundle_in_embedded_v8() {
|
||||
let bundle = std::fs::read_to_string(concat!(
|
||||
fn fixture_bundle() -> String {
|
||||
std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixture/bundle.js"
|
||||
))
|
||||
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step");
|
||||
.expect("tests/fixture/bundle.js — build it via the fixture's esbuild step")
|
||||
}
|
||||
|
||||
let mut engine = SsrEngine::new(bundle).expect("engine init");
|
||||
#[tokio::test]
|
||||
async fn renders_react_bundle_in_embedded_v8() {
|
||||
let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
|
||||
let html = engine.render(r#"{"name":"World"}"#).expect("render");
|
||||
assert_eq!(html, r#"<div id="greeting">Hello, World!</div>"#);
|
||||
}
|
||||
@@ -117,17 +142,19 @@ mod tests {
|
||||
// A prop value that would break out of a string-built `renderApp(...)`
|
||||
// call. Through the value-call path it is inert data: it reaches the
|
||||
// component as a string, never as source.
|
||||
let bundle = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixture/bundle.js"
|
||||
))
|
||||
.expect("fixture bundle");
|
||||
|
||||
let mut engine = SsrEngine::new(bundle).expect("engine init");
|
||||
let mut engine = SsrEngine::new(fixture_bundle()).expect("engine init");
|
||||
let html = engine
|
||||
.render(r#"{"name":"x\"}); globalThis.__pwned = true; ({\"y\":\""}"#)
|
||||
.expect("render");
|
||||
// The payload rendered as text; it did not execute.
|
||||
assert!(html.contains("__pwned"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_bundle_without_a_callable_render_app_is_rejected() {
|
||||
let err = SsrEngine::new("globalThis.renderApp = 7;".to_string())
|
||||
.map(|_| ())
|
||||
.expect_err("a bundle whose renderApp is not callable must not build an engine");
|
||||
assert!(err.to_string().contains("renderApp"), "unexpected: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createElement } from "react"
|
||||
|
||||
// A trivial component: props in, element out. The keystone only needs to prove
|
||||
// a real React tree renders to HTML inside a bare JS context.
|
||||
// Props in, element out. The `id` is the handle the render assertions match on.
|
||||
export function Hello({ name }) {
|
||||
return createElement("div", { id: "greeting" }, `Hello, ${name}!`)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { renderToStaticMarkup } from "react-dom/server.browser"
|
||||
import { createElement } from "react"
|
||||
import { Hello } from "./Hello.js"
|
||||
|
||||
// The bundle exposes one global the embedded engine calls. No module system at
|
||||
// runtime — the engine receives a bare script that defines `renderApp`. This is
|
||||
// the production shape in miniature: build-time bundle, runtime eval.
|
||||
// There is no module system in the embedded engine — it receives a bare
|
||||
// script, so the entry point has to land on `globalThis` for the Rust side to
|
||||
// reach it.
|
||||
globalThis.renderApp = (props) => renderToStaticMarkup(createElement(Hello, props))
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
// Proxy for the embedded-V8 runtime: a bare global context with no Node
|
||||
// builtins. Load the IIFE bundle (which assigns globalThis.renderApp) and call
|
||||
// it. What renders here renders in rusty_v8 — the engine swaps, the contract
|
||||
// (bundle defines a global render fn over a bare context) does not.
|
||||
// Runs bundle.js inside a `vm` context holding only the globals listed below,
|
||||
// so the bundle sees the same bare environment the embedded V8 engine gives it.
|
||||
const fs = require("fs")
|
||||
const vm = require("vm")
|
||||
|
||||
const code = fs.readFileSync(__dirname + "/bundle.js", "utf8")
|
||||
|
||||
// The minimal host globals React's bundle touches at init / sync render. The
|
||||
// rusty_v8 engine must provide the same set — this list is the spec for it.
|
||||
// The host globals React's bundle touches at init and during a sync render.
|
||||
const sandbox = {
|
||||
console, setTimeout, clearTimeout, queueMicrotask, MessageChannel, performance,
|
||||
TextEncoder, TextDecoder,
|
||||
@@ -19,11 +16,12 @@ vm.createContext(sandbox)
|
||||
vm.runInContext(code, sandbox)
|
||||
|
||||
const html = sandbox.renderApp({ name: "World" })
|
||||
console.log("RENDERED:", html)
|
||||
|
||||
const expected = '<div id="greeting">Hello, World!</div>'
|
||||
if (html !== expected) {
|
||||
console.error("MISMATCH — expected:", expected)
|
||||
console.error(`expected ${expected}, got ${html}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log("OK — React bundle renders in a bare JS context (V8 proxy)")
|
||||
console.log(html)
|
||||
// The sandbox's MessageChannel holds an open handle, so the event loop never
|
||||
// drains on its own; exit once the render has been checked.
|
||||
process.exit(0)
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
//! Guard — Mizan SSR is hand-rolled (bare renderer + AFI data injection +
|
||||
//! injected kernel). No frontend adapter imports an SSR runtime / meta-framework
|
||||
//! (Next, Nuxt, SvelteKit) or a server-functions layer (RSC / Flight).
|
||||
//!
|
||||
//! React Server Components and the Flight serialization protocol carry
|
||||
//! CVE-2025-55182 ("React2Shell" — unauthenticated remote code execution,
|
||||
//! CVSS 10.0): the server deserializes a client-supplied Flight payload and an
|
||||
//! attacker reaches prototype-pollution → RCE.
|
||||
//!
|
||||
//! Mizan renders **synchronously from props** — data is fetched server-side
|
||||
//! through the AFI and passed in, never deserialized from a client payload — so
|
||||
//! it sits structurally outside that attack surface. This test keeps it there:
|
||||
//! it goes red the instant any RSC / Flight / streaming surface enters the
|
||||
//! authored SSR source or its dependencies. Absence is not enough; this is the
|
||||
//! forcing function that makes re-entry loud.
|
||||
//! Scans the SSR fixture's authored JS for tokens that only appear when React
|
||||
//! Server Components, the Flight protocol, or a meta-framework SSR runtime is
|
||||
//! in play. The scan goes red the moment one of them enters the source.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Tokens that only appear when RSC / Flight / streaming rendering is in play.
|
||||
const FORBIDDEN: &[&str] = &[
|
||||
// React Server Components / Flight — CVE-2025-55182 (pre-auth RCE, CVSS 10.0)
|
||||
// React Server Components / Flight
|
||||
"react-server-dom",
|
||||
"renderToReadableStream",
|
||||
"renderToPipeableStream",
|
||||
"createFromReadableStream",
|
||||
"createFromFetch",
|
||||
"use server",
|
||||
// SSR runtimes / meta-frameworks — forbidden across every frontend adapter
|
||||
// SSR runtimes / meta-frameworks
|
||||
"next/",
|
||||
"nuxt",
|
||||
"@sveltejs/kit",
|
||||
@@ -39,15 +29,16 @@ const SCANNED: &[&str] = &[
|
||||
#[test]
|
||||
fn ssr_has_no_rsc_or_flight_surface() {
|
||||
for path in SCANNED {
|
||||
let Ok(src) = std::fs::read_to_string(path) else {
|
||||
continue; // a generated/optional file absent is fine; authored source is the point
|
||||
};
|
||||
assert!(
|
||||
Path::new(path).is_file(),
|
||||
"{path} is a tracked fixture this scan reads; it is missing",
|
||||
);
|
||||
let src = std::fs::read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("reading {path} for the RSC scan: {e}"));
|
||||
for needle in FORBIDDEN {
|
||||
assert!(
|
||||
!src.contains(needle),
|
||||
"RSC/Flight surface {needle:?} found in {path} — forbidden. \
|
||||
RSC carries CVE-2025-55182 (unauth RCE, CVSS 10.0); Mizan SSR is \
|
||||
classic renderToString-family only, rendered synchronously from props.",
|
||||
"{needle:?} found in {path}; this scan forbids it",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user