From 3d3ff815c9be92108b52c63103bcc34aabd4cb69 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 13 Oct 2026 02:43:12 -0400 Subject: [PATCH] wai-quantum-serve 0.5.1: limits a request cannot pass, and a body is read whole or refused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A circuit the algorithm library builds is small (its builders clamp to 12 qubits), but a caller's own OpenQASM could name 26 qubits and any number of gates, and a receipt any number of shots: one request could hold a host's memory and CPU. Every such request is now refused before it is simulated or sealed: - a POST body over 256 KiB: 413. Before, the body was cut at 4 MiB and the cut text parsed as if it were the request; - a circuit on more than 20 qubits, or of more than 2^27 amplitude updates (gates × 2^n): 422. Served as a component on an M4 Max core, 2^30 took 15 s and 2^27 about 2 s; - a measurement of more than 1,000,000 shots: 422; - top above 1024 is read as 1024. GET / states the limits. The module docs say a host should bound the component too: time per request, memory per instance, instances at once. Checked under wasmtime serve 49.0.1 with a 10 s timeout, a 256 MiB memory limit and a pool of 8 memories: every endpoint, every refusal, a tampered receipt refused, a circuit at the work limit run in 1.9 s, and of 14 concurrent requests at that limit 8 served and 6 refused at once by the pool, the server unaffected after. Co-Authored-By: Claude Opus 5.5 --- wai/wai-quantum-serve/Cargo.lock | 2 +- wai/wai-quantum-serve/Cargo.toml | 2 +- wai/wai-quantum-serve/src/lib.rs | 182 +++++++++++++++++++++++++++---- 3 files changed, 163 insertions(+), 23 deletions(-) diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index 13028d600..6982538a9 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -598,7 +598,7 @@ dependencies = [ [[package]] name = "wai-quantum-serve" -version = "0.5.0" +version = "0.5.1" dependencies = [ "ed25519-dalek", "wai", diff --git a/wai/wai-quantum-serve/Cargo.toml b/wai/wai-quantum-serve/Cargo.toml index 578953629..a74bcde20 100644 --- a/wai/wai-quantum-serve/Cargo.toml +++ b/wai/wai-quantum-serve/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum-serve" -version = "0.5.0" +version = "0.5.1" edition = "2021" license = "Apache-2.0" description = "The WAI quantum stack as a WASI-HTTP component — deterministic quantum computation as a serverless function. Runs on any wasi:http/proxy host (wasmtime serve, and other component-model runtimes); returns the same statevector hashes and the same signed receipt bytes as a native run." diff --git a/wai/wai-quantum-serve/src/lib.rs b/wai/wai-quantum-serve/src/lib.rs index b32824155..574dcc938 100644 --- a/wai/wai-quantum-serve/src/lib.rs +++ b/wai/wai-quantum-serve/src/lib.rs @@ -27,6 +27,23 @@ //! alone, so it is the same bytes a native run seals. A circuit whose hash //! cannot be computed is a 400. //! +//! **Limits.** A circuit the algorithm library builds is small already: its +//! builders clamp to at most 12 qubits. What a caller writes itself is bounded +//! here, so one request cannot take a host's memory or hold its CPU: +//! +//! - a `POST` body larger than [`MAX_BODY`] is refused whole (413), never +//! read in part; +//! - a circuit on more than [`MAX_QUBITS`] qubits, or whose simulation would +//! take more than [`MAX_WORK`] amplitude updates (gates × 2ⁿ), is refused +//! (422), before it is simulated or sealed; +//! - a `/receipt` measurement of more than [`MAX_SHOTS`] shots is refused +//! (422); +//! - `top` is read as at most [`MAX_TOP`]. +//! +//! `GET /` states them. A host serving the component should bound it as well: +//! a time limit per request, a memory limit per instance and a cap on the +//! instances alive at once. +//! //! `POST /verify` accepts a receipt in either form: as sealed, or with its //! joule figure's acquisition class inside the signature //! (`quantum_energy::Labelled`), and restates the class in its verdict. It @@ -48,6 +65,41 @@ use wasi::http::types::{ wasi::http::proxy::export!(Component); struct Component; +/// The largest `POST` body read, in bytes: a larger one is refused whole. +pub const MAX_BODY: usize = 256 << 10; +/// The most qubits a circuit may have here: its statevector is 2ⁿ amplitudes +/// of 16 bytes, 16 MiB at 20. +pub const MAX_QUBITS: u8 = 20; +/// The most amplitude updates (gates × 2ⁿ, `statevector_work`) a circuit's +/// simulation may take here: 2²⁷, 128 gates on 20 qubits. Served as a +/// component (`wasmtime serve` 49.0.1 on an M4 Max core), 2³⁰ took 15 s and +/// 2²⁷ about 2 s. +pub const MAX_WORK: u64 = 1 << 27; +/// The most shots a `/receipt` measurement may take. +pub const MAX_SHOTS: u64 = 1_000_000; +/// The most outcomes `/run` lists; a larger `top` is read as this. +pub const MAX_TOP: usize = 1024; + +/// `GET /`: what the service is, its endpoints and its limits. +fn description() -> String { + format!( + "{{\"service\":\"wai-quantum\",\"kind\":\"wasi:http/proxy component\",\ + \"deterministic\":true,\"note\":\"statevector hashes and receipt bytes are identical to a native run\",\ + \"gateset\":{{\"default\":\"v2\",\"v2\":\"wai.quantum.circuit2\",\"v1\":\"wai.quantum.circuit\"}},\ + \"limits\":{{\"body_bytes\":{MAX_BODY},\"qubits\":{MAX_QUBITS},\"amp_updates\":{MAX_WORK},\"shots\":{MAX_SHOTS},\"top\":{MAX_TOP}}},\ + \"endpoints\":[\"/health\",\"/algorithms\",\"/run?algo=ghz&n=5\",\"/qasm?algo=bell\",\"/receipt?algo=ghz&n=3\",\"POST /run (OpenQASM)\",\"POST /verify (receipt JSON)\"]}}" + ) +} + +/// Why `c` is over the limits, or `None` when it is within them. +fn over_limits(c: &Circuit) -> Option { + if c.n_qubits > MAX_QUBITS { + return Some(format!("{} qubits: this service simulates at most {MAX_QUBITS}", c.n_qubits)); + } + let work = qt::statevector_work(c); + (work > MAX_WORK).then(|| format!("{work} amplitude updates: this service performs at most {MAX_WORK} per circuit")) +} + /// The `POST /verify` verdict for a receipt JSON body: an HTTP status and a /// JSON body — 200 valid, 409 invalid, 400 unparseable. /// @@ -181,7 +233,9 @@ impl Reply { } } -fn read_body(body: IncomingBody) -> Vec { +/// The request body, or `None` when it is larger than [`MAX_BODY`]: a body +/// cut short would be parsed as another request. +fn read_body(body: IncomingBody) -> Option> { let mut buf = Vec::new(); if let Ok(stream) = body.stream() { loop { @@ -189,15 +243,20 @@ fn read_body(body: IncomingBody) -> Vec { Ok(chunk) if chunk.is_empty() => break, Ok(chunk) => { buf.extend_from_slice(&chunk); - if buf.len() > 4 << 20 { - break; // 4 MiB cap + if buf.len() > MAX_BODY { + return None; } } Err(_) => break, // stream closed = EOF } } } - buf + Some(buf) +} + +/// The 413 a body larger than [`MAX_BODY`] gets. +fn too_large(out: ResponseOutparam) { + err(out, 413, &format!("the body is larger than {MAX_BODY} bytes")) } /// `name=value` pairs from a `path?query` string (percent-decoding `%XX` and `+`). @@ -300,10 +359,18 @@ fn run_json(c: &Circuit, top: usize) -> Result { )) } -/// `/run`: 200 and the run JSON for the circuit the request built, or 400. +/// `/run`: 200 and the run JSON for the circuit the request built (at most +/// [`MAX_TOP`] outcomes), 422 for one over the limits, or 400. fn run_reply(built: Result, top: usize) -> Reply { - match built.and_then(|c| run_json(&c, top).map(|body| (c.gateset, body))) { - Ok((gs, body)) => Reply { status: 200, body, capability: Some(gs.capability()) }, + let c = match built { + Ok(c) => c, + Err(e) => return Reply::error(400, &e), + }; + if let Some(why) = over_limits(&c) { + return Reply::error(422, &why); + } + match run_json(&c, top.min(MAX_TOP)) { + Ok(body) => Reply { status: 200, body, capability: Some(c.gateset.capability()) }, Err(e) => Reply::error(400, &e), } } @@ -316,14 +383,21 @@ fn receipt_reply(pq: &str) -> Reply { Err(e) => return Reply::error(400, &e), }; let shots: u64 = qnum(pq, "shots", 0); + if shots > MAX_SHOTS { + return Reply::error(422, &format!("{shots} shots: this service measures at most {MAX_SHOTS}")); + } let seed: u64 = qnum(pq, "seed", 0); seal_reply(&c, (shots > 0).then_some(Measure { seed, shots })) } -/// Seal `c`: 200 and the receipt JSON, or 400 when the seal refuses the -/// circuit. Every refusal is the circuit's own: one its gateset does not allow, -/// so it has no circuit hash, or one too large to simulate. +/// Seal `c`: 200 and the receipt JSON, 422 for a circuit over the limits, or +/// 400 when the seal refuses the circuit. Every refusal is the circuit's own: +/// one its gateset does not allow, so it has no circuit hash, or one too large +/// to simulate. fn seal_reply(c: &Circuit, measure: Option) -> Reply { + if let Some(why) = over_limits(c) { + return Reply::error(422, &why); + } // Deterministic demo key so the sealed bytes match a native run. let key = SigningKey::from_bytes(&[42u8; 32]); match QuantumReceipt::seal(&key, "did:key:wai-quantum-cli", c, measure, 0, None) { @@ -340,15 +414,7 @@ impl wasi::exports::http::incoming_handler::Guest for Component { let is_post = matches!(method, Method::Post); match (is_post, path.as_str()) { - (false, "/") => json( - out, - 200, - "{\"service\":\"wai-quantum\",\"kind\":\"wasi:http/proxy component\",\ - \"deterministic\":true,\"note\":\"statevector hashes and receipt bytes are identical to a native run\",\ - \"gateset\":{\"default\":\"v2\",\"v2\":\"wai.quantum.circuit2\",\"v1\":\"wai.quantum.circuit\"},\ - \"endpoints\":[\"/health\",\"/algorithms\",\"/run?algo=ghz&n=5\",\"/qasm?algo=bell\",\"/receipt?algo=ghz&n=3\",\"POST /run (OpenQASM)\",\"POST /verify (receipt JSON)\"]}" - .to_string(), - ), + (false, "/") => json(out, 200, description()), (false, "/health") => json(out, 200, "{\"ok\":true}".to_string()), @@ -374,7 +440,10 @@ impl wasi::exports::http::incoming_handler::Guest for Component { let Ok(body) = req.consume() else { return err(out, 400, "no body"); }; - let src = String::from_utf8_lossy(&read_body(body)).into_owned(); + let Some(bytes) = read_body(body) else { + return too_large(out); + }; + let src = String::from_utf8_lossy(&bytes).into_owned(); let built = qt::from_qasm(&src).map_err(|e| format!("QASM parse: {e}")); run_reply(built.and_then(|c| in_gateset(c, &pq)), top).send(out) } @@ -390,7 +459,10 @@ impl wasi::exports::http::incoming_handler::Guest for Component { let Ok(body) = req.consume() else { return err(out, 400, "no body"); }; - let src = String::from_utf8_lossy(&read_body(body)).into_owned(); + let Some(bytes) = read_body(body) else { + return too_large(out); + }; + let src = String::from_utf8_lossy(&bytes).into_owned(); let (status, verdict) = verify_response(&src); json(out, status, verdict) } @@ -575,4 +647,72 @@ mod tests { let (_, body) = verify_response(&r1.to_json()); assert!(body.contains("\"joules_micro\":7,\"metered\":true"), "{body}"); } + + /// A circuit wider than the service simulates, or whose simulation would + /// take more amplitude updates than it performs, is refused before it is + /// simulated or sealed; one at both limits is within them. + #[test] + fn a_circuit_over_the_limits_is_refused_and_one_at_them_is_not() { + let wide = |n: u8, gates: usize| { + let mut c = Circuit::new(n); + for g in 0..gates { + c.h((g % n as usize) as u8); + } + c + }; + let too_wide = wide(MAX_QUBITS + 1, 1); + let r = run_reply(Ok(too_wide.clone()), 8); + assert_eq!((r.status, r.capability), (422, None), "{}", r.body); + assert!(r.body.contains(&format!("simulates at most {MAX_QUBITS}")), "{}", r.body); + assert_eq!(seal_reply(&too_wide, None).status, 422); + // The same refusal for a circuit written in OpenQASM. + let qasm = format!("OPENQASM 3.0;\nqubit[{}] q;\nh q[0];\n", MAX_QUBITS + 1); + assert_eq!(run_reply(qt::from_qasm(&qasm), 8).status, 422); + + // On the widest register, the most gates the work limit allows, and one more. + let at = (MAX_WORK >> MAX_QUBITS) as usize; + assert_eq!(over_limits(&wide(MAX_QUBITS, at)), None); + let why = over_limits(&wide(MAX_QUBITS, at + 1)).expect("one gate past the limit"); + assert!(why.contains("amplitude updates"), "{why}"); + assert_eq!(run_reply(Ok(wide(MAX_QUBITS, at + 1)), 8).status, 422); + // A circuit within both runs, on the widest register. + let r = run_reply(Ok(wide(MAX_QUBITS, 2)), 8); + assert_eq!(r.status, 200, "{}", r.body); + } + + /// `top` above the limit is read as the limit. + #[test] + fn top_lists_at_most_max_top_outcomes() { + let c = qt::build_algorithm("qft", 12, 0).expect("the 12-qubit transform"); + let r = run_reply(Ok(c), usize::MAX); + assert_eq!(r.status, 200, "{}", r.body); + assert_eq!(r.body.matches("\"state\":").count(), MAX_TOP); + } + + /// A measurement of more shots than the service takes is refused; one of + /// exactly that many is sealed. + #[test] + fn a_measurement_over_the_shot_limit_is_refused() { + let r = receipt_reply(&format!("/receipt?algo=bell&shots={}&seed=7", MAX_SHOTS + 1)); + assert_eq!((r.status, r.capability), (422, None), "{}", r.body); + assert!(r.body.contains(&format!("at most {MAX_SHOTS}")), "{}", r.body); + assert_eq!(receipt_reply(&format!("/receipt?algo=bell&shots={MAX_SHOTS}&seed=7")).status, 200); + } + + /// `GET /` states every limit, as the numbers the handler enforces. + #[test] + fn the_description_states_the_limits() { + let d = description(); + let want = [ + ("body_bytes", MAX_BODY as u64), + ("qubits", u64::from(MAX_QUBITS)), + ("amp_updates", MAX_WORK), + ("shots", MAX_SHOTS), + ("top", MAX_TOP as u64), + ]; + for (k, v) in want { + assert!(d.contains(&format!("\"{k}\":{v}")), "{k}: {d}"); + } + assert_eq!(d.matches('{').count(), d.matches('}').count(), "{d}"); + } } -- 2.54.0 (Apple Git-157)