wai-quantum-serve 0.5.1: limits a request cannot pass, and a body is read whole or refused #235
3 changed files with 163 additions and 23 deletions
2
wai/wai-quantum-serve/Cargo.lock
generated
2
wai/wai-quantum-serve/Cargo.lock
generated
|
|
@ -598,7 +598,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "wai-quantum-serve"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"wai",
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue