Restore approved state (tree of 4effcc7 "Added LICENSE")
Roll the working tree back to the last approved shape, before the post-LICENSE span that false-greened the AFI parity matrix with symbol-presence probes and smuggled an unauthorized SQLAlchemy dependency into FastAPI's Shapes binding.
Forward commit, not a history rewrite — the six commits since 4effcc7 stay in the log as the record of what happened.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
108
cores/mizan-rust/Cargo.lock
generated
108
cores/mizan-rust/Cargo.lock
generated
@@ -13,82 +13,12 @@ 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"
|
||||
@@ -104,12 +34,6 @@ 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"
|
||||
@@ -141,14 +65,11 @@ name = "mizan-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"hmac",
|
||||
"indoc",
|
||||
"linkme",
|
||||
"mizan-macros",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -228,23 +149,6 @@ 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"
|
||||
@@ -256,24 +160,12 @@ 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"
|
||||
|
||||
@@ -11,9 +11,6 @@ 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"
|
||||
|
||||
@@ -1,552 +0,0 @@
|
||||
//! 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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
//! 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, ¶ms_tree, user_id, 0) {
|
||||
backend.delete(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,14 +26,6 @@ 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)]
|
||||
|
||||
@@ -160,29 +160,6 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,7 +464,7 @@ fn walk_shape_refs<F: FnMut(&'static str)>(shape: &TypeShape, visit: &mut F) {
|
||||
walk_shape_refs(b, visit);
|
||||
}
|
||||
}
|
||||
TypeShape::Primitive(_) | TypeShape::Enum(_) | TypeShape::Upload { .. } => {}
|
||||
TypeShape::Primitive(_) | TypeShape::Enum(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,43 +14,25 @@
|
||||
//! 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, format_invalidate_header, InvalidationTarget,
|
||||
MergeEntry, MizanError, RequestHandle,
|
||||
compute_invalidation, compute_merges, 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.
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
//! 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(),
|
||||
}
|
||||
}
|
||||
@@ -135,75 +135,6 @@ 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 = ¶ms[*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)]
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
//! 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())
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
//! 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();
|
||||
}
|
||||
}
|
||||
@@ -53,12 +53,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
//! 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
//! 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, ¶ms, uid, rev);
|
||||
|
||||
// Build the Python call: derive_cache_key(secret, ctx, params, user_id, rev).
|
||||
let params_json = serde_json::to_string(
|
||||
¶ms.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}",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
//! 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"])
|
||||
);
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
Reference in New Issue
Block a user