AFI parity: close all 35 gaps — every adapter wires every AFI-common capability

The conformance board (tests/afi/test_capability_parity.py) is now fully green:
90 capability cells + 4 meta-locks + 3 codegen byte-parity = 97 passed. The
gaps the prose table used to launder as "Django-only" / "out of scope" are
wired, against the pinned-spec model (single-authored spec, byte-identical
conformance across languages) — never per-language reimplementation.

FastAPI — edge_manifest + PSR (logic single-sourced in mizan_core.manifest),
WebSocket RPC (/ws/ through the shared dispatch), SSR (the framework-agnostic
SSRBridge relocated to mizan_core.ssr; Django rides it from there), Shapes
(SQLAlchemy projection, same declaration surface as django-readers), Forms
(Pydantic schema/validate/submit).

Rust (Axum + Tauri + cores/mizan-rust) — X-Mizan-Invalidate header, auth=
enforcement, origin HMAC cache, edge manifest + PSR, WebSocket handler / IPC
subscription channel, multipart upload, SSR bridge, Shapes, Forms; JWT/MWT
mint+verify and cache-key derivation byte-pinned to the Python reference
(cache_keys_pin, token_pin, invalidate_header_pin).

TypeScript — a KDL IR emitter byte-identical to the Python build_ir (so a TS
backend can feed the codegen — the largest gap), multipart upload, session-init,
WebSocket transport, SSR bridge, JWT/MWT mint (pinned to Python), Shapes, Forms.

Verified in the merged tree: core 25, fastapi 74, django 353/21-skip,
mizan-rust (incl. cross-language pins) green, axum 10, tauri 8, mizan-ts 103/2-skip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-04 13:44:35 -04:00
parent 58d2cb2848
commit 6c5f6f1fba
81 changed files with 9893 additions and 463 deletions

View File

@@ -13,12 +13,82 @@ dependencies = [
"syn",
]
[[package]]
name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "indoc"
version = "2.0.7"
@@ -34,6 +104,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "linkme"
version = "0.3.36"
@@ -65,11 +141,14 @@ name = "mizan-core"
version = "0.1.0"
dependencies = [
"async-trait",
"base64",
"hmac",
"indoc",
"linkme",
"mizan-macros",
"serde",
"serde_json",
"sha2",
]
[[package]]
@@ -149,6 +228,23 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
@@ -160,12 +256,24 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "typenum"
version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "zmij"
version = "1.0.21"

View File

@@ -11,6 +11,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1"
async-trait = "0.1"
mizan-macros = { path = "../mizan-rust-macros" }
hmac = "0.12"
sha2 = "0.10"
base64 = "0.22"
[dev-dependencies]
indoc = "2"

View File

@@ -0,0 +1,552 @@
//! JWT + MWT — HS256 mint and verify, byte-pinned to the Python core.
//!
//! Pinned references:
//! * JWT → `cores/mizan-python/src/mizan_core/auth/jwt.py`
//! * MWT → `cores/mizan-python/src/mizan_core/mwt.py`
//!
//! These are RFC 7519 JWTs over HMAC-SHA256. Byte-identical output to PyJWT
//! 2.x requires reproducing its exact serialization, which a generic JWT crate
//! does not expose:
//!
//! * the JOSE **header** keys are emitted in **sorted** order with compact
//! `(",", ":")` separators — `{"alg":"HS256","typ":"JWT"}`, or with a
//! `kid`, `{"alg":"HS256","kid":"v1","typ":"JWT"}`;
//! * the **payload** keys are emitted in **insertion** order (PyJWT does not
//! sort the claims) with the same compact separators;
//! * both segments are base64url-encoded **without padding**.
//!
//! So a mint here builds each segment's bytes deliberately (sorted header,
//! ordered claims) and signs `header.payload`. `tests/token_pin.rs` pins the
//! exact tokens against the Python reference for fixed inputs.
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
/// Current unix time in seconds — the `now` adapters pass to mint/verify when
/// they aren't pinning a fixed clock (tests inject a fixed value for byte
/// determinism).
pub fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn b64url(bytes: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(bytes)
}
fn b64url_decode(s: &str) -> Option<Vec<u8>> {
URL_SAFE_NO_PAD.decode(s).ok()
}
fn sign(secret: &str, signing_input: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC any key length");
mac.update(signing_input.as_bytes());
b64url(&mac.finalize().into_bytes())
}
/// Build a JOSE header for HS256 with optional `kid`, keys in sorted order
/// (`alg` < `kid` < `typ`) and compact separators — byte-identical to PyJWT.
fn header_json(kid: Option<&str>) -> String {
match kid {
Some(kid) => format!(
"{{\"alg\":\"HS256\",\"kid\":{},\"typ\":\"JWT\"}}",
json_str(kid)
),
None => "{\"alg\":\"HS256\",\"typ\":\"JWT\"}".to_string(),
}
}
/// Encode one JSON string literal byte-for-byte with PyJWT's serializer,
/// which is `json.dumps` with the default `ensure_ascii=True`: short escapes
/// for `"`, `\`, `\b\f\n\r\t`, and `\uXXXX` for the rest of the C0 range and
/// every non-ASCII code point (surrogate pairs above the BMP).
fn json_str(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("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{08}' => out.push_str("\\b"),
'\u{0c}' => out.push_str("\\f"),
c if (c as u32) < 0x20 || (c as u32) > 0x7e => {
let mut buf = [0u16; 2];
for unit in c.encode_utf16(&mut buf) {
out.push_str(&format!("\\u{unit:04x}"));
}
}
c => out.push(c),
}
}
out.push('"');
out
}
fn json_bool(b: bool) -> &'static str {
if b {
"true"
} else {
"false"
}
}
/// Mint `header.payload.signature` from a pre-serialized payload body. The
/// payload bytes are authored by the caller so claim ordering is under exact
/// control (PyJWT preserves insertion order).
fn encode(secret: &str, kid: Option<&str>, payload_json: &str) -> String {
let header = b64url(header_json(kid).as_bytes());
let payload = b64url(payload_json.as_bytes());
let signing_input = format!("{header}.{payload}");
let sig = sign(secret, &signing_input);
format!("{signing_input}.{sig}")
}
/// Verify the HS256 signature over `header.payload` and return the decoded
/// payload bytes. Constant-time-ish: recompute and compare the signature.
fn verify_signature(secret: &str, token: &str) -> Option<Vec<u8>> {
let mut parts = token.splitn(3, '.');
let header_b64 = parts.next()?;
let payload_b64 = parts.next()?;
let sig_b64 = parts.next()?;
if parts.next().is_some() {
return None;
}
let signing_input = format!("{header_b64}.{payload_b64}");
let expected = sign(secret, &signing_input);
// base64url of HMAC is fixed-length; a direct compare is adequate here and
// matches the reference's PyJWT-side verification semantics.
if !ct_eq(expected.as_bytes(), sig_b64.as_bytes()) {
return None;
}
b64url_decode(payload_b64)
}
fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
/// Read the `kid` claim from the (unverified) JOSE header — needed before
/// signature verification to mirror `decode_mwt`'s `get_unverified_header`.
fn unverified_kid(token: &str) -> Option<String> {
let header_b64 = token.split('.').next()?;
let bytes = b64url_decode(header_b64)?;
let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
v.get("kid")
.and_then(|k| k.as_str())
.map(|s| s.to_string())
}
// ─── JWT ──────────────────────────────────────────────────────────────────
/// JWT signing/verification config — Rust analog of `JWTConfig`. HS256 only
/// here (the byte-pinned algorithm); `private_key` doubles as the verify key.
#[derive(Debug, Clone)]
pub struct JwtConfig {
pub secret: String,
pub access_ttl: i64,
pub refresh_ttl: i64,
}
impl JwtConfig {
pub fn new(secret: impl Into<String>) -> Self {
Self {
secret: secret.into(),
access_ttl: 300,
refresh_ttl: 604_800,
}
}
}
/// Decoded JWT claims — Rust analog of `TokenPayload`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct JwtPayload {
pub sub: String,
pub sid: String,
pub staff: bool,
pub superuser: bool,
pub token_type: String,
pub iat: i64,
pub exp: i64,
}
/// Build the JWT claims body in PyJWT-insertion order: sub, sid, staff, super,
/// type, iat, exp. (Matches `jwt.py::_mint`.)
fn jwt_payload_json(
sub: &str,
sid: &str,
staff: bool,
superuser: bool,
token_type: &str,
iat: i64,
exp: i64,
) -> String {
format!(
"{{\"sub\":{},\"sid\":{},\"staff\":{},\"super\":{},\"type\":{},\"iat\":{},\"exp\":{}}}",
json_str(sub),
json_str(sid),
json_bool(staff),
json_bool(superuser),
json_str(token_type),
iat,
exp,
)
}
#[allow(clippy::too_many_arguments)]
fn mint_jwt(
cfg: &JwtConfig,
sub: &str,
sid: &str,
token_type: &str,
ttl: i64,
staff: bool,
superuser: bool,
now: i64,
) -> String {
let payload = jwt_payload_json(sub, sid, staff, superuser, token_type, now, now + ttl);
encode(&cfg.secret, None, &payload)
}
/// Mint an access token. `now` is unix-seconds (injected for determinism).
pub fn create_access_token(
cfg: &JwtConfig,
sub: &str,
sid: &str,
staff: bool,
superuser: bool,
now: i64,
) -> String {
mint_jwt(cfg, sub, sid, "access", cfg.access_ttl, staff, superuser, now)
}
/// Mint a refresh token.
pub fn create_refresh_token(
cfg: &JwtConfig,
sub: &str,
sid: &str,
staff: bool,
superuser: bool,
now: i64,
) -> String {
mint_jwt(
cfg,
sub,
sid,
"refresh",
cfg.refresh_ttl,
staff,
superuser,
now,
)
}
/// Decode + validate a JWT. `None` on a bad signature, malformed token,
/// expiry (against `now`), or a `type` mismatch. Mirrors `decode_token`.
pub fn decode_jwt(
token: &str,
cfg: &JwtConfig,
expected_type: Option<&str>,
now: i64,
) -> Option<JwtPayload> {
let payload_bytes = verify_signature(&cfg.secret, token)?;
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
let exp = v.get("exp")?.as_i64()?;
if now >= exp {
return None;
}
let token_type = v.get("type")?.as_str()?.to_string();
if let Some(want) = expected_type {
if token_type != want {
return None;
}
}
Some(JwtPayload {
sub: v.get("sub")?.as_str()?.to_string(),
sid: v.get("sid")?.as_str()?.to_string(),
staff: v.get("staff").and_then(|b| b.as_bool()).unwrap_or(false),
superuser: v.get("super").and_then(|b| b.as_bool()).unwrap_or(false),
token_type,
iat: v.get("iat")?.as_i64()?,
exp,
})
}
// ─── MWT ────────────────────────────────────────────────────────────────────
/// Decoded MWT claims — Rust analog of `MWTPayload`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MwtPayload {
pub sub: String,
pub staff: bool,
pub superuser: bool,
pub pkey: String,
pub kid: String,
pub aud: String,
pub iat: i64,
pub exp: i64,
}
/// Compute the permission-state hash — full SHA-256 hex over
/// `"{staff}:{super}:{sorted,comma-joined perms}"`. Matches
/// `mwt.py::compute_permission_key` byte-for-byte.
pub fn compute_permission_key(staff: bool, superuser: bool, perms: &[String]) -> String {
use sha2::Digest;
let mut sorted: Vec<&String> = perms.iter().collect();
sorted.sort();
let staff_c = if staff { "1" } else { "0" };
let super_c = if superuser { "1" } else { "0" };
let joined: Vec<&str> = sorted.iter().map(|s| s.as_str()).collect();
let blob = format!("{staff_c}:{super_c}:{}", joined.join(","));
let digest = Sha256::digest(blob.as_bytes());
digest.iter().map(|b| format!("{b:02x}")).collect()
}
/// Build the MWT claims body in `create_mwt` insertion order: sub, staff,
/// super, pkey, aud, iat, nbf, exp.
#[allow(clippy::too_many_arguments)]
fn mwt_payload_json(
sub: &str,
staff: bool,
superuser: bool,
pkey: &str,
aud: &str,
iat: i64,
nbf: i64,
exp: i64,
) -> String {
format!(
"{{\"sub\":{},\"staff\":{},\"super\":{},\"pkey\":{},\"aud\":{},\"iat\":{},\"nbf\":{},\"exp\":{}}}",
json_str(sub),
json_bool(staff),
json_bool(superuser),
json_str(pkey),
json_str(aud),
iat,
nbf,
exp,
)
}
/// Mint an MWT from already-resolved identity fields. `pkey` is the permission
/// hash (see `compute_permission_key`); `now` is unix-seconds.
#[allow(clippy::too_many_arguments)]
pub fn create_mwt(
secret: &str,
sub: &str,
staff: bool,
superuser: bool,
pkey: &str,
ttl: i64,
audience: &str,
kid: &str,
now: i64,
) -> String {
let payload = mwt_payload_json(sub, staff, superuser, pkey, audience, now, now, now + ttl);
encode(secret, Some(kid), &payload)
}
/// Decode + validate an MWT. `None` on bad signature, malformed token, expiry,
/// not-yet-valid (`nbf`), or audience mismatch. Mirrors `decode_mwt`.
pub fn decode_mwt(token: &str, secret: &str, audience: &str, now: i64) -> Option<MwtPayload> {
let kid = unverified_kid(token).unwrap_or_else(|| "v1".to_string());
let payload_bytes = verify_signature(secret, token)?;
let v: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
let exp = v.get("exp")?.as_i64()?;
if now >= exp {
return None;
}
if let Some(nbf) = v.get("nbf").and_then(|n| n.as_i64()) {
if now < nbf {
return None;
}
}
let aud = v.get("aud").and_then(|a| a.as_str()).unwrap_or("");
if aud != audience {
return None;
}
Some(MwtPayload {
sub: v.get("sub")?.as_str()?.to_string(),
staff: v.get("staff").and_then(|b| b.as_bool()).unwrap_or(false),
superuser: v.get("super").and_then(|b| b.as_bool()).unwrap_or(false),
pkey: v
.get("pkey")
.and_then(|p| p.as_str())
.unwrap_or("")
.to_string(),
kid,
aud: audience.to_string(),
iat: v.get("iat")?.as_i64()?,
exp,
})
}
// ─── Identity + auth-guard enforcement ───────────────────────────────────────
/// The identity a token resolves to — Rust analog of `Identity`. `None`
/// (anonymous) and `Invalid` (a present-but-bad token) are distinct: the
/// adapter must REJECT on `Invalid`, never silently downgrade to anonymous.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub user_id: String,
pub is_staff: bool,
pub is_superuser: bool,
}
impl From<&JwtPayload> for Identity {
fn from(p: &JwtPayload) -> Self {
Self {
user_id: p.sub.clone(),
is_staff: p.staff,
is_superuser: p.superuser,
}
}
}
impl From<&MwtPayload> for Identity {
fn from(p: &MwtPayload) -> Self {
Self {
user_id: p.sub.clone(),
is_staff: p.staff,
is_superuser: p.superuser,
}
}
}
/// Result of resolving identity from request headers. Mirrors the Python
/// `Identity | INVALID | None` contract.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthOutcome {
/// A valid token resolved to this identity.
Authenticated(Identity),
/// No token was offered — the adapter may fall back to session identity.
Anonymous,
/// A token was present but failed validation — the adapter MUST reject.
Invalid,
}
/// Auth config carried by the adapter — JWT and/or MWT secrets. Either may be
/// absent; a token type with no configured secret is ignored. Mirrors
/// `AuthConfig`.
#[derive(Debug, Clone, Default)]
pub struct AuthConfig {
pub jwt: Option<JwtConfig>,
pub mwt_secret: Option<String>,
pub mwt_audience: String,
}
impl AuthConfig {
pub fn new() -> Self {
Self {
jwt: None,
mwt_secret: None,
mwt_audience: "mizan".to_string(),
}
}
}
/// Resolve identity from `X-Mizan-Token` (MWT) then `Authorization: Bearer`
/// (JWT). Header lookup is case-sensitive on the names the adapter passes in;
/// pass both casings or normalize upstream. Mirrors `authenticate`.
pub fn authenticate(
mwt_header: Option<&str>,
bearer_header: Option<&str>,
config: &AuthConfig,
now: i64,
) -> AuthOutcome {
if let (Some(mwt), Some(secret)) = (mwt_header, config.mwt_secret.as_deref()) {
if !mwt.is_empty() {
return match decode_mwt(mwt, secret, &config.mwt_audience, now) {
Some(p) => AuthOutcome::Authenticated(Identity::from(&p)),
None => AuthOutcome::Invalid,
};
}
}
if let (Some(bearer), Some(jwt_cfg)) = (bearer_header, config.jwt.as_ref()) {
if let Some(token) = bearer.strip_prefix("Bearer ") {
return match decode_jwt(token, jwt_cfg, Some("access"), now) {
Some(p) => AuthOutcome::Authenticated(Identity::from(&p)),
None => AuthOutcome::Invalid,
};
}
}
AuthOutcome::Anonymous
}
/// The `@client(auth=...)` requirement a function declares. `Callable` carries
/// the host's own predicate — the adapter resolves it; the core stays free of
/// the native request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuthRequirement {
None,
Required,
Staff,
Superuser,
}
impl AuthRequirement {
/// Parse the IR/`FunctionSpec` auth string into a requirement.
/// `"required" | "staff" | "superuser"` → the matching variant; anything
/// else (including the absence of an `auth=`) → `None`.
pub fn from_str_opt(s: Option<&str>) -> Self {
match s {
Some("required") | Some("true") => AuthRequirement::Required,
Some("staff") => AuthRequirement::Staff,
Some("superuser") => AuthRequirement::Superuser,
_ => AuthRequirement::None,
}
}
}
/// Enforce a function's `auth=` against the resolved identity. `Ok(())` to
/// proceed; `Err(MizanError)` (`Unauthorized`/`Forbidden`) to reject. Mirrors
/// `authguard.enforce_auth`.
pub fn enforce_auth(
identity: Option<&Identity>,
requirement: &AuthRequirement,
) -> Result<(), crate::runtime::MizanError> {
use crate::runtime::MizanError;
if matches!(requirement, AuthRequirement::None) {
return Ok(());
}
let ident = match identity {
Some(i) => i,
None => return Err(MizanError::Unauthorized("Authentication required".into())),
};
match requirement {
AuthRequirement::None | AuthRequirement::Required => Ok(()),
AuthRequirement::Staff => {
if ident.is_staff {
Ok(())
} else {
Err(MizanError::Forbidden("Staff access required".into()))
}
}
AuthRequirement::Superuser => {
if ident.is_superuser {
Ok(())
} else {
Err(MizanError::Forbidden("Superuser access required".into()))
}
}
}
}

View File

@@ -0,0 +1,272 @@
//! Origin-side cache: HMAC-SHA256 key derivation + a pluggable backend.
//!
//! Byte-pinned to `cores/mizan-python/src/mizan_core/cache/keys.py`. The HMAC
//! message is the JSON-canonical form `{"c":ctx,"p":{sorted params},"r":rev}`
//! (with optional `"u":user_id`), emitted with Python's `json.dumps(...,
//! sort_keys=True, separators=(",", ":"))` byte layout: keys sorted, no
//! whitespace. Every Mizan adapter must produce the identical key for
//! identical inputs — `tests/cache_keys_pin.rs` pins this against the Python
//! reference and the committed cross-language vectors.
use hmac::{Hmac, Mac};
use serde_json::Value;
use sha2::Sha256;
use std::collections::BTreeMap;
/// Context prefix for broad purge (SCAN pattern), mirroring Python's
/// `CONTEXT_KEY_PREFIX`.
pub const CONTEXT_KEY_PREFIX: &str = "ctx:";
type HmacSha256 = Hmac<Sha256>;
/// Normalize a param value to its cross-language-stable string form.
///
/// Python `str(True)` is `"True"` but JS `String(true)` is `"true"`; the
/// reference picks the JSON-native spelling. Numbers and strings stringify
/// directly. This must match `keys.py::_normalize` exactly.
fn normalize(v: &Value) -> String {
match v {
Value::Bool(true) => "true".to_string(),
Value::Bool(false) => "false".to_string(),
Value::Null => "null".to_string(),
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
// Arrays/objects have no scalar param meaning; fall back to the JSON
// text, matching Python's `str(v)` catch-all for non-scalars.
other => other.to_string(),
}
}
/// JSON-escape a string into `out` byte-for-byte with Python's
/// `json.dumps(..., ensure_ascii=True)`: the short escapes for `"`, `\`,
/// `\b\f\n\r\t`, `\uXXXX` for the rest of the C0 control range, and — because
/// the reference leaves `ensure_ascii` at its default `True` — `\uXXXX` for
/// every non-ASCII code point, encoded as a UTF-16 surrogate pair when the
/// code point is above the BMP (e.g. `😀` → `😀`).
fn push_json_string(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"),
'\u{08}' => out.push_str("\\b"),
'\u{0c}' => out.push_str("\\f"),
c if (c as u32) < 0x20 || (c as u32) > 0x7e => {
let mut buf = [0u16; 2];
for unit in c.encode_utf16(&mut buf) {
out.push_str(&format!("\\u{unit:04x}"));
}
}
c => out.push(c),
}
}
out.push('"');
}
/// Build the exact HMAC message bytes: `{"c":...,"p":{...},"r":...}` with an
/// optional `"u":...`. Keys are emitted in sorted order (c, p, r, u) and the
/// `p` object's keys are sorted too — equivalent to `sort_keys=True`.
fn canonical_message(
context: &str,
params: &BTreeMap<String, Value>,
user_id: Option<&str>,
rev: i64,
) -> String {
let mut msg = String::new();
msg.push('{');
// "c"
msg.push_str("\"c\":");
push_json_string(&mut msg, context);
// "p" — object of normalized, sorted params (BTreeMap iterates sorted).
msg.push_str(",\"p\":{");
for (i, (k, v)) in params.iter().enumerate() {
if i > 0 {
msg.push(',');
}
push_json_string(&mut msg, k);
msg.push(':');
push_json_string(&mut msg, &normalize(v));
}
msg.push('}');
// "r"
msg.push_str(",\"r\":");
msg.push_str(&rev.to_string());
// "u" (optional) — sorts after "r".
if let Some(uid) = user_id {
msg.push_str(",\"u\":");
push_json_string(&mut msg, uid);
}
msg.push('}');
msg
}
/// Derive a deterministic HMAC-SHA256 cache key.
///
/// Returns `ctx:{context}:{hmac_hex}` so broad purge can SCAN by the prefix
/// `ctx:{context}:*`. Byte-identical to the Python/TS reference for identical
/// inputs.
pub fn derive_cache_key(
secret: &str,
context: &str,
params: &BTreeMap<String, Value>,
user_id: Option<&str>,
rev: i64,
) -> String {
let message = canonical_message(context, params, user_id, rev);
let mut mac =
HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts any key length");
mac.update(message.as_bytes());
let digest = mac.finalize().into_bytes();
let hex: String = digest.iter().map(|b| format!("{b:02x}")).collect();
format!("{CONTEXT_KEY_PREFIX}{context}:{hex}")
}
/// Pluggable origin cache store. The HTTP adapter injects a backend (memory
/// for tests, Redis in production); dispatch reads/writes through it.
pub trait CacheBackend: Send + Sync {
fn get(&self, key: &str) -> Option<Vec<u8>>;
fn set(&self, key: &str, value: Vec<u8>);
fn delete(&self, key: &str);
/// Delete every key beginning with `prefix` (broad purge).
fn delete_by_prefix(&self, prefix: &str);
}
/// In-memory `CacheBackend` for tests and single-process deployments. Mirrors
/// the Python `MemoryCache` — a dict guarded by a lock, no persistence.
#[derive(Default)]
pub struct MemoryCache {
store: std::sync::Mutex<BTreeMap<String, Vec<u8>>>,
}
impl MemoryCache {
pub fn new() -> Self {
Self::default()
}
}
impl CacheBackend for MemoryCache {
fn get(&self, key: &str) -> Option<Vec<u8>> {
self.store.lock().unwrap().get(key).cloned()
}
fn set(&self, key: &str, value: Vec<u8>) {
self.store.lock().unwrap().insert(key.to_string(), value);
}
fn delete(&self, key: &str) {
self.store.lock().unwrap().remove(key);
}
fn delete_by_prefix(&self, prefix: &str) {
self.store
.lock()
.unwrap()
.retain(|k, _| !k.starts_with(prefix));
}
}
/// Origin-side cache orchestrator — backend + secret injected by the adapter
/// (the config seam). Mirrors Python's `CacheOrchestrator`: disabled (a no-op)
/// until both a backend and a secret are present.
pub struct CacheOrchestrator {
backend: Option<std::sync::Arc<dyn CacheBackend>>,
secret: Option<String>,
}
impl CacheOrchestrator {
pub fn new(backend: Option<std::sync::Arc<dyn CacheBackend>>, secret: Option<String>) -> Self {
Self { backend, secret }
}
/// A disabled orchestrator — every op is a no-op. Used by stateless apps.
pub fn disabled() -> Self {
Self {
backend: None,
secret: None,
}
}
pub fn enabled(&self) -> bool {
self.backend.is_some() && self.secret.as_deref().is_some_and(|s| !s.is_empty())
}
fn key(
&self,
context: &str,
params: &BTreeMap<String, Value>,
user_id: Option<&str>,
rev: i64,
) -> Option<String> {
let secret = self.secret.as_deref()?;
Some(derive_cache_key(secret, context, params, user_id, rev))
}
pub fn get(
&self,
context: &str,
params: &BTreeMap<String, Value>,
user_id: Option<&str>,
rev: i64,
) -> Option<Vec<u8>> {
if !self.enabled() {
return None;
}
let backend = self.backend.as_ref()?;
let key = self.key(context, params, user_id, rev)?;
backend.get(&key)
}
pub fn put(
&self,
context: &str,
params: &BTreeMap<String, Value>,
value: Vec<u8>,
user_id: Option<&str>,
rev: i64,
) {
if !self.enabled() {
return;
}
if let (Some(backend), Some(key)) =
(self.backend.as_ref(), self.key(context, params, user_id, rev))
{
backend.set(&key, value);
}
}
/// Purge the cache entries named by an invalidation list. A scoped entry
/// (`ScopedContext`) deletes its single derived key; a bare context purges
/// by prefix — exactly Python's `CacheOrchestrator.purge`.
pub fn purge(&self, invalidate: &[crate::runtime::InvalidationTarget], user_id: Option<&str>) {
if !self.enabled() {
return;
}
let backend = match self.backend.as_ref() {
Some(b) => b,
None => return,
};
for entry in invalidate {
match entry {
crate::runtime::InvalidationTarget::Context(ctx)
| crate::runtime::InvalidationTarget::Function(ctx) => {
backend.delete_by_prefix(&format!("{CONTEXT_KEY_PREFIX}{ctx}:"));
}
crate::runtime::InvalidationTarget::ScopedContext { context, params } => {
let params_tree: BTreeMap<String, Value> =
params.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
if let Some(key) = self.key(context, &params_tree, user_id, 0) {
backend.delete(&key);
}
}
}
}
}
}

View File

@@ -26,6 +26,14 @@ pub enum TypeShape {
Optional(Box<TypeShape>),
Enum(Vec<&'static str>),
Union(Vec<TypeShape>),
/// An `Upload`-typed field — a binary file input. Emits the IR `upload`
/// type-child (matching `cores/mizan-python`'s `_emit_upload_node`), with
/// optional declarative `max-size` / `content-type` constraints. `None`s
/// mean an unconstrained upload.
Upload {
max_size: Option<i64>,
content_types: &'static [&'static str],
},
}
#[derive(Debug, Clone, Copy)]

View File

@@ -160,6 +160,29 @@ impl<'a> Emitter<'a> {
}
self.close(indent);
}
TypeShape::Upload {
max_size,
content_types,
} => {
// Match Python's `_emit_upload_node`: `max-size` is the bare
// integer (its `repr`); content-types become nested children;
// the unconstrained case is a bare `upload` leaf.
let mut header: Vec<String> = vec!["upload".into()];
if let Some(ms) = max_size {
header.push(format!("max-size={ms}"));
}
let header_refs: Vec<&str> = header.iter().map(String::as_str).collect();
if content_types.is_empty() {
self.leaf(indent, &header_refs);
} else {
self.open(indent, &header_refs);
for ct in content_types.iter() {
let lit = kdl_string(ct);
self.leaf(indent + 1, &["content-type", &lit]);
}
self.close(indent);
}
}
}
}
@@ -464,7 +487,7 @@ fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
walk_shape_refs(b, visit);
}
}
TypeShape::Primitive(_) | TypeShape::Enum(_) => {}
TypeShape::Primitive(_) | TypeShape::Enum(_) | TypeShape::Upload { .. } => {}
}
}

View File

@@ -14,25 +14,43 @@
//! Consumers `use mizan_core::prelude::*;` and alias the crate as `mizan` at
//! their call sites so authored code reads `#[mizan::context]` / `#[mizan(...)]`.
pub mod auth;
pub mod cache;
pub mod graph_check;
pub mod ir;
pub mod kdl;
pub mod manifest;
pub mod registry;
pub mod runtime;
pub mod shapes;
pub mod ssr;
pub mod traits;
pub mod upload;
pub use auth::{
authenticate, compute_permission_key, create_access_token, create_mwt, create_refresh_token,
decode_jwt, decode_mwt, enforce_auth, now_unix, AuthConfig, AuthOutcome, AuthRequirement,
Identity, JwtConfig, JwtPayload, MwtPayload,
};
pub use upload::Upload;
pub use cache::{
derive_cache_key, CacheBackend, CacheOrchestrator, MemoryCache, CONTEXT_KEY_PREFIX,
};
pub use ir::{
AffectTarget, DefaultValue, NamedType, Primitive, StructField, Transport, TypeShape,
};
pub use kdl::{build_ir, snake_to_camel};
pub use manifest::{generate_edge_manifest, generate_edge_manifest_json};
pub use registry::{
context_members, lookup_context, lookup_function, ContextEntry, TypeEntry, CONTEXTS,
FUNCTIONS, TYPES,
};
pub use runtime::{
compute_invalidation, compute_merges, InvalidationTarget, MergeEntry, MizanError,
RequestHandle,
compute_invalidation, compute_merges, format_invalidate_header, InvalidationTarget,
MergeEntry, MizanError, RequestHandle,
};
pub use shapes::{QueryProjection, ShapeField};
pub use ssr::{SsrBridge, SsrError, WorkerCommand};
pub use traits::{ContextMarker, FunctionSpec, InputParam, MizanType};
// Re-export proc macros so consumers depend on one crate.

View File

@@ -0,0 +1,190 @@
//! Edge manifest — the static JSON that Mizan Edge reads to configure CDN
//! cache rules + invalidation routing.
//!
//! Mirrors `backends/mizan-django/src/mizan/export/__init__.py`'s
//! `generate_edge_manifest`: a `{version, contexts, mutations}` document where
//! each context carries its functions, endpoints, params, `user_scoped`, and
//! `render_strategy` (the PSR axis), and each mutation carries its `affects`
//! and `auto_scoped_params`. Keys are emitted alphabetically (the Django
//! command serializes with `sort_keys=True`); `to_json_string` matches that.
use crate::registry::{context_members, CONTEXTS, FUNCTIONS};
use crate::traits::FunctionSpec;
use serde_json::{json, Map, Value};
use std::collections::BTreeSet;
/// Params that imply a user-scoped context → `render_strategy:
/// "dynamic_cached"`. Anything else renders as `"psr"`. Matches Python's
/// `_USER_SCOPED_PARAMS`.
const USER_SCOPED_PARAMS: [&str; 4] = ["user_id", "user", "owner_id", "account_id"];
/// Build the edge manifest as a `serde_json::Value`. `base_url` is the Mizan
/// mount point (default `/api/mizan`).
pub fn generate_edge_manifest(base_url: &str) -> Value {
let mut contexts = Map::new();
// Contexts, alphabetical by name (BTreeSet over the registered names).
let ctx_names: BTreeSet<&'static str> = CONTEXTS.iter().map(|c| c.name).collect();
for ctx_name in &ctx_names {
let members = context_members(ctx_name);
if members.is_empty() {
continue;
}
let mut param_names: BTreeSet<&'static str> = BTreeSet::new();
let mut functions_meta: Vec<Value> = Vec::new();
let mut page_routes: Vec<String> = Vec::new();
for fn_spec in &members {
for p in fn_spec.input_params() {
param_names.insert(p.name);
}
// The Rust IR has no view-path/route metadata yet; every function
// is an RPC path. (`route`/`view_path` land with the view-path
// macro extension.)
functions_meta.push(json!({ "name": fn_spec.name(), "path": "rpc" }));
}
let user_scoped = param_names
.iter()
.any(|p| USER_SCOPED_PARAMS.contains(p));
let mut ctx_entry = Map::new();
ctx_entry.insert("functions".into(), Value::Array(functions_meta));
ctx_entry.insert(
"endpoints".into(),
json!([format!("{base_url}/ctx/{ctx_name}/")]),
);
ctx_entry.insert(
"params".into(),
Value::Array(
param_names
.iter()
.map(|p| Value::String((*p).to_string()))
.collect(),
),
);
ctx_entry.insert("user_scoped".into(), Value::Bool(user_scoped));
ctx_entry.insert(
"render_strategy".into(),
Value::String(
if user_scoped {
"dynamic_cached"
} else {
"psr"
}
.to_string(),
),
);
if !page_routes.is_empty() {
page_routes.sort();
ctx_entry.insert(
"page_routes".into(),
Value::Array(page_routes.into_iter().map(Value::String).collect()),
);
}
contexts.insert((*ctx_name).to_string(), Value::Object(ctx_entry));
}
// Mutations — every non-private function declaring `affects`, alphabetical.
let mut fns: Vec<&'static dyn FunctionSpec> = FUNCTIONS.iter().copied().collect();
fns.sort_by_key(|f| f.name());
let mut mutations = Map::new();
for fn_spec in &fns {
let affected: BTreeSet<&'static str> = fn_spec
.affects()
.iter()
.filter_map(|a| match a {
crate::ir::AffectTarget::Context(name) => Some(*name),
crate::ir::AffectTarget::Function { context, .. } => *context,
})
.collect();
if affected.is_empty() {
continue;
}
let mut mutation = Map::new();
mutation.insert(
"affects".into(),
Value::Array(
affected
.iter()
.map(|c| Value::String((*c).to_string()))
.collect(),
),
);
// Auto-scoped params: this mutation's params that also name a param of
// an affected context.
let fn_params: BTreeSet<&'static str> =
fn_spec.input_params().iter().map(|p| p.name).collect();
let mut auto_scoped: BTreeSet<&'static str> = BTreeSet::new();
for ctx in &affected {
let mut ctx_params: BTreeSet<&'static str> = BTreeSet::new();
for m in context_members(ctx) {
for p in m.input_params() {
ctx_params.insert(p.name);
}
}
for p in fn_params.intersection(&ctx_params) {
auto_scoped.insert(*p);
}
}
if !auto_scoped.is_empty() {
mutation.insert(
"auto_scoped_params".into(),
Value::Array(
auto_scoped
.iter()
.map(|p| Value::String((*p).to_string()))
.collect(),
),
);
}
if fn_spec.private() {
mutation.insert("private".into(), Value::Bool(true));
}
mutations.insert(fn_spec.name().to_string(), Value::Object(mutation));
}
json!({
"version": 1,
"contexts": Value::Object(contexts),
"mutations": Value::Object(mutations),
})
}
/// JSON-serialize the manifest with sorted keys (the Django command uses
/// `json.dumps(..., sort_keys=True)`); `indent` of 0 → compact.
pub fn generate_edge_manifest_json(base_url: &str, indent: usize) -> String {
let value = generate_edge_manifest(base_url);
let sorted = sort_value(&value);
if indent == 0 {
serde_json::to_string(&sorted).unwrap()
} else {
serde_json::to_string_pretty(&sorted).unwrap()
}
}
/// Recursively re-key every object so serialization is sorted-key, matching
/// Python's `sort_keys=True`. (serde_json::Map preserves insertion order, so
/// we rebuild via BTreeMap ordering.)
fn sort_value(v: &Value) -> Value {
match v {
Value::Object(m) => {
let mut keys: Vec<&String> = m.keys().collect();
keys.sort();
let mut out = Map::new();
for k in keys {
out.insert(k.clone(), sort_value(&m[k]));
}
Value::Object(out)
}
Value::Array(a) => Value::Array(a.iter().map(sort_value).collect()),
other => other.clone(),
}
}

View File

@@ -135,6 +135,75 @@ impl InvalidationTarget {
}
}
/// Percent-encode for the `X-Mizan-Invalidate` header, matching Python's
/// `urllib.parse.quote(str(v), safe='')`: the RFC 3986 unreserved set
/// (`A-Za-z0-9_.-~`) passes through; every other byte (of the UTF-8 encoding)
/// becomes `%XX` with **upper-case** hex.
fn url_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_' | b'.' | b'-' | b'~' => {
out.push(b as char);
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// Render an invalidation value to a JSON-ish string for header param values.
/// Mirrors Python's `str(v)`: a JSON string yields its raw text; numbers and
/// booleans their literal spelling (`true`/`false`); other shapes their JSON.
fn header_value_str(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Bool(b) => b.to_string(),
Value::Number(n) => n.to_string(),
Value::Null => "None".to_string(),
other => other.to_string(),
}
}
/// Serialize a list of targets to the `X-Mizan-Invalidate` header value —
/// byte-for-byte with `cores/mizan-python`'s `format_invalidate_header`:
/// comma-separated contexts, semicolon-separated URL-encoded params per
/// context (params sorted by key).
///
/// `[Context("user")]` → `user`
/// `[Context("user"), Context("notifications")]` → `user, notifications`
/// `[ScopedContext{user, {user_id:5}}]` → `user;user_id=5`
/// `[ScopedContext{search, {q:"hello world"}}]` → `search;q=hello%20world`
pub fn format_invalidate_header(targets: &[InvalidationTarget]) -> String {
let mut parts: Vec<String> = Vec::new();
for t in targets {
match t {
InvalidationTarget::Context(name) | InvalidationTarget::Function(name) => {
parts.push(name.clone());
}
InvalidationTarget::ScopedContext { context, params } => {
if params.is_empty() {
parts.push(context.clone());
} else {
// BTreeMap-sort the keys to match Python's `sorted(params.items())`.
let mut keys: Vec<&String> = params.keys().collect();
keys.sort();
let param_str = keys
.iter()
.map(|k| {
let v = &params[*k];
format!("{}={}", url_encode(k), url_encode(&header_value_str(v)))
})
.collect::<Vec<_>>()
.join(";");
parts.push(format!("{context};{param_str}"));
}
}
}
}
parts.join(", ")
}
/// One entry in the response's `merge` array. Server-resolved slot — the
/// kernel writes the value into `bundle[slot]` directly.
#[derive(Debug, Clone)]

View File

@@ -0,0 +1,146 @@
//! Shapes — typed query projection over the registered type graph.
//!
//! The AFI-common capability is "given the typed shape a function returns,
//! derive the field projection a query layer should select" — the same role
//! django-readers plays on Django (a `Shape` declares fields + nested shapes,
//! and `_spec` is the projection handed to the ORM). The binding is per-ORM;
//! the *capability* — deriving the projection from the declared shape — is
//! shared, so it lives here in the core and each adapter rides it.
//!
//! A `QueryProjection` is computed from a registered named type's struct
//! shape: scalar fields become leaf selections, `Ref`-to-struct fields become
//! nested projections (recursively), lists/optionals unwrap to their element.
//! It is the typed, ORM-agnostic answer to "what columns/relations does this
//! response need?" — the dead-field-elimination the whole-stack story wants,
//! reached from the response type.
use crate::ir::{NamedType, TypeShape};
use crate::registry::TYPES;
use std::collections::BTreeMap;
/// One selected field of a projection: a scalar leaf, or a nested projection
/// for a related struct.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ShapeField {
/// A scalar/primitive column.
Leaf(String),
/// A related struct, with its own projection.
Nested(String, QueryProjection),
}
impl ShapeField {
pub fn name(&self) -> &str {
match self {
ShapeField::Leaf(n) | ShapeField::Nested(n, _) => n,
}
}
}
/// A typed, ORM-agnostic field projection derived from a named struct type.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct QueryProjection {
/// The named type this projects (the struct's IR name).
pub type_name: String,
pub fields: Vec<ShapeField>,
}
impl QueryProjection {
/// The flat list of scalar leaf field names selected at this level.
pub fn leaf_names(&self) -> Vec<&str> {
self.fields
.iter()
.filter_map(|f| match f {
ShapeField::Leaf(n) => Some(n.as_str()),
_ => None,
})
.collect()
}
/// The nested relations selected at this level, name → sub-projection.
pub fn nested(&self) -> Vec<(&str, &QueryProjection)> {
self.fields
.iter()
.filter_map(|f| match f {
ShapeField::Nested(n, p) => Some((n.as_str(), p)),
_ => None,
})
.collect()
}
}
/// Build the registry's named-type table once (name → shape).
fn type_table() -> BTreeMap<&'static str, NamedType> {
let mut t = BTreeMap::new();
for entry in TYPES {
t.insert(entry.name, (entry.shape_fn)());
}
t
}
/// Unwrap a `TypeShape` to the named struct it ultimately references, if any
/// — peeling `List`/`Optional`. Returns the referenced type name.
fn referenced_struct<'a>(
shape: &TypeShape,
table: &'a BTreeMap<&'static str, NamedType>,
) -> Option<&'a str> {
match shape {
TypeShape::Ref(name) => {
// Only treat it as nested if it resolves to a struct.
match table.get(name) {
Some(NamedType::Struct(_)) => Some(name),
_ => None,
}
}
TypeShape::List(inner) | TypeShape::Optional(inner) => referenced_struct(inner, table),
_ => None,
}
}
/// Derive the projection for a registered named type by its IR name. `None`
/// if the name is absent or is not a struct.
pub fn project(type_name: &str) -> Option<QueryProjection> {
let table = type_table();
project_inner(type_name, &table, &mut Vec::new())
}
fn project_inner(
type_name: &str,
table: &BTreeMap<&'static str, NamedType>,
stack: &mut Vec<String>,
) -> Option<QueryProjection> {
let body = table.get(type_name)?;
let fields = match body {
NamedType::Struct(fields) => fields,
_ => return None,
};
// Guard against recursive types (self-referential shapes): a name already
// on the stack projects to its scalar leaves only, no further descent.
let recursing = stack.iter().any(|n| n == type_name);
stack.push(type_name.to_string());
let mut out = Vec::new();
for field in fields {
if !recursing {
if let Some(nested_name) = referenced_struct(&field.shape, table) {
if let Some(sub) = project_inner(nested_name, table, stack) {
out.push(ShapeField::Nested(field.name.to_string(), sub));
continue;
}
}
}
out.push(ShapeField::Leaf(field.name.to_string()));
}
stack.pop();
Some(QueryProjection {
type_name: type_name.to_string(),
fields: out,
})
}
/// Derive the projection for a function's output type, by function name.
pub fn project_function_output(fn_name: &str) -> Option<QueryProjection> {
let fn_spec = crate::registry::lookup_function(fn_name)?;
project(fn_spec.output_type())
}

268
cores/mizan-rust/src/ssr.rs Normal file
View File

@@ -0,0 +1,268 @@
//! SSR bridge — drive a persistent Bun subprocess for React `renderToString`.
//!
//! Same wire protocol as the Python `SSRBridge`
//! (`backends/mizan-django/src/mizan/ssr/bridge.py`): newline-delimited
//! JSON-RPC over the worker's stdin/stdout.
//!
//! → {"id": 1, "method": "render", "params": {"file": "/abs/X.tsx", "props": {...}}}
//! ← {"id": 1, "html": "<div>...</div>"}
//!
//! The worker emits `{"id": 0, "ready": true}` once on startup; `render`
//! blocks until that arrives. A background reader thread demultiplexes
//! responses by `id` and parks each caller on a per-request condvar. The
//! subprocess stays alive across requests and is respawned on the next render
//! if it has died. `command` is injected so a test can drive the exact same
//! framing/correlation path against a stub worker without Bun installed.
use serde_json::{json, Value};
use std::collections::HashMap;
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Condvar, Mutex};
use std::time::Duration;
/// How the bridge launches its worker. The default is `bun run <worker>`; a
/// test injects a stub program that speaks the same JSON-RPC framing.
#[derive(Clone)]
pub struct WorkerCommand {
pub program: String,
pub args: Vec<String>,
}
impl WorkerCommand {
/// The production launcher: `bun run <worker_path>`.
pub fn bun(worker_path: impl Into<String>) -> Self {
Self {
program: "bun".to_string(),
args: vec!["run".to_string(), worker_path.into()],
}
}
}
#[derive(Debug)]
pub enum SsrError {
Spawn(String),
Timeout(String),
Render(String),
Pipe(String),
}
impl std::fmt::Display for SsrError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SsrError::Spawn(m) => write!(f, "SSR worker spawn failed: {m}"),
SsrError::Timeout(m) => write!(f, "SSR render timed out: {m}"),
SsrError::Render(m) => write!(f, "SSR render failed: {m}"),
SsrError::Pipe(m) => write!(f, "SSR worker pipe broken: {m}"),
}
}
}
impl std::error::Error for SsrError {}
/// Shared slot a parked caller waits on. The reader thread fills `result` and
/// flips `done`, then notifies.
#[derive(Default)]
struct Slot {
done: Mutex<Option<Value>>,
cv: Condvar,
}
struct Inner {
child: Option<Child>,
stdin: Option<ChildStdin>,
pending: Arc<Mutex<HashMap<u64, Arc<Slot>>>>,
ready: Arc<(Mutex<bool>, Condvar)>,
counter: u64,
}
/// A persistent Bun SSR subprocess, thread-safe across concurrent `render`s.
pub struct SsrBridge {
command: WorkerCommand,
timeout: Duration,
inner: Mutex<Inner>,
}
impl SsrBridge {
pub fn new(command: WorkerCommand, timeout: Duration) -> Self {
Self {
command,
timeout,
inner: Mutex::new(Inner {
child: None,
stdin: None,
pending: Arc::new(Mutex::new(HashMap::new())),
ready: Arc::new((Mutex::new(false), Condvar::new())),
counter: 0,
}),
}
}
/// Production constructor: `bun run <worker>` with a 5s render timeout.
pub fn bun(worker_path: impl Into<String>) -> Self {
Self::new(WorkerCommand::bun(worker_path), Duration::from_secs(5))
}
fn ensure_running(&self, inner: &mut Inner) -> Result<(), SsrError> {
if let Some(child) = inner.child.as_mut() {
if matches!(child.try_wait(), Ok(None)) {
return Ok(()); // still alive
}
}
*inner.ready.0.lock().unwrap() = false;
inner.pending.lock().unwrap().clear();
let mut child = Command::new(&self.command.program)
.args(&self.command.args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| SsrError::Spawn(e.to_string()))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| SsrError::Spawn("no stdout".into()))?;
inner.stdin = Some(
child
.stdin
.take()
.ok_or_else(|| SsrError::Spawn("no stdin".into()))?,
);
inner.child = Some(child);
let pending = inner.pending.clone();
let ready = inner.ready.clone();
std::thread::Builder::new()
.name("mizan-ssr-reader".to_string())
.spawn(move || Self::read_loop(stdout, pending, ready))
.map_err(|e| SsrError::Spawn(e.to_string()))?;
// Block until the worker signals readiness.
let (lock, cv) = &*inner.ready;
let mut is_ready = lock.lock().unwrap();
while !*is_ready {
let (g, timed_out) = cv.wait_timeout(is_ready, self.timeout).unwrap();
is_ready = g;
if timed_out.timed_out() && !*is_ready {
return Err(SsrError::Timeout("worker failed to start".into()));
}
}
Ok(())
}
fn read_loop(
stdout: std::process::ChildStdout,
pending: Arc<Mutex<HashMap<u64, Arc<Slot>>>>,
ready: Arc<(Mutex<bool>, Condvar)>,
) {
let reader = BufReader::new(stdout);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
let line = line.trim();
if line.is_empty() {
continue;
}
let msg: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue, // malformed line; skip, matching Python
};
let id = msg.get("id").and_then(|v| v.as_u64());
// Ready signal: {"id": 0, "ready": true}.
if id == Some(0) && msg.get("ready").and_then(|r| r.as_bool()) == Some(true) {
let (lock, cv) = &*ready;
*lock.lock().unwrap() = true;
cv.notify_all();
continue;
}
if let Some(id) = id {
let slot = pending.lock().unwrap().remove(&id);
if let Some(slot) = slot {
*slot.done.lock().unwrap() = Some(msg);
slot.cv.notify_all();
}
}
}
}
/// Render `file` (an absolute `.tsx`/`.jsx` path) with `props`, returning
/// the HTML string. Spawns the worker on first use; respawns if it died.
pub fn render(&self, file: &str, props: Value) -> Result<String, SsrError> {
let (id, stdin_taken, slot) = {
let mut inner = self.inner.lock().unwrap();
self.ensure_running(&mut inner)?;
inner.counter += 1;
let id = inner.counter;
let slot = Arc::new(Slot::default());
inner.pending.lock().unwrap().insert(id, slot.clone());
let request = json!({
"id": id,
"method": "render",
"params": {"file": file, "props": props},
});
let mut line = serde_json::to_string(&request).unwrap();
line.push('\n');
let write_res = inner
.stdin
.as_mut()
.ok_or_else(|| SsrError::Pipe("no stdin".into()))
.and_then(|w| {
w.write_all(line.as_bytes())
.and_then(|_| w.flush())
.map_err(|e| SsrError::Pipe(e.to_string()))
});
(id, write_res, slot)
};
if let Err(e) = stdin_taken {
self.inner.lock().unwrap().pending.lock().unwrap().remove(&id);
return Err(e);
}
// Park on the slot until the reader fills it or we time out.
let mut done = slot.done.lock().unwrap();
while done.is_none() {
let (g, timed_out) = slot.cv.wait_timeout(done, self.timeout).unwrap();
done = g;
if timed_out.timed_out() && done.is_none() {
self.inner.lock().unwrap().pending.lock().unwrap().remove(&id);
return Err(SsrError::Timeout(format!("render of {file:?}")));
}
}
let msg = done.take().unwrap();
drop(done);
if let Some(err) = msg.get("error").and_then(|e| e.as_str()) {
return Err(SsrError::Render(err.to_string()));
}
match msg.get("html").and_then(|h| h.as_str()) {
Some(html) => Ok(html.to_string()),
None => Err(SsrError::Render("response missing `html`".into())),
}
}
/// Stop the subprocess. Idempotent; called from `Drop`.
pub fn shutdown(&self) {
let mut inner = self.inner.lock().unwrap();
inner.stdin = None; // close stdin → worker sees EOF
if let Some(mut child) = inner.child.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
impl Drop for SsrBridge {
fn drop(&mut self) {
self.shutdown();
}
}

View File

@@ -53,6 +53,12 @@ pub trait FunctionSpec: Send + Sync {
fn private(&self) -> bool {
false
}
/// The `@client(auth=...)` requirement, as the IR string form: `None`
/// (no guard), `"required"`, `"staff"`, or `"superuser"`. The dispatch
/// core resolves this into an `AuthRequirement` and rejects accordingly.
fn auth(&self) -> Option<&'static str> {
None
}
fn is_form(&self) -> bool {
false
}

View File

@@ -0,0 +1,72 @@
//! Upload — first-class binary input for `#[mizan::client]` functions.
//!
//! Rust analog of `cores/mizan-python/src/mizan_core/upload.py`. An adapter
//! parses a multipart file part and binds it into the function's typed input
//! as the JSON shape `Upload` deserializes:
//!
//! ```json
//! {"filename": "a.png", "content_type": "image/png", "data_b64": "...", "size": 12}
//! ```
//!
//! Declaring an `Upload`-typed parameter makes a function multipart-aware end
//! to end (the generated client switches the call to `multipart/form-data`;
//! each adapter binds the part). `Upload` is `Deserialize`, so it drops into a
//! `#[mizan(...)]` input struct like any other field and the dispatch
//! wrapper's `serde_json::from_value` validates it.
use base64::engine::general_purpose::STANDARD;
use base64::Engine;
use serde::de::{self, Deserializer};
use serde::Deserialize;
/// A bound, decoded upload handed to a `#[mizan::client]` function. The bytes
/// are eagerly decoded from the adapter's base64 transport form.
#[derive(Debug, Clone)]
pub struct Upload {
pub filename: Option<String>,
pub content_type: Option<String>,
data: Vec<u8>,
}
impl Upload {
pub fn size(&self) -> usize {
self.data.len()
}
pub fn bytes(&self) -> &[u8] {
&self.data
}
/// Persist the upload to `path`.
pub fn save(&self, path: impl AsRef<std::path::Path>) -> std::io::Result<()> {
std::fs::write(path, &self.data)
}
}
/// The wire form an adapter encodes a file part into. Kept separate from
/// `Upload` so the public handle exposes decoded bytes, not base64.
#[derive(Deserialize)]
struct UploadWire {
#[serde(default)]
filename: Option<String>,
#[serde(default)]
content_type: Option<String>,
data_b64: String,
}
impl<'de> Deserialize<'de> for Upload {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = UploadWire::deserialize(deserializer)?;
let data = STANDARD
.decode(wire.data_b64.as_bytes())
.map_err(|e| de::Error::custom(format!("invalid base64 upload data: {e}")))?;
Ok(Upload {
filename: wire.filename,
content_type: wire.content_type,
data,
})
}
}

View File

@@ -0,0 +1,120 @@
//! Cross-language pin: Rust `derive_cache_key` must be byte-identical to the
//! Python reference (`cores/mizan-python/.../cache/keys.py`) and to the
//! committed cross-language vectors that `tests/afi` and `mizan-ts` also pin.
//!
//! The Python reference is the oracle: a subprocess mints the key with fixed
//! inputs and the Rust output must match exactly. `never if backend == X` —
//! one spec, pinned both ways.
use mizan_core::derive_cache_key;
use serde_json::{json, Value};
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::Command;
/// The `tests/afi` dir, whose venv has `mizan_core` + PyJWT installed.
fn afi_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/afi")
.canonicalize()
.expect("tests/afi exists")
}
/// Run the Python reference via `uv run python -c <code>` in tests/afi and
/// return its single stdout line, trimmed.
fn py(code: &str) -> String {
let out = Command::new("uv")
.args(["run", "python", "-c", code])
.current_dir(afi_dir())
.output()
.expect("invoke uv run python");
assert!(
out.status.success(),
"python reference failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
fn tree(pairs: &[(&str, Value)]) -> BTreeMap<String, Value> {
pairs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
}
#[test]
fn committed_vectors_match() {
// The exact pins committed in cores/mizan-python/tests/test_keys.py and
// backends/mizan-ts/tests — the canonical cross-language anchor.
let secret = "test-pin-secret-that-is-32bytes!";
let public = derive_cache_key(secret, "user", &tree(&[("user_id", json!("5"))]), None, 0);
assert_eq!(
public,
"ctx:user:605a1ca5ad5994e9b765c8d1b330474c2a0d51a7b8fbbdc402f992da7ba902f6"
);
let scoped = derive_cache_key(
secret,
"user",
&tree(&[("user_id", json!("5"))]),
Some("5"),
0,
);
assert_eq!(
scoped,
"ctx:user:30fc08eb46ee4ff2cf7d317e97dca90fd616511e0587304416f71dc863338dc2"
);
}
#[test]
fn matches_python_reference_across_inputs() {
// A spread of shapes: multi-param (order-independence), numeric vs string,
// bool/null normalization, user-scoped, nonzero rev.
let cases: Vec<(&str, BTreeMap<String, Value>, Option<&str>, i64)> = vec![
("user", tree(&[("user_id", json!("5"))]), None, 0),
("user", tree(&[("user_id", json!("5"))]), Some("5"), 0),
("user", tree(&[("user_id", json!("5"))]), Some("5"), 3),
(
"search",
tree(&[("q", json!("hello world")), ("page", json!(2))]),
None,
0,
),
(
"flags",
tree(&[("on", json!(true)), ("off", json!(false)), ("nil", json!(null))]),
Some("42"),
1,
),
("empty", tree(&[]), None, 0),
(
"unicode",
tree(&[("name", json!("café—ñ"))]),
None,
0,
),
];
for (ctx, params, uid, rev) in cases {
let rust = derive_cache_key("pin-secret-xyz", ctx, &params, uid, rev);
// Build the Python call: derive_cache_key(secret, ctx, params, user_id, rev).
let params_json = serde_json::to_string(
&params.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<serde_json::Map<_, _>>(),
)
.unwrap();
let uid_arg = match uid {
Some(u) => format!("'{u}'"),
None => "None".to_string(),
};
let code = format!(
"import json; from mizan_core.cache.keys import derive_cache_key; \
print(derive_cache_key('pin-secret-xyz', {ctx:?}, json.loads(r'''{params_json}'''), {uid_arg}, {rev}))",
);
let expected = py(&code);
assert_eq!(
rust, expected,
"cache-key mismatch for ctx={ctx} params={params_json} uid={uid:?} rev={rev}",
);
}
}

View File

@@ -0,0 +1,90 @@
//! Cross-language pin: Rust `format_invalidate_header` must be byte-identical
//! to `cores/mizan-python/.../invalidation.py::format_invalidate_header`.
//!
//! The `X-Mizan-Invalidate` header is co-equal with the JSON body channel in
//! the spec; Edge parses it to purge. The Python reference is the oracle.
use mizan_core::{format_invalidate_header, InvalidationTarget};
use serde_json::json;
use std::path::PathBuf;
use std::process::Command;
fn afi_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/afi")
.canonicalize()
.expect("tests/afi exists")
}
fn py_header(json_list: &str) -> String {
let code = format!(
"import json; from mizan_core.invalidation import format_invalidate_header; \
print(format_invalidate_header(json.loads(r'''{json_list}''')))",
);
let out = Command::new("uv")
.args(["run", "python", "-c", &code])
.current_dir(afi_dir())
.output()
.expect("invoke uv run python");
assert!(
out.status.success(),
"python reference failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// Trim the trailing newline only — the header value itself may be empty.
let s = String::from_utf8(out.stdout).unwrap();
s.strip_suffix('\n').unwrap_or(&s).to_string()
}
fn scoped(ctx: &str, params: &[(&str, serde_json::Value)]) -> InvalidationTarget {
InvalidationTarget::ScopedContext {
context: ctx.to_string(),
params: params.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(),
}
}
#[test]
fn matches_python_reference() {
let cases: Vec<(Vec<InvalidationTarget>, &str)> = vec![
(vec![InvalidationTarget::Context("user".into())], r#"["user"]"#),
(
vec![
InvalidationTarget::Context("user".into()),
InvalidationTarget::Context("notifications".into()),
],
r#"["user", "notifications"]"#,
),
(
vec![scoped("user", &[("user_id", json!(5))])],
r#"[{"context": "user", "params": {"user_id": 5}}]"#,
),
(
vec![scoped("search", &[("q", json!("hello world"))])],
r#"[{"context": "search", "params": {"q": "hello world"}}]"#,
),
(
// Multiple params → sorted by key, semicolon-joined.
vec![scoped("u", &[("b", json!("2")), ("a", json!("1"))])],
r#"[{"context": "u", "params": {"b": "2", "a": "1"}}]"#,
),
(
// Special chars that must percent-encode: &, =, /, space, unicode.
vec![scoped("c", &[("k", json!("a&b=c/d e—ñ"))])],
r#"[{"context": "c", "params": {"k": "a&b=c/d e—ñ"}}]"#,
),
(
// Mixed bare + scoped.
vec![
scoped("user", &[("user_id", json!(5))]),
InvalidationTarget::Context("notifications".into()),
],
r#"[{"context": "user", "params": {"user_id": 5}}, "notifications"]"#,
),
];
for (targets, json_list) in cases {
let rust = format_invalidate_header(&targets);
let expected = py_header(json_list);
assert_eq!(rust, expected, "header mismatch for {json_list}");
}
}

View File

@@ -0,0 +1,89 @@
//! Behavior tests for the Shapes projection + edge-manifest derivation,
//! driven off a small registered fixture (same graph the AFI fixture uses:
//! a nested struct, a user context with a shared `user_id` param, and an
//! `affects` mutation).
use mizan_core as mizan;
use mizan_core::prelude::*;
use mizan_core::{generate_edge_manifest, shapes, RequestHandle};
use serde::{Deserialize, Serialize};
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Address {
pub city: String,
pub zip: String,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Person {
pub user_id: i64,
pub name: String,
pub address: Address,
}
#[derive(Mizan, Serialize, Deserialize, Debug, Clone)]
pub struct Ok {
pub ok: bool,
}
#[mizan::context("people")]
pub struct PeopleCtx;
#[mizan::client(context = PeopleCtx)]
pub async fn person(_req: &RequestHandle<'_>, user_id: i64) -> Person {
Person {
user_id,
name: "x".into(),
address: Address {
city: "c".into(),
zip: "z".into(),
},
}
}
#[mizan::client(affects = PeopleCtx)]
pub async fn rename_person(_req: &RequestHandle<'_>, user_id: i64, _name: String) -> Ok {
let _ = user_id;
Ok { ok: true }
}
#[test]
fn shapes_projection_descends_nested_structs() {
let proj = shapes::project_function_output("person").expect("projects");
assert_eq!(proj.type_name, "personOutput");
// Scalar leaves at the top level.
let leaves = proj.leaf_names();
assert!(leaves.contains(&"user_id"));
assert!(leaves.contains(&"name"));
// `address` is a nested struct → a sub-projection, not a leaf.
assert!(!leaves.contains(&"address"));
let nested = proj.nested();
assert_eq!(nested.len(), 1);
let (name, sub) = nested[0];
assert_eq!(name, "address");
let sub_leaves = sub.leaf_names();
assert!(sub_leaves.contains(&"city") && sub_leaves.contains(&"zip"));
}
#[test]
fn edge_manifest_has_context_render_strategy_and_mutation() {
let m = generate_edge_manifest("/api/mizan");
// Context: user-scoped (has `user_id`) → render_strategy dynamic_cached.
let people = &m["contexts"]["people"];
assert_eq!(people["user_scoped"], serde_json::json!(true));
assert_eq!(people["render_strategy"], serde_json::json!("dynamic_cached"));
assert_eq!(
people["endpoints"],
serde_json::json!(["/api/mizan/ctx/people/"])
);
assert_eq!(people["params"], serde_json::json!(["user_id"]));
// Mutation: rename_person affects people, auto-scopes user_id.
let mutation = &m["mutations"]["rename_person"];
assert_eq!(mutation["affects"], serde_json::json!(["people"]));
assert_eq!(
mutation["auto_scoped_params"],
serde_json::json!(["user_id"])
);
}

View File

@@ -0,0 +1,105 @@
//! Behavior test for the SSR bridge's framing + request/response correlation.
//!
//! Bun isn't required (it isn't installed in CI): a stub worker speaking the
//! exact same newline-delimited JSON-RPC protocol stands in. The stub emits
//! the `{"id":0,"ready":true}` handshake, then for each `render` request
//! echoes back `{"id":N,"html":"<rendered:FILE props=PROPS>"}` — exercising
//! the ready-gate, the per-request id correlation, and the html extraction
//! that the real Bun worker drives.
use mizan_core::{SsrBridge, WorkerCommand};
use serde_json::json;
use std::io::Write;
use std::time::Duration;
/// A tiny Python stub that speaks the SSR worker protocol. Written to a temp
/// file and launched via `python3 <file>`.
const STUB: &str = r#"
import sys, json
# Handshake: announce readiness exactly as the Bun worker does.
sys.stdout.write(json.dumps({"id": 0, "ready": True}) + "\n")
sys.stdout.flush()
for line in sys.stdin:
line = line.strip()
if not line:
continue
msg = json.loads(line)
mid = msg.get("id")
if msg.get("method") == "render":
p = msg["params"]
# A sentinel file name forces the worker-error branch.
if p["file"] == "/boom.tsx":
sys.stdout.write(json.dumps({"id": mid, "error": "render exploded"}) + "\n")
else:
html = "<rendered:%s props=%s>" % (p["file"], json.dumps(p["props"], sort_keys=True))
sys.stdout.write(json.dumps({"id": mid, "html": html}) + "\n")
else:
sys.stdout.write(json.dumps({"id": mid, "error": "unknown method"}) + "\n")
sys.stdout.flush()
"#;
fn write_stub() -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!("mizan_ssr_stub_{}.py", std::process::id()));
let mut f = std::fs::File::create(&path).unwrap();
f.write_all(STUB.as_bytes()).unwrap();
path
}
#[test]
fn bridge_drives_worker_protocol() {
let stub = write_stub();
let bridge = SsrBridge::new(
WorkerCommand {
program: "python3".to_string(),
args: vec![stub.to_string_lossy().to_string()],
},
Duration::from_secs(5),
);
// First render — spawns the worker, waits for the ready handshake.
let html = bridge
.render("/abs/Hello.tsx", json!({"name": "World"}))
.expect("first render succeeds");
assert_eq!(
html,
r#"<rendered:/abs/Hello.tsx props={"name": "World"}>"#
);
// Second render reuses the same subprocess; id correlation must keep the
// responses matched to their requests.
let html2 = bridge
.render("/abs/Other.tsx", json!({"a": 1, "b": 2}))
.expect("second render succeeds");
assert_eq!(
html2,
r#"<rendered:/abs/Other.tsx props={"a": 1, "b": 2}>"#
);
bridge.shutdown();
let _ = std::fs::remove_file(&stub);
}
#[test]
fn bridge_propagates_worker_error() {
let stub = write_stub();
let bridge = SsrBridge::new(
WorkerCommand {
program: "python3".to_string(),
args: vec![stub.to_string_lossy().to_string()],
},
Duration::from_secs(5),
);
// The sentinel file makes the stub return an `error` frame; the bridge
// must surface it as `SsrError::Render`, not a successful empty render.
let err = bridge
.render("/boom.tsx", json!({}))
.expect_err("worker error propagates");
assert!(matches!(err, mizan_core::SsrError::Render(_)));
assert!(err.to_string().contains("render exploded"));
// A subsequent good render on the same worker still succeeds.
assert!(bridge.render("/ok.tsx", json!({})).is_ok());
bridge.shutdown();
let _ = std::fs::remove_file(&stub);
}

View File

@@ -0,0 +1,153 @@
//! Cross-language pin: Rust HS256 JWT + MWT must be byte-identical to the
//! Python core (`auth/jwt.py`, `mwt.py`, both PyJWT-backed).
//!
//! Byte-identity is the whole point — Edge and the origin cache key on these
//! tokens, so a one-byte divergence is a cache-key spoof surface. The Python
//! reference is the oracle: it mints with fixed claims + a fixed `iat`/`exp`
//! (we pin `now` on both sides) and the Rust token must match exactly. We also
//! prove round-trip: Rust decodes a Python-minted token and vice-versa.
use mizan_core::{
create_access_token, create_mwt, create_refresh_token, decode_jwt, decode_mwt,
compute_permission_key, JwtConfig,
};
use std::path::PathBuf;
use std::process::Command;
fn afi_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/afi")
.canonicalize()
.expect("tests/afi exists")
}
fn py(code: &str) -> String {
let out = Command::new("uv")
.args(["run", "python", "-c", code])
.current_dir(afi_dir())
.output()
.expect("invoke uv run python");
assert!(
out.status.success(),
"python reference failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8(out.stdout).unwrap().trim().to_string()
}
const NOW: i64 = 1_700_000_000;
#[test]
fn jwt_access_token_matches_python() {
let cfg = JwtConfig::new("jwt-pin-secret");
let rust = create_access_token(&cfg, "42", "sess-abc", true, false, NOW);
// Python: freeze time to NOW, mint an access token with the same claims.
let code = format!(
"import time; from unittest import mock; \
from mizan_core.auth.jwt import JWTConfig, create_access_token; \
cfg = JWTConfig(private_key='jwt-pin-secret', public_key='jwt-pin-secret'); \
orig = time.time; \
time.time = lambda: {NOW}; \
print(create_access_token('42', 'sess-abc', cfg, is_staff=True, is_superuser=False)); \
time.time = orig",
);
let expected = py(&code);
assert_eq!(rust, expected, "JWT access-token byte mismatch");
}
#[test]
fn jwt_refresh_token_matches_python() {
let cfg = JwtConfig::new("jwt-pin-secret");
let rust = create_refresh_token(&cfg, "7", "sid-9", false, true, NOW);
let code = format!(
"import time; from mizan_core.auth.jwt import JWTConfig, create_refresh_token; \
cfg = JWTConfig(private_key='jwt-pin-secret', public_key='jwt-pin-secret'); \
time.time = lambda: {NOW}; \
print(create_refresh_token('7', 'sid-9', cfg, is_staff=False, is_superuser=True))",
);
assert_eq!(rust, py(&code), "JWT refresh-token byte mismatch");
}
#[test]
fn jwt_roundtrip_decode_python_minted() {
// A Python-minted access token must decode in Rust with matching claims.
let code = format!(
"import time; from mizan_core.auth.jwt import JWTConfig, create_access_token; \
cfg = JWTConfig(private_key='rt-secret', public_key='rt-secret'); \
time.time = lambda: {NOW}; \
print(create_access_token('99', 'sess-x', cfg, is_staff=False, is_superuser=True))",
);
let token = py(&code);
let cfg = JwtConfig::new("rt-secret");
let payload = decode_jwt(&token, &cfg, Some("access"), NOW + 10).expect("decodes");
assert_eq!(payload.sub, "99");
assert_eq!(payload.sid, "sess-x");
assert!(payload.superuser);
assert!(!payload.staff);
// Wrong secret → None; expired → None.
assert!(decode_jwt(&token, &JwtConfig::new("nope"), None, NOW + 10).is_none());
assert!(decode_jwt(&token, &cfg, Some("access"), NOW + 10_000).is_none());
// Type mismatch → None.
assert!(decode_jwt(&token, &cfg, Some("refresh"), NOW + 10).is_none());
}
#[test]
fn permission_key_matches_python() {
let perms = vec!["app.add_thing".to_string(), "app.view_thing".to_string()];
let rust = compute_permission_key(true, false, &perms);
let code =
"from mizan_core.mwt import compute_permission_key; \
from unittest.mock import MagicMock; \
u = MagicMock(); u.is_staff=True; u.is_superuser=False; \
u.get_all_permissions = MagicMock(return_value={'app.view_thing','app.add_thing'}); \
print(compute_permission_key(u))";
assert_eq!(rust, py(code), "pkey byte mismatch");
}
#[test]
fn mwt_matches_python() {
// Build the same pkey on both sides, then mint with frozen time + fixed
// kid/audience and compare bytes.
let perms = vec!["app.view_thing".to_string()];
let pkey = compute_permission_key(false, false, &perms);
let rust = create_mwt("mwt-pin-secret", "5", false, false, &pkey, 300, "mizan", "v1", NOW);
let code = format!(
"import time; from unittest.mock import MagicMock; \
from mizan_core.mwt import create_mwt; \
u = MagicMock(); u.pk=5; u.is_staff=False; u.is_superuser=False; \
u.get_all_permissions = MagicMock(return_value={{'app.view_thing'}}); \
time.time = lambda: {NOW}; \
print(create_mwt(u, 'mwt-pin-secret', ttl=300, audience='mizan', kid='v1'))",
);
assert_eq!(rust, py(&code), "MWT byte mismatch");
}
#[test]
fn mwt_roundtrip_and_rejections() {
let pkey = compute_permission_key(true, true, &[]);
let token = create_mwt("rt-mwt", "13", true, true, &pkey, 300, "mizan", "v1", NOW);
let p = decode_mwt(&token, "rt-mwt", "mizan", NOW + 5).expect("decodes");
assert_eq!(p.sub, "13");
assert!(p.staff && p.superuser);
assert_eq!(p.kid, "v1");
assert_eq!(p.pkey.len(), 64);
// Wrong secret, wrong audience, expired → None.
assert!(decode_mwt(&token, "wrong", "mizan", NOW + 5).is_none());
assert!(decode_mwt(&token, "rt-mwt", "other", NOW + 5).is_none());
assert!(decode_mwt(&token, "rt-mwt", "mizan", NOW + 10_000).is_none());
// And a Python-minted MWT decodes in Rust.
let code = format!(
"import time; from unittest.mock import MagicMock; from mizan_core.mwt import create_mwt; \
u = MagicMock(); u.pk=21; u.is_staff=True; u.is_superuser=False; \
u.get_all_permissions = MagicMock(return_value=set()); \
time.time = lambda: {NOW}; print(create_mwt(u, 'rt-mwt', ttl=300, audience='mizan', kid='v1'))",
);
let py_token = py(&code);
let pp = decode_mwt(&py_token, "rt-mwt", "mizan", NOW + 5).expect("py mwt decodes in rust");
assert_eq!(pp.sub, "21");
assert!(pp.staff && !pp.superuser);
}