wai-quantum-serve 0.5.1: limits a request cannot pass, and a body is read whole or refused #235

Merged
dcharlot merged 1 commit from wai/quantum-serve-limits into main 2026-10-13 06:38:19 -04:00
3 changed files with 163 additions and 23 deletions

View file

@ -598,7 +598,7 @@ dependencies = [
[[package]]
name = "wai-quantum-serve"
version = "0.5.0"
version = "0.5.1"
dependencies = [
"ed25519-dalek",
"wai",

View file

@ -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."

View file

@ -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<String> {
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<u8> {
/// 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<Vec<u8>> {
let mut buf = Vec::new();
if let Ok(stream) = body.stream() {
loop {
@ -189,15 +243,20 @@ fn read_body(body: IncomingBody) -> Vec<u8> {
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<String, String> {
))
}
/// `/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<Circuit, String>, 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<Measure>) -> 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}");
}
}