autumn_web/plugin_sandbox/host.rs
1//! The sandbox host: a `wasmi` interpreter plus a deny-by-default WASI shim.
2//!
3//! # The sandbox *is* this file's import list
4//!
5//! A WebAssembly guest can do exactly two things on its own: compute, and call
6//! a function the host gave it. It has no syscalls, no ambient file
7//! descriptors and no way to name anything outside its own linear memory. So
8//! the entire authority a sandboxed plugin holds is the list of host functions
9//! `define_wasi_shim` registers — which is why they are all in one function,
10//! in one place, readable top to bottom.
11//!
12//! Every one of them either serves the request dialogue or says no:
13//!
14//! | Import | Behaviour |
15//! | --- | --- |
16//! | `fd_read` (fd 0) | the next bytes of the request frame |
17//! | `fd_write` (fd 1) | the response frame, one NDJSON line (charged per byte parsed) |
18//! | `fd_write` (fd 2) | captured as diagnostics, surfaced in the failure detail |
19//! | `random_get` | a fixed-seed PRNG — entropy is not authority, but it is not ambient either |
20//! | `clock_time_get` | a fixed instant |
21//! | `sched_yield`, `fd_close`, `fd_fdstat_get`, `fd_seek`, `fd_tell` | inert |
22//! | `environ_*`, `args_*` | **empty, and each call recorded as a denial** |
23//! | every `path_*`, every `fd_*` on an unknown descriptor | **`ENOTCAPABLE`/`EBADF`, recorded** |
24//! | every `sock_*`, `poll_oneoff`, `proc_raise` | **`ENOTCAPABLE`, recorded** |
25//! | `proc_exit` | ends the guest, never the host |
26//!
27//! There is no `path_open` that opens, no `fd_prestat_get` that resolves, and
28//! no socket that connects. A capability is not "off by default" here — for
29//! everything but request handling, there is no implementation to turn on.
30//!
31//! # The world is closed
32//!
33//! Anything the module imports that this shim does not define is refused at
34//! **load**, before the artifact can run once, and the refusal names the
35//! import. That is what makes a bespoke seam — `autumn_db::query`,
36//! `env::system` — a non-starter rather than a runtime error a guest could
37//! catch and retry.
38//!
39//! # Everything a guest does wrong is a value, never a process event
40//!
41//! A trap (what a Rust panic compiles to on wasm), a `proc_exit`, fuel
42//! exhaustion, a refused allocation, a malformed frame, a guest that never
43//! answers: all of them come back as [`SandboxFailure`] inside an
44//! [`SandboxOutcome`]. Nothing in this module can abort, exit or panic the host
45//! process.
46// autumn-panic-gate: request-path module — production code path must be panic-free.
47// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
48// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
49#![cfg_attr(
50 not(test),
51 deny(
52 clippy::unwrap_used,
53 clippy::expect_used,
54 clippy::panic,
55 clippy::unreachable,
56 clippy::todo,
57 clippy::unimplemented,
58 clippy::indexing_slicing,
59 clippy::string_slice,
60 clippy::arithmetic_side_effects,
61 )
62)]
63
64use std::collections::VecDeque;
65use std::fmt;
66
67use tokio::sync::Semaphore;
68use wasmi::{Caller, Config, Engine, Linker, Module, Store};
69
70use super::artifact::SandboxArtifact;
71use super::manifest::{ResourceLimits, SandboxManifest};
72use super::wire::{
73 GuestFrame, HostFrame, SandboxRequest, SandboxResponse, from_guest_line, to_exact_line, to_line,
74};
75
76/// The WASI module name every import in the shim lives under.
77const WASI: &str = "wasi_snapshot_preview1";
78
79/// WASI `errno` values this shim returns.
80mod errno {
81 pub(super) const SUCCESS: i32 = 0;
82 /// Bad file descriptor.
83 pub(super) const BADF: i32 = 8;
84 /// Invalid argument.
85 pub(super) const INVAL: i32 = 28;
86 /// Seek on a pipe.
87 pub(super) const SPIPE: i32 = 70;
88 /// Capability insufficient — the sandbox's universal "no".
89 pub(super) const NOTCAPABLE: i32 = 76;
90}
91
92/// Bytes in one WebAssembly page.
93const WASM_PAGE_BYTES: u64 = 64 * 1024;
94
95/// The size of one WASI `iovec` (two `u32`s: pointer and length).
96const IOVEC_SIZE: usize = 8;
97
98/// Size of a WASI `fdstat` struct.
99const FDSTAT_SIZE: usize = 24;
100
101/// Scratch-buffer size for host-side copies of guest-supplied ranges (64 KiB).
102///
103/// An in-bounds iovec can legitimately span the guest's whole linear memory,
104/// but the host must never mirror one in a single allocation — a few concurrent
105/// requests doing so would exhaust host memory that no guest-side limit
106/// accounts for. Copies run through a buffer of this size, and the output
107/// budget is applied per chunk, so a runaway write fails long before its full
108/// length is copied.
109const HOST_IO_CHUNK_BYTES: usize = 64 * 1024;
110
111/// Cap on accumulated guest stderr, in bytes (64 KiB).
112const STDERR_BUDGET_BYTES: usize = 64 * 1024;
113
114/// Largest `iovec` array the shim will walk in one call.
115///
116/// Real WASI callers pass a handful; an array of millions is a guest asking the
117/// host to do a million times the work of one call. Bounding the array bounds
118/// the per-call amplification factor, which the fuel charge below then prices.
119const MAX_IOVECS: i32 = 64;
120
121/// The largest number of table elements one instance may hold, across all of
122/// its tables.
123///
124/// A table holds function references, not bytes, but it is still per-instance
125/// host storage: at four tables of 65,536 entries each it would be megabytes an
126/// instance, multiplied by `max_concurrency`, that
127/// [`ResourceLimits::request_footprint_bytes`](crate::plugin_sandbox::ResourceLimits::request_footprint_bytes)
128/// never counted. Bounded here to a number small enough that the footprint can
129/// carry it as a constant, and generous enough for any real guest's
130/// indirect-call table.
131pub const MAX_TABLE_ELEMENTS: u32 = 16_384;
132
133/// The most tables one instance may have.
134///
135/// The store limiter refuses the fifth at instantiation, which is per request,
136/// so the count has to be a load-time verdict too — a module with five empty
137/// tables clears the *element* ceiling for free. Defined once and read by both,
138/// so the number the limiter enforces and the number the loader admits cannot
139/// drift apart.
140pub const MAX_TABLES: usize = 4;
141
142/// The most linear memories a module may declare.
143///
144/// The `MemoryLimiter` reports one, so wasmi refuses to create a second at
145/// instantiation — which is per request, as a gateway error, on an artifact
146/// `autumn plugin inspect` said was fine. wasmi's own `Config` enables the
147/// multi-memory proposal by default, so `Module::new` accepts such a module
148/// and only the limiter objects, and the limiter runs too late to tell anyone
149/// useful. The one export named `memory` is what the shim reads and writes, so
150/// a second is authority the wire could not describe even if it were created.
151///
152/// Said once at load, like the table ceiling beside it, rather than once per
153/// request forever.
154pub const MAX_MEMORIES: usize = 1;
155
156/// The most globals a module may declare.
157///
158/// Every instance allocates and initialises its own copy of each, and an
159/// instance is per request — so globals are per-request storage and per-request
160/// work in exactly the way tables and segments are. The aggregate declared-entry
161/// ceiling is far too generous to bound them on its own: a module can sit well
162/// under a million total entries and still carry hundreds of thousands of
163/// globals, which no fuel charge priced and no footprint counted.
164pub const MAX_GLOBALS: usize = 4096;
165
166/// The most functions a module may define.
167///
168/// Every instance allocates an entry per defined function, so functions are
169/// per-instance storage and per-instance work exactly as globals and tables
170/// are. Neither general ceiling bounds them: half a million tiny functions sit
171/// under both the aggregate declared-entry cap and the code-section byte cap,
172/// because each one is a couple of bytes of body and one byte of type index.
173/// Generous against real output — a whole Rust web application compiles to a
174/// few tens of thousands of functions.
175pub const MAX_FUNCTIONS: usize = 65_536;
176
177/// The most bytes a module's code section may occupy.
178///
179/// The declaration counts bound how many *things* a module names; they say
180/// nothing about how much instruction stream sits inside one of them. A single
181/// function whose body fills the file allowance declares exactly one entry and
182/// still hands `Module::new` tens of megabytes to translate into wasmi's larger
183/// internal representation, which is the allocation amplification the file
184/// ceiling was never a bound on. Generous against real output — a whole Rust
185/// web application compiles to a few megabytes of code — and far under what a
186/// 64 MiB artifact can carry.
187pub const MAX_CODE_BYTES: usize = 16 * 1024 * 1024;
188
189/// The largest total data + element section a module may carry (16 MiB).
190///
191/// Every request instantiates a **fresh** module — that is what makes "no state
192/// survives a request" true — and instantiation copies the module's data and
193/// element segments before the first guest instruction runs. wasmi does not
194/// meter that phase, so the only real bound on it is a bound on the segments
195/// themselves, checked once at load. A hello-world Rust guest carries tens of
196/// kilobytes here; a large one, a few megabytes.
197pub const MAX_INIT_SECTION_BYTES: usize = 16 * 1024 * 1024;
198
199/// The largest number of data + element segments a module may declare.
200///
201/// Bytes alone do not bound the work: a segment costs a bounds check and a
202/// copy set-up regardless of its length, so a module of a million empty
203/// segments is small on disk and expensive to instantiate. Both are capped
204/// because instantiation cost is a function of both.
205pub const MAX_INIT_SEGMENTS: usize = 4096;
206
207/// Bytes of host-side copying one unit of fuel buys.
208///
209/// wasmi meters the guest's own instructions, and a host call costs a handful
210/// of units no matter how much the host then copies on the guest's behalf.
211/// Without a charge here, a guest could buy gigabytes of `memcpy` for single
212/// digits of fuel — a spin *inside the host* rather than inside the
213/// interpreter, which the CPU ceiling would never see and no deadline exists to
214/// catch. The rate matches the order of wasmi's own pricing for bulk memory
215/// operations, so byte-work and instruction-work share one budget and the
216/// ceiling means what the manifest says it means.
217const BYTES_PER_FUEL: u64 = 64;
218
219/// Characters of stderr echoed into a failure detail.
220const STDERR_EXCERPT: usize = 512;
221
222/// Characters of a guest-influenced string kept for a log line or an error.
223///
224/// Every `String` a [`SandboxFailure`] carries came, directly or by way of an
225/// interpreter message quoting one, from an artifact nobody audited. It is
226/// evidence, so it has to survive; it is also attacker-controlled text on its
227/// way into a log the operator trusts, so it cannot survive intact.
228const DETAIL_EXCERPT: usize = 512;
229
230/// What a bounded excerpt says when it had to leave something out.
231///
232/// Named because two places append it — [`bounded_guest_text`], which meets the
233/// bound while walking, and `stderr_excerpt`, which is handed a string already
234/// at it — and an excerpt that says it was cut in two different ways is worse
235/// than one that never says so.
236pub(super) const TRUNCATION_MARKER: &str = " … (truncated)";
237
238/// Bound and neutralise a string the guest influenced.
239///
240/// Two hazards, and the fix for one is not the fix for the other. *Length*: a
241/// detail can be as large as the stdout budget — megabytes at the maximum
242/// response ceiling — and a plugin that fails in a loop writes one per request,
243/// which is a log-storage exhaustion with no guest instruction spent on it.
244/// *Content*: a newline ends the host's record and starts one the operator did
245/// not write, and an ANSI escape repaints records already on screen; either
246/// turns "the log says" into something a plugin controls.
247///
248/// Control characters are escaped rather than dropped, so a detail that was
249/// trying to forge a line reads as one that tried. Real text is kept, including
250/// non-ASCII: an author debugging a plugin that writes error text in their own
251/// language should be able to read it.
252///
253/// `is_control` alone is not the line between those two, because it covers the
254/// C0/C1 codes and stops there. The Unicode formatting characters do the same
255/// job by other means — U+202E reverses the run that follows it, so a guest can
256/// make a denial record *display* as something other than what was recorded,
257/// which is the same forgery a newline attempts and just as available. They are
258/// escaped by the same predicate the consent screen refuses them with, so the
259/// two surfaces cannot disagree about what counts as display-altering.
260///
261/// Escaped rather than refused, unlike a route path: a path must mean exactly
262/// one thing, but a failure detail is evidence, and evidence that tried
263/// something is worth keeping in a legible form.
264pub(super) fn guest_text(text: &str) -> String {
265 bounded_guest_text(text.chars(), text.len())
266}
267
268/// The bound-and-escape rule itself, over characters rather than a `&str`.
269///
270/// Taking a `&str` means the whole string already exists, which is fine when it
271/// does and is the entire problem when it does not: an import's operation is
272/// `module::name`, and joining those two before bounding them copies a name an
273/// artifact may have spent most of its 64 MiB on, in order to say it is
274/// refused. `hint` is only a capacity, and it is capped, so a caller cannot
275/// turn it into the allocation this exists to avoid.
276fn bounded_guest_text(chars: impl Iterator<Item = char>, hint: usize) -> String {
277 let mut out = String::with_capacity(hint.min(DETAIL_EXCERPT));
278 for (kept, ch) in chars.enumerate() {
279 if kept == DETAIL_EXCERPT {
280 out.push_str(TRUNCATION_MARKER);
281 break;
282 }
283 if ch.is_control() || is_line_separator(ch) || super::manifest::is_display_reordering(ch) {
284 out.extend(ch.escape_debug());
285 } else {
286 out.push(ch);
287 }
288 }
289 out
290}
291
292/// The two characters Unicode calls line breaks that are not control characters.
293///
294/// C0/C1 gets escaped by `is_control`, and the invisible and bidi characters by
295/// [`is_display_reordering`](super::manifest::is_display_reordering) — and
296/// U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR fall between the two.
297/// They are general category Zl and Zp: not `Cc`, so `is_control` says no, and
298/// neither format nor default-ignorable, so the other predicate says no either.
299/// Yet a plain-text log consumer that splits on Unicode line breaks renders
300/// them as exactly the newline the escaping exists to deny, which puts a guest
301/// back in the business of writing its own operator-facing record — the whole
302/// point of escaping C0 in the first place.
303///
304/// A route path needs nothing here: `validate_route_path` refuses
305/// `char::is_whitespace`, and both of these are `White_Space`. The gap is only
306/// in a detail, which is escaped rather than refused.
307const fn is_line_separator(ch: char) -> bool {
308 matches!(ch, '\u{2028}' | '\u{2029}')
309}
310
311/// The characters of a lossy UTF-8 decode, without decoding all of it first.
312///
313/// `String::from_utf8_lossy` answers the same question by building the whole
314/// replacement string, which is the one thing a caller keeping a bounded
315/// excerpt of guest bytes must not do. `utf8_chunks` yields each maximal
316/// invalid subpart separately, which is exactly the grouping `from_utf8_lossy`
317/// replaces one-for-one, so this produces the same characters lazily.
318fn lossy_chars(bytes: &[u8]) -> impl Iterator<Item = char> + '_ {
319 bytes.utf8_chunks().flat_map(|chunk| {
320 chunk
321 .valid()
322 .chars()
323 .chain((!chunk.invalid().is_empty()).then_some(char::REPLACEMENT_CHARACTER))
324 })
325}
326
327/// An import's `module::name`, bounded and escaped without ever being joined.
328///
329/// Written down here rather than left to the review surface. The surface does
330/// excerpt it — but that excerpt runs after this string exists, and the copy is
331/// the cost, not the printing. `MAX_IMPORTS` bounds how many names a module may
332/// declare and nothing bounded how long one may be, so a single import was
333/// enough to make refusing an artifact as expensive as accepting it.
334fn import_operation(module: &str, name: &str) -> String {
335 bounded_guest_text(
336 module.chars().chain("::".chars()).chain(name.chars()),
337 module.len().saturating_add(name.len()).saturating_add(2),
338 )
339}
340
341/// What one recorded denial can hold, at its worst.
342///
343/// [`guest_text`] bounds a detail at `DETAIL_EXCERPT` *characters*, and
344/// escaping is where characters become bytes: `escape_debug` writes an
345/// unprintable scalar as `\u{10ffff}`, ten bytes for one character. So the byte
346/// bound is ten times the character bound, with room beside it for the
347/// truncation marker and the operation and capability names.
348const DENIAL_RECORD_BYTES: usize = DETAIL_EXCERPT * 10 + 256;
349
350/// Host buffers a request holds no matter what its manifest declares.
351///
352/// [`ResourceLimits::request_footprint_bytes`](crate::plugin_sandbox::manifest::ResourceLimits::request_footprint_bytes)
353/// scales every other term with a ceiling the manifest names. These do not
354/// scale with anything, which is why they were missed: the stderr budget the
355/// state holds for the whole request, the scratch buffer an `fd_write` or
356/// `fd_read` allocates while that budget is still resident, and the denial
357/// ledger beside them. Fixed per request is still per request, and multiplied
358/// by a concurrency near the product ceiling it is tens of megabytes the
359/// advertised bound did not know about.
360pub const FIXED_HOST_BUFFER_BYTES: usize = STDERR_BUDGET_BYTES
361 .saturating_add(HOST_IO_CHUNK_BYTES)
362 .saturating_add(MAX_DENIALS.saturating_mul(DENIAL_RECORD_BYTES))
363 // Capability replies the guest has queued and not yet read, and the ledger
364 // of what it asked for. Both are bounded by their own constants below and
365 // both are held for the whole request, so they belong in the same term as
366 // the stderr budget rather than in the manifest's scaling ones.
367 .saturating_add(MAX_QUEUED_REPLY_BYTES)
368 .saturating_add(
369 crate::plugin_sandbox::capability::MAX_EVENTS.saturating_mul(CAPABILITY_EVENT_BYTES),
370 )
371 // Slack for the bookkeeping around them — the frame's own scalars, the
372 // outcome struct, the excerpt built from the stderr budget when it is read.
373 .saturating_add(4096);
374
375/// Host bytes one capability call's reply may hold, unread, in the guest's
376/// stdin queue.
377///
378/// A guest is free to write a call frame and never read the answer — nothing
379/// obliges it to, and a buggy one will. Every unread reply stays in the queue,
380/// so without a ceiling the `calls` quota (128 by default) multiplied by the
381/// largest reply (an outbound response, up to that quota's own byte ceiling)
382/// would be tens of megabytes of host memory per in-flight request, at a
383/// concurrency the footprint validator approved on other grounds.
384///
385/// Crossing it ends the request rather than dropping the reply: a guest that is
386/// not reading its answers is one that will not notice a dropped one either,
387/// and quietly losing a reply is how a plugin comes to believe a write happened.
388///
389/// It bounds what is **resident**, not what has been sent: `take_stdin` gives
390/// the budget back as the guest drains it, so a guest that reads each answer
391/// before making the next call never approaches it however many calls it makes.
392/// Counting cumulatively instead killed a well-behaved plugin at its 63rd
393/// `kv-get` — inside the quotas its own operator had approved — and told it it
394/// had a bug it did not have.
395///
396/// One MiB rather than four: the resident set is one reply at a time for any
397/// honest guest, the largest single reply is bounded by the
398/// `outbound_response_bytes` quota, and every byte here is charged against
399/// [`FIXED_HOST_BUFFER_BYTES`] for *every* plugin — including one granted no
400/// capability that can produce a reply. Four MiB moved the largest admissible
401/// `max_concurrency` for an otherwise-default manifest from 16 to 15, which is a
402/// load-time break for a manifest that used to be fine.
403pub const MAX_QUEUED_REPLY_BYTES: usize = 1024 * 1024;
404
405/// Host bytes one capability-ledger entry may hold, for the footprint term.
406///
407/// An event carries a capability, a `&'static str` operation, its outcome, and a
408/// target the guest chose. The target is the term that has to be priced
409/// carefully, and at the right rate: it is bounded at
410/// [`MAX_TARGET_CHARS`](crate::plugin_sandbox::capability::MAX_TARGET_CHARS)
411/// *characters*, and `guest_text` escapes a hostile character to as many as ten
412/// bytes — the same `× 10` [`DENIAL_RECORD_BYTES`] already applies for the same
413/// reason. Pricing it at the character count understated the ledger eightfold.
414const CAPABILITY_EVENT_BYTES: usize =
415 crate::plugin_sandbox::capability::MAX_TARGET_CHARS * 10 + 256;
416
417/// Fuel one capability call costs before its reply is priced by the byte.
418///
419/// A call is host work the guest chose to cause: a lock, a lookup, a frame
420/// encoded and queued. Priced like an imported call rather than like a copy,
421/// because that is what it is, and priced at all because a plugin with a
422/// generous fuel budget would otherwise get its quota's worth of host work for
423/// the cost of writing a few hundred bytes to stdout.
424const CAPABILITY_CALL_FUEL: u64 = 2_000;
425
426/// Fuel a guest keeps after a call's reply is priced, so it can write its
427/// answer.
428///
429/// The host reserves it before dispatching: a call whose reply the budget could
430/// pay for but whose guest could not then finish would commit and then end the
431/// request as `FuelExhausted`. A call that commits also keeps
432/// [`GUEST_READ_FUEL_PER_BYTE`] to read its reply.
433const FINISH_ALLOWANCE_FUEL: u64 = 100_000;
434
435/// Fuel per reply byte that a guest keeps to read the reply of a call that
436/// commits (#2523).
437///
438/// Measured: a Rust guest that reads the line and parses it with `serde_json`
439/// spent 6 fuel per byte on plain text, 77 on escaped NULs and 106 on escaped
440/// quotes. A JSON body is escape-heavy, so the rate covers the worst case.
441///
442/// Only for a call that commits. A read that fails changed nothing, so the
443/// host does not refuse it for this fuel.
444const GUEST_READ_FUEL_PER_BYTE: u64 = 128;
445
446/// Refuse a request whose body is over the manifest's declared ceiling.
447///
448/// The ceiling is enforced here, not only in the Axum adapter. The adapter
449/// applies it while reading, so an oversized body never gets buffered on that
450/// path — but [`SandboxHost::run`] is public, and an embedder calling it
451/// directly hands over a `SandboxRequest` that has already been built. Without
452/// this the body is cloned into the frame and base64-expanded regardless of the
453/// ceiling, and the footprint the manifest advertises — which counts the body
454/// at `4 × max_request_body_bytes` — stops bounding anything.
455///
456/// The encoding price charged below it is a real bound, but only against a
457/// manifest whose fuel is small relative to its body ceiling; a generous fuel
458/// budget buys an arbitrarily large host-side copy. So the ceiling is checked
459/// first, before the request is priced or walked at all.
460pub const MAX_REQUEST_METADATA_BYTES: usize = 256 * 1024;
461
462/// Every byte of a request that is not its body, summed — or the first running
463/// total to cross [`MAX_REQUEST_METADATA_BYTES`], whichever comes first.
464///
465/// One definition, used by both the ceiling below and [`encoding_fuel`], so the
466/// bytes that are refused and the bytes that are charged for cannot drift apart
467/// as fields are added to the frame.
468///
469/// The walk stops at the ceiling rather than finishing and reporting a true
470/// total. Charging every entry made an oversized list *fail*; it did not bound
471/// the work of discovering that it fails, and that discovery happens before the
472/// permit is taken — so an unbounded list still bought an unbounded scan, per
473/// concurrent caller, from a ceiling meant to refuse it cheaply. Past the
474/// ceiling the answer cannot change, because the total only grows, so the
475/// entries after it have nothing left to establish.
476///
477/// The cost of stopping is that a value over the ceiling is a floor rather than
478/// a total. Only the refusal ever sees one: `encoding_fuel` prices requests that
479/// were already admitted, and an admitted request finished the walk.
480/// What one metadata pair costs, contents plus the structure around them.
481///
482/// Two 24-byte `String` headers in the vector, and the `["",""],` the serialiser
483/// writes around them. Counting only the *contents* would leave a list of a
484/// million empty pairs summing to zero — past a byte ceiling for free, then
485/// cloned and expanded into real syntax anyway.
486///
487/// Public to the crate so the adapter can charge a header the same way before
488/// deciding to clone it. One definition, so the early refusal and the ceiling
489/// cannot disagree about what a pair costs.
490pub(crate) const fn metadata_pair_bytes(name: &str, value: &str) -> usize {
491 /// Two `String` headers plus the `["",""],` around them.
492 const ENTRY: usize = 56;
493 name.len().saturating_add(value.len()).saturating_add(ENTRY)
494}
495
496/// What a metadata pair the frame will discard still costs.
497///
498/// Not its contents. Those never cross, and charging them refused requests
499/// that cost the guest nothing — a 256 KiB `Cookie` turning a confidentiality
500/// promise into an availability one. But the pair sits in a list the host
501/// walks three times before the guest starts: once for the ceiling, once to
502/// price the encoding, once to filter it back out. That walk is per entry and
503/// is not priced by fuel, which the guest has not begun to spend, nor bounded
504/// by `max_concurrency`, which limits guests and not the work done to reach
505/// one. So the entry is charged even when nothing in it is.
506///
507/// At 56 bytes an entry this caps a request at a few thousand headers, which
508/// is far above what any HTTP client sends and far below what costs anything
509/// to walk.
510pub(crate) const DROPPED_PAIR_BYTES: usize = metadata_pair_bytes("", "");
511
512fn request_metadata_bytes(request: &SandboxRequest) -> usize {
513 let mut total = request
514 .method
515 .len()
516 .saturating_add(request.path.len())
517 .saturating_add(request.query.len())
518 .saturating_add(request.route.len());
519 if total > MAX_REQUEST_METADATA_BYTES {
520 return total;
521 }
522 for (name, value) in &request.headers {
523 // Contents only for the headers that will actually cross.
524 // `HostFrame::request` filters through the same allowlist, so a
525 // `Cookie` or `Authorization` a direct `run` caller happens to be
526 // holding is dropped before the frame is built and never reaches the
527 // guest — counting its bytes here refused a request that would have
528 // cost nothing, and made the public API stricter than the adapter that
529 // is merely its politest caller. The entry itself is still charged;
530 // see `DROPPED_PAIR_BYTES` for why the two halves separate.
531 total = total.saturating_add(if super::wire::request_header_allowed(name) {
532 metadata_pair_bytes(name, value)
533 } else {
534 DROPPED_PAIR_BYTES
535 });
536 if total > MAX_REQUEST_METADATA_BYTES {
537 return total;
538 }
539 }
540 for (name, value) in &request.path_params {
541 total = total.saturating_add(metadata_pair_bytes(name, value));
542 if total > MAX_REQUEST_METADATA_BYTES {
543 return total;
544 }
545 }
546 total
547}
548
549fn refuse_oversized_request(
550 request: &SandboxRequest,
551 limits: ResourceLimits,
552) -> Option<SandboxOutcome> {
553 if request.body.len() > limits.max_request_body_bytes {
554 return Some(SandboxOutcome::refused(
555 SandboxFailure::RequestBudget {
556 max: limits.max_request_body_bytes,
557 len: request.body.len(),
558 },
559 0,
560 ));
561 }
562 // The body ceiling is the manifest's; this one is the host's, because no
563 // manifest declares a query or header budget. `run` is public and the adapter's
564 // own limits do not reach it, so an embedder building a `SandboxRequest` by hand
565 // could hand over a gigabyte of query string — cloned into the frame and
566 // serialised into the NDJSON line before the guest starts, against a footprint
567 // that budgets for the body alone. The encoding charge prices those bytes, but
568 // pricing is not a bound: at the manifest's maximum fuel it permits more than a
569 // terabyte. The ceiling is what keeps `request_footprint_bytes` honest.
570 let metadata = request_metadata_bytes(request);
571 (metadata > MAX_REQUEST_METADATA_BYTES).then(|| {
572 SandboxOutcome::refused(
573 SandboxFailure::RequestMetadataBudget {
574 max: MAX_REQUEST_METADATA_BYTES,
575 len: metadata,
576 },
577 0,
578 )
579 })
580}
581
582/// Imports listed on a review surface before it stops enumerating them.
583///
584/// The names themselves are excerpted where they are rendered, but the *count*
585/// is a separate amplification: a legal 64 MiB module can carry millions of
586/// tiny import entries, and formatting each into its own `String` turns the
587/// artifact into hundreds of megabytes in the process that is trying to refuse
588/// it. A review surface needs enough to recognise what a module reaches for,
589/// not every repetition of it.
590/// The most imports a module may declare.
591///
592/// A structural ceiling, checked before anything walks the import table. Every
593/// import is resolved and retained *per instance*, and an instance is per
594/// request, so a module repeating one allowlisted import a million times makes
595/// every request pay for a million resolutions and hold the results — work no
596/// fuel charge priced and storage `request_footprint_bytes` never counted.
597/// Refusing the shape at load is what keeps that footprint the real bound.
598///
599/// It is checked *first* for a second reason: `forbidden_imports` builds one
600/// owned denial per offending import, so an unbounded import table amplifies
601/// through the refusal path too — including under `autumn plugin inspect`,
602/// which runs it on artifacts nobody has audited. This ceiling does not weaken
603/// that gate: a module hiding one forbidden import behind a million decoys is
604/// still refused at load, now for its shape rather than its contents.
605/// The most entries a module's sections may declare between them.
606///
607/// Checked from the section headers before the module is compiled, because
608/// compilation is itself the allocation that needs bounding. Generous against
609/// real output — a large Rust module declares tens of thousands of functions
610/// and types — and far below what a 64 MiB file of one-byte declarations can
611/// claim.
612pub const MAX_DECLARED_ENTRIES: usize = 1_000_000;
613
614/// The most sections a module may carry, custom ones included.
615///
616/// Every other shape ceiling is read from a section's leading count, which is
617/// what makes reading them cheap. A custom section has no such count — its body
618/// is a name and opaque bytes — so it contributes to nothing, and an empty one
619/// encodes in three bytes. A 64 MiB artifact of those is roughly 22 million
620/// section headers walked to reach a verdict this loop exists to reach cheaply.
621///
622/// So the headers are counted too, and the walk stops at the ceiling rather
623/// than running to the end and refusing afterwards: here the iteration *is* the
624/// cost, unlike the per-entry walks below it, where refusing on the count alone
625/// is enough. A real module carries a dozen sections; a toolchain that emits
626/// names, producers and debug info carries a few dozen.
627pub const MAX_SECTIONS: usize = 1_024;
628
629pub const MAX_IMPORTS: usize = 1024;
630
631const MAX_REPORTED_IMPORTS: usize = 256;
632
633/// Format a module's imports for review, bounded in number and in length.
634///
635/// The total is still reported honestly — an operator must not read a truncated
636/// list as the whole of what an artifact imports — but only the first
637/// [`MAX_REPORTED_IMPORTS`] are allocated. Counting the rest costs nothing: the
638/// import table is already parsed, and walking it without formatting allocates
639/// nothing per entry.
640///
641/// Each entry is built with [`import_operation`], the same bounded formatter
642/// the denial path uses, so the *length* is capped where the string is made
643/// rather than where it is rendered. A count-only bound left 256 names each as
644/// long as an artifact cared to make them, and the review surface's own excerpt
645/// runs afterwards — which is one copy too late to matter.
646///
647/// This bounds the *review* surface only. The load gate checks every import
648/// against the shim's allowlist with no cap, because a module that hides a
649/// forbidden import behind a million decoys must still be refused.
650fn reported_imports<'a>(imports: impl Iterator<Item = wasmi::ImportType<'a>>) -> Vec<String> {
651 let mut names: Vec<String> = Vec::new();
652 let mut total: usize = 0;
653 for import in imports {
654 total = total.saturating_add(1);
655 if names.len() < MAX_REPORTED_IMPORTS {
656 names.push(import_operation(import.module(), import.name()));
657 }
658 }
659 if let Some(hidden) = total.checked_sub(names.len()).filter(|more| *more > 0) {
660 names.push(format!("… and {hidden} more (truncated)"));
661 }
662 names
663}
664
665/// What encoding one request's frame costs, in fuel.
666///
667/// Priced off the request's own bytes at [`BYTES_PER_FUEL`], the rate every
668/// other host-side copy pays, multiplied by the number of times those bytes are
669/// walked on the way to the guest's stdin.
670///
671/// A flat multiplier over the *raw* input was the wrong shape, because the
672/// passes are not all over the raw input. The body is base64-encoded, which
673/// expands it by 4/3, and every walk after that is over the expansion.
674/// Metadata is JSON-escaped, and `serde_json` writes a control character as
675/// `\u0000` — six bytes for one — so a direct caller filling a header value
676/// with them expands it sixfold. Then [`HostState::seed_from`] reads the
677/// finished line end to end, so the largest single walk is over the expanded
678/// form, not the raw one. Charging four times the raw bytes let a request buy
679/// host work the ceiling never saw.
680///
681/// So body and metadata are priced separately, each at its own worst-case
682/// expansion:
683///
684/// | | raw passes | expanded passes | charged |
685/// |---|---|---|---|
686/// | body | clone, encode-read, sizing read | encode-write and escape-scan in both passes, seed scan | `3 + 5 × 4/3 = 9 2/3`, charged as 10 |
687/// | metadata | clone, escape-read, sizing read | escape-write, sizing write, seed scan | `3 + 3 × 6 = 21` |
688///
689/// The sizing walks are `to_line`'s first pass, which counts the line so the
690/// second pass can write into an exact buffer.
691///
692/// Both are upper bounds rather than measurements, because this runs *before*
693/// the line exists — that is the point of it. The expansion factors are the
694/// same ones [`ResourceLimits::request_footprint_bytes`](crate::plugin_sandbox::manifest::ResourceLimits::request_footprint_bytes)
695/// budgets memory at, so the two describe the same request.
696fn encoding_fuel(request: &SandboxRequest) -> u64 {
697 /// Raw-equivalent walks of the body: three over the raw bytes, five over the
698 /// base64 expansion of them, which is 4/3. `3 + 5 × 4/3` is 9 2/3, so 10.
699 const BODY_PASSES: u64 = 10;
700 /// Raw-equivalent walks of the metadata: three over the raw bytes, three
701 /// over a JSON escaping that can reach six bytes per byte.
702 const METADATA_PASSES: u64 = 21;
703
704 let charge = |bytes: usize, passes: u64| {
705 u64::try_from(bytes)
706 .unwrap_or(u64::MAX)
707 .saturating_mul(passes)
708 };
709
710 charge(request.body.len(), BODY_PASSES)
711 .saturating_add(charge(request_metadata_bytes(request), METADATA_PASSES))
712 .checked_div(BYTES_PER_FUEL)
713 .unwrap_or(u64::MAX)
714 .saturating_add(1)
715}
716
717/// Largest number of distinct denials recorded for one request.
718///
719/// The ledger is a diagnostic, and a guest that calls a denied import in a
720/// loop must not be able to grow one. Denials are deduplicated by
721/// `(capability, operation)` first, so hitting this bound at all means a guest
722/// found more distinct refusals than the shim has functions.
723const MAX_DENIALS: usize = 64;
724
725// ── Denials ──────────────────────────────────────────────────────────────
726
727/// The class of authority a guest reached for and did not get.
728#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
729#[non_exhaustive]
730pub enum DeniedCapability {
731 /// Reading, writing, opening or discovering anything on a filesystem.
732 Filesystem,
733 /// Any outbound socket operation.
734 Network,
735 /// Environment variables and process arguments.
736 Environment,
737 /// Signalling, blocking or otherwise steering the host process.
738 ProcessControl,
739 /// An allocation over the plugin's declared memory ceiling.
740 Memory,
741 /// A response header a plugin is not allowed to set.
742 ResponseHeader,
743 /// An import no host function defines. Refused at load.
744 UnknownImport,
745}
746
747impl DeniedCapability {
748 /// Stable lowercase tag used in logs and reports.
749 #[must_use]
750 pub const fn as_str(self) -> &'static str {
751 match self {
752 Self::Filesystem => "filesystem",
753 Self::Network => "network",
754 Self::Environment => "environment",
755 Self::ProcessControl => "process-control",
756 Self::Memory => "memory",
757 Self::ResponseHeader => "response-header",
758 Self::UnknownImport => "unknown-import",
759 }
760 }
761}
762
763impl fmt::Display for DeniedCapability {
764 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
765 f.write_str(self.as_str())
766 }
767}
768
769/// One thing a guest reached for and was refused.
770///
771/// Denials are the *observable* half of deny-by-default: without them, a
772/// sandbox that silently swallows a `path_open` and one that has a bug look
773/// exactly alike from outside.
774#[derive(Debug, Clone, PartialEq, Eq)]
775#[non_exhaustive]
776pub struct CapabilityDenial {
777 /// Which class of authority was refused.
778 pub capability: DeniedCapability,
779 /// The guest-visible operation, e.g. `path_open` or `autumn_db::query`.
780 pub operation: String,
781 /// What the guest was told, for the log.
782 pub detail: String,
783}
784
785impl fmt::Display for CapabilityDenial {
786 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
787 write!(
788 f,
789 "{capability}: {operation} — {detail}",
790 capability = self.capability,
791 operation = self.operation,
792 detail = self.detail
793 )
794 }
795}
796
797// ── Failures ─────────────────────────────────────────────────────────────
798
799/// Why a request produced no answer from the plugin.
800///
801/// Almost every variant is a *plugin* failure — none of those is a host
802/// failure, and none can be anything other than a 5xx on the plugin's own
803/// prefix. [`RequestBudget`](Self::RequestBudget) and
804/// [`RequestMetadataBudget`](Self::RequestMetadataBudget) are the exceptions:
805/// it is the *caller's* request that was refused, before the plugin was asked
806/// anything, so they answer 413 the way the ceiling does everywhere else.
807#[derive(Debug, Clone, PartialEq, Eq)]
808#[non_exhaustive]
809pub enum SandboxFailure {
810 /// The module could not be instantiated for this request.
811 Instantiation(String),
812 /// The guest burned its whole fuel budget without answering.
813 FuelExhausted {
814 /// The budget it was given.
815 budget: u64,
816 },
817 /// The guest trapped.
818 Trap(String),
819 /// The guest called `proc_exit`.
820 Exited(i32),
821 /// The guest returned without answering.
822 NoAnswer,
823 /// The guest wrote what may well be a complete frame but never ended the
824 /// line, so the host never saw one.
825 PartialFrame,
826 /// The guest wrote something that is not a frame this version knows.
827 MalformedFrame(String),
828 /// The guest reported its own failure.
829 GuestError(String),
830 /// The guest answered, but with something HTTP or the manifest refuses.
831 ResponseRefused(String),
832 /// The guest wrote more than its response ceiling without ending a line.
833 OutputBudget {
834 /// The ceiling it blew through.
835 max: usize,
836 },
837 /// The plugin already has `max_concurrency` requests executing, so this
838 /// one was not started.
839 AtCapacity {
840 /// The ceiling it ran into.
841 max: usize,
842 },
843 /// The request handed to [`SandboxHost::run`] carried a body over the
844 /// manifest's declared ceiling. The guest was never started.
845 RequestBudget {
846 /// The ceiling it blew through.
847 max: usize,
848 /// What the caller actually handed over.
849 len: usize,
850 },
851 /// The request handed to [`SandboxHost::run`] carried more metadata — path,
852 /// query, route, headers, path parameters — than the host's ceiling allows.
853 /// The guest was never started.
854 RequestMetadataBudget {
855 /// The ceiling it blew through.
856 max: usize,
857 /// How much metadata the caller handed over — at least this much.
858 ///
859 /// A floor rather than a total: the walk stops as soon as it knows the
860 /// answer, so the entries past the ceiling are never counted. Bounding
861 /// the work of refusing costs the exact figure, and the exact figure is
862 /// not what the refusal turns on.
863 len: usize,
864 },
865}
866
867impl SandboxFailure {
868 /// The status this failure serves on the plugin's prefix.
869 ///
870 /// A budget exhaustion is a 504: the plugin was given a deadline and missed
871 /// it. An oversized request is a 413, the same answer the adapter gives
872 /// before it ever gets here — one condition must not have two statuses
873 /// depending on which door the request came through. Everything else is a
874 /// 502: the plugin answered badly or not at all, which is exactly what a
875 /// bad gateway is.
876 #[must_use]
877 pub const fn status(&self) -> http::StatusCode {
878 match self {
879 Self::FuelExhausted { .. } | Self::OutputBudget { .. } => {
880 http::StatusCode::GATEWAY_TIMEOUT
881 }
882 Self::RequestBudget { .. } | Self::RequestMetadataBudget { .. } => {
883 http::StatusCode::PAYLOAD_TOO_LARGE
884 }
885 Self::AtCapacity { .. } => http::StatusCode::SERVICE_UNAVAILABLE,
886 _ => http::StatusCode::BAD_GATEWAY,
887 }
888 }
889}
890
891impl fmt::Display for SandboxFailure {
892 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
893 match self {
894 Self::Instantiation(detail) => {
895 write!(f, "the plugin could not be instantiated: {detail}")
896 }
897 Self::FuelExhausted { budget } => write!(
898 f,
899 "the plugin exhausted its {budget}-unit CPU budget without answering"
900 ),
901 Self::Trap(detail) => write!(f, "the plugin trapped: {detail}"),
902 Self::Exited(code) => write!(f, "the plugin called proc_exit({code})"),
903 Self::NoAnswer => write!(f, "the plugin returned without answering"),
904 Self::PartialFrame => write!(
905 f,
906 "the plugin wrote a partial frame with no terminating newline; a frame is one \
907 NDJSON line, so it must end with `\\n` (use `println!`, not `print!`)"
908 ),
909 Self::MalformedFrame(detail) => {
910 write!(f, "the plugin wrote a malformed frame: {detail}")
911 }
912 Self::GuestError(detail) => write!(f, "the plugin reported a failure: {detail}"),
913 Self::ResponseRefused(detail) => {
914 write!(f, "the plugin's answer was refused: {detail}")
915 }
916 Self::OutputBudget { max } => write!(
917 f,
918 "the plugin wrote more than its {max}-byte response ceiling without ending a frame"
919 ),
920 Self::AtCapacity { max } => write!(
921 f,
922 "the plugin already has its {max} permitted requests executing"
923 ),
924 Self::RequestBudget { max, len } => write!(
925 f,
926 "the request body is {len} bytes, over the plugin's {max}-byte request ceiling"
927 ),
928 Self::RequestMetadataBudget { max, len } => write!(
929 f,
930 "the request's path, query, headers and parameters are {len} bytes, over the \
931 host's {max}-byte ceiling"
932 ),
933 }
934 }
935}
936
937impl std::error::Error for SandboxFailure {}
938
939/// Why an artifact could not be loaded at all.
940#[derive(Debug, Clone, PartialEq, Eq)]
941#[non_exhaustive]
942pub enum SandboxLoadError {
943 /// The manifest handed to [`SandboxHost::from_module`] does not satisfy the
944 /// rules parsing enforces — it was built or mutated rather than parsed.
945 InvalidManifest(String),
946 /// The bytes are not a WebAssembly module this engine can compile.
947 Wasm(String),
948 /// The module imports something no host function defines.
949 ForbiddenImports(Vec<CapabilityDenial>),
950 /// The manifest's fuel budget cannot cover what every request must spend
951 /// before the guest runs at all, so no route it declares could ever answer.
952 FuelBelowFixedCharges {
953 /// The budget the manifest declares.
954 fuel: u64,
955 /// What instantiating this module costs, every request, before `_start`.
956 instantiation: u64,
957 },
958 /// The module's data or element segments would make every request's
959 /// instantiation expensive, in host work no fuel budget prices.
960 InstantiationTooExpensive {
961 /// What was counted.
962 what: &'static str,
963 /// How many the module carries.
964 found: usize,
965 /// The ceiling.
966 max: usize,
967 },
968 /// The module is larger than [`MAX_MODULE_BYTES`](super::MAX_MODULE_BYTES).
969 ///
970 /// The container reader applies this to a module it unpacks; this applies
971 /// it to one handed straight to [`SandboxHost::from_module`] or
972 /// [`SandboxHost::imports_of`], which never passed through that reader.
973 ModuleTooLarge {
974 /// The module's length.
975 found: usize,
976 /// The ceiling.
977 max: usize,
978 },
979 /// The module exports no `_start` of type `() -> ()`.
980 MissingStart,
981 /// The module exports no linear memory named `memory`.
982 MissingMemory,
983 /// The module carries a WebAssembly `start` section.
984 ///
985 /// Guest code that runs at instantiation, before any request and outside
986 /// the `_start` the shim calls. If it traps, every instantiation fails and
987 /// the plugin can never answer; and running it at load to find out would
988 /// mean executing an unaudited artifact's code just to inspect it.
989 StartSectionForbidden,
990 /// An active data segment does not fit the module's own initial memory.
991 ///
992 /// Copied in during instantiation, which is per request — so a segment
993 /// past the end compiles clean and then fails every instantiation the
994 /// artifact is ever given.
995 SegmentOutOfBounds {
996 /// Which store the segment writes into, for the message.
997 what: &'static str,
998 /// One past the segment's furthest write.
999 end: u64,
1000 /// What the module starts that store with.
1001 capacity: u64,
1002 },
1003 /// The module's *initial* linear memory is already over the manifest's
1004 /// ceiling, so no request could ever instantiate it.
1005 MemoryTooLarge {
1006 /// The module's initial memory, in bytes.
1007 found: u64,
1008 /// The manifest's ceiling.
1009 max: usize,
1010 },
1011 /// The engine could not be configured.
1012 Engine(String),
1013}
1014
1015impl fmt::Display for SandboxLoadError {
1016 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1017 match self {
1018 Self::InvalidManifest(detail) => {
1019 write!(f, "the manifest is not one this host will serve: {detail}")
1020 }
1021 Self::Wasm(detail) => write!(f, "the plugin module could not be loaded: {detail}"),
1022 Self::ForbiddenImports(denials) => {
1023 // Written straight through rather than cloned into a `Vec` and
1024 // joined. Each operation is already bounded, so the clone was
1025 // not unbounded — but it was a second full copy of every name
1026 // on the path that exists to refuse the artifact cheaply, and
1027 // the joined string was a third.
1028 write!(
1029 f,
1030 "the plugin imports {count} host function(s) the sandbox does not provide, \
1031 so it is refused before it runs: ",
1032 count = denials.len(),
1033 )?;
1034 for (written, denial) in denials.iter().enumerate() {
1035 if written > 0 {
1036 f.write_str(", ")?;
1037 }
1038 f.write_str(&denial.operation)?;
1039 }
1040 Ok(())
1041 }
1042 Self::FuelBelowFixedCharges {
1043 fuel,
1044 instantiation,
1045 } => write!(
1046 f,
1047 "the plugin's {fuel}-unit fuel budget cannot cover the {instantiation} units \
1048 instantiating this module costs before `_start` runs, so every request would \
1049 exhaust the budget without the guest executing an instruction"
1050 ),
1051 Self::ModuleTooLarge { found, max } => write!(
1052 f,
1053 "sandboxed plugin module is {found} bytes, over the {max}-byte ceiling"
1054 ),
1055 Self::InstantiationTooExpensive { what, found, max } => write!(
1056 f,
1057 "the plugin declares {found} {what}, over the {max} ceiling: every request \
1058 re-instantiates the module, and that work happens before the first guest \
1059 instruction — so it is bounded here rather than priced per request"
1060 ),
1061 Self::MissingStart => write!(
1062 f,
1063 "the plugin exports no `_start` of type `() -> ()`; it must be built as a \
1064 wasm32-wasip1 *command*"
1065 ),
1066 Self::MissingMemory => write!(
1067 f,
1068 "the plugin exports no linear memory named `memory`; every host function reads \
1069 and writes through it, so a plugin without one can never answer"
1070 ),
1071 Self::MemoryTooLarge { found, max } => write!(
1072 f,
1073 "the plugin's initial linear memory is {found} bytes, over the manifest's \
1074 {max}-byte ceiling; no request could instantiate it"
1075 ),
1076 Self::StartSectionForbidden => write!(
1077 f,
1078 "the plugin carries a WebAssembly `start` section; a sandboxed plugin answers \
1079 through its exported `_start` and runs no code at instantiation"
1080 ),
1081 Self::SegmentOutOfBounds {
1082 what,
1083 end,
1084 capacity,
1085 } => write!(
1086 f,
1087 "an active segment writes up to {end} of the plugin's {what}, past the \
1088 {capacity} it starts with; every instantiation would fail on it"
1089 ),
1090 Self::Engine(detail) => write!(f, "the sandbox engine could not be built: {detail}"),
1091 }
1092 }
1093}
1094
1095impl std::error::Error for SandboxLoadError {}
1096
1097// ── The outcome ──────────────────────────────────────────────────────────
1098
1099/// Everything one request produced: the answer or the failure, plus the
1100/// evidence.
1101#[derive(Debug)]
1102#[non_exhaustive]
1103pub struct SandboxOutcome {
1104 /// The plugin's answer, or why there is none.
1105 pub result: Result<SandboxResponse, SandboxFailure>,
1106 /// Everything the guest reached for and did not get.
1107 pub denials: Vec<CapabilityDenial>,
1108 /// Fuel the guest consumed.
1109 pub fuel_used: u64,
1110 /// The high-water mark of the guest's linear memory, in bytes.
1111 pub peak_memory_bytes: usize,
1112 /// What the guest wrote to stderr, truncated.
1113 pub stderr: String,
1114 /// Every capability call this request made, allowed or refused (#1632).
1115 ///
1116 /// The operator audit surface is built from these; see
1117 /// [`capability::audit`](crate::plugin_sandbox::capability::audit).
1118 pub activity: Vec<crate::plugin_sandbox::capability::CapabilityEvent>,
1119 /// Capability calls this request made that the bounded ledger could not
1120 /// hold, so `activity` is a floor rather than a count.
1121 ///
1122 /// Has no times. Ingest [`dropped_calls`](Self::dropped_calls) instead.
1123 pub dropped_activity: u64,
1124 /// The same calls, with when they occurred. `None` when none was dropped.
1125 ///
1126 /// Give it to
1127 /// [`PluginActivityLog::ingest_dropped_calls`](crate::plugin_sandbox::PluginActivityLog::ingest_dropped_calls).
1128 pub dropped_calls: Option<crate::plugin_sandbox::capability::DroppedCalls>,
1129}
1130
1131impl SandboxOutcome {
1132 /// A request refused before the guest produced anything: no denials, no
1133 /// peak, no stderr, because nothing of the guest's ever ran.
1134 ///
1135 /// `fuel_used` is what the *host* had already committed on its behalf — the
1136 /// whole budget when a charge could not be covered, zero when the request
1137 /// was turned away before it was priced.
1138 const fn refused(failure: SandboxFailure, fuel_used: u64) -> Self {
1139 Self {
1140 result: Err(failure),
1141 denials: Vec::new(),
1142 fuel_used,
1143 peak_memory_bytes: 0,
1144 stderr: String::new(),
1145 activity: Vec::new(),
1146 dropped_activity: 0,
1147 dropped_calls: None,
1148 }
1149 }
1150}
1151
1152/// Everything one render-slot call produced (#1632).
1153///
1154/// Separate from [`SandboxOutcome`] rather than a variant of it: the two carry
1155/// different answers, and a caller filling a slot on a page has a different
1156/// failure posture — it omits the fragment and serves the page — from one
1157/// serving a request, which returns a status.
1158#[derive(Debug)]
1159#[non_exhaustive]
1160pub struct SandboxRenderOutcome {
1161 /// The rendered HTML fragment, or why there is none.
1162 ///
1163 /// Every `Err` here means the same thing to a caller: leave the slot empty.
1164 pub fragment: Result<String, SandboxFailure>,
1165 /// Everything the guest reached for and did not get.
1166 pub denials: Vec<CapabilityDenial>,
1167 /// Fuel the guest consumed.
1168 pub fuel_used: u64,
1169 /// What the guest wrote to stderr, truncated.
1170 pub stderr: String,
1171 /// The high-water mark of the guest's linear memory, in bytes.
1172 pub peak_memory_bytes: usize,
1173 /// Every capability call the hook made.
1174 pub activity: Vec<crate::plugin_sandbox::capability::CapabilityEvent>,
1175 /// Calls the bounded ledger could not hold.
1176 ///
1177 /// Has no times. Ingest [`dropped_calls`](Self::dropped_calls) instead.
1178 pub dropped_events: u64,
1179 /// The same calls, with when they occurred. `None` when none was dropped.
1180 ///
1181 /// Give it to
1182 /// [`PluginActivityLog::ingest_dropped_calls`](crate::plugin_sandbox::PluginActivityLog::ingest_dropped_calls).
1183 pub dropped_calls: Option<crate::plugin_sandbox::capability::DroppedCalls>,
1184}
1185
1186// ── The host ─────────────────────────────────────────────────────────────
1187
1188/// A compiled sandboxed plugin, ready to serve requests.
1189///
1190/// Compilation happens once in [`load`](SandboxHost::load); every call to
1191/// [`run`](SandboxHost::run) builds a *fresh* store and instance, so no state
1192/// survives a request and one request's misbehaviour cannot reach the next.
1193pub struct SandboxHost {
1194 engine: Engine,
1195 module: Module,
1196 /// One permit per concurrently-executing request.
1197 ///
1198 /// `SandboxedPlugin::serve` has a semaphore of its own, which it holds
1199 /// across the body read as well as the run, so the declared footprint
1200 /// bounds the whole request rather than only the part a guest is running.
1201 /// This one is narrower and lives here because [`run`](SandboxHost::run)
1202 /// is public: an embedder calling it directly never passes through
1203 /// `serve`, and `request_footprint_bytes() × max_concurrency` is the
1204 /// premise the manifest validator accepts limits on.
1205 ///
1206 /// The two never fight. At most `max_concurrency` requests are in `serve`
1207 /// at once, so they can hold at most that many of these — this semaphore
1208 /// is only ever the binding constraint on a direct caller, which is the
1209 /// one it exists for.
1210 permits: Semaphore,
1211 /// The sealed digest of the whole artifact, when this host came from one.
1212 /// Stamped on every job the plugin enqueues.
1213 artifact_sha256: Option<String>,
1214 /// The paths this plugin declared, as the router matches them.
1215 ///
1216 /// Built once here because a redirect may only name a route the plugin
1217 /// serves, and rebuilding the matcher per response would be a per-request
1218 /// allocation for a set fixed at load. See [`OwnedRoutes`].
1219 owned_routes: super::wire::OwnedRoutes,
1220 /// What one instantiation of this module costs, in fuel. Bounded at load by
1221 /// [`MAX_INIT_SEGMENTS`] and [`MAX_INIT_SECTION_BYTES`]; charged per request
1222 /// so the declared CPU ceiling prices it — see [`SandboxHost::run`].
1223 instantiation_fuel: u64,
1224 manifest: SandboxManifest,
1225}
1226
1227impl fmt::Debug for SandboxHost {
1228 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1229 f.debug_struct("SandboxHost")
1230 .field("plugin", &self.manifest.name)
1231 .field("prefix", &self.manifest.prefix)
1232 .finish_non_exhaustive()
1233 }
1234}
1235
1236/// What one context entry costs beyond its bytes: two `String` headers, a
1237/// tuple, and the JSON punctuation it becomes on the wire.
1238///
1239/// Counted in both the ceiling and the fuel charge, so a thousand empty pairs
1240/// are neither free to hold nor free to encode.
1241pub const RENDER_CONTEXT_ENTRY_OVERHEAD: usize = 64;
1242
1243/// The most context entries one render hook is handed.
1244///
1245/// A slot's context is a handful of values a panel needs — an id, a locale, a
1246/// count. Far above that, and far below anything that could matter.
1247pub const MAX_RENDER_CONTEXT_ENTRIES: usize = 32;
1248
1249/// The most bytes one render hook's context may carry across all its entries.
1250pub const MAX_RENDER_CONTEXT_BYTES: usize = 16 * 1024;
1251
1252/// Take as much of `context` as the ceilings above allow.
1253///
1254/// Applied by [`SandboxHost::render`] itself, not only by the wrapper: the
1255/// entry point is public, an embedder may call it with context built from a
1256/// row or a query string, and a ceiling that only one of two callers reaches is
1257/// a ceiling the other does not have.
1258///
1259/// Per-entry overhead is counted, not just the strings: a thousand empty pairs
1260/// cost real allocation and real serialization, and a budget measured only in
1261/// string length prices them at nothing.
1262#[must_use]
1263pub fn bounded_context(context: &[(String, String)]) -> Vec<(String, String)> {
1264 let mut out = Vec::with_capacity(context.len().min(MAX_RENDER_CONTEXT_ENTRIES));
1265 let mut total = 0_usize;
1266 for (name, value) in context {
1267 if out.len() >= MAX_RENDER_CONTEXT_ENTRIES {
1268 break;
1269 }
1270 let weight = name
1271 .len()
1272 .saturating_add(value.len())
1273 .saturating_add(RENDER_CONTEXT_ENTRY_OVERHEAD);
1274 if total.saturating_add(weight) > MAX_RENDER_CONTEXT_BYTES {
1275 break;
1276 }
1277 total = total.saturating_add(weight);
1278 out.push((name.clone(), value.clone()));
1279 }
1280 out
1281}
1282
1283impl SandboxHost {
1284 /// Compile a verified artifact into a runnable host.
1285 ///
1286 /// # Errors
1287 ///
1288 /// Returns [`SandboxLoadError`] if the module does not compile, imports
1289 /// something the sandbox does not provide, or exports no `_start`.
1290 pub fn load(artifact: &SandboxArtifact) -> Result<Self, SandboxLoadError> {
1291 // The whole container's digest, kept here so a direct `run` stamps jobs
1292 // with it too, not only a host built through `SandboxedPlugin`.
1293 let digest = artifact
1294 .artifact_digest()
1295 .map_err(|err| SandboxLoadError::InvalidManifest(err.to_string()))?;
1296 Ok(
1297 Self::from_module(artifact.manifest().clone(), artifact.module())?
1298 .with_artifact_sha256(digest),
1299 )
1300 }
1301
1302 /// Compile a module against a manifest.
1303 ///
1304 /// Prefer [`load`](Self::load), which also proves the manifest describes
1305 /// *these* bytes.
1306 ///
1307 /// The manifest is re-validated here rather than trusted. Parsing is where
1308 /// a manifest's rules are enforced, but [`SandboxManifest`]'s fields are
1309 /// public and this constructor is too, so "it was parsed once" is an
1310 /// invariant a caller can step around by building or editing one. The
1311 /// values that matter are the ones something downstream would *panic* on
1312 /// rather than merely misbehave over — a `max_concurrency` past the
1313 /// semaphore's ceiling, a route path axum refuses to build — and this crate
1314 /// does not panic on plugin input.
1315 ///
1316 /// # Errors
1317 ///
1318 /// See [`load`](Self::load), plus
1319 /// [`InvalidManifest`](SandboxLoadError::InvalidManifest) if the manifest
1320 /// does not satisfy the rules parsing enforces.
1321 pub fn from_module(manifest: SandboxManifest, wasm: &[u8]) -> Result<Self, SandboxLoadError> {
1322 manifest
1323 .validate()
1324 .map_err(|err| SandboxLoadError::InvalidManifest(err.to_string()))?;
1325
1326 // Before `Module::new`, deliberately: compiling is what allocates a
1327 // representation of every declaration, so a ceiling checked afterwards
1328 // is a ceiling checked too late.
1329 let shape = refuse_unbounded_shape(wasm)?;
1330
1331 let mut config = Config::default();
1332 // Fuel metering is what turns "a plugin might loop forever" into "a
1333 // plugin gets a bounded number of instructions". It must be on before
1334 // the module is compiled, because the counting is compiled in.
1335 config.consume_fuel(true);
1336 let engine = Engine::new(&config);
1337 let module =
1338 Module::new(&engine, wasm).map_err(|err| SandboxLoadError::Wasm(err.to_string()))?;
1339
1340 let import_count = module.imports().count();
1341 if import_count > MAX_IMPORTS {
1342 return Err(SandboxLoadError::InstantiationTooExpensive {
1343 what: "imports",
1344 found: import_count,
1345 max: MAX_IMPORTS,
1346 });
1347 }
1348
1349 let forbidden = forbidden_imports(&module);
1350 if !forbidden.is_empty() {
1351 return Err(SandboxLoadError::ForbiddenImports(forbidden));
1352 }
1353
1354 // Every shim function reads and writes through an export named
1355 // `memory`; without one they all answer `EINVAL` and the plugin can
1356 // never serve a request. And a module whose *initial* memory is already
1357 // over the manifest's ceiling is refused by the limiter at
1358 // instantiation — per request, as a gateway error, when it could have
1359 // been said once here.
1360 let memory = module
1361 .exports()
1362 .find(|export| export.name() == "memory")
1363 .and_then(|export| export.ty().memory().copied());
1364 let Some(memory) = memory else {
1365 return Err(SandboxLoadError::MissingMemory);
1366 };
1367 let initial_bytes =
1368 u64::from(u32::from(memory.initial_pages())).saturating_mul(WASM_PAGE_BYTES);
1369 if initial_bytes > manifest.limits.memory_bytes as u64 {
1370 return Err(SandboxLoadError::MemoryTooLarge {
1371 found: initial_bytes,
1372 max: manifest.limits.memory_bytes,
1373 });
1374 }
1375
1376 // Both ceilings are enforced by `refuse_unbounded_shape` above, before
1377 // `Module::new` rather than after it.
1378 let (segments, init_bytes) = (shape.segments, shape.init_bytes);
1379 // A budget that cannot cover instantiation is a manifest whose every route is
1380 // already broken: the charge is unavoidable and paid before `_start`, so the
1381 // guest never executes an instruction. Refusing at load is what makes `autumn
1382 // plugin inspect` mean something — a passing verdict on an artifact that can
1383 // only answer 504 is worse than no verdict, because an operator installs on
1384 // the strength of it. Compared against instantiation alone rather than the
1385 // whole per-request cost: the frame encoding varies with the request, so there
1386 // is no single number to check it against, while this charge is fixed by the
1387 // module and known now.
1388 let instantiation = instantiation_fuel(
1389 segments,
1390 init_bytes,
1391 import_count,
1392 shape.global_count,
1393 initial_bytes,
1394 shape.table_elements,
1395 )
1396 .saturating_add(u64::try_from(shape.function_count).unwrap_or(u64::MAX));
1397 if manifest.limits.fuel <= instantiation {
1398 return Err(SandboxLoadError::FuelBelowFixedCharges {
1399 fuel: manifest.limits.fuel,
1400 instantiation,
1401 });
1402 }
1403
1404 // Not merely "some function called `_start`": the host looks it up as
1405 // `() -> ()`, so a `_start` with parameters or results is a module that
1406 // loads and then fails on every request.
1407 let start_is_callable = module.exports().any(|export| {
1408 export.name() == "_start"
1409 && export
1410 .ty()
1411 .func()
1412 .is_some_and(|ty| ty.params().is_empty() && ty.results().is_empty())
1413 });
1414 if !start_is_callable {
1415 return Err(SandboxLoadError::MissingStart);
1416 }
1417
1418 refuse_unrunnable_shape(&shape, initial_bytes)?;
1419
1420 Ok(Self {
1421 engine,
1422 module,
1423 permits: Semaphore::new(manifest.limits.max_concurrency),
1424 artifact_sha256: None,
1425 instantiation_fuel: instantiation,
1426 owned_routes: super::wire::OwnedRoutes::from_routes(
1427 manifest
1428 .routes
1429 .iter()
1430 .map(|route| (route.method.as_str(), route.path.as_str())),
1431 ),
1432 manifest,
1433 })
1434 }
1435
1436 /// Record the sealed digest of the artifact this host came from.
1437 #[must_use]
1438 pub(super) fn with_artifact_sha256(mut self, digest: String) -> Self {
1439 self.artifact_sha256 = Some(digest);
1440 self
1441 }
1442
1443 /// The manifest this host enforces.
1444 #[must_use]
1445 pub const fn manifest(&self) -> &SandboxManifest {
1446 &self.manifest
1447 }
1448
1449 /// Every import a module declares, as `module::name`, without loading it.
1450 ///
1451 /// The review surface for an artifact the sandbox *refuses*: what it wanted
1452 /// is the whole reason it was refused, so a consent screen must be able to
1453 /// show it.
1454 ///
1455 /// # Errors
1456 ///
1457 /// Returns [`SandboxLoadError::Wasm`] if the bytes are not a module.
1458 pub fn imports_of(wasm: &[u8]) -> Result<Vec<String>, SandboxLoadError> {
1459 // `inspect` calls this on artifacts nobody has audited, and on the ones
1460 // the sandbox is about to refuse — so the review surface must not be
1461 // the way an artifact exhausts the process reviewing it.
1462 refuse_unbounded_shape(wasm)?;
1463 let engine = Engine::default();
1464 let module =
1465 Module::new(&engine, wasm).map_err(|err| SandboxLoadError::Wasm(err.to_string()))?;
1466 Ok(reported_imports(module.imports()))
1467 }
1468
1469 /// What one request pays, in fuel, just to instantiate this module.
1470 ///
1471 /// Bounded at load; charged per request. Surfaced so a packaging tool can
1472 /// show an author what their module costs before it costs anyone else.
1473 #[must_use]
1474 pub const fn instantiation_fuel(&self) -> u64 {
1475 self.instantiation_fuel
1476 }
1477
1478 /// Every import the module declares, as `module::name`, for review.
1479 #[must_use]
1480 pub fn imports(&self) -> Vec<String> {
1481 reported_imports(self.module.imports())
1482 }
1483
1484 /// Serve one request, with no capability backends wired.
1485 ///
1486 /// This is synchronous and CPU-bound by design — it is an interpreter loop.
1487 /// Callers on an async runtime must dispatch it to a blocking worker; the
1488 /// [`plugin`](super::plugin) module does exactly that.
1489 ///
1490 /// Never panics and never fails: a plugin that misbehaves in any way
1491 /// produces an [`SandboxOutcome`] whose `result` is `Err`.
1492 ///
1493 /// A plugin granted `kv`, `db`, `http-outbound` or `jobs` still *runs* here
1494 /// — its calls are answered [`unavailable`](crate::plugin_sandbox::DenialReason::Unavailable)
1495 /// and recorded. Wire the backends with [`run_with`](Self::run_with).
1496 #[must_use]
1497 pub fn run(&self, request: &SandboxRequest) -> SandboxOutcome {
1498 self.run_with(request, crate::plugin_sandbox::CapabilityServices::none())
1499 }
1500
1501 /// Serve one request against `services` (issue #1632).
1502 ///
1503 /// The services are per-call rather than per-host because the tenant is:
1504 /// one compiled plugin serves every tenant's requests, and the scoping that
1505 /// makes that safe comes from `services.tenant` being the *request's*
1506 /// tenant. A host that held the services would hold one tenant.
1507 #[must_use]
1508 pub fn run_with(
1509 &self,
1510 request: &SandboxRequest,
1511 services: crate::plugin_sandbox::CapabilityServices,
1512 ) -> SandboxOutcome {
1513 let limits = self.manifest.limits;
1514 // Bounds where the response may redirect a client; see `sanitize`.
1515 let rules = ResponseRules {
1516 prefix: self.manifest.prefix.as_str(),
1517 owned: &self.owned_routes,
1518 requested: &request.method,
1519 };
1520
1521 if let Some(refusal) = refuse_oversized_request(request, limits) {
1522 return refusal;
1523 }
1524
1525 // Borrowed, not cloned: `HostFrame::request` reads the grants to decide
1526 // what the guest is told it may do and never keeps them, so copying the
1527 // vector on every request bought nothing. Validation refuses a repeated
1528 // grant, so this list is at most one entry per capability this build
1529 // understands.
1530 // The frame is *built* inside `execute`, not here. Encoding it clones
1531 // the body and base64-expands it, which is the host work
1532 // `encoding_fuel` exists to price and `max_concurrency` exists to
1533 // bound — so doing it before admission and before the charge undid
1534 // both: a manifest with a 64 MiB body ceiling and almost no fuel would
1535 // expand every request in full and only then answer `FuelExhausted`,
1536 // once per request, for as long as a client cared to ask.
1537 let capabilities = &self.manifest.capabilities;
1538 match self.execute(
1539 || HostFrame::request(request, capabilities),
1540 encoding_fuel(request),
1541 Exchange::Request,
1542 services,
1543 ) {
1544 Execution::Refused(failure, fuel_used) => SandboxOutcome::refused(failure, fuel_used),
1545 Execution::Ran(store, permit, result) => {
1546 // The permit is still held, and dropped only once `finish` has
1547 // drained the store: sanitising and size-checking the response
1548 // clones up to `max_response_bytes`, which is the term
1549 // `request_footprint_bytes` charges to `max_concurrency`.
1550 let outcome = finish(store, limits, &rules, result);
1551 drop(permit);
1552 outcome
1553 }
1554 }
1555 }
1556
1557 /// Fill one render slot (issue #1632).
1558 ///
1559 /// `context` is whatever the host chose to tell the plugin about the page —
1560 /// an order id, a locale. It is the host's to decide and the plugin's to
1561 /// read; nothing about the request, the session or the user crosses unless
1562 /// the host put it here.
1563 ///
1564 /// Every failure produces an `Err` fragment and nothing else: a slow hook, a
1565 /// trapping one, a fragment carrying a tag this build will not emit, one
1566 /// over the `render_bytes` quota. A caller omits the fragment and serves the
1567 /// page — see [`SandboxRenderOutcome`].
1568 #[must_use]
1569 pub fn render(
1570 &self,
1571 slot: &str,
1572 context: &[(String, String)],
1573 services: crate::plugin_sandbox::CapabilityServices,
1574 ) -> SandboxRenderOutcome {
1575 let limits = self.manifest.limits;
1576 let refused = |failure, fuel_used| SandboxRenderOutcome {
1577 fragment: Err(failure),
1578 denials: Vec::new(),
1579 fuel_used,
1580 peak_memory_bytes: 0,
1581 stderr: String::new(),
1582 activity: Vec::new(),
1583 dropped_events: 0,
1584 dropped_calls: None,
1585 };
1586
1587 // Checked here rather than left to the guest: a plugin that was not
1588 // granted the slot must not run at all for it, because running is what
1589 // costs the page its latency.
1590 if !self
1591 .manifest
1592 .is_granted(super::manifest::SandboxCapability::Render)
1593 || !self
1594 .manifest
1595 .grants
1596 .allows(super::manifest::SandboxCapability::Render, slot)
1597 {
1598 return refused(
1599 SandboxFailure::GuestError(format!(
1600 "this plugin was not granted the render slot {slot:?}",
1601 slot = super::manifest::rejected(slot)
1602 )),
1603 0,
1604 );
1605 }
1606
1607 // Bounded here, at the public entry point, rather than only in
1608 // `SandboxedPlugin::render_slot`. An embedder may call this directly
1609 // with context built from a row or a query string, and a ceiling only
1610 // one of two callers reaches is a ceiling the other does not have.
1611 let context = &bounded_context(context);
1612
1613 // The context is the host's own text rather than a client's body, so it
1614 // is priced by its length the same way an encoded request is. Measured
1615 // from the parts rather than from the encoded frame, because encoding
1616 // it is the work being priced. Per-entry overhead is included, so a
1617 // context of many empty pairs is not charged nothing for allocation and
1618 // serialization it really costs.
1619 let encoding = context
1620 .iter()
1621 .map(|(name, value)| {
1622 u64::try_from(
1623 name.len()
1624 .saturating_add(value.len())
1625 .saturating_add(RENDER_CONTEXT_ENTRY_OVERHEAD),
1626 )
1627 .unwrap_or(u64::MAX)
1628 })
1629 .fold(
1630 u64::try_from(slot.len()).unwrap_or(u64::MAX),
1631 u64::saturating_add,
1632 )
1633 / BYTES_PER_FUEL;
1634
1635 let capabilities = &self.manifest.capabilities;
1636 match self.execute(
1637 || HostFrame::render(slot, context, capabilities),
1638 encoding,
1639 Exchange::Render,
1640 services,
1641 ) {
1642 Execution::Refused(failure, fuel_used) => refused(failure, fuel_used),
1643 Execution::Ran(store, permit, result) => {
1644 let fuel_used = store
1645 .get_fuel()
1646 .map_or(limits.fuel, |left| limits.fuel.saturating_sub(left));
1647 let mut state = store.into_data();
1648 // The same drain `finish` performs, and for the same reason: a
1649 // hook that kept hitting its memory ceiling is the likeliest
1650 // cause of an omitted fragment, and an operator reading an empty
1651 // denial list would be reading silence rather than evidence.
1652 let peak_memory_bytes = record_limiter_refusals(&mut state, limits);
1653 let activity = state.runtime.take_events();
1654 let dropped_events = state.runtime.dropped_events();
1655 let dropped_calls = state.runtime.dropped_calls();
1656 let max_bytes = state.runtime.quotas().render_bytes as usize;
1657 let fragment = match result {
1658 Ok(GuestAnswer::Fragment(nodes)) => {
1659 super::capability::render::render(&nodes, max_bytes).map_err(|err| {
1660 SandboxFailure::ResponseRefused(guest_text(&err.to_string()))
1661 })
1662 }
1663 Ok(GuestAnswer::Response(_)) => Err(SandboxFailure::MalformedFrame(
1664 "a render slot is answered with a `fragment` frame, not a `response`"
1665 .to_owned(),
1666 )),
1667 Err(failure) => Err(failure),
1668 };
1669 let stderr = state.stderr_excerpt();
1670 drop(permit);
1671 SandboxRenderOutcome {
1672 fragment,
1673 denials: state.denials,
1674 fuel_used,
1675 peak_memory_bytes,
1676 stderr,
1677 activity,
1678 dropped_events,
1679 dropped_calls,
1680 }
1681 }
1682 }
1683 }
1684
1685 /// Instantiate the module and run it to its first terminal frame.
1686 ///
1687 /// The half `run_with` and `render` share: everything from admission to the
1688 /// `_start` call is identical between them, and a second copy of it would be
1689 /// a second place for one of these ceilings to go missing.
1690 fn execute(
1691 &self,
1692 frame: impl FnOnce() -> HostFrame,
1693 encoding: u64,
1694 exchange: Exchange,
1695 services: crate::plugin_sandbox::CapabilityServices,
1696 ) -> Execution<'_> {
1697 let limits = self.manifest.limits;
1698
1699 // Admission, before a single buffer is built. `serve` holds a permit of
1700 // its own across the whole request, so this is never what stops an
1701 // ordinary HTTP request — it is here for the embedder who calls the
1702 // public entry points directly and would otherwise start as many
1703 // instances as it liked, against a footprint the manifest validator
1704 // accepted on the premise that `max_concurrency` bounds them.
1705 //
1706 // Held for the body of the run: dropping the guard on the way out is
1707 // what makes the permit mean "executing now".
1708 let Ok(permit) = self.permits.try_acquire() else {
1709 return Execution::Refused(
1710 SandboxFailure::AtCapacity {
1711 max: limits.max_concurrency,
1712 },
1713 0,
1714 );
1715 };
1716
1717 // Building the guest's stdin is host work proportional to the frame:
1718 // the body is cloned into it and base64-expanded into the NDJSON line,
1719 // all of it before a single guest instruction runs. Unpriced, that is
1720 // megabytes of host CPU per request for a manifest that declares a
1721 // large body ceiling and almost no fuel — and a client can repeat it
1722 // for as long as it likes.
1723 //
1724 // So it is charged at the same rate as every other host-side copy, and
1725 // charged *before* the copies happen: a budget that cannot cover the
1726 // encoding refuses without doing it. The guest then starts on what is
1727 // left, which is the same arrangement instantiation already has.
1728 let Some(after_encoding) = limits.fuel.checked_sub(encoding) else {
1729 return Execution::Refused(
1730 SandboxFailure::FuelExhausted {
1731 budget: limits.fuel,
1732 },
1733 limits.fuel,
1734 );
1735 };
1736
1737 // Built and encoded here, after admission and after the charge above —
1738 // see the note in `run_with`. The frame's bytes are then *moved* into
1739 // the queue rather than copied into it: `VecDeque::from(Vec<u8>)`
1740 // reuses the allocation, and the `String` is gone afterwards. For a
1741 // plugin with a large body ceiling that is a whole base64-expanded copy
1742 // of the request that no longer exists at the same time as the others.
1743 let frame = frame();
1744 // Only the request frame is large, and only its encoding is priced for
1745 // the second pass above. Render frames keep the single pass.
1746 let line = match if matches!(frame, HostFrame::Request { .. }) {
1747 to_exact_line(&frame)
1748 } else {
1749 to_line(&frame)
1750 } {
1751 Ok(line) => line,
1752 Err(err) => {
1753 // The host could not encode its own frame. Reported as a
1754 // plugin-prefix failure rather than propagating: the rest of
1755 // the application is unaffected either way.
1756 return Execution::Refused(
1757 SandboxFailure::Instantiation(guest_text(&err.to_string())),
1758 0,
1759 );
1760 }
1761 };
1762
1763 let mut state = HostState::new(
1764 self.manifest.name.clone(),
1765 limits,
1766 line.as_bytes(),
1767 exchange,
1768 crate::plugin_sandbox::capability::CapabilityRuntime::new(&self.manifest, services)
1769 .with_artifact_sha256(
1770 self.artifact_sha256
1771 .clone()
1772 .unwrap_or_else(|| self.manifest.sha256.clone()),
1773 ),
1774 );
1775 state.stdin = VecDeque::from(line.into_bytes());
1776
1777 let mut store = Store::new(&self.engine, state);
1778 store.limiter(|state| &mut state.limiter);
1779 // Set before instantiation, so the budget is in place the moment the
1780 // first guest instruction runs.
1781 if let Err(err) = store.set_fuel(after_encoding) {
1782 return Execution::Refused(
1783 SandboxFailure::Instantiation(guest_text(&err.to_string())),
1784 0,
1785 );
1786 }
1787
1788 // wasmi does not meter instantiation: every request copies the module's
1789 // data and element segments before `_start` runs. Two things bound it,
1790 // and it needs both. The *ceiling* is at load — a module whose segments
1791 // are structurally expensive is refused outright, because a per-request
1792 // charge cannot stop work that has already been admitted. The *price* is
1793 // here, so the work that is admitted still comes out of the budget the
1794 // manifest declared rather than being free.
1795 let Some(left) = after_encoding.checked_sub(self.instantiation_fuel) else {
1796 return Execution::Ran(
1797 store,
1798 permit,
1799 Err(SandboxFailure::FuelExhausted {
1800 budget: limits.fuel,
1801 }),
1802 );
1803 };
1804 if let Err(err) = store.set_fuel(left) {
1805 return Execution::Refused(
1806 SandboxFailure::Instantiation(guest_text(&err.to_string())),
1807 0,
1808 );
1809 }
1810
1811 let mut linker = <Linker<HostState>>::new(&self.engine);
1812 if let Err(err) = define_wasi_shim(&mut linker) {
1813 return Execution::Refused(
1814 SandboxFailure::Instantiation(guest_text(&err.to_string())),
1815 0,
1816 );
1817 }
1818
1819 let started = linker
1820 .instantiate(&mut store, &self.module)
1821 .and_then(|pre| pre.start(&mut store));
1822 let instance = match started {
1823 Ok(instance) => instance,
1824 Err(err) => {
1825 return Execution::Ran(store, permit, Err(instantiation_failure(&err, limits)));
1826 }
1827 };
1828
1829 let Ok(start) = instance.get_typed_func::<(), ()>(&store, "_start") else {
1830 // `from_module` already refused a module without `_start`; reaching
1831 // here would mean the export changed shape, which is still the
1832 // plugin's problem and not the host's.
1833 return Execution::Ran(store, permit, Err(SandboxFailure::NoAnswer));
1834 };
1835
1836 let trap = start.call(&mut store, ()).err();
1837 let partial = !store.data().stdout_line.is_empty();
1838 let result = match (store.data().answer.clone(), trap) {
1839 // A guest that answered and *then* trapped still answered: the
1840 // first frame is the answer, and everything after it — including
1841 // the host's own `AnswerComplete` unwind — is noise after the fact.
1842 (Some(answer), _) => answer,
1843 (None, Some(err)) => Err(guest_failure(&err, limits)),
1844 (None, None) if partial => Err(SandboxFailure::PartialFrame),
1845 (None, None) => Err(SandboxFailure::NoAnswer),
1846 };
1847 Execution::Ran(store, permit, result)
1848 }
1849}
1850
1851/// What one instantiation-and-run produced.
1852///
1853/// `Refused` is a request that never reached a guest instruction — at capacity,
1854/// or a budget that could not cover its own fixed charges — and carries no
1855/// store, because there is none. `Ran` carries the store so the caller can drain
1856/// the evidence: fuel, memory peak, denials, stderr and the capability ledger.
1857#[allow(
1858 clippy::large_enum_variant,
1859 reason = "`Ran` carries the wasmi `Store` itself, which is large by construction and is the \
1860 common path; boxing it would add an allocation per request without shrinking the \
1861 store, and this value is a return value moved once, never held in a collection"
1862)]
1863enum Execution<'a> {
1864 /// Turned away before a store existed.
1865 Refused(SandboxFailure, u64),
1866 /// Ran to a terminal frame, or failed trying.
1867 ///
1868 /// The permit comes back out with the store rather than being dropped at
1869 /// the end of `execute`: draining the store is where the response is
1870 /// sanitised, size-checked and cloned, which is the `5 ×
1871 /// max_response_bytes` term `max_concurrency` is validated against. A
1872 /// permit released before that work happens bounds the interpreter and not
1873 /// the request.
1874 Ran(
1875 Store<HostState>,
1876 tokio::sync::SemaphorePermit<'a>,
1877 Result<GuestAnswer, SandboxFailure>,
1878 ),
1879}
1880
1881/// Drain the store into an outcome, applying the response sanitation and the
1882/// size ceiling that only the host can enforce.
1883/// What a response may carry, gathered because the three travel together.
1884///
1885/// A `Location` is judged against all of them at once: it must stay under the
1886/// prefix, name a route the plugin actually serves — an application route may
1887/// sit under the prefix — and land on that route under the method the client
1888/// will replay, which the status and the request's own method decide between
1889/// them. Passing them as one argument keeps `run`'s exits readable.
1890struct ResponseRules<'a> {
1891 prefix: &'a str,
1892 owned: &'a super::wire::OwnedRoutes,
1893 requested: &'a str,
1894}
1895
1896fn finish(
1897 store: Store<HostState>,
1898 limits: ResourceLimits,
1899 rules: &ResponseRules<'_>,
1900 result: Result<GuestAnswer, SandboxFailure>,
1901) -> SandboxOutcome {
1902 let fuel_used = store
1903 .get_fuel()
1904 .map_or(limits.fuel, |left| limits.fuel.saturating_sub(left));
1905 let mut state = store.into_data();
1906 let peak_memory_bytes = record_limiter_refusals(&mut state, limits);
1907
1908 let activity = state.runtime.take_events();
1909 let dropped_activity = state.runtime.dropped_events();
1910 let dropped_calls = state.runtime.dropped_calls();
1911 let result = match result {
1912 Ok(GuestAnswer::Fragment(_)) => Err(SandboxFailure::MalformedFrame(
1913 "a request is answered with a `response` frame, not a `fragment`".to_owned(),
1914 )),
1915 Ok(GuestAnswer::Response(response)) => {
1916 let (response, denied) = response.sanitize(rules.prefix, rules.owned, rules.requested);
1917 for name in denied {
1918 // The name is the guest's, so it is as long and as hostile as
1919 // the guest cares to make it — bounded only by the stdout line
1920 // ceiling. `deny` clones it into the ledger and logs it, and a
1921 // name is not validated before it gets here (that it is invalid
1922 // is often *why* it was denied), so it can carry newlines and
1923 // terminal escapes too. Same treatment as every other
1924 // guest-influenced string that reaches a log.
1925 state.deny(
1926 DeniedCapability::ResponseHeader,
1927 &guest_text(&name),
1928 "a sandboxed plugin may not set this response header",
1929 );
1930 }
1931 response.refused_content_type().map_or_else(
1932 || {
1933 response
1934 .validate()
1935 .and_then(|()| response.check_size(limits.max_response_bytes))
1936 .map_or_else(
1937 |err| {
1938 Err(SandboxFailure::ResponseRefused(guest_text(
1939 &err.to_string(),
1940 )))
1941 },
1942 |()| Ok(response.clone()),
1943 )
1944 },
1945 |essence| {
1946 // `refused_content_type` returns everything before the
1947 // first `;`, so a guest that writes no parameter hands back
1948 // its whole header value — bounded only by the stdout
1949 // ceiling, which is megabytes. Both strings built here are
1950 // logged, the denial by `deny` and the failure by `serve`,
1951 // so the guest's text gets the same cap and control-escaping
1952 // every other guest-influenced string gets. The branch beside
1953 // this one already did; this one did not.
1954 let essence = guest_text(&essence);
1955 let detail = format!(
1956 "a sandboxed plugin may not serve `{essence}`: a document or a script \
1957 from the host's own origin would carry the host's authority"
1958 );
1959 state.deny(DeniedCapability::ResponseHeader, "content-type", &detail);
1960 Err(SandboxFailure::ResponseRefused(
1961 super::wire::WireError::UnsupportedContentType(essence).to_string(),
1962 ))
1963 },
1964 )
1965 }
1966 Err(failure) => Err(failure),
1967 };
1968
1969 let stderr = state.stderr_excerpt();
1970 SandboxOutcome {
1971 result,
1972 denials: state.denials,
1973 fuel_used,
1974 peak_memory_bytes,
1975 stderr,
1976 activity,
1977 dropped_activity,
1978 dropped_calls,
1979 }
1980}
1981
1982/// Turn the memory limiter's refusal counts into denials, and report the peak.
1983///
1984/// Shared by `finish` and by the render path. Two exits that both drain a store
1985/// are two places for one of these to be forgotten — and the render path did
1986/// forget them, so a hook that died against its memory ceiling reported an empty
1987/// denial list, which reads as "nothing was refused" rather than as the answer.
1988fn record_limiter_refusals(state: &mut HostState, limits: ResourceLimits) -> usize {
1989 let peak_memory_bytes = state.limiter.peak;
1990 let (memory_refusals, table_refusals) =
1991 (state.limiter.memory_refusals, state.limiter.table_refusals);
1992 if memory_refusals > 0 {
1993 let detail = format!(
1994 "{memory_refusals} allocation(s) over the plugin's {max}-byte memory ceiling were refused",
1995 max = limits.memory_bytes,
1996 );
1997 state.deny(DeniedCapability::Memory, "memory.grow", &detail);
1998 }
1999 // Its own operation and its own ceiling. `deny` deduplicates by
2000 // `(capability, operation)`, so this is a second entry beside the byte one
2001 // rather than a collision with it, and a guest that hit both is recorded as
2002 // having hit both.
2003 if table_refusals > 0 {
2004 let detail = format!(
2005 "{table_refusals} table growth(s) over the sandbox's {MAX_TABLE_ELEMENTS}-element table ceiling were refused",
2006 );
2007 state.deny(DeniedCapability::Memory, "table.grow", &detail);
2008 }
2009 peak_memory_bytes
2010}
2011
2012fn instantiation_failure(err: &wasmi::Error, limits: ResourceLimits) -> SandboxFailure {
2013 if err.as_trap_code() == Some(wasmi::core::TrapCode::OutOfFuel) {
2014 SandboxFailure::FuelExhausted {
2015 budget: limits.fuel,
2016 }
2017 } else {
2018 SandboxFailure::Instantiation(guest_text(&err.to_string()))
2019 }
2020}
2021
2022fn guest_failure(err: &wasmi::Error, limits: ResourceLimits) -> SandboxFailure {
2023 if let Some(code) = err.i32_exit_status() {
2024 return SandboxFailure::Exited(code);
2025 }
2026 if err.as_trap_code() == Some(wasmi::core::TrapCode::OutOfFuel) {
2027 return SandboxFailure::FuelExhausted {
2028 budget: limits.fuel,
2029 };
2030 }
2031 if err.downcast_ref::<OutputBudgetExhausted>().is_some() {
2032 return SandboxFailure::OutputBudget {
2033 max: limits.max_response_bytes,
2034 };
2035 }
2036 SandboxFailure::Trap(guest_text(&err.to_string()))
2037}
2038
2039/// What a module's section headers declare, without compiling it.
2040///
2041/// Walks the top-level sections directly. wasmi's public API does not expose
2042/// segments, and these are the numbers that have to be bounded at load: the
2043/// alternative is discovering them per request, in host work no budget prices.
2044///
2045/// Returns `None` for bytes that are not a well-formed section stream. This now
2046/// runs *before* wasmi compiles the module, so malformed bytes reach it — and
2047/// refusing them is the right answer.
2048#[derive(Debug, Clone, Copy)]
2049struct ModuleShape {
2050 /// How many linear memories the module declares for itself.
2051 memory_count: usize,
2052 /// Section headers the module carries, custom ones included.
2053 ///
2054 /// Counted because a custom section contributes to no other ceiling and
2055 /// costs three bytes to encode; see `MAX_SECTIONS`.
2056 sections: usize,
2057 /// Data and element segments, which every instantiation copies.
2058 segments: usize,
2059 /// Bytes those sections hold.
2060 init_bytes: usize,
2061 /// Entries every section *declares*, summed.
2062 ///
2063 /// Read from each section's leading count, so this costs one LEB128 per
2064 /// section rather than a walk over the entries themselves — the point is to
2065 /// know the shape before anything allocates per entry.
2066 declared_entries: usize,
2067 /// The first active element segment that writes past its table, as
2068 /// (one past its furthest write, what the table starts with).
2069 element_overflow: Option<(u64, u64)>,
2070 /// The furthest byte any active data segment writes to.
2071 ///
2072 /// Zero when the module has none, or when the offsets are not constants
2073 /// this walk can evaluate.
2074 data_end: u64,
2075 /// How many imports the module declares.
2076 ///
2077 /// `MAX_IMPORTS` was enforced from `module.imports().count()`, which is
2078 /// only available *after* `Module::new` has already built a representation
2079 /// of every one of them — the ceiling ran after the allocation it exists to
2080 /// prevent. Read from the section header here, so it can run before.
2081 import_count: usize,
2082 /// How many functions the module defines.
2083 function_count: usize,
2084 /// Whether the module carries a `start` section.
2085 has_start: bool,
2086 /// Bytes of instruction stream in the code section.
2087 code_bytes: usize,
2088 /// How many globals the module declares.
2089 global_count: usize,
2090 /// Bytes of instruction stream in the global section.
2091 ///
2092 /// A global's initializer is a constant expression, and the extended-const
2093 /// proposal lets that expression run to arbitrary length — one global can
2094 /// carry most of the module. Those bytes are instruction volume, the same
2095 /// thing the code-section ceiling measures, so they share it rather than
2096 /// hiding behind the entry count.
2097 global_bytes: usize,
2098 /// How many tables the module declares.
2099 ///
2100 /// Sizes and count are separate ceilings: five empty tables cost no
2101 /// elements at all and still exceed what the store will build.
2102 table_count: usize,
2103 /// Elements the module's own tables declare as their *initial* size.
2104 ///
2105 /// The limiter enforces `MAX_TABLE_ELEMENTS` at instantiation, which is
2106 /// per request; knowing the declared total at load is what turns "every
2107 /// request fails" into "this artifact does not load".
2108 table_elements: u64,
2109}
2110
2111/// What an element section declares, and whether it writes past a table.
2112struct ElementSectionShape {
2113 /// The first active segment that writes past its table, as (one past its
2114 /// furthest write, what the table starts with).
2115 overflow: Option<(u64, u64)>,
2116 /// Items the section's segments declare between them.
2117 ///
2118 /// Read from each segment's leading count rather than by counting the
2119 /// items walked, which is what lets the walk stop at the ceiling instead
2120 /// of reaching it one entry at a time.
2121 items: usize,
2122}
2123
2124/// What the element section declares, and whether any *active* segment writes
2125/// past the table it targets.
2126///
2127/// `None` when the section cannot be read — the same restraint the data walk
2128/// uses: an offset this cannot evaluate is left to the engine rather than
2129/// guessed at.
2130fn element_section_shape(
2131 wasm: &[u8],
2132 start: usize,
2133 section_end: usize,
2134 table_minimums: &[u64],
2135) -> Option<ElementSectionShape> {
2136 let (count, mut at) = leb128(wasm, start)?;
2137 let mut overflow = None;
2138 let mut declared = 0usize;
2139 for _ in 0..count {
2140 let (flags, next) = leb128(wasm, at)?;
2141 at = next;
2142 // Only an active segment is written at instantiation; passive (1, 5)
2143 // and declarative (3, 7) ones are not.
2144 let active = matches!(flags, 0 | 2 | 4 | 6);
2145 let mut table_index = 0usize;
2146 if flags == 2 || flags == 6 {
2147 let (index, next) = leb128(wasm, at)?;
2148 table_index = index;
2149 at = next;
2150 }
2151 let mut offset = None;
2152 if active {
2153 let (value, next) = const_i32(wasm, at, section_end)?;
2154 offset = value;
2155 at = next;
2156 }
2157 // Every form but 0 and 4 carries an elemkind or reftype byte.
2158 if !matches!(flags, 0 | 4) {
2159 at = at.checked_add(1)?;
2160 }
2161 let (items, next) = leb128(wasm, at)?;
2162 at = next;
2163 // The segment's own count, before a single item is read. Nothing else sees it:
2164 // `declared_entries` sums each section's leading count, so one segment holding
2165 // millions of indices adds one, and `MAX_TABLE_ELEMENTS` bounds only what a
2166 // table starts with, which a passive segment never touches. The ceiling that
2167 // exists to bound declarations before anything allocates per declaration was
2168 // therefore blind to the one place a declaration is nested. Return here rather
2169 // than walk on: past the ceiling the module is refused whatever the remaining
2170 // segments hold, so reading them is work an artifact chose for this process —
2171 // the same rule the section-count walk follows, one level down.
2172 declared = declared.saturating_add(items);
2173 if declared > MAX_DECLARED_ENTRIES {
2174 return Some(ElementSectionShape {
2175 overflow,
2176 items: declared,
2177 });
2178 }
2179 if flags >= 4 {
2180 // `vec(expr)`: each element is a constant expression.
2181 for _ in 0..items {
2182 at = skip_const_expr(wasm, at, section_end)?;
2183 }
2184 } else {
2185 // `vec(funcidx)`: each element is one LEB128.
2186 for _ in 0..items {
2187 let (_, next) = leb128(wasm, at)?;
2188 at = next;
2189 }
2190 }
2191 if at > section_end {
2192 return None;
2193 }
2194 // The same rule the data section walk reaches by way of
2195 // `DataSectionEnd::ActiveUnevaluable`: an active segment is written at
2196 // instantiation whether or not this can work out where, so an offset it
2197 // cannot evaluate is treated as one that cannot fit. Skipping it left
2198 // the bounds check silently not running — the defect this walk exists
2199 // to prevent, in the sibling of the walk where it was just fixed.
2200 if active {
2201 let capacity = table_minimums.get(table_index).copied().unwrap_or(0);
2202 let end = match offset {
2203 Some(offset) => offset.checked_add(items as u64)?,
2204 None => u64::MAX,
2205 };
2206 if end > capacity && overflow.is_none() {
2207 overflow = Some((end, capacity));
2208 }
2209 }
2210 }
2211 Some(ElementSectionShape {
2212 overflow,
2213 items: declared,
2214 })
2215}
2216
2217/// Skip one constant expression, returning the offset just past its `end`.
2218///
2219/// Scanning for the `0x0b` terminator instead of decoding is wrong, and quietly
2220/// so: `ref.func 11` encodes its immediate as `0x0b`, so a scan stops on the
2221/// operand and every following byte is read at the wrong offset. That desync
2222/// made the walk bail and silently drop the bounds check it exists to perform.
2223/// Only the instructions a constant expression may contain are decoded; an
2224/// unknown opcode returns `None`, which leaves the module to the engine rather
2225/// than to a guess.
2226fn skip_const_expr(wasm: &[u8], mut at: usize, limit: usize) -> Option<usize> {
2227 loop {
2228 if at > limit {
2229 return None;
2230 }
2231 let opcode = *wasm.get(at)?;
2232 at = at.checked_add(1)?;
2233 match opcode {
2234 // end
2235 0x0b => return Some(at),
2236 // i32.const / i64.const: one signed LEB128. `ref.null` (0xd0)
2237 // takes the same step: the format writes a heap type as a signed
2238 // LEB too. Every heap type this engine accepts fits one byte —
2239 // `funcref` is 0x70, `externref` 0x6f, and wasmi enables neither
2240 // function-references nor the GC proposal's concrete type
2241 // references, so a type index cannot appear here — which makes
2242 // reading the LEB identical to reading the byte today, and keeps
2243 // this walk in step if that ever stops being true.
2244 0x41 | 0x42 | 0xd0 => at = sleb128(wasm, at)?.1,
2245 // f32.const / f64.const: raw little-endian bits
2246 0x43 => at = at.checked_add(4)?,
2247 0x44 => at = at.checked_add(8)?,
2248 // ref.func / global.get: one unsigned LEB128
2249 0xd2 | 0x23 => at = leb128(wasm, at)?.1,
2250 // extended-const arithmetic: no immediates. wasmi enables the
2251 // proposal by default, so these appear in offsets the engine
2252 // accepts and this walk has to keep step with.
2253 0x6a..=0x6c | 0x7c..=0x7e => {}
2254 _ => return None,
2255 }
2256 }
2257}
2258
2259/// The `i32` value a constant expression evaluates to, if this can tell.
2260///
2261/// Returns the offset past the expression either way, so a caller can keep
2262/// walking past an expression it cannot evaluate.
2263///
2264/// Reading only a bare `i32.const` was not enough, and the gap was a fail-open.
2265/// wasmi enables the extended-const proposal by default, so
2266/// `i32.const 65535; i32.const 2; i32.add` is a legal active data offset that
2267/// the engine compiles happily. Against the bare-form reader it was not merely
2268/// unevaluable: `skip_const_expr` did not know `i32.add` either, so the whole
2269/// data section's walk returned `None`, the caller dropped it, and `data_end`
2270/// stayed at zero. The bounds check did not run at all, and packaging approved
2271/// an artifact whose every request would then fail at instantiation — the one
2272/// outcome the load-time check exists to prevent.
2273///
2274/// So the arithmetic is evaluated. Everything else a constant expression may
2275/// contain is still stepped over and reported as unevaluable rather than
2276/// guessed at: `global.get` reads a global this walk has not tracked, and the
2277/// float and reference forms are not offsets at all.
2278fn const_i32(wasm: &[u8], at: usize, limit: usize) -> Option<(Option<u64>, usize)> {
2279 // The proposal's grammar is a fold over constants, so a handful of slots is
2280 // more than any real offset needs; a deeper expression is reported as
2281 // unevaluable rather than growing a stack for an untrusted module.
2282 let mut stack = [0i32; 16];
2283 let mut depth = 0usize;
2284 let mut evaluable = true;
2285 let mut at = at;
2286 loop {
2287 if at > limit {
2288 return None;
2289 }
2290 let opcode = *wasm.get(at)?;
2291 at = at.checked_add(1)?;
2292 match opcode {
2293 // end
2294 0x0b => break,
2295 // i32.const
2296 0x41 => {
2297 let (value, next) = sleb128(wasm, at)?;
2298 at = next;
2299 match (i32::try_from(value), stack.get_mut(depth)) {
2300 (Ok(narrow), Some(slot)) => {
2301 *slot = narrow;
2302 depth = depth.saturating_add(1);
2303 }
2304 _ => evaluable = false,
2305 }
2306 }
2307 // i32.add / i32.sub / i32.mul, which wrap in wasm as they do here.
2308 0x6a..=0x6c => {
2309 // Folded through `checked_sub` and `get` rather than indexing:
2310 // this module is in the request-path panic-gate manifest, and
2311 // the depth these run against comes out of an untrusted module.
2312 let folded = depth.checked_sub(2).and_then(|left_at| {
2313 let right = *stack.get(depth.checked_sub(1)?)?;
2314 let left = *stack.get(left_at)?;
2315 let value = match opcode {
2316 0x6a => left.wrapping_add(right),
2317 0x6b => left.wrapping_sub(right),
2318 _ => left.wrapping_mul(right),
2319 };
2320 Some((left_at, value))
2321 });
2322 match folded {
2323 Some((left_at, value)) => {
2324 if let Some(slot) = stack.get_mut(left_at) {
2325 *slot = value;
2326 }
2327 depth = left_at.saturating_add(1);
2328 }
2329 None => evaluable = false,
2330 }
2331 }
2332 // i64.const, and the i64 arithmetic: stepped over, not evaluated —
2333 // a memory or table offset is an `i32`, so an expression built from
2334 // these is not one this caller is asking about.
2335 0x42 => {
2336 at = sleb128(wasm, at)?.1;
2337 evaluable = false;
2338 }
2339 0x7c..=0x7e => evaluable = false,
2340 // f32.const / f64.const: raw little-endian bits, never an offset.
2341 0x43 => {
2342 at = at.checked_add(4)?;
2343 evaluable = false;
2344 }
2345 0x44 => {
2346 at = at.checked_add(8)?;
2347 evaluable = false;
2348 }
2349 // ref.null: one heap type byte.
2350 0xd0 => {
2351 at = at.checked_add(1)?;
2352 evaluable = false;
2353 }
2354 // ref.func / global.get: one unsigned LEB128.
2355 0xd2 | 0x23 => {
2356 at = leb128(wasm, at)?.1;
2357 evaluable = false;
2358 }
2359 _ => return None,
2360 }
2361 }
2362 if let (true, 1, Some(&result)) = (evaluable, depth, stack.first()) {
2363 // A wasm offset is a `u32`, so `i32.const -1` addresses 4294967295 —
2364 // past the end of any memory or table the sandbox will admit. Dropping
2365 // a negative as "unevaluable" was a fail-open: the one class of offset
2366 // that is *certainly* out of bounds was the one recorded as unknown.
2367 let unsigned = u64::from(u32::from_ne_bytes(result.to_ne_bytes()));
2368 return Some((Some(unsigned), at));
2369 }
2370 Some((None, at))
2371}
2372
2373/// Read an unsigned LEB128 at `at`, returning the value and the next offset.
2374fn leb128(wasm: &[u8], at: usize) -> Option<(usize, usize)> {
2375 let mut value: usize = 0;
2376 let mut shift: u32 = 0;
2377 let mut cursor = at;
2378 loop {
2379 let byte = *wasm.get(cursor)?;
2380 cursor = cursor.checked_add(1)?;
2381 value = value.checked_add(usize::from(byte & 0x7f).checked_shl(shift)?)?;
2382 if byte & 0x80 == 0 {
2383 return Some((value, cursor));
2384 }
2385 shift = shift.checked_add(7)?;
2386 if shift > 63 {
2387 return None;
2388 }
2389 }
2390}
2391
2392/// A signed LEB128, for the `i32.const` in a segment's offset expression.
2393fn sleb128(wasm: &[u8], at: usize) -> Option<(i64, usize)> {
2394 let mut value: i64 = 0;
2395 let mut shift: u32 = 0;
2396 let mut cursor = at;
2397 loop {
2398 let byte = *wasm.get(cursor)?;
2399 cursor = cursor.checked_add(1)?;
2400 value |= i64::from(byte & 0x7f).checked_shl(shift)?;
2401 shift = shift.checked_add(7)?;
2402 if byte & 0x80 == 0 {
2403 if shift < 64 && byte & 0x40 != 0 {
2404 value |= -1i64 << shift;
2405 }
2406 return Some((value, cursor));
2407 }
2408 if shift > 63 {
2409 return None;
2410 }
2411 }
2412}
2413
2414/// The furthest byte the data section's *active* segments write to.
2415///
2416/// `None` when nothing in the section can be evaluated — a passive-only
2417/// section. The two `None`s are different answers and the caller treats them
2418/// differently: the outer one means the walk failed and the bounds check did
2419/// not happen, so the module is refused; the inner one means the walk
2420/// succeeded and there was no active write to measure — a passive-only
2421/// section copies nothing at instantiation — so there is nothing to refuse.
2422/// What a walk of the data section found.
2423///
2424/// Three answers, not two, and collapsing any pair of them has been a bug in
2425/// this file already: reading "nothing to measure" as "the check did not run"
2426/// refused a runnable module, and reading "the check did not run" as "nothing
2427/// to measure" admitted a segment past the end of memory.
2428enum DataSectionEnd {
2429 /// The walk failed — a malformed section, or an opcode a later proposal
2430 /// added that this does not know. The bounds check did not happen.
2431 Unreadable,
2432 /// The walk succeeded and found no active write to measure: every segment
2433 /// is passive, so nothing is copied at instantiation.
2434 NothingActive,
2435 /// The walk succeeded, and an active segment's offset could not be
2436 /// evaluated — an expression past the evaluator's stack, or one reading a
2437 /// global this walk has not tracked. The segment *is* copied in at
2438 /// instantiation; this simply cannot say where, which is the one thing the
2439 /// bounds check needs to know.
2440 ActiveUnevaluable,
2441 /// The furthest byte an active segment writes.
2442 Furthest(u64),
2443}
2444
2445impl DataSectionEnd {
2446 /// What this contributes to the module's `data_end`.
2447 ///
2448 /// An unreadable section contributes `u64::MAX`, so an offset that cannot
2449 /// be read is treated as one that cannot fit — silently contributing
2450 /// nothing is how a segment past the end of memory got admitted.
2451 const fn contribution(&self) -> u64 {
2452 match *self {
2453 Self::Unreadable | Self::ActiveUnevaluable => u64::MAX,
2454 Self::NothingActive => 0,
2455 Self::Furthest(end) => end,
2456 }
2457 }
2458}
2459
2460fn data_section_end(wasm: &[u8], start: usize, section_end: usize) -> DataSectionEnd {
2461 walk_data_section(wasm, start, section_end).unwrap_or(DataSectionEnd::Unreadable)
2462}
2463
2464/// Walk the section, or `None` if it cannot be walked to the end.
2465///
2466/// The `?`s below are all the same answer — the walk did not finish, so the
2467/// bounds check did not happen — and [`data_section_end`] turns that into
2468/// [`DataSectionEnd::Unreadable`]. Kept separate so `?` can say it once rather
2469/// than every early return spelling out the enum.
2470fn walk_data_section(wasm: &[u8], start: usize, section_end: usize) -> Option<DataSectionEnd> {
2471 let (count, mut at) = leb128(wasm, start)?;
2472 let mut furthest: Option<u64> = None;
2473 // An active segment whose offset this cannot evaluate is not the same as no
2474 // active segment at all, and reading it as one was a bounds check that
2475 // silently did not run: the bytes are copied in regardless of whether this
2476 // could work out where.
2477 let mut active_unevaluable = false;
2478 for _ in 0..count {
2479 let (flags, next) = leb128(wasm, at)?;
2480 at = next;
2481 // Flag 1 is a passive segment: nothing is copied at instantiation,
2482 // so it cannot be out of bounds.
2483 let active = flags != 1;
2484 if flags == 2 {
2485 let (_memory_index, next) = leb128(wasm, at)?;
2486 at = next;
2487 }
2488 let mut offset: Option<u64> = None;
2489 if active {
2490 let (value, next) = const_i32(wasm, at, section_end)?;
2491 offset = value;
2492 at = next;
2493 }
2494 let (len, next) = leb128(wasm, at)?;
2495 at = next.checked_add(len)?;
2496 if at > section_end {
2497 return None;
2498 }
2499 match (active, offset) {
2500 (true, Some(offset)) => {
2501 let segment_end = offset.checked_add(len as u64)?;
2502 furthest = Some(furthest.map_or(segment_end, |f: u64| f.max(segment_end)));
2503 }
2504 (true, None) => active_unevaluable = true,
2505 (false, _) => {}
2506 }
2507 }
2508 if active_unevaluable {
2509 return Some(DataSectionEnd::ActiveUnevaluable);
2510 }
2511 Some(furthest.map_or(DataSectionEnd::NothingActive, DataSectionEnd::Furthest))
2512}
2513
2514/// Section ids for the element and data sections.
2515const ELEMENT_SECTION: u8 = 9;
2516const DATA_SECTION: u8 = 11;
2517/// The table section, whose entries carry the initial sizes the limiter
2518/// will be asked to admit.
2519const TABLE_SECTION: u8 = 4;
2520/// `vec(memtype)` — every linear memory the module declares for itself.
2521const MEMORY_SECTION: u8 = 5;
2522/// The global section, whose entries each become per-instance storage.
2523const GLOBAL_SECTION: u8 = 6;
2524/// The import section: entries the compiler builds a representation for.
2525const IMPORT_SECTION: u8 = 2;
2526/// The function section: one type index per function the module defines.
2527const FUNCTION_SECTION: u8 = 3;
2528/// The start section: a function the engine runs at instantiation.
2529const START_SECTION: u8 = 8;
2530/// The code section, whose *size* is the instruction volume the compiler
2531/// walks — a thing no entry count reveals.
2532const CODE_SECTION: u8 = 10;
2533/// The custom section is the one whose payload is not a counted vector.
2534const CUSTOM_SECTION: u8 = 0;
2535
2536/// The gates that prove an artifact can actually be instantiated.
2537///
2538/// Distinct from the ceilings in `refuse_unbounded_shape`, which bound what
2539/// loading may *cost*: these say the module can be built at all. Each one
2540/// stands for a defect where the artifact loaded clean, inspected clean, and
2541/// then failed every single request it was ever given.
2542const fn refuse_unrunnable_shape(
2543 shape: &ModuleShape,
2544 initial_bytes: u64,
2545) -> Result<(), SandboxLoadError> {
2546 // An active segment is copied in during instantiation, which is per
2547 // request, so one that does not fit the memory the module starts with
2548 // fails every instantiation the artifact is ever given. Said once here
2549 // rather than 502 for the life of the plugin.
2550 if shape.has_start {
2551 return Err(SandboxLoadError::StartSectionForbidden);
2552 }
2553 if shape.data_end > initial_bytes {
2554 return Err(SandboxLoadError::SegmentOutOfBounds {
2555 what: "linear memory",
2556 end: shape.data_end,
2557 capacity: initial_bytes,
2558 });
2559 }
2560
2561 // And the same for a segment written into a table rather than into
2562 // memory: fixing one without the other would leave half the defect.
2563 if let Some((end, capacity)) = shape.element_overflow {
2564 return Err(SandboxLoadError::SegmentOutOfBounds {
2565 what: "table elements",
2566 end,
2567 capacity,
2568 });
2569 }
2570 Ok(())
2571}
2572
2573/// Read a table section's declared count and the elements its tables start with.
2574///
2575/// `vec(tabletype)`, and a `tabletype` is a reftype byte then `limits`: a flag,
2576/// the minimum, and a maximum when the flag says so. Only the minimum matters
2577/// here — it is what the instance allocates before the guest runs.
2578///
2579/// Returns the declared count and the summed minimums, and writes the first
2580/// `MAX_TABLES` minimums into `minimums`.
2581fn table_section_shape(
2582 wasm: &[u8],
2583 after_size: usize,
2584 end: usize,
2585 already_counted: usize,
2586 minimums: &mut [u64; MAX_TABLES],
2587 seen: &mut usize,
2588) -> Option<(usize, u64)> {
2589 let (count, mut at) = leb128(wasm, after_size)?;
2590 // The same argument as the segment walk in the caller, and the same shape
2591 // its own comment describes one step further down: bounding the
2592 // *allocation* per entry left the *iteration* per entry unbounded, and an
2593 // artifact buys that at three bytes an entry. `MAX_TABLES` refuses on the
2594 // count alone, so past it there is nothing left for a walk to establish.
2595 let walked = if already_counted.saturating_add(count) <= MAX_TABLES {
2596 count
2597 } else {
2598 0
2599 };
2600 let mut elements = 0u64;
2601 for _ in 0..walked {
2602 at = at.checked_add(1)?; // reftype
2603 let flag = *wasm.get(at)?;
2604 at = at.checked_add(1)?;
2605 let (minimum, next) = leb128(wasm, at)?;
2606 at = next;
2607 elements = elements.saturating_add(minimum as u64);
2608 // Only the first `MAX_TABLES` can ever be admitted, so only those are
2609 // worth keeping. Pushing every declaration into a growing vector put an
2610 // unbounded per-entry allocation inside the very walk whose whole
2611 // purpose is to avoid one: a 64 MiB artifact of empty table
2612 // declarations would have expanded here, in the code meant to refuse it
2613 // before anything expanded.
2614 if let Some(slot) = minimums.get_mut(*seen) {
2615 *slot = minimum as u64;
2616 *seen = seen.saturating_add(1);
2617 }
2618 if flag == 0x01 {
2619 let (_, after_max) = leb128(wasm, at)?;
2620 at = after_max;
2621 }
2622 if at > end {
2623 return None;
2624 }
2625 }
2626 Some((count, elements))
2627}
2628
2629fn module_shape(wasm: &[u8]) -> Option<ModuleShape> {
2630 let mut cursor = 8usize; // magic + version
2631 let mut sections = 0usize;
2632 let mut memory_count = 0usize;
2633 let mut segments = 0usize;
2634 let mut bytes = 0usize;
2635 let mut declared_entries = 0usize;
2636 let mut table_elements = 0u64;
2637 let mut table_count = 0usize;
2638 let mut global_count = 0usize;
2639 let mut global_bytes = 0usize;
2640 let mut code_bytes = 0usize;
2641 let mut has_start = false;
2642 let mut function_count = 0usize;
2643 let mut import_count = 0usize;
2644 let mut data_end = 0u64;
2645 let mut table_minimums = [0u64; MAX_TABLES];
2646 let mut table_count_seen = 0usize;
2647 let mut element_overflow: Option<(u64, u64)> = None;
2648 while cursor < wasm.len() {
2649 let id = *wasm.get(cursor)?;
2650 let (size, after_size) = leb128(wasm, cursor.checked_add(1)?)?;
2651 let end = after_size.checked_add(size)?;
2652 if end > wasm.len() {
2653 return None;
2654 }
2655 sections = sections.saturating_add(1);
2656 if sections > MAX_SECTIONS {
2657 // Stopping here rather than at the end is the whole point: past the
2658 // ceiling the module is refused whatever the remaining headers say,
2659 // so reading them is work an artifact chose for this process.
2660 break;
2661 }
2662 if id != CUSTOM_SECTION {
2663 let (count, _) = leb128(wasm, after_size)?;
2664 declared_entries = declared_entries.saturating_add(count);
2665 }
2666 if id == ELEMENT_SECTION || id == DATA_SECTION {
2667 let (count, _) = leb128(wasm, after_size)?;
2668 segments = segments.saturating_add(count);
2669 bytes = bytes.saturating_add(size);
2670 }
2671 // Past the ceiling the module is refused on this count alone, whatever the
2672 // per-segment walks below would find, so the walking is pure cost — and the
2673 // cost is the attack: a near-64 MiB module of two-byte passive segments is tens
2674 // of millions of iterations spent reaching the refusal whose purpose is to
2675 // bound that work.
2676 //
2677 // Skipping is safe because `refuse_unbounded_shape` rejects on `segments` by
2678 // itself: the refusal is unconditional and the walk's findings cannot matter.
2679 // It rejects on `init_bytes` by itself too, which is the other half — a single
2680 // element segment holding tens of millions of function indices keeps `segments`
2681 // at one and clears the count ceiling, so the count alone let the walk run over
2682 // every item on the way to a refusal the byte ceiling had already decided. Both
2683 // counters gate the walk because either one alone refuses the module.
2684 let within_segment_ceiling =
2685 segments <= MAX_INIT_SEGMENTS && bytes <= MAX_INIT_SECTION_BYTES;
2686 if id == ELEMENT_SECTION && within_segment_ceiling {
2687 // The table section precedes this one in a well-formed module, so
2688 // the minimums are known by the time the segments are read.
2689 if let Some(element) = element_section_shape(
2690 wasm,
2691 after_size,
2692 end,
2693 table_minimums.get(..table_count_seen).unwrap_or_default(),
2694 ) {
2695 element_overflow = element.overflow;
2696 declared_entries = declared_entries.saturating_add(element.items);
2697 }
2698 }
2699 if id == DATA_SECTION && within_segment_ceiling {
2700 // Active segments carry a constant offset and a length, and both
2701 // are needed to know whether the copy fits the memory the module
2702 // starts with. A segment whose offset is not a plain `i32.const`
2703 // (a `global.get`, say) is left alone: the shim exports no globals
2704 // for one to read, so such a module fails to link for its own
2705 // reasons rather than being mis-refused here.
2706 data_end = data_end.max(data_section_end(wasm, after_size, end).contribution());
2707 }
2708 // The sections whose leading count (or size) is the whole answer. The
2709 // rest need their entries walked and are handled above.
2710 let leading_count = || leb128(wasm, after_size).map(|(count, _)| count);
2711 match id {
2712 IMPORT_SECTION => import_count = import_count.saturating_add(leading_count()?),
2713 FUNCTION_SECTION => function_count = function_count.saturating_add(leading_count()?),
2714 GLOBAL_SECTION => {
2715 global_count = global_count.saturating_add(leading_count()?);
2716 // The initializers are an instruction stream, not data —
2717 // extended-const lets one global's expression run to
2718 // arbitrary length — so their bytes count toward the
2719 // instruction-volume ceiling the same way the code
2720 // section's do. Counted from the header: no walk needed.
2721 global_bytes = global_bytes.saturating_add(size);
2722 }
2723 MEMORY_SECTION => memory_count = memory_count.saturating_add(leading_count()?),
2724 CODE_SECTION => code_bytes = code_bytes.saturating_add(size),
2725 START_SECTION => has_start = true,
2726 _ => {}
2727 }
2728 if id == TABLE_SECTION {
2729 let (count, elements) = table_section_shape(
2730 wasm,
2731 after_size,
2732 end,
2733 table_count,
2734 &mut table_minimums,
2735 &mut table_count_seen,
2736 )?;
2737 table_count = table_count.saturating_add(count);
2738 table_elements = table_elements.saturating_add(elements);
2739 }
2740 cursor = end;
2741 }
2742 Some(ModuleShape {
2743 sections,
2744 memory_count,
2745 segments,
2746 init_bytes: bytes,
2747 declared_entries,
2748 import_count,
2749 function_count,
2750 has_start,
2751 element_overflow,
2752 data_end,
2753 code_bytes,
2754 global_count,
2755 global_bytes,
2756 table_count,
2757 table_elements,
2758 })
2759}
2760
2761/// Refuse a module whose declared shape would cost more to *compile* than the
2762/// file's own size suggests.
2763///
2764/// `Module::new` is the first thing that touches an unaudited artifact's bytes,
2765/// and it builds an in-memory representation of every type, function, export
2766/// and global before any ceiling here has run. A legal 64 MiB file of tiny
2767/// declarations expands many times over in that representation, so the file
2768/// bound is not a bound on what compiling it costs. The section headers carry
2769/// the counts, so the shape is knowable *first* — one LEB128 per section, no
2770/// per-entry work — which is what makes refusing it cheap enough to do before
2771/// wasmi ever sees the bytes.
2772fn refuse_unbounded_shape(wasm: &[u8]) -> Result<ModuleShape, SandboxLoadError> {
2773 // Before the walk, because the walk is the cheap part. `check_module`
2774 // applies this to a module the container reader unpacked, but
2775 // `from_module` and `imports_of` are public and take the bytes directly,
2776 // so on that path nothing had imposed it. The counts below do not cover
2777 // the gap: one import whose module or field name is enormous is one
2778 // import, under every count ceiling here, and `Module::new` must
2779 // materialise that name before the import allowlist can refuse it.
2780 if wasm.len() > super::MAX_MODULE_BYTES {
2781 return Err(SandboxLoadError::ModuleTooLarge {
2782 found: wasm.len(),
2783 max: super::MAX_MODULE_BYTES,
2784 });
2785 }
2786 let Some(shape) = module_shape(wasm) else {
2787 return Err(SandboxLoadError::Wasm(
2788 "the module's section stream could not be walked".to_owned(),
2789 ));
2790 };
2791 // First, because it is the one ceiling the walk stops early for: past it
2792 // the shape is only partly read, so every count below is a floor rather
2793 // than a total, and refusing on one of those would name the wrong reason.
2794 if shape.sections > MAX_SECTIONS {
2795 return Err(SandboxLoadError::InstantiationTooExpensive {
2796 what: "sections",
2797 found: shape.sections,
2798 max: MAX_SECTIONS,
2799 });
2800 }
2801 // Before `Module::new`, with the rest of them. These two used to be
2802 // checked in `from_module` *after* compiling — so a module declaring half
2803 // a million empty segments, under the aggregate entry ceiling and thus
2804 // never refused for that, had a representation built for every one of them
2805 // before the ceiling that exists to prevent exactly that work ran. Moving
2806 // the per-segment walk earlier last round bounded the walking and left the
2807 // compiling; this is the other half.
2808 if shape.segments > MAX_INIT_SEGMENTS {
2809 return Err(SandboxLoadError::InstantiationTooExpensive {
2810 what: "data and element segments",
2811 found: shape.segments,
2812 max: MAX_INIT_SEGMENTS,
2813 });
2814 }
2815 if shape.init_bytes > MAX_INIT_SECTION_BYTES {
2816 return Err(SandboxLoadError::InstantiationTooExpensive {
2817 what: "bytes of data and element sections",
2818 found: shape.init_bytes,
2819 max: MAX_INIT_SECTION_BYTES,
2820 });
2821 }
2822 if shape.code_bytes > MAX_CODE_BYTES {
2823 return Err(SandboxLoadError::InstantiationTooExpensive {
2824 what: "code section bytes",
2825 found: shape.code_bytes,
2826 max: MAX_CODE_BYTES,
2827 });
2828 }
2829 // The global section's initializers are an instruction stream too —
2830 // extended-const lets one global's expression run to arbitrary length —
2831 // so their bytes share the code section's instruction-volume ceiling.
2832 // The entry count never saw this: one global sits under `MAX_GLOBALS`
2833 // however long its initializer is, and `Module::new` would build a
2834 // representation of every one of those instructions. The sum keeps the
2835 // two numbers separately recorded on `ModuleShape`, so the refusal can
2836 // name the sections that pushed the module over rather than
2837 // misattributing it to the code section alone.
2838 //
2839 // Audit of the other instruction-carrying sections, stated explicitly
2840 // rather than assumed: element and data section offset and item
2841 // expressions already sit inside `init_bytes`, which has its own
2842 // `MAX_INIT_SECTION_BYTES` ceiling above. The global section was the
2843 // only one the scanner counted by entries alone.
2844 let instruction_bytes = shape.code_bytes.saturating_add(shape.global_bytes);
2845 if instruction_bytes > MAX_CODE_BYTES {
2846 return Err(SandboxLoadError::InstantiationTooExpensive {
2847 what: "code and global section bytes",
2848 found: instruction_bytes,
2849 max: MAX_CODE_BYTES,
2850 });
2851 }
2852 if shape.import_count > MAX_IMPORTS {
2853 return Err(SandboxLoadError::InstantiationTooExpensive {
2854 what: "imports",
2855 found: shape.import_count,
2856 max: MAX_IMPORTS,
2857 });
2858 }
2859 if shape.function_count > MAX_FUNCTIONS {
2860 return Err(SandboxLoadError::InstantiationTooExpensive {
2861 what: "functions",
2862 found: shape.function_count,
2863 max: MAX_FUNCTIONS,
2864 });
2865 }
2866 if shape.global_count > MAX_GLOBALS {
2867 return Err(SandboxLoadError::InstantiationTooExpensive {
2868 what: "globals",
2869 found: shape.global_count,
2870 max: MAX_GLOBALS,
2871 });
2872 }
2873 // The per-instance stores the limiter guards. These say the module can be built at
2874 // all, so they read as runnability rules, but they are also cost ceilings, and that
2875 // is what decides where they live. A module declaring hundreds of thousands of empty
2876 // memories or tables sits under `MAX_DECLARED_ENTRIES` and is refused by these
2877 // anyway, so compiling it first buys a representation of every declaration on the
2878 // way to a verdict that never depended on one. Checked after that compile, they were
2879 // the right answer at the wrong time. All three move together: `table_elements` is
2880 // the same argument as the other two — tables already over the ceiling at rest are
2881 // refused whatever `Module::new` would say — and leaving it behind would be the
2882 // half-fix this file warns about elsewhere.
2883 if shape.memory_count > MAX_MEMORIES {
2884 return Err(SandboxLoadError::InstantiationTooExpensive {
2885 what: "linear memories",
2886 found: shape.memory_count,
2887 max: MAX_MEMORIES,
2888 });
2889 }
2890 if shape.table_count > MAX_TABLES {
2891 return Err(SandboxLoadError::InstantiationTooExpensive {
2892 what: "tables",
2893 found: shape.table_count,
2894 max: MAX_TABLES,
2895 });
2896 }
2897 if shape.table_elements > u64::from(MAX_TABLE_ELEMENTS) {
2898 return Err(SandboxLoadError::InstantiationTooExpensive {
2899 what: "initial table elements",
2900 found: usize::try_from(shape.table_elements).unwrap_or(usize::MAX),
2901 max: MAX_TABLE_ELEMENTS as usize,
2902 });
2903 }
2904 if shape.declared_entries > MAX_DECLARED_ENTRIES {
2905 return Err(SandboxLoadError::InstantiationTooExpensive {
2906 what: "declared section entries",
2907 found: shape.declared_entries,
2908 max: MAX_DECLARED_ENTRIES,
2909 });
2910 }
2911 Ok(shape)
2912}
2913
2914/// Imports the shim will not satisfy — by name **or** by type — as denials.
2915///
2916/// Checking the type here rather than letting `Linker::instantiate` discover it
2917/// is what keeps `autumn plugin package` honest: a module importing an
2918/// allowlisted name with the wrong signature would otherwise pass packaging and
2919/// inspection, then fail on every request as a gateway error nobody can explain
2920/// from the outside.
2921fn forbidden_imports(module: &Module) -> Vec<CapabilityDenial> {
2922 module
2923 .imports()
2924 .filter_map(|import| {
2925 let operation = import_operation(import.module(), import.name());
2926 if import.module() != WASI {
2927 return Some((operation, "the sandbox defines no such host module"));
2928 }
2929 let Some(declared) = import.ty().func() else {
2930 return Some((
2931 operation,
2932 "the sandbox provides host functions only, not memories, tables or globals",
2933 ));
2934 };
2935 let Some((params, results)) = shim_signature(import.name()) else {
2936 return Some((operation, "the sandbox defines no such host function"));
2937 };
2938 match signature_type(params, results) {
2939 Some(expected) if expected == *declared => None,
2940 _ => Some((
2941 operation,
2942 "the sandbox defines this host function with a different signature, so it \
2943 could never link",
2944 )),
2945 }
2946 })
2947 .map(|(operation, why)| CapabilityDenial {
2948 capability: DeniedCapability::UnknownImport,
2949 operation,
2950 detail: format!("{why}, so the plugin is refused before it runs"),
2951 })
2952 .take(MAX_DENIALS)
2953 .collect()
2954}
2955
2956/// A guest blew its output budget. Carried as a host error so the trap it
2957/// causes can be told apart from any other trap.
2958#[derive(Debug)]
2959struct OutputBudgetExhausted;
2960
2961/// The guest answered, so there is nothing left for it to do. Carried as a host
2962/// error to unwind the interpreter at the frame rather than letting a guest
2963/// hold a permit and a blocking worker for its whole budget after the exchange
2964/// is over.
2965#[derive(Debug)]
2966struct AnswerComplete;
2967
2968impl fmt::Display for AnswerComplete {
2969 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2970 f.write_str("the plugin answered")
2971 }
2972}
2973
2974impl wasmi::core::HostError for AnswerComplete {}
2975
2976impl fmt::Display for OutputBudgetExhausted {
2977 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2978 f.write_str("the plugin exceeded its response ceiling")
2979 }
2980}
2981
2982impl wasmi::core::HostError for OutputBudgetExhausted {}
2983
2984// ── Host state ───────────────────────────────────────────────────────────
2985
2986/// Which dialogue a store is running.
2987///
2988/// The two differ only in which host frame goes in and which guest frame is a
2989/// legal answer, which is exactly why they are one enum rather than two code
2990/// paths: everything else — the shim, the fuel, the capability channel, the
2991/// ledger — is shared, and a second path would be a second place for a bound to
2992/// be missing.
2993#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2994enum Exchange {
2995 /// A `request` frame in, a `response` frame out.
2996 Request,
2997 /// A `render` frame in, a `fragment` frame out.
2998 Render,
2999}
3000
3001/// The terminal frame a guest produced.
3002#[derive(Debug, Clone)]
3003enum GuestAnswer {
3004 /// The answer to a request.
3005 Response(SandboxResponse),
3006 /// The answer to a render slot.
3007 Fragment(Vec<super::capability::FragmentNode>),
3008}
3009
3010/// The guest's memory ceiling, and the evidence that it was applied.
3011#[derive(Debug)]
3012struct MemoryLimiter {
3013 max: usize,
3014 peak: usize,
3015 /// Counted apart from `table_refusals`, because the two are refused by
3016 /// *different ceilings* and an operator reading the ledger is trying to
3017 /// learn which one the guest hit. One shared counter reported every table
3018 /// refusal as `memory.grow` over the manifest's byte ceiling — evidence
3019 /// that is not merely vague but wrong, naming a limit that was never
3020 /// applied.
3021 memory_refusals: usize,
3022 table_refusals: usize,
3023 /// Elements held across every table of this instance, so the ceiling is on
3024 /// the instance rather than on each table separately.
3025 table_elements: u32,
3026}
3027
3028impl wasmi::ResourceLimiter for MemoryLimiter {
3029 fn memory_growing(
3030 &mut self,
3031 _current: usize,
3032 desired: usize,
3033 _maximum: Option<usize>,
3034 ) -> Result<bool, wasmi::errors::MemoryError> {
3035 if desired > self.max {
3036 self.memory_refusals = self.memory_refusals.saturating_add(1);
3037 // `Ok(false)` makes the guest's `memory.grow` return -1, which is a
3038 // legal outcome a well-written allocator handles. Returning `Err`
3039 // would trap instead — a harsher answer that tells a hostile guest
3040 // less and an honest one nothing useful.
3041 return Ok(false);
3042 }
3043 self.peak = self.peak.max(desired);
3044 Ok(true)
3045 }
3046
3047 fn table_growing(
3048 &mut self,
3049 current: u32,
3050 desired: u32,
3051 _maximum: Option<u32>,
3052 ) -> Result<bool, wasmi::errors::TableError> {
3053 // Across the instance, not per table: four tables each under a per-table
3054 // ceiling would still be four times that ceiling of host storage, which
3055 // is exactly the accounting hole this closes.
3056 let total = self
3057 .table_elements
3058 .saturating_sub(current)
3059 .saturating_add(desired);
3060 if total > MAX_TABLE_ELEMENTS {
3061 self.table_refusals = self.table_refusals.saturating_add(1);
3062 return Ok(false);
3063 }
3064 self.table_elements = total;
3065 Ok(true)
3066 }
3067
3068 fn instances(&self) -> usize {
3069 1
3070 }
3071
3072 fn tables(&self) -> usize {
3073 MAX_TABLES
3074 }
3075
3076 fn memories(&self) -> usize {
3077 1
3078 }
3079}
3080
3081struct HostState {
3082 /// The plugin's name, so a denial line names who was refused. Carried here
3083 /// rather than logged by the caller so the refusal is logged exactly once,
3084 /// at the point it happens, whoever is driving the host.
3085 plugin: String,
3086 /// Bytes of the request frame the guest has not read yet.
3087 stdin: VecDeque<u8>,
3088 /// The partial stdout line being accumulated.
3089 stdout_line: Vec<u8>,
3090 /// Everything the guest wrote to stderr, bounded.
3091 stderr: Vec<u8>,
3092 /// The first terminal frame the guest produced.
3093 answer: Option<Result<GuestAnswer, SandboxFailure>>,
3094 /// Which dialogue this store is running: a request, or a render slot.
3095 ///
3096 /// Carried in the state rather than inferred from the frame, because it
3097 /// decides which *answer* is legal. A guest that answers a render with an
3098 /// HTTP response is not producing a page fragment, and without this the
3099 /// host would have to decide what it meant.
3100 exchange: Exchange,
3101 /// Capabilities, grants, quotas and the ledger for this request.
3102 runtime: crate::plugin_sandbox::capability::CapabilityRuntime,
3103 /// Fuel owed for capability work done while the guest was inside a
3104 /// `fd_write`, where the `Caller` needed to charge it is not in scope.
3105 pending_charge: u64,
3106 /// Calls parsed out of a completed stdout line and **not yet dispatched**.
3107 ///
3108 /// Queued rather than serviced where they are parsed, so the shim can take
3109 /// each call's fixed fuel charge *before* it reaches a backend. Charging
3110 /// afterwards let a budget that could pay for the `fd_write` copy but not
3111 /// for the call itself perform a KV write, a job enqueue or an outbound
3112 /// POST and *then* end the request as `FuelExhausted` — an effect that
3113 /// happened, on a request whose caller was told it did not, which a retry
3114 /// then does again.
3115 ///
3116 /// Bounded by the chunk that produced it: one `fd_write` copies at most
3117 /// `HOST_IO_CHUNK_BYTES`, and every queued call was parsed out of those
3118 /// bytes.
3119 pending_calls: VecDeque<super::capability::CapabilityCall>,
3120 /// Bytes of capability replies **currently resident** in `stdin`.
3121 ///
3122 /// Counted apart from `stdin.len()`, which also holds the unread tail of
3123 /// the *request* frame. A manifest may declare a 64 MiB body ceiling, so a
3124 /// guest that makes its first call before reading its request would sit
3125 /// over any reply ceiling measured against the whole queue — every
3126 /// capability call refused, for a reason that has nothing to do with
3127 /// replies. The request frame is budgeted separately, at
3128 /// `4 × max_request_body_bytes` in `request_footprint_bytes`; this counts
3129 /// only what the capability channel added, and only while it is still
3130 /// there: `take_stdin` gives it back as the guest reads.
3131 queued_replies: usize,
3132 /// Whether a call that commits has reached its backend.
3133 committed: bool,
3134 /// Everything the guest reached for and did not get.
3135 denials: Vec<CapabilityDenial>,
3136 /// Request-seeded PRNG state, so `random_get` is deterministic without
3137 /// being a published constant.
3138 random_state: u64,
3139 limiter: MemoryLimiter,
3140 limits: ResourceLimits,
3141}
3142
3143impl HostState {
3144 /// The starting point the request is mixed into. Arbitrary but fixed.
3145 const RANDOM_SEED: u64 = 0x2545_F491_4F6C_DD1D;
3146
3147 /// Derive this request's PRNG seed from the request itself.
3148 ///
3149 /// A single fixed seed would make `random_get` a *constant* across every
3150 /// request to every deployment of an artifact — anyone holding the same
3151 /// bytes could predict every value a guest ever derived from it. Mixing the
3152 /// request in keeps the property that actually matters (the same request
3153 /// twice produces the same bytes, so an author can reproduce a bug from the
3154 /// request alone) without publishing the stream.
3155 ///
3156 /// This is **not** cryptographic entropy, and the sandbox offers none: a
3157 /// guest holds no capability that would make a secret useful to it.
3158 fn seed_from(frame: &[u8]) -> u64 {
3159 let mut seed = Self::RANDOM_SEED;
3160 for byte in frame {
3161 seed = seed.rotate_left(7) ^ u64::from(*byte);
3162 seed = seed.wrapping_mul(0x0000_0100_0000_01B3);
3163 }
3164 seed
3165 }
3166
3167 fn new(
3168 plugin: String,
3169 limits: ResourceLimits,
3170 frame: &[u8],
3171 exchange: Exchange,
3172 runtime: crate::plugin_sandbox::capability::CapabilityRuntime,
3173 ) -> Self {
3174 Self {
3175 plugin,
3176 stdin: VecDeque::new(),
3177 stdout_line: Vec::new(),
3178 stderr: Vec::new(),
3179 answer: None,
3180 exchange,
3181 runtime,
3182 pending_charge: 0,
3183 pending_calls: VecDeque::new(),
3184 queued_replies: 0,
3185 committed: false,
3186 denials: Vec::new(),
3187 random_state: Self::seed_from(frame),
3188 limiter: MemoryLimiter {
3189 max: limits.memory_bytes,
3190 peak: 0,
3191 memory_refusals: 0,
3192 table_refusals: 0,
3193 table_elements: 0,
3194 },
3195 limits,
3196 }
3197 }
3198
3199 /// Record one refusal, deduplicated by `(capability, operation)`.
3200 ///
3201 /// Deduplication is what keeps a guest that calls `path_open` in a loop
3202 /// from turning the ledger into its own memory-exhaustion channel, while
3203 /// still recording the fact that it tried.
3204 fn deny(&mut self, capability: DeniedCapability, operation: &str, detail: &str) {
3205 let already = self
3206 .denials
3207 .iter()
3208 .any(|denial| denial.capability == capability && denial.operation == operation);
3209 if already || self.denials.len() >= MAX_DENIALS {
3210 return;
3211 }
3212 let denial = CapabilityDenial {
3213 capability,
3214 operation: operation.to_owned(),
3215 detail: detail.to_owned(),
3216 };
3217 tracing::warn!(
3218 plugin = self.plugin,
3219 capability = capability.as_str(),
3220 operation,
3221 detail,
3222 "sandboxed plugin was denied a capability it reached for"
3223 );
3224 self.denials.push(denial);
3225 }
3226
3227 /// Handle one complete line the guest wrote to stdout.
3228 ///
3229 /// Most frames end the exchange. A `call` frame does not: it is serviced
3230 /// and answered in the guest's stdin, and the guest carries on. That is the
3231 /// whole of the capability channel — see
3232 /// [`GuestFrame::Call`](super::wire::GuestFrame::Call).
3233 fn on_guest_line(&mut self, line: &str) {
3234 if self.answer.is_some() {
3235 // Already answered. Anything after is a guest that does not respect
3236 // the protocol; ignoring it is what stops a second frame from
3237 // overwriting a good first one.
3238 return;
3239 }
3240 self.answer = Some(match from_guest_line(line) {
3241 Ok(GuestFrame::Call(call)) => {
3242 // Queued, not serviced: the fixed charge is the shim's to take
3243 // first. See `pending_calls`.
3244 self.pending_calls.push_back(call);
3245 return;
3246 }
3247 Ok(GuestFrame::Response(response)) => match self.exchange {
3248 Exchange::Request => Ok(GuestAnswer::Response(response)),
3249 Exchange::Render => Err(SandboxFailure::MalformedFrame(
3250 "a render slot is answered with a `fragment` frame, not a `response`"
3251 .to_owned(),
3252 )),
3253 },
3254 Ok(GuestFrame::Fragment { nodes }) => match self.exchange {
3255 Exchange::Render => Ok(GuestAnswer::Fragment(nodes)),
3256 Exchange::Request => Err(SandboxFailure::MalformedFrame(
3257 "a request is answered with a `response` frame, not a `fragment`".to_owned(),
3258 )),
3259 },
3260 Ok(GuestFrame::Error { detail }) => {
3261 Err(SandboxFailure::GuestError(guest_text(&detail)))
3262 }
3263 Err(err) => Err(SandboxFailure::MalformedFrame(guest_text(&err.to_string()))),
3264 });
3265 }
3266
3267 /// Answer one capability call by appending its result to the guest's stdin.
3268 ///
3269 /// Never fails the request over the *content* of a call: everything a guest
3270 /// can get wrong comes back as a denial it can read. The two exits that do
3271 /// end the request are the host's own encoder failing, and a guest that has
3272 /// stopped reading its replies — see [`MAX_QUEUED_REPLY_BYTES`].
3273 fn service(&mut self, call: &super::capability::CapabilityCall, fuel_left: u64) {
3274 // The room the reply must fit, decided *before* the call runs. A reply
3275 // cannot be measured until its call has committed, and a request that
3276 // fails over a committed write is retried by the caller. So a call is
3277 // dispatched only when the worst-case reply fits the unread-reply queue
3278 // and the fuel left covers pricing it plus a fixed finishing allowance
3279 // for the guest. A call that commits also keeps the fuel to read that
3280 // reply (#2523). A call that does not fit is answered with a denial the
3281 // guest can read, and nothing runs.
3282 let queue_room = MAX_QUEUED_REPLY_BYTES.saturating_sub(self.queued_replies);
3283 // A quarter of the budget at most, so a small budget still serves calls.
3284 let allowance = FINISH_ALLOWANCE_FUEL.min(self.limits.fuel / 4);
3285 // After a write has run, any call can still fail the request, so every
3286 // call keeps the read fuel. The guest must read its unread replies too.
3287 let keep_read = self.committed || call.commits();
3288 let unread = u64::try_from(self.queued_replies).unwrap_or(u64::MAX);
3289 let fuel_covers = |reserve: usize| {
3290 let reserve = u64::try_from(reserve).unwrap_or(u64::MAX);
3291 let read = if keep_read {
3292 reserve
3293 .saturating_add(unread)
3294 .saturating_mul(GUEST_READ_FUEL_PER_BYTE)
3295 } else {
3296 0
3297 };
3298 fuel_left
3299 >= allowance
3300 .saturating_add(read)
3301 .saturating_add(reserve.div_ceil(BYTES_PER_FUEL))
3302 };
3303 let result = self.runtime.dispatch_checked(call, queue_room, fuel_covers);
3304 if call.commits() && reached_backend(&result) {
3305 self.committed = true;
3306 }
3307 let line = match to_line(&HostFrame::CallResult(result)) {
3308 Ok(line) => line,
3309 Err(err) => {
3310 // The host could not encode its own answer. Reported as a
3311 // plugin-prefix failure rather than propagating, exactly as
3312 // `run` treats the same failure on the request frame.
3313 self.answer = Some(Err(SandboxFailure::Instantiation(guest_text(
3314 &err.to_string(),
3315 ))));
3316 return;
3317 }
3318 };
3319 if self.queued_replies.saturating_add(line.len()) > MAX_QUEUED_REPLY_BYTES {
3320 self.answer = Some(Err(SandboxFailure::MalformedFrame(format!(
3321 "the guest has left over {MAX_QUEUED_REPLY_BYTES} bytes of capability replies \
3322 unread; it must read each answer before making the next call"
3323 ))));
3324 return;
3325 }
3326 // Only the per-byte part. The fixed part — which prices the dispatch: a
3327 // lock, a lookup, a frame built — is taken by the shim *before* this
3328 // runs, so a budget that cannot cover it never reaches a backend. What
3329 // is left is the reply the host has just materialised, priced at the
3330 // same rate every other host-side copy pays; it cannot be charged
3331 // earlier because it cannot be measured before the call is answered.
3332 self.pending_charge = self
3333 .pending_charge
3334 .saturating_add(u64::try_from(line.len()).unwrap_or(u64::MAX) / BYTES_PER_FUEL);
3335 self.queued_replies = self.queued_replies.saturating_add(line.len());
3336 self.stdin.extend(line.as_bytes());
3337 }
3338
3339 /// Dispatch the next queued call, if any.
3340 ///
3341 /// Returns `false` when the queue is empty. The caller charges
3342 /// [`CAPABILITY_CALL_FUEL`] before each `true` step, which is the whole
3343 /// point of the queue: see [`pending_calls`](Self::pending_calls).
3344 fn service_next(&mut self, fuel_left: u64) -> bool {
3345 let Some(call) = self.pending_calls.pop_front() else {
3346 return false;
3347 };
3348 self.service(&call, fuel_left);
3349 true
3350 }
3351
3352 /// Whether a parsed call is waiting for its fixed charge.
3353 fn has_pending_call(&self) -> bool {
3354 !self.pending_calls.is_empty()
3355 }
3356
3357 /// Take the fuel owed for capability work, leaving nothing owed.
3358 const fn take_pending_charge(&mut self) -> u64 {
3359 std::mem::replace(&mut self.pending_charge, 0)
3360 }
3361
3362 /// Buffer stdout bytes, consuming each completed NDJSON line.
3363 ///
3364 /// Returns `false` — the guest's write must fail — once the pending line
3365 /// outgrows what a legal response could possibly be.
3366 #[must_use]
3367 fn write_stdout(&mut self, bytes: &[u8]) -> bool {
3368 // Base64 inflates a body by 4/3, and the frame carries headers and JSON
3369 // punctuation around it, so the pending-line budget is the declared
3370 // response ceiling doubled plus slack. A frame that cannot fit under
3371 // this could never pass `check_size` anyway.
3372 let budget = self
3373 .limits
3374 .max_response_bytes
3375 .saturating_mul(2)
3376 .saturating_add(4096);
3377 for byte in bytes {
3378 if *byte == b'\n' {
3379 let line = std::mem::take(&mut self.stdout_line);
3380 // Strictly, not lossily. `from_utf8_lossy` turns each invalid
3381 // byte into a three-byte replacement character while the
3382 // original is still alive, so a guest that filled its whole
3383 // stdout budget with invalid bytes would make the host hold
3384 // four times that budget — memory no manifest accounted for.
3385 // A frame is JSON, so it is UTF-8 or it is not a frame.
3386 match String::from_utf8(line) {
3387 Ok(line) => self.on_guest_line(&line),
3388 Err(_) => {
3389 self.answer = Some(Err(SandboxFailure::MalformedFrame(
3390 "the frame is not valid UTF-8".to_owned(),
3391 )));
3392 }
3393 }
3394 } else {
3395 if self.stdout_line.len() >= budget {
3396 return false;
3397 }
3398 // Grow geometrically, but never past the budget. Left to
3399 // itself `Vec::push` doubles, taking a buffer whose length
3400 // stops at `budget` to a capacity of the next power of two
3401 // above it — for the default ceiling, 16 MiB of allocation
3402 // behind an 8 MiB bound. `request_footprint_bytes` reserves
3403 // `2 × max_response_bytes` for this line and validates the
3404 // concurrency product against that reservation, so the slack
3405 // `Vec` takes for its own amortisation is host memory nothing
3406 // accounted for, at every concurrent request at once. Clamping
3407 // the reservation keeps the doubling, and the amortised push
3408 // with it, right up to the point where the next one would
3409 // overrun the bound. The 64-byte floor is below the smallest
3410 // budget this can compute (`saturating_add(4096)`), so it
3411 // covers only the first few pushes.
3412 if self.stdout_line.len() == self.stdout_line.capacity() {
3413 let want = self
3414 .stdout_line
3415 .capacity()
3416 .saturating_mul(2)
3417 .clamp(64, budget);
3418 self.stdout_line
3419 .reserve_exact(want.saturating_sub(self.stdout_line.len()));
3420 }
3421 self.stdout_line.push(*byte);
3422 }
3423 }
3424 true
3425 }
3426
3427 /// Buffer stderr bytes, silently dropping everything past the budget — a
3428 /// guest dying loudly should not be punished for being chatty.
3429 fn write_stderr(&mut self, bytes: &[u8]) {
3430 let room = STDERR_BUDGET_BYTES.saturating_sub(self.stderr.len());
3431 if let Some(kept) = bytes.get(..bytes.len().min(room)) {
3432 self.stderr.extend_from_slice(kept);
3433 }
3434 }
3435
3436 /// Whether every further stderr byte would be discarded.
3437 const fn stderr_is_full(&self) -> bool {
3438 self.stderr.len() >= STDERR_BUDGET_BYTES
3439 }
3440
3441 fn stderr_excerpt(&self) -> String {
3442 // Bounded and neutralised: truncation stops a flood, but a forged record fits
3443 // comfortably inside 512 characters.
3444 //
3445 // Decoded a chunk at a time rather than through `String::from_utf8_lossy`, for
3446 // the same reason the stdout frame is decoded strictly one function up. Lossy
3447 // decoding writes a three-byte replacement character per invalid subpart, so a
3448 // guest that fills its 64 KiB stderr budget with invalid bytes materialises
3449 // 192 KiB beside the still-live buffer, all to keep 512 characters of it. The
3450 // manifest footprint budgets the buffer, not that expansion, so it was 128 KiB
3451 // per concurrent request outside the ceiling. Streaming keeps the peak at the
3452 // excerpt itself.
3453 let mut chars = lossy_chars(&self.stderr).skip_while(|ch| ch.is_whitespace());
3454 let mut kept: String = chars.by_ref().take(STDERR_EXCERPT).collect();
3455 let cut = chars.next().is_some();
3456 if !cut {
3457 // The window reached the end of the buffer, so trailing whitespace
3458 // in it is the buffer's own and `trim` would have taken it. When
3459 // more bytes follow, it is interior whitespace and `trim` would
3460 // not have — truncating after the trim is what the two cases
3461 // distinguish.
3462 while kept.ends_with(char::is_whitespace) {
3463 kept.pop();
3464 }
3465 }
3466 // `guest_text` marks its own truncation, but only on meeting a 513th
3467 // character — and this hands it 512 at most, so the marker could never
3468 // fire here and a cut excerpt read as though the guest had stopped
3469 // there. The suffix is usually the interesting part of a failure, so
3470 // saying it was cut matters more than the characters the marker costs.
3471 // Appended after escaping: inside `kept` it would be escaped as guest
3472 // text, and would count against the bound it is reporting.
3473 let mut excerpt = guest_text(&kept);
3474 if cut {
3475 excerpt.push_str(TRUNCATION_MARKER);
3476 }
3477 excerpt
3478 }
3479
3480 /// `SplitMix64`: tiny, deterministic, and good enough for a shim whose
3481 /// entire job is to be reproducible.
3482 const fn next_random_byte(&mut self) -> u8 {
3483 self.random_state = self.random_state.wrapping_add(0x9E37_79B9_7F4A_7C15);
3484 let mut z = self.random_state;
3485 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
3486 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
3487 #[allow(
3488 clippy::cast_possible_truncation,
3489 reason = "taking the low byte of the mixed state is the point"
3490 )]
3491 {
3492 (z ^ (z >> 31)) as u8
3493 }
3494 }
3495
3496 /// Pop up to `len` bytes of the pending request frame.
3497 /// Hand the guest up to `len` bytes of its request frame.
3498 ///
3499 /// Bounded per call at `HOST_IO_CHUNK_BYTES`, the same scratch ceiling
3500 /// `fd_write` and `random_get` hold themselves to and the one
3501 /// [`FIXED_HOST_BUFFER_BYTES`] budgets. The length is the guest's to choose
3502 /// — it is an iovec — and the copy comes out of a queue that keeps its
3503 /// whole allocation as it drains, so one iovec spanning the frame put a
3504 /// second copy of it beside the first. A frame is the request's metadata
3505 /// and a base64 body with JSON escaping over both, which makes that copy
3506 /// several times the raw request and none of it was in the footprint
3507 /// `max_concurrency` is validated against.
3508 ///
3509 /// Returning less than was asked for is what this call already promises:
3510 /// the caller breaks on a short chunk and reports the count, and the queue
3511 /// running dry short-reads today, so a guest that does not loop is already
3512 /// broken.
3513 fn take_stdin(&mut self, len: usize) -> Vec<u8> {
3514 let len = len.min(HOST_IO_CHUNK_BYTES);
3515 // The queue is the request frame followed by whatever capability
3516 // replies have been appended, drained front to back — so bytes come out
3517 // of the request frame until it is exhausted and only then out of the
3518 // replies. Computing the boundary before the drain is what lets the
3519 // reply budget be given back exactly as it is consumed.
3520 let head = self.stdin.len().saturating_sub(self.queued_replies);
3521 let mut out = Vec::with_capacity(len.min(self.stdin.len()));
3522 while out.len() < len {
3523 match self.stdin.pop_front() {
3524 Some(byte) => out.push(byte),
3525 None => break,
3526 }
3527 }
3528 let from_replies = out.len().saturating_sub(head);
3529 self.queued_replies = self.queued_replies.saturating_sub(from_replies);
3530 out
3531 }
3532}
3533
3534// ── The WASI shim ────────────────────────────────────────────────────────
3535
3536type Shim = Linker<HostState>;
3537
3538fn memory_of(caller: &Caller<'_, HostState>) -> Option<wasmi::Memory> {
3539 caller
3540 .get_export("memory")
3541 .and_then(wasmi::Extern::into_memory)
3542}
3543
3544fn read_u32(caller: &Caller<'_, HostState>, memory: wasmi::Memory, at: usize) -> Option<u32> {
3545 let mut buffer = [0u8; 4];
3546 memory.read(caller, at, &mut buffer).ok()?;
3547 Some(u32::from_le_bytes(buffer))
3548}
3549
3550fn write_u32(
3551 caller: &mut Caller<'_, HostState>,
3552 memory: wasmi::Memory,
3553 at: usize,
3554 value: u32,
3555) -> Option<()> {
3556 memory.write(caller, at, &value.to_le_bytes()).ok()
3557}
3558
3559/// Read one `iovec` at `index` of the array starting at `base`.
3560fn iovec(
3561 caller: &Caller<'_, HostState>,
3562 memory: wasmi::Memory,
3563 base: i32,
3564 index: i32,
3565) -> Option<(usize, usize)> {
3566 let base = usize::try_from(base).ok()?;
3567 let offset = usize::try_from(index).ok()?.checked_mul(IOVEC_SIZE)?;
3568 let at = base.checked_add(offset)?;
3569 let pointer = read_u32(caller, memory, at)?;
3570 let length = read_u32(caller, memory, at.checked_add(4)?)?;
3571 Some((
3572 usize::try_from(pointer).ok()?,
3573 usize::try_from(length).ok()?,
3574 ))
3575}
3576
3577/// The WASI functions the shim implements itself, rather than refusing.
3578///
3579/// Name, parameter signature, and result signature — `i` for `i32`, `l` for
3580/// `i64`, empty for none. The signatures are here so a module can be
3581/// **type**-checked at load and not merely name-checked: an import whose type
3582/// disagrees with the shim links nowhere, and finding that out per request (as
3583/// a 502 the operator cannot explain) instead of at `autumn plugin package` is
3584/// exactly the failure this lane exists to move earlier.
3585const SERVED_IMPORTS: &[(&str, &str, &str)] = &[
3586 ("args_get", "ii", "i"),
3587 ("args_sizes_get", "ii", "i"),
3588 ("clock_res_get", "ii", "i"),
3589 ("clock_time_get", "ili", "i"),
3590 ("environ_get", "ii", "i"),
3591 ("environ_sizes_get", "ii", "i"),
3592 ("fd_close", "i", "i"),
3593 ("fd_fdstat_get", "ii", "i"),
3594 ("fd_read", "iiii", "i"),
3595 ("fd_seek", "ilii", "i"),
3596 ("fd_tell", "ii", "i"),
3597 ("fd_write", "iiii", "i"),
3598 ("proc_exit", "i", ""),
3599 ("random_get", "ii", "i"),
3600 ("sched_yield", "", "i"),
3601];
3602
3603/// The WASI functions the shim answers with a refusal: name, the capability
3604/// class it belongs to, the detail an operator reads, and its signature.
3605///
3606/// The signature is `i` for `i32` and `l` for `i64`, in WASI order, and every
3607/// one of these returns an `errno`. `wasmi` matches host functions by
3608/// signature, so a wrong descriptor here would make an honest guest fail to
3609/// instantiate rather than fail to escape — which is why
3610/// `every_refusal_stub_matches_the_wasi_signature_it_stands_in_for` builds a
3611/// module importing all of them and proves they link.
3612const DENIED_IMPORTS: &[(&str, DeniedCapability, &str, &str)] = &[
3613 // There is no filesystem.
3614 ("fd_advise", DeniedCapability::Filesystem, FS_DETAIL, "illi"),
3615 (
3616 "fd_allocate",
3617 DeniedCapability::Filesystem,
3618 FS_DETAIL,
3619 "ill",
3620 ),
3621 ("fd_datasync", DeniedCapability::Filesystem, FS_DETAIL, "i"),
3622 (
3623 "fd_fdstat_set_flags",
3624 DeniedCapability::Filesystem,
3625 FS_DETAIL,
3626 "ii",
3627 ),
3628 (
3629 "fd_fdstat_set_rights",
3630 DeniedCapability::Filesystem,
3631 FS_DETAIL,
3632 "ill",
3633 ),
3634 (
3635 "fd_filestat_get",
3636 DeniedCapability::Filesystem,
3637 FS_DETAIL,
3638 "ii",
3639 ),
3640 (
3641 "fd_filestat_set_size",
3642 DeniedCapability::Filesystem,
3643 FS_DETAIL,
3644 "il",
3645 ),
3646 (
3647 "fd_filestat_set_times",
3648 DeniedCapability::Filesystem,
3649 FS_DETAIL,
3650 "illi",
3651 ),
3652 ("fd_pread", DeniedCapability::Filesystem, FS_DETAIL, "iiili"),
3653 (
3654 "fd_prestat_dir_name",
3655 DeniedCapability::Filesystem,
3656 FS_DETAIL,
3657 "iii",
3658 ),
3659 (
3660 "fd_prestat_get",
3661 DeniedCapability::Filesystem,
3662 FS_DETAIL,
3663 "ii",
3664 ),
3665 (
3666 "fd_pwrite",
3667 DeniedCapability::Filesystem,
3668 FS_DETAIL,
3669 "iiili",
3670 ),
3671 (
3672 "fd_readdir",
3673 DeniedCapability::Filesystem,
3674 FS_DETAIL,
3675 "iiili",
3676 ),
3677 ("fd_renumber", DeniedCapability::Filesystem, FS_DETAIL, "ii"),
3678 ("fd_sync", DeniedCapability::Filesystem, FS_DETAIL, "i"),
3679 (
3680 "path_create_directory",
3681 DeniedCapability::Filesystem,
3682 FS_DETAIL,
3683 "iii",
3684 ),
3685 (
3686 "path_filestat_get",
3687 DeniedCapability::Filesystem,
3688 FS_DETAIL,
3689 "iiiii",
3690 ),
3691 (
3692 "path_filestat_set_times",
3693 DeniedCapability::Filesystem,
3694 FS_DETAIL,
3695 "iiiilli",
3696 ),
3697 (
3698 "path_link",
3699 DeniedCapability::Filesystem,
3700 FS_DETAIL,
3701 "iiiiiii",
3702 ),
3703 (
3704 "path_open",
3705 DeniedCapability::Filesystem,
3706 FS_DETAIL,
3707 "iiiiillii",
3708 ),
3709 (
3710 "path_readlink",
3711 DeniedCapability::Filesystem,
3712 FS_DETAIL,
3713 "iiiiii",
3714 ),
3715 (
3716 "path_remove_directory",
3717 DeniedCapability::Filesystem,
3718 FS_DETAIL,
3719 "iii",
3720 ),
3721 (
3722 "path_rename",
3723 DeniedCapability::Filesystem,
3724 FS_DETAIL,
3725 "iiiiii",
3726 ),
3727 (
3728 "path_symlink",
3729 DeniedCapability::Filesystem,
3730 FS_DETAIL,
3731 "iiiii",
3732 ),
3733 (
3734 "path_unlink_file",
3735 DeniedCapability::Filesystem,
3736 FS_DETAIL,
3737 "iii",
3738 ),
3739 // There is no network.
3740 ("sock_accept", DeniedCapability::Network, NET_DETAIL, "iii"),
3741 ("sock_recv", DeniedCapability::Network, NET_DETAIL, "iiiiii"),
3742 ("sock_send", DeniedCapability::Network, NET_DETAIL, "iiiii"),
3743 ("sock_shutdown", DeniedCapability::Network, NET_DETAIL, "ii"),
3744 // The host process is not the guest's to steer.
3745 (
3746 "poll_oneoff",
3747 DeniedCapability::ProcessControl,
3748 "a sandboxed plugin may not block the host on external events",
3749 "iiii",
3750 ),
3751 (
3752 "proc_raise",
3753 DeniedCapability::ProcessControl,
3754 "a sandboxed plugin may not signal the host process",
3755 "i",
3756 ),
3757];
3758
3759const FS_DETAIL: &str = "a sandboxed plugin has no filesystem";
3760const NET_DETAIL: &str = "a sandboxed plugin has no outbound network";
3761
3762/// The signature the shim defines for a WASI function of this name, if any.
3763///
3764/// The load-time gate and the shim read the same two tables, so an import that
3765/// links at runtime is exactly one the gate admits — they cannot drift apart.
3766fn shim_signature(name: &str) -> Option<(&'static str, &'static str)> {
3767 if let Some((_, params, results)) = SERVED_IMPORTS.iter().find(|(known, ..)| *known == name) {
3768 return Some((params, results));
3769 }
3770 DENIED_IMPORTS
3771 .iter()
3772 .find(|(known, ..)| *known == name)
3773 .map(|(_, _, _, params)| (*params, "i"))
3774}
3775
3776/// Whether the shim defines a WASI function of this name.
3777///
3778/// Test-only: the load gate needs the *signature*, not just the name, so it
3779/// calls [`shim_signature`] directly. This stays as the shape the
3780/// `the_load_gate_admits_exactly_what_the_shim_defines` invariant is written
3781/// against.
3782#[cfg(test)]
3783fn is_shim_function(name: &str) -> bool {
3784 shim_signature(name).is_some()
3785}
3786
3787/// Build a [`wasmi::FuncType`] from a signature descriptor pair.
3788fn signature_type(params: &str, results: &str) -> Option<wasmi::FuncType> {
3789 fn types(descriptor: &str) -> Option<Vec<wasmi::core::ValType>> {
3790 descriptor
3791 .chars()
3792 .map(|ch| match ch {
3793 'i' => Some(wasmi::core::ValType::I32),
3794 'l' => Some(wasmi::core::ValType::I64),
3795 _ => None,
3796 })
3797 .collect()
3798 }
3799 Some(wasmi::FuncType::new(types(params)?, types(results)?))
3800}
3801
3802/// What one instantiation costs in fuel: the bytes copied, plus a unit per
3803/// segment for the bounds check and copy set-up each one needs regardless of
3804/// its length, plus a unit per import.
3805///
3806/// Imports are counted for the same reason segments are: resolving one is
3807/// per-instance work the guest does not execute but every request pays for, so
3808/// leaving it unpriced is a way to buy host CPU with no fuel. `MAX_IMPORTS`
3809/// bounds the count; this makes the admitted ones cost something.
3810fn instantiation_fuel(
3811 segments: usize,
3812 init_bytes: usize,
3813 imports: usize,
3814 globals: usize,
3815 initial_memory_bytes: u64,
3816 table_elements: u64,
3817) -> u64 {
3818 let bytes = u64::try_from(init_bytes).unwrap_or(u64::MAX);
3819 let segments = u64::try_from(segments).unwrap_or(u64::MAX);
3820 let imports = u64::try_from(imports).unwrap_or(u64::MAX);
3821 let globals = u64::try_from(globals).unwrap_or(u64::MAX);
3822 // The instance's linear memory, at the same rate as every other host-side byte. A
3823 // module can declare its initial size and no data segments at all, so the
3824 // init-section terms above price none of it — yet the host allocates and zero-fills
3825 // the whole thing on every request, before the guest runs an instruction. Near the
3826 // manifest's ceiling that is hundreds of megabytes of memset a client can ask for
3827 // repeatedly, for one fuel unit: the same "buy host CPU with no fuel" the copying
3828 // charge exists to stop. The limiter bounds how much memory; only this bounds how
3829 // often it can be paid for.
3830 let memory = initial_memory_bytes
3831 .checked_div(BYTES_PER_FUEL)
3832 .unwrap_or(u64::MAX);
3833 // The table's initial entries, for exactly the reason the memory term
3834 // above exists — and the sibling that term did not cover. A module can
3835 // declare a table's minimum with no element segments at all, so `segments`
3836 // and `init_bytes` price none of it, and wasmi still allocates and
3837 // initialises every slot on every instantiation before the guest runs.
3838 // Charged per entry rather than per byte, like `segments`, `imports` and
3839 // `globals` beside it: what the host pays here is a slot initialised, not a
3840 // block copied.
3841 bytes
3842 .checked_div(BYTES_PER_FUEL)
3843 .unwrap_or(u64::MAX)
3844 .saturating_add(memory)
3845 .saturating_add(table_elements)
3846 .saturating_add(segments)
3847 .saturating_add(imports)
3848 .saturating_add(globals)
3849 .saturating_add(1)
3850}
3851
3852/// Whether `result` comes from a call that reached its backend.
3853///
3854/// A denial that comes before dispatch did not. A backend error may have: an
3855/// outbound request can fail after the upstream got it.
3856const fn reached_backend(result: &super::capability::CallResult) -> bool {
3857 !matches!(
3858 result.denial(),
3859 Some(
3860 super::capability::DenialReason::CapabilityNotGranted
3861 | super::capability::DenialReason::NotInGrant
3862 | super::capability::DenialReason::QuotaExceeded
3863 | super::capability::DenialReason::Malformed
3864 | super::capability::DenialReason::Unavailable
3865 )
3866 )
3867}
3868
3869/// Charge the guest's fuel budget for `bytes` of host-side work.
3870///
3871/// Returns the `OutOfFuel` trap when the budget cannot cover it, so a guest
3872/// that tries to buy unbounded copying ends exactly as a guest that spins does:
3873/// [`SandboxFailure::FuelExhausted`], and a 504 on its own prefix.
3874/// Charge fuel for host work priced per byte *copied*.
3875///
3876/// [`BYTES_PER_FUEL`] is a bulk-copy rate: a memcpy moves many bytes for what
3877/// one instruction costs, so 64 of them to a unit is honest. It is the wrong
3878/// rate for work that computes each byte — see [`charge_units`].
3879fn charge_bytes(caller: &mut Caller<'_, HostState>, bytes: usize) -> Result<(), wasmi::Error> {
3880 let units = u64::try_from(bytes)
3881 .unwrap_or(u64::MAX)
3882 .checked_div(BYTES_PER_FUEL)
3883 .unwrap_or(u64::MAX)
3884 .saturating_add(1);
3885 charge_units(caller, units)
3886}
3887
3888/// Charge fuel directly, for host work a byte count does not describe.
3889fn charge_units(caller: &mut Caller<'_, HostState>, units: u64) -> Result<(), wasmi::Error> {
3890 let left = caller.get_fuel()?;
3891 let remaining = left
3892 .checked_sub(units)
3893 .ok_or_else(|| wasmi::Error::from(wasmi::core::TrapCode::OutOfFuel))?;
3894 caller.set_fuel(remaining)
3895}
3896
3897/// Charge `units`, taking the budget to zero rather than trapping when it does
3898/// not cover them.
3899///
3900/// For the one charge that necessarily *follows* a committed side effect: a
3901/// reply can only be measured once the call has been answered, and by then a KV
3902/// write, a job enqueue or an outbound POST has happened. Trapping there ends
3903/// the request over work already done — and a retry does that work again, which
3904/// for a POST is the difference between one order and two.
3905///
3906/// Taking the budget to zero instead is bounded and terminal in the same
3907/// breath: the overshoot is one reply's worth, the next dispatch cannot start
3908/// because [`CAPABILITY_CALL_FUEL`] is charged before it with [`charge_units`],
3909/// which does trap, and the guest's own instructions run out on the next one.
3910/// The request still ends here — it ends after the effect is accounted for
3911/// rather than after it is disowned.
3912fn charge_units_saturating(
3913 caller: &mut Caller<'_, HostState>,
3914 units: u64,
3915) -> Result<(), wasmi::Error> {
3916 let left = caller.get_fuel()?;
3917 caller.set_fuel(left.saturating_sub(units))
3918}
3919
3920/// Register the guest-visible WASI surface.
3921///
3922/// One registration per import, all in one place, because this list **is** the
3923/// sandbox. Read it top to bottom and you have read every capability a
3924/// sandboxed plugin holds.
3925#[allow(
3926 clippy::too_many_lines,
3927 reason = "splitting this up would hide the fact that this list IS the sandbox"
3928)]
3929fn define_wasi_shim(linker: &mut Shim) -> Result<(), SandboxLoadError> {
3930 fn engine_error(err: impl fmt::Display) -> SandboxLoadError {
3931 SandboxLoadError::Engine(err.to_string())
3932 }
3933
3934 // ── the dialogue ─────────────────────────────────────────────────
3935
3936 linker
3937 .func_wrap(
3938 WASI,
3939 "fd_read",
3940 |mut caller: Caller<'_, HostState>, fd: i32, iovs: i32, iovs_len: i32, nread: i32| {
3941 if fd != 0 {
3942 // The guest was given one descriptor. Anything else is a
3943 // reach for a file it was never handed.
3944 caller.data_mut().deny(
3945 DeniedCapability::Filesystem,
3946 "fd_read",
3947 "a sandboxed plugin has no descriptors beyond the request dialogue",
3948 );
3949 return Ok(errno::BADF);
3950 }
3951 let Some(memory) = memory_of(&caller) else {
3952 return Ok(errno::INVAL);
3953 };
3954 if iovs_len > MAX_IOVECS {
3955 return Ok(errno::INVAL);
3956 }
3957
3958 let mut total: u32 = 0;
3959 for index in 0..iovs_len {
3960 let Some((pointer, length)) = iovec(&caller, memory, iovs, index) else {
3961 return Ok(errno::INVAL);
3962 };
3963 // Bounds-check BEFORE consuming: `take_stdin` pops the bytes,
3964 // and a write that then fails would have destroyed part of
3965 // the request frame with no way for the guest to recover it.
3966 let in_bounds = pointer
3967 .checked_add(length)
3968 .is_some_and(|end| end <= memory.data_size(&caller));
3969 if !in_bounds {
3970 return Ok(errno::INVAL);
3971 }
3972 // Charged for what is actually copied, not for what was
3973 // asked: `take_stdin` bounds the copy at
3974 // `HOST_IO_CHUNK_BYTES` and by what is left, so pricing the
3975 // whole iovec would bill the guest for bytes it did not get.
3976 // The queue's remaining length belongs in the minimum for the
3977 // same reason the chunk ceiling does — a guest reading the
3978 // tail of its frame through a large buffer would otherwise pay
3979 // a full chunk for a few bytes, and a tight but sufficient
3980 // budget could fail on it.
3981 let take = HOST_IO_CHUNK_BYTES
3982 .min(length)
3983 .min(caller.data().stdin.len());
3984 charge_bytes(&mut caller, take)?;
3985 let chunk = caller.data_mut().take_stdin(take);
3986 if memory.write(&mut caller, pointer, &chunk).is_err() {
3987 return Ok(errno::INVAL);
3988 }
3989 let Ok(read) = u32::try_from(chunk.len()) else {
3990 return Ok(errno::INVAL);
3991 };
3992 let Some(sum) = total.checked_add(read) else {
3993 return Ok(errno::INVAL);
3994 };
3995 total = sum;
3996 if chunk.len() < length {
3997 break;
3998 }
3999 }
4000
4001 let Ok(at) = usize::try_from(nread) else {
4002 return Ok(errno::INVAL);
4003 };
4004 if write_u32(&mut caller, memory, at, total).is_none() {
4005 return Ok(errno::INVAL);
4006 }
4007 Ok(errno::SUCCESS)
4008 },
4009 )
4010 .map_err(engine_error)?;
4011
4012 linker
4013 .func_wrap(
4014 WASI,
4015 "fd_write",
4016 |mut caller: Caller<'_, HostState>,
4017 fd: i32,
4018 iovs: i32,
4019 iovs_len: i32,
4020 nwritten: i32|
4021 -> Result<i32, wasmi::Error> {
4022 if fd != 1 && fd != 2 {
4023 caller.data_mut().deny(
4024 DeniedCapability::Filesystem,
4025 "fd_write",
4026 "a sandboxed plugin may only write its response frame and diagnostics",
4027 );
4028 return Ok(errno::BADF);
4029 }
4030 let Some(memory) = memory_of(&caller) else {
4031 return Ok(errno::INVAL);
4032 };
4033 if iovs_len > MAX_IOVECS {
4034 return Ok(errno::INVAL);
4035 }
4036
4037 let mut total: u32 = 0;
4038 for index in 0..iovs_len {
4039 // Per descriptor inspected, not per byte copied. Reading an
4040 // iovec out of guest memory and bounds-checking it is host
4041 // work whatever the length says, and the only other charge in
4042 // this function lives inside `while offset < length`, which a
4043 // zero-length iovec skips and which breaks once stderr is past
4044 // its budget. Sixty-four free descriptor walks per imported
4045 // call, against a budget that limits only how many calls, is
4046 // host CPU on a blocking worker no ceiling sees.
4047 //
4048 // `charge_units` rather than `charge_bytes`, for the reason
4049 // `random_get` uses it: this is per-item work, not a bulk move.
4050 // `fd_read` needs no such line — its `charge_bytes` sits
4051 // unconditionally in the loop body and floors at one unit, so a
4052 // drained queue already costs a descriptor's worth of fuel.
4053 charge_units(&mut caller, 1)?;
4054 let Some((pointer, length)) = iovec(&caller, memory, iovs, index) else {
4055 return Ok(errno::INVAL);
4056 };
4057 // Bounds-check BEFORE any copy: `length` is guest-chosen,
4058 // and a `u32::MAX` iovec must fail rather than start a copy
4059 // the host then has to abandon.
4060 let in_bounds = pointer
4061 .checked_add(length)
4062 .is_some_and(|end| end <= memory.data_size(&caller));
4063 if !in_bounds {
4064 return Ok(errno::INVAL);
4065 }
4066 let Ok(written) = u32::try_from(length) else {
4067 return Ok(errno::INVAL);
4068 };
4069 let Some(sum) = total.checked_add(written) else {
4070 return Ok(errno::INVAL);
4071 };
4072 total = sum;
4073
4074 // Copy through a bounded scratch buffer: an in-bounds iovec
4075 // can span the guest's whole memory, and the host must never
4076 // mirror it in one allocation.
4077 let mut offset = 0usize;
4078 while offset < length {
4079 // Stderr past its budget is discarded, so copying it is
4080 // pure host work with no output. Stop rather than
4081 // faithfully copying bytes into the bin.
4082 if fd == 2 && caller.data().stderr_is_full() {
4083 break;
4084 }
4085 let take = HOST_IO_CHUNK_BYTES.min(length.saturating_sub(offset));
4086 charge_bytes(&mut caller, take)?;
4087 if fd == 1 {
4088 // The line parser inspects each stdout byte, so
4089 // add the per-byte rate to the copy rate. The host
4090 // may charge for bytes it never parses. This is safe.
4091 charge_units(&mut caller, u64::try_from(take).unwrap_or(u64::MAX))?;
4092 }
4093 let mut scratch = vec![0u8; take];
4094 let at = pointer.saturating_add(offset);
4095 if memory.read(&caller, at, &mut scratch).is_err() {
4096 return Ok(errno::INVAL);
4097 }
4098 if fd == 1 {
4099 let accepted = caller.data_mut().write_stdout(&scratch);
4100 // Fixed charge, dispatch, byte charge — in that
4101 // order, once per call the chunk completed. The
4102 // fixed charge is taken *before* the call reaches a
4103 // backend, so a budget too small to pay for it
4104 // cannot leave a KV write, a job enqueue or an
4105 // outbound POST behind on a request that then ends
4106 // as `FuelExhausted`. A trap here leaves the rest of
4107 // the queue undispatched, which is the same
4108 // property one level up.
4109 //
4110 // The loop stops on a terminal answer as well as on
4111 // an empty queue. One chunk may carry several call
4112 // frames, and servicing an early one can end the
4113 // request — a reply the guest never read pushing the
4114 // queue over `MAX_QUEUED_REPLY_BYTES`, or the host
4115 // failing to encode its own answer. Draining the
4116 // rest afterwards would perform a job enqueue, a DB
4117 // write or an outbound POST on a request that is
4118 // already going to fail, which is the property the
4119 // charge-before-dispatch order exists to establish.
4120 //
4121 // And only when the write was accepted. A chunk can
4122 // carry a complete call frame followed by a line that
4123 // overruns the stdout budget: `write_stdout` parses
4124 // and queues the first, then refuses at the second and
4125 // answers `false`. Servicing the queue anyway would
4126 // commit that call's side effect on a request the
4127 // `!accepted` branch below is about to end as
4128 // `OutputBudget` — the guest is told the write failed
4129 // and may retry it, so the enqueue or the POST happens
4130 // twice. It is the same property the
4131 // charge-before-dispatch order establishes for fuel,
4132 // reached through the output ceiling instead.
4133 if accepted {
4134 while caller.data().has_pending_call()
4135 && caller.data().answer.is_none()
4136 {
4137 charge_units(&mut caller, CAPABILITY_CALL_FUEL)?;
4138 let fuel_left = caller.get_fuel()?;
4139 let _ = caller.data_mut().service_next(fuel_left);
4140 // Saturating, because this charge follows a
4141 // call that has already run: see
4142 // `charge_units_saturating`. The pre-charge
4143 // above is the trapping one, and it is what
4144 // stops the next call.
4145 let owed = caller.data_mut().take_pending_charge();
4146 charge_units_saturating(&mut caller, owed)?;
4147 }
4148 }
4149 // Charged whether the write was accepted or not, and
4150 // drained before the branch below, so no exit from
4151 // this arm can leave fuel owed.
4152 let owed = caller.data_mut().take_pending_charge();
4153 charge_units(&mut caller, owed)?;
4154 if !accepted {
4155 // The response ceiling is a hard stop, not an
4156 // errno the guest can ignore and retry: trap so
4157 // the request ends here.
4158 return Err(wasmi::Error::host(OutputBudgetExhausted));
4159 }
4160 if caller.data().answer.is_some() {
4161 // The exchange is over. A guest that keeps
4162 // running would hold a permit and a blocking
4163 // worker for its whole budget and then serve the
4164 // answer it already had.
4165 return Err(wasmi::Error::host(AnswerComplete));
4166 }
4167 } else {
4168 caller.data_mut().write_stderr(&scratch);
4169 }
4170 offset = offset.saturating_add(take);
4171 }
4172 }
4173
4174 let Ok(at) = usize::try_from(nwritten) else {
4175 return Ok(errno::INVAL);
4176 };
4177 if write_u32(&mut caller, memory, at, total).is_none() {
4178 return Ok(errno::INVAL);
4179 }
4180 Ok(errno::SUCCESS)
4181 },
4182 )
4183 .map_err(engine_error)?;
4184
4185 // ── inert, because a guest's runtime expects them to exist ────────
4186
4187 linker
4188 .func_wrap(WASI, "fd_close", |_: Caller<'_, HostState>, fd: i32| {
4189 if (0..=2).contains(&fd) {
4190 errno::SUCCESS
4191 } else {
4192 errno::BADF
4193 }
4194 })
4195 .map_err(engine_error)?;
4196 linker
4197 .func_wrap(
4198 WASI,
4199 "fd_seek",
4200 |mut caller: Caller<'_, HostState>, fd: i32, _offset: i64, _whence: i32, _out: i32| {
4201 // Unknown descriptor: refuse and record, like `fd_read`.
4202 if !(0..=2).contains(&fd) {
4203 caller.data_mut().deny(
4204 DeniedCapability::Filesystem,
4205 "fd_seek",
4206 "a sandboxed plugin has no descriptors beyond the request dialogue",
4207 );
4208 return errno::BADF;
4209 }
4210 // stdio is a pipe. Saying so is more useful than saying no.
4211 errno::SPIPE
4212 },
4213 )
4214 .map_err(engine_error)?;
4215 linker
4216 .func_wrap(
4217 WASI,
4218 "fd_tell",
4219 |_: Caller<'_, HostState>, _fd: i32, _out: i32| errno::SPIPE,
4220 )
4221 .map_err(engine_error)?;
4222 linker
4223 .func_wrap(
4224 WASI,
4225 "fd_fdstat_get",
4226 |mut caller: Caller<'_, HostState>, fd: i32, out: i32| {
4227 if !(0..=2).contains(&fd) {
4228 caller.data_mut().deny(
4229 DeniedCapability::Filesystem,
4230 "fd_fdstat_get",
4231 "a sandboxed plugin has no descriptors beyond the request dialogue",
4232 );
4233 return errno::BADF;
4234 }
4235 let Some(memory) = memory_of(&caller) else {
4236 return errno::INVAL;
4237 };
4238 let Ok(at) = usize::try_from(out) else {
4239 return errno::INVAL;
4240 };
4241 // filetype 2 = character device, no rights, no flags.
4242 let mut stat = [0u8; FDSTAT_SIZE];
4243 stat[0] = 2;
4244 if memory.write(&mut caller, at, &stat).is_err() {
4245 return errno::INVAL;
4246 }
4247 errno::SUCCESS
4248 },
4249 )
4250 .map_err(engine_error)?;
4251 linker
4252 .func_wrap(WASI, "sched_yield", |_: Caller<'_, HostState>| {
4253 errno::SUCCESS
4254 })
4255 .map_err(engine_error)?;
4256
4257 // ── time and entropy: fixed, not ambient ─────────────────────────
4258
4259 linker
4260 .func_wrap(
4261 WASI,
4262 "clock_time_get",
4263 |mut caller: Caller<'_, HostState>, _id: i32, _precision: i64, out: i32| {
4264 let Some(memory) = memory_of(&caller) else {
4265 return errno::INVAL;
4266 };
4267 let Ok(at) = usize::try_from(out) else {
4268 return errno::INVAL;
4269 };
4270 // A fixed instant. The host's wall clock is not a capability a
4271 // plugin was granted, and a plugin that is a function of its
4272 // request is one an author can reason about.
4273 if memory.write(&mut caller, at, &0u64.to_le_bytes()).is_err() {
4274 return errno::INVAL;
4275 }
4276 errno::SUCCESS
4277 },
4278 )
4279 .map_err(engine_error)?;
4280 linker
4281 .func_wrap(
4282 WASI,
4283 "clock_res_get",
4284 |mut caller: Caller<'_, HostState>, _id: i32, out: i32| {
4285 let Some(memory) = memory_of(&caller) else {
4286 return errno::INVAL;
4287 };
4288 let Ok(at) = usize::try_from(out) else {
4289 return errno::INVAL;
4290 };
4291 if memory.write(&mut caller, at, &1u64.to_le_bytes()).is_err() {
4292 return errno::INVAL;
4293 }
4294 errno::SUCCESS
4295 },
4296 )
4297 .map_err(engine_error)?;
4298 linker
4299 .func_wrap(
4300 WASI,
4301 "random_get",
4302 |mut caller: Caller<'_, HostState>, buf: i32, len: i32| -> Result<i32, wasmi::Error> {
4303 let Some(memory) = memory_of(&caller) else {
4304 return Ok(errno::INVAL);
4305 };
4306 let (Ok(at), Ok(len)) = (usize::try_from(buf), usize::try_from(len)) else {
4307 return Ok(errno::INVAL);
4308 };
4309 let in_bounds = at
4310 .checked_add(len)
4311 .is_some_and(|end| end <= memory.data_size(&caller));
4312 if !in_bounds {
4313 return Ok(errno::INVAL);
4314 }
4315 let mut written = 0usize;
4316 while written < len {
4317 let take = HOST_IO_CHUNK_BYTES.min(len.saturating_sub(written));
4318 // Priced per byte, not at the bulk-copy rate. Every byte
4319 // here is a whole `SplitMix64` step — an add, two
4320 // multiplies and four shift-XOR pairs — where `fd_write`'s
4321 // byte is a memcpy. Charging 64 of these to one unit let a
4322 // guest buy the mixer's work at a copy's price and hold a
4323 // blocking worker far longer than its declared ceiling
4324 // says it may.
4325 charge_units(&mut caller, u64::try_from(take).unwrap_or(u64::MAX))?;
4326 let mut scratch = vec![0u8; take];
4327 for slot in &mut scratch {
4328 *slot = caller.data_mut().next_random_byte();
4329 }
4330 if memory
4331 .write(&mut caller, at.saturating_add(written), &scratch)
4332 .is_err()
4333 {
4334 return Ok(errno::INVAL);
4335 }
4336 written = written.saturating_add(take);
4337 }
4338 Ok(errno::SUCCESS)
4339 },
4340 )
4341 .map_err(engine_error)?;
4342
4343 // ── the process is not the guest's to steer ──────────────────────
4344
4345 linker
4346 .func_wrap(
4347 WASI,
4348 "proc_exit",
4349 |_: Caller<'_, HostState>, code: i32| -> Result<(), wasmi::Error> {
4350 // Ends the *guest*. The host process is not the guest's to end.
4351 Err(wasmi::Error::i32_exit(code))
4352 },
4353 )
4354 .map_err(engine_error)?;
4355
4356 // ── the environment is empty, and asking is recorded ─────────────
4357
4358 linker
4359 .func_wrap(
4360 WASI,
4361 "environ_sizes_get",
4362 |mut caller: Caller<'_, HostState>, count: i32, size: i32| {
4363 caller.data_mut().deny(
4364 DeniedCapability::Environment,
4365 "environ_sizes_get",
4366 "a sandboxed plugin sees an empty environment",
4367 );
4368 write_two_zeroes(&mut caller, count, size)
4369 },
4370 )
4371 .map_err(engine_error)?;
4372 linker
4373 .func_wrap(
4374 WASI,
4375 "environ_get",
4376 |mut caller: Caller<'_, HostState>, _ptrs: i32, _buf: i32| {
4377 caller.data_mut().deny(
4378 DeniedCapability::Environment,
4379 "environ_get",
4380 "a sandboxed plugin sees an empty environment",
4381 );
4382 // Nothing to write: the environment is empty, so success is the
4383 // truthful answer and needs no bytes.
4384 errno::SUCCESS
4385 },
4386 )
4387 .map_err(engine_error)?;
4388 linker
4389 .func_wrap(
4390 WASI,
4391 "args_sizes_get",
4392 |mut caller: Caller<'_, HostState>, count: i32, size: i32| {
4393 caller.data_mut().deny(
4394 DeniedCapability::Environment,
4395 "args_sizes_get",
4396 "a sandboxed plugin sees no process arguments",
4397 );
4398 write_two_zeroes(&mut caller, count, size)
4399 },
4400 )
4401 .map_err(engine_error)?;
4402 linker
4403 .func_wrap(
4404 WASI,
4405 "args_get",
4406 |mut caller: Caller<'_, HostState>, _ptrs: i32, _buf: i32| {
4407 caller.data_mut().deny(
4408 DeniedCapability::Environment,
4409 "args_get",
4410 "a sandboxed plugin sees no process arguments",
4411 );
4412 errno::SUCCESS
4413 },
4414 )
4415 .map_err(engine_error)?;
4416
4417 // ── everything else is a refusal ─────────────────────────────────
4418
4419 for (name, capability, detail, signature) in DENIED_IMPORTS {
4420 deny(linker, name, *capability, detail, signature)?;
4421 }
4422
4423 Ok(())
4424}
4425
4426/// Write two zero `u32`s — the "empty list" answer `*_sizes_get` needs.
4427fn write_two_zeroes(caller: &mut Caller<'_, HostState>, first: i32, second: i32) -> i32 {
4428 let Some(memory) = memory_of(caller) else {
4429 return errno::INVAL;
4430 };
4431 let (Ok(first), Ok(second)) = (usize::try_from(first), usize::try_from(second)) else {
4432 return errno::INVAL;
4433 };
4434 if write_u32(caller, memory, first, 0).is_none()
4435 || write_u32(caller, memory, second, 0).is_none()
4436 {
4437 return errno::INVAL;
4438 }
4439 errno::SUCCESS
4440}
4441
4442/// Register one refusal stub.
4443///
4444/// `wasmi` matches host functions by signature, so a stub must have exactly the
4445/// shape of the WASI function it stands in for — hence the descriptor rather
4446/// than a hand-written closure per function. The bodies are identical by
4447/// design: there is nothing to implement, only a refusal to record.
4448fn deny(
4449 linker: &mut Shim,
4450 name: &'static str,
4451 capability: DeniedCapability,
4452 detail: &'static str,
4453 signature: &str,
4454) -> Result<(), SandboxLoadError> {
4455 let mut params = Vec::with_capacity(signature.len());
4456 for ch in signature.chars() {
4457 params.push(match ch {
4458 'i' => wasmi::core::ValType::I32,
4459 'l' => wasmi::core::ValType::I64,
4460 other => {
4461 return Err(SandboxLoadError::Engine(format!(
4462 "unknown signature character `{other}` for {name}"
4463 )));
4464 }
4465 });
4466 }
4467 let ty = wasmi::FuncType::new(params, [wasmi::core::ValType::I32]);
4468 linker
4469 .func_new(
4470 WASI,
4471 name,
4472 ty,
4473 move |mut caller: Caller<'_, HostState>, _args: &[wasmi::Val], results| {
4474 caller.data_mut().deny(capability, name, detail);
4475 if let Some(slot) = results.first_mut() {
4476 *slot = wasmi::Val::I32(errno::NOTCAPABLE);
4477 }
4478 Ok(())
4479 },
4480 )
4481 .map_err(|err| SandboxLoadError::Engine(err.to_string()))?;
4482 Ok(())
4483}
4484
4485#[cfg(test)]
4486mod tests {
4487 use super::*;
4488 use crate::plugin_sandbox::manifest::{ResourceLimits, SandboxCapability, SandboxManifest};
4489 use crate::plugin_sandbox::test_guests as guests;
4490
4491 fn manifest_with(limits: ResourceLimits) -> SandboxManifest {
4492 let mut manifest = SandboxManifest::parse(&format!(
4493 r#"
4494name = "autumn-plugin-hello"
4495version = "0.1.0"
4496wire_version = 1
4497prefix = "/hello"
4498capabilities = ["http-request"]
4499sha256 = "{digest}"
4500
4501[[routes]]
4502method = "GET"
4503path = "/hello/greet"
4504"#,
4505 digest = "a".repeat(64)
4506 ))
4507 .expect("valid manifest");
4508 manifest.limits = limits;
4509 manifest
4510 }
4511
4512 /// A bare `HostState` for the shim unit tests: an ordinary request
4513 /// exchange, and a capability runtime with no grants and no backends, which
4514 /// is what a first-slice plugin has.
4515 fn bare_state(limits: ResourceLimits, frame: &[u8]) -> HostState {
4516 HostState::new(
4517 "hello".to_owned(),
4518 limits,
4519 frame,
4520 Exchange::Request,
4521 crate::plugin_sandbox::capability::CapabilityRuntime::new(
4522 &manifest_with(limits),
4523 crate::plugin_sandbox::CapabilityServices::none(),
4524 ),
4525 )
4526 }
4527
4528 fn try_host(wat: &str) -> Result<SandboxHost, SandboxLoadError> {
4529 try_host_with(wat, ResourceLimits::default())
4530 }
4531
4532 fn try_host_with(wat: &str, limits: ResourceLimits) -> Result<SandboxHost, SandboxLoadError> {
4533 let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
4534 SandboxHost::from_module(manifest_with(limits), &wasm)
4535 }
4536
4537 fn host(wat: &str) -> SandboxHost {
4538 try_host(wat).expect("the fixture loads")
4539 }
4540
4541 fn request(method: &str, path: &str) -> SandboxRequest {
4542 SandboxRequest {
4543 method: method.to_owned(),
4544 // The declared pattern and the concrete path agree here: the
4545 // fixtures dispatch on the frame's *content*, so a route that
4546 // never varied would make every test look like a match.
4547 route: path.to_owned(),
4548 path: path.to_owned(),
4549 query: String::new(),
4550 path_params: vec![],
4551 headers: vec![("accept".to_owned(), "text/plain".to_owned())],
4552 body: vec![],
4553 }
4554 }
4555
4556 fn get(path: &str) -> SandboxRequest {
4557 request("GET", path)
4558 }
4559
4560 fn denied(outcome: &SandboxOutcome, capability: DeniedCapability) -> Vec<String> {
4561 outcome
4562 .denials
4563 .iter()
4564 .filter(|denial| denial.capability == capability)
4565 .map(|denial| denial.operation.clone())
4566 .collect()
4567 }
4568
4569 // ── the happy path ───────────────────────────────────────────────
4570
4571 #[test]
4572 fn a_well_behaved_guest_answers_the_request() {
4573 let outcome = host(guests::HELLO).run(&get("/hello/greet"));
4574 let response = outcome.result.expect("answers");
4575 assert_eq!(response.status, 200);
4576 assert_eq!(response.body, b"hello from the sandbox");
4577 assert!(outcome.denials.is_empty(), "{:?}", outcome.denials);
4578 assert!(outcome.fuel_used > 0);
4579 }
4580
4581 #[test]
4582 fn the_guest_sees_the_request_it_was_sent() {
4583 let host = host(guests::HELLO);
4584 assert_eq!(
4585 host.run(&get("/hello/other"))
4586 .result
4587 .expect("answers")
4588 .status,
4589 404
4590 );
4591 assert_eq!(
4592 host.run(&request("POST", "/hello/greet"))
4593 .result
4594 .expect("answers")
4595 .status,
4596 405
4597 );
4598 }
4599
4600 #[test]
4601 fn each_request_gets_a_fresh_instance() {
4602 // Nothing a guest does can survive into the next request, which is what
4603 // makes one request's misbehaviour unable to poison the next.
4604 let host = host(guests::HELLO);
4605 let first = host.run(&get("/hello/greet"));
4606 let second = host.run(&get("/hello/greet"));
4607 assert_eq!(
4608 first.result.expect("answers"),
4609 second.result.expect("answers")
4610 );
4611 assert_eq!(first.fuel_used, second.fuel_used);
4612 }
4613
4614 // ── resource bounds ──────────────────────────────────────────────
4615
4616 #[test]
4617 fn a_guest_that_never_stops_is_stopped() {
4618 let limits = ResourceLimits {
4619 fuel: 5_000_000,
4620 ..ResourceLimits::default()
4621 };
4622 let outcome = try_host_with(guests::CPU_SPIN, limits)
4623 .expect("loads")
4624 .run(&get("/hello/greet"));
4625 let failure = outcome.result.expect_err("must not answer");
4626 assert!(
4627 matches!(failure, SandboxFailure::FuelExhausted { .. }),
4628 "{failure}"
4629 );
4630 assert!(failure.status().is_server_error(), "{failure}");
4631 }
4632
4633 #[test]
4634 fn a_guest_that_allocates_without_bound_is_capped() {
4635 let limits = ResourceLimits {
4636 fuel: 20_000_000,
4637 memory_bytes: 1024 * 1024,
4638 ..ResourceLimits::default()
4639 };
4640 let outcome = try_host_with(guests::MEMORY_BOMB, limits)
4641 .expect("loads")
4642 .run(&get("/hello/greet"));
4643 assert!(outcome.result.is_err(), "a memory bomb must not answer");
4644 assert!(
4645 outcome.peak_memory_bytes <= 1024 * 1024,
4646 "peak {} exceeded the ceiling",
4647 outcome.peak_memory_bytes
4648 );
4649 assert!(
4650 !denied(&outcome, DeniedCapability::Memory).is_empty(),
4651 "the refused growth must be observable: {:?}",
4652 outcome.denials
4653 );
4654 }
4655
4656 #[test]
4657 fn a_guest_that_floods_stdout_without_a_newline_is_cut_off() {
4658 let limits = ResourceLimits {
4659 max_response_bytes: 4096,
4660 fuel: 50_000_000,
4661 ..ResourceLimits::default()
4662 };
4663 let outcome = try_host_with(guests::OUTPUT_FLOOD, limits)
4664 .expect("loads")
4665 .run(&get("/hello/greet"));
4666 let failure = outcome.result.expect_err("must not answer");
4667 assert!(
4668 matches!(failure, SandboxFailure::OutputBudget { .. }),
4669 "{failure}"
4670 );
4671 }
4672
4673 // ── fault isolation ──────────────────────────────────────────────
4674
4675 #[test]
4676 fn host_side_copying_is_charged_against_the_guest_s_fuel() {
4677 // wasmi meters the guest's instructions, not what the host does on its
4678 // behalf. Without a charge, a guest buys gigabytes of memcpy for single
4679 // digits of fuel — a spin inside the host that the CPU ceiling never
4680 // sees. This guest asks the host to copy 1 MiB in ~50 instructions.
4681 const COPIED: u64 = 1024 * 1024;
4682 let bulk = host(guests::STDOUT_BULK).run(&get("/hello/greet"));
4683 assert!(
4684 bulk.fuel_used >= COPIED / 64,
4685 "1 MiB of host-side copying cost only {} fuel units",
4686 bulk.fuel_used
4687 );
4688 // …and the loop itself is nothing, so the charge is the copy.
4689 let honest = host(guests::HELLO).run(&get("/hello/greet"));
4690 assert!(
4691 honest.fuel_used < COPIED / 64,
4692 "the baseline is not a baseline: {} units",
4693 honest.fuel_used
4694 );
4695 }
4696
4697 #[test]
4698 fn walking_an_iovec_vector_is_charged_even_when_it_moves_no_bytes() {
4699 // `fd_write`'s only byte charge lives inside `while offset < length`, which a
4700 // zero-length descriptor skips outright. Reading that descriptor out of guest
4701 // memory and bounds-checking it is host work all the same, and a guest may
4702 // present `MAX_IOVECS` of them per imported call: a budget that prices only the
4703 // calls buys sixty-four times the host CPU it charged for, on a blocking worker
4704 // no ceiling sees. The control is the same module with one descriptor instead
4705 // of sixty-four — identical guest instructions, so the whole gap between them is
4706 // host work priced per descriptor. Only a per-descriptor charge can satisfy this.
4707 const CALLS: u32 = 8192;
4708 const WIDE: u32 = MAX_IOVECS as u32;
4709
4710 let wide = host(&guests::empty_iovecs(WIDE, CALLS)).run(&get("/hello/greet"));
4711 let narrow = host(&guests::empty_iovecs(1, CALLS)).run(&get("/hello/greet"));
4712 assert!(wide.result.is_ok(), "{:?}", wide.result);
4713 assert!(narrow.result.is_ok(), "{:?}", narrow.result);
4714
4715 let extra = wide.fuel_used.saturating_sub(narrow.fuel_used);
4716 // The exact figure is `CALLS * (WIDE - 1)`; halved here so the assertion
4717 // is about the charge existing and scaling, not about wasmi's per
4718 // instruction accounting staying byte-identical between two modules.
4719 let expected = u64::from(CALLS) * u64::from(WIDE - 1) / 2;
4720 assert!(
4721 extra >= expected,
4722 "sixty-four empty descriptors per call cost {extra} more fuel than one, \
4723 short of the {expected} that walking them is worth"
4724 );
4725 }
4726
4727 #[test]
4728 fn parsing_a_stdout_line_is_charged_per_byte_not_at_the_copy_rate() {
4729 // The parser inspects every byte of a stdout line, so whitespace padding
4730 // costs the host one step per byte. The copy charge alone is one unit per
4731 // 64 bytes. The two modules differ in one `i32.const` (the pad length), so
4732 // the whole `fuel_used` gap is host work. Copy only: about 1,025 units.
4733 // Copy plus parse: about 66,561. The bound tells them apart.
4734 // It does not fix either rate.
4735 const PAD: u32 = 65_536;
4736
4737 let padded = host(&guests::padded_frame(PAD)).run(&get("/hello/greet"));
4738 let plain = host(&guests::padded_frame(0)).run(&get("/hello/greet"));
4739 // The padded frame was accepted, so the gap is not an early-exit artifact.
4740 assert!(padded.result.is_ok(), "{:?}", padded.result);
4741 assert!(plain.result.is_ok(), "{:?}", plain.result);
4742
4743 let extra = padded.fuel_used.saturating_sub(plain.fuel_used);
4744 assert!(
4745 extra > u64::from(PAD) / 2,
4746 "{PAD} bytes of padding cost {extra} more fuel than none, \
4747 short of the per-byte price of parsing them"
4748 );
4749 }
4750
4751 #[test]
4752 fn instantiating_the_module_is_priced_against_the_budget() {
4753 // wasmi meters guest instructions, not instantiation: every request
4754 // copies the module's data and element segments before `_start` runs.
4755 // The ceiling on that is at load (below); this is the price, so the work
4756 // that IS admitted still comes out of the declared budget.
4757 let charged = host(guests::HELLO).instantiation_fuel();
4758 assert!(charged > 1, "the fixture carries data segments");
4759
4760 // A budget that cannot even cover instantiation is refused at *load*
4761 // now: the artifact could never answer a request, so it never mounts.
4762 let err = try_host_with(
4763 guests::HELLO,
4764 ResourceLimits {
4765 fuel: charged / 2,
4766 ..ResourceLimits::default()
4767 },
4768 )
4769 .expect_err("a budget below the fixed charge must not produce a host");
4770 assert!(
4771 matches!(err, SandboxLoadError::FuelBelowFixedCharges { .. }),
4772 "{err:?}"
4773 );
4774
4775 // The per-request refusal still matters, and is still reached: the load
4776 // floor compares against instantiation alone, but the frame encoding is
4777 // subtracted *first*, so a budget that clears the floor can still be
4778 // spent before `_start`. That is the case no load-time check can catch,
4779 // and it must still refuse before instantiating rather than after.
4780 let outcome = try_host_with(
4781 guests::HELLO,
4782 ResourceLimits {
4783 fuel: charged + 1,
4784 ..ResourceLimits::default()
4785 },
4786 )
4787 .expect("a budget above the fixed charge loads")
4788 .run(&get("/hello/greet"));
4789 assert!(
4790 matches!(outcome.result, Err(SandboxFailure::FuelExhausted { .. })),
4791 "{:?}",
4792 outcome.result
4793 );
4794
4795 // …and an honest request pays for it out of the same budget.
4796 let outcome = host(guests::HELLO).run(&get("/hello/greet"));
4797 assert!(outcome.result.is_ok());
4798 assert!(
4799 outcome.fuel_used >= charged,
4800 "instantiation cost {} of a {charged}-unit module",
4801 outcome.fuel_used
4802 );
4803 }
4804
4805 #[test]
4806 fn a_frame_that_is_not_utf8_is_refused_rather_than_expanded() {
4807 // `from_utf8_lossy` turns each invalid byte into a three-byte
4808 // replacement while the original is still alive, so a guest filling its
4809 // whole stdout budget with invalid bytes would make the host hold four
4810 // times that budget — memory no manifest accounted for.
4811 let outcome = host(guests::INVALID_UTF8).run(&get("/hello/greet"));
4812 let failure = outcome.result.expect_err("must not answer");
4813 assert!(
4814 matches!(failure, SandboxFailure::MalformedFrame(ref detail) if detail.contains("UTF-8")),
4815 "{failure}"
4816 );
4817 }
4818
4819 #[test]
4820 fn a_module_without_the_memory_the_shim_needs_is_refused_at_load() {
4821 // Every host function reads and writes through an export named
4822 // `memory`. Without one they all answer EINVAL, so the plugin loads and
4823 // then fails every request — which packaging exists to prevent.
4824 let wat = r#"(module (func (export "_start") (nop)))"#;
4825 assert!(matches!(
4826 try_host(wat),
4827 Err(SandboxLoadError::MissingMemory)
4828 ));
4829 }
4830
4831 #[test]
4832 fn a_module_whose_initial_memory_exceeds_the_ceiling_is_refused_at_load() {
4833 // 32 pages = 2 MiB of *initial* memory against a 1 MiB ceiling: the
4834 // limiter would refuse it at instantiation, per request, as a gateway
4835 // error — when it can be said once, here.
4836 let wat = r#"(module (memory (export "memory") 32) (func (export "_start") (nop)))"#;
4837 let limits = ResourceLimits {
4838 memory_bytes: 1024 * 1024,
4839 ..ResourceLimits::default()
4840 };
4841 let err = try_host_with(wat, limits).expect_err("must be refused");
4842 assert!(
4843 matches!(err, SandboxLoadError::MemoryTooLarge { found, .. } if found == 2 * 1024 * 1024),
4844 "{err}"
4845 );
4846 }
4847
4848 #[test]
4849 fn table_growth_past_the_ceiling_is_refused_while_the_guest_runs() {
4850 // A module already over the ceiling at rest is now refused at load,
4851 // summed across its tables. The limiter is still what holds the line
4852 // here, and this is the case it alone can: a guest that starts *under*
4853 // the ceiling and reaches for more with `table.grow` while running,
4854 // which no load-time check can see.
4855 let wat = format!(
4856 r#"(module
4857 (memory (export "memory") 1)
4858 (table 1 funcref)
4859 (func (export "_start")
4860 (drop (table.grow 0 (ref.null func) (i32.const {MAX_TABLE_ELEMENTS})))))"#
4861 );
4862 let outcome = host(&wat).run(&get("/hello/greet"));
4863 assert!(outcome.result.is_err(), "{:?}", outcome.result);
4864 // Refused, and recorded: an operator has to be able to see that the
4865 // plugin reached past its ceiling rather than merely that it 5xx'd.
4866 let operations = denied(&outcome, DeniedCapability::Memory);
4867 assert!(
4868 !operations.is_empty(),
4869 "the refusal was not recorded: {:?}",
4870 outcome.denials
4871 );
4872 // …and recorded as the ceiling it actually hit. Both hooks used to
4873 // share one counter, so this arrived as `memory.grow` over the
4874 // manifest's *byte* ceiling — a limit that was never applied. Vague
4875 // evidence would be a nuisance; wrong evidence sends an operator to
4876 // raise the wrong number in the manifest, which cannot help.
4877 assert!(
4878 operations.iter().any(|op| op == "table.grow"),
4879 "a table refusal was not reported as one: {operations:?}"
4880 );
4881 assert!(
4882 !operations.iter().any(|op| op == "memory.grow"),
4883 "a table refusal was reported against the byte ceiling: {operations:?}"
4884 );
4885 let detail = outcome
4886 .denials
4887 .iter()
4888 .find(|denial| denial.operation == "table.grow")
4889 .map(|denial| denial.detail.clone())
4890 .expect("the table denial is present");
4891 assert!(
4892 detail.contains(&MAX_TABLE_ELEMENTS.to_string()),
4893 "the detail does not name the ceiling that applied: {detail}"
4894 );
4895 }
4896
4897 #[test]
4898 fn a_module_that_is_expensive_to_instantiate_is_refused_at_load() {
4899 // Segment *count* is the sharp edge: each one costs a bounds check and a
4900 // copy set-up regardless of its length, so a module of many empty
4901 // segments is small on disk and expensive on every single request. A
4902 // per-request charge cannot bound work that has already been admitted,
4903 // so the ceiling is at load.
4904 use std::fmt::Write as _;
4905
4906 let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
4907 for offset in 0..=MAX_INIT_SEGMENTS {
4908 let _ = writeln!(wat, " (data (i32.const {offset}) \"x\")");
4909 }
4910 wat.push_str(" (func (export \"_start\") (nop))\n)");
4911
4912 let err = try_host(&wat).expect_err("must be refused");
4913 assert!(
4914 matches!(
4915 err,
4916 SandboxLoadError::InstantiationTooExpensive { max, .. } if max == MAX_INIT_SEGMENTS
4917 ),
4918 "{err}"
4919 );
4920 assert!(err.to_string().contains("re-instantiates"), "{err}");
4921 }
4922
4923 #[test]
4924 fn a_guest_that_floods_stderr_runs_out_of_fuel_rather_than_time() {
4925 let limits = ResourceLimits {
4926 fuel: 2_000_000,
4927 ..ResourceLimits::default()
4928 };
4929 let outcome = try_host_with(guests::STDERR_FLOOD, limits)
4930 .expect("loads")
4931 .run(&get("/hello/greet"));
4932 let failure = outcome.result.expect_err("must not answer");
4933 assert!(
4934 matches!(failure, SandboxFailure::FuelExhausted { .. }),
4935 "{failure}"
4936 );
4937 }
4938
4939 #[test]
4940 fn a_guest_that_answers_and_then_spins_does_not_hold_its_whole_budget() {
4941 // The exchange is over at the frame. A guest that keeps running would
4942 // otherwise hold a permit and a blocking worker for its whole budget
4943 // and then serve the answer it already had.
4944 let limits = ResourceLimits {
4945 fuel: 500_000_000,
4946 ..ResourceLimits::default()
4947 };
4948 let outcome = try_host_with(guests::ANSWER_THEN_SPIN, limits)
4949 .expect("loads")
4950 .run(&get("/hello/greet"));
4951 assert_eq!(outcome.result.as_ref().expect("answers").status, 200);
4952 assert!(
4953 outcome.fuel_used < 1_000_000,
4954 "the answer cost {} fuel; the guest was allowed to keep spinning",
4955 outcome.fuel_used
4956 );
4957 }
4958
4959 #[test]
4960 fn a_frame_without_its_newline_says_what_the_author_did() {
4961 let outcome = host(guests::PARTIAL_FRAME).run(&get("/hello/greet"));
4962 let failure = outcome.result.expect_err("must not answer");
4963 assert!(matches!(failure, SandboxFailure::PartialFrame), "{failure}");
4964 assert!(failure.to_string().contains("println!"), "{failure}");
4965 }
4966
4967 #[test]
4968 fn a_guest_may_not_forge_the_host_s_own_response_headers() {
4969 let outcome = host(guests::FORGE_ATTRIBUTION).run(&get("/hello/greet"));
4970 let denied = denied(&outcome, DeniedCapability::ResponseHeader);
4971 assert!(
4972 denied.contains(&"x-autumn-sandboxed".to_owned()),
4973 "{denied:?}"
4974 );
4975 assert!(
4976 denied.contains(&"x-content-type-options".to_owned()),
4977 "{denied:?}"
4978 );
4979 let response = outcome.result.expect("answers");
4980 assert!(
4981 response
4982 .headers
4983 .iter()
4984 .all(|(name, _)| name.starts_with("content-"))
4985 );
4986 }
4987
4988 #[test]
4989 fn a_document_content_type_is_refused_rather_than_served_from_the_host_s_origin() {
4990 for essence in [
4991 "text/html",
4992 "application/javascript",
4993 "image/svg+xml",
4994 "text/css",
4995 ] {
4996 let response = SandboxResponse {
4997 status: 200,
4998 headers: vec![("content-type".to_owned(), essence.to_owned())],
4999 body: b"<script>".to_vec(),
5000 };
5001 assert_eq!(
5002 response.refused_content_type().as_deref(),
5003 Some(essence),
5004 "{essence} must be refused"
5005 );
5006 }
5007 for essence in ["text/plain; charset=utf-8", "application/json", "image/png"] {
5008 let response = SandboxResponse {
5009 status: 200,
5010 headers: vec![("Content-Type".to_owned(), essence.to_owned())],
5011 body: vec![],
5012 };
5013 assert_eq!(
5014 response.refused_content_type(),
5015 None,
5016 "{essence} must be served"
5017 );
5018 }
5019 }
5020
5021 #[test]
5022 fn entropy_is_deterministic_per_request_without_being_a_published_constant() {
5023 // The guest folds an entropy byte into its status, so this only holds
5024 // if the host's stream is a function of the request.
5025 let host = host(guests::ENTROPY);
5026 let first = host.run(&get("/hello/greet")).result.expect("answers");
5027 let again = host.run(&get("/hello/greet")).result.expect("answers");
5028 assert_eq!(first.status, again.status, "the same request must replay");
5029
5030 let other = host.run(&get("/hello/other")).result.expect("answers");
5031 assert!(
5032 (200..=207).contains(&other.status),
5033 "unexpected status {}",
5034 other.status
5035 );
5036 // Not asserted equal *or* unequal: one byte mod 8 collides one time in
5037 // eight. What matters is that the seed is not a global constant, which
5038 // the seed function's own test below pins.
5039 }
5040
5041 #[test]
5042 fn the_entropy_seed_is_a_function_of_the_request() {
5043 assert_ne!(
5044 HostState::seed_from(b"one request"),
5045 HostState::seed_from(b"another request")
5046 );
5047 assert_eq!(
5048 HostState::seed_from(b"one request"),
5049 HostState::seed_from(b"one request")
5050 );
5051 }
5052
5053 #[test]
5054 fn a_trap_is_an_error_value_not_a_dead_process() {
5055 let outcome = host(guests::TRAP).run(&get("/hello/greet"));
5056 let failure = outcome.result.expect_err("must not answer");
5057 assert!(matches!(failure, SandboxFailure::Trap(_)), "{failure}");
5058 }
5059
5060 #[test]
5061 fn proc_exit_does_not_exit_the_host() {
5062 let outcome = host(guests::EXIT).run(&get("/hello/greet"));
5063 let failure = outcome.result.expect_err("must not answer");
5064 assert!(matches!(failure, SandboxFailure::Exited(3)), "{failure}");
5065 }
5066
5067 #[test]
5068 fn a_guest_that_never_answers_is_a_failure_not_a_hang() {
5069 let outcome = host(guests::SILENT).run(&get("/hello/greet"));
5070 assert!(
5071 matches!(outcome.result, Err(SandboxFailure::NoAnswer)),
5072 "{:?}",
5073 outcome.result
5074 );
5075 }
5076
5077 #[test]
5078 fn a_module_without_a_start_is_refused_at_load() {
5079 let err = try_host(guests::NO_START).expect_err("must be refused");
5080 assert!(matches!(err, SandboxLoadError::MissingStart), "{err}");
5081 }
5082
5083 // ── deny-by-default ──────────────────────────────────────────────
5084
5085 #[test]
5086 fn a_file_read_is_denied_and_logged() {
5087 let outcome = host(guests::READ_FILE).run(&get("/hello/greet"));
5088 assert_eq!(
5089 denied(&outcome, DeniedCapability::Filesystem),
5090 vec!["path_open".to_owned()]
5091 );
5092 assert_eq!(outcome.result.expect("still answers").status, 200);
5093 }
5094
5095 /// A subscriber that records the `capability` / `operation` fields of every
5096 /// event, so "observable in logs" can be asserted rather than assumed.
5097 #[derive(Default)]
5098 struct DenialLog(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
5099
5100 impl tracing::Subscriber for DenialLog {
5101 fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
5102 true
5103 }
5104 fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
5105 Some(tracing::level_filters::LevelFilter::TRACE)
5106 }
5107 fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
5108 tracing::span::Id::from_u64(1)
5109 }
5110 fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
5111 fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
5112 fn event(&self, event: &tracing::Event<'_>) {
5113 struct Fields<'a>(&'a mut Vec<String>);
5114 impl tracing::field::Visit for Fields<'_> {
5115 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
5116 self.0.push(format!("{}={value:?}", field.name()));
5117 }
5118 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
5119 self.0.push(format!("{}={value}", field.name()));
5120 }
5121 }
5122 let mut fields = Vec::new();
5123 event.record(&mut Fields(&mut fields));
5124 if let Ok(mut recorded) = self.0.lock() {
5125 recorded.push(format!(
5126 "{} [{}]",
5127 event.metadata().level(),
5128 fields.join(" ")
5129 ));
5130 }
5131 }
5132 fn enter(&self, _: &tracing::span::Id) {}
5133 fn exit(&self, _: &tracing::span::Id) {}
5134 }
5135
5136 use rusty_fork::rusty_fork_test;
5137
5138 rusty_fork_test! {
5139 #[test]
5140 fn a_denial_reaches_the_log_and_not_only_the_ledger() {
5141 // The ledger is what tests read; the log is what an operator reads. If the
5142 // two could drift, "each denial observable in logs" would be a claim about
5143 // a field nobody sees. Forked, because `tracing`'s callsite-interest cache
5144 // and max-level hint are process-global: a sibling test that installs a
5145 // global subscriber can filter this event out before any thread-local
5146 // subscriber is consulted, which would make an in-process version of this
5147 // test pass or fail on test ordering.
5148 let recorded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
5149 tracing::subscriber::with_default(
5150 DenialLog(std::sync::Arc::clone(&recorded)),
5151 || {
5152 let _ = host(guests::READ_FILE).run(&get("/hello/greet"));
5153 },
5154 );
5155 let lines = recorded.lock().expect("not poisoned").clone();
5156 let denial = lines
5157 .iter()
5158 .find(|line| line.contains("operation=path_open"))
5159 .unwrap_or_else(|| panic!("no denial line in {lines:#?}"));
5160 assert!(denial.starts_with("WARN"), "{denial}");
5161 assert!(denial.contains("capability=filesystem"), "{denial}");
5162 assert!(denial.contains("plugin=autumn-plugin-hello"), "{denial}");
5163 assert!(denial.contains("no filesystem"), "{denial}");
5164 }
5165 }
5166
5167 #[test]
5168 fn there_are_no_preopened_directories_to_discover() {
5169 let outcome = host(guests::DISCOVER_PREOPENS).run(&get("/hello/greet"));
5170 assert_eq!(
5171 denied(&outcome, DeniedCapability::Filesystem),
5172 vec!["fd_prestat_get".to_owned()]
5173 );
5174 }
5175
5176 #[test]
5177 fn a_stray_descriptor_read_is_denied() {
5178 let outcome = host(guests::READ_STRAY_FD).run(&get("/hello/greet"));
5179 assert_eq!(
5180 denied(&outcome, DeniedCapability::Filesystem),
5181 vec!["fd_read".to_owned()]
5182 );
5183 }
5184
5185 /// A guest that seeks `fd`, traps unless the answer is `errno`, then
5186 /// writes a fixed 200 response.
5187 fn seek_guest(fd: i32, errno: i32) -> String {
5188 format!(
5189 r#"(module
5190 (import "wasi_snapshot_preview1" "fd_seek" (func $seek (param i32 i64 i32 i32) (result i32)))
5191 (import "wasi_snapshot_preview1" "fd_write" (func $write (param i32 i32 i32 i32) (result i32)))
5192 (memory (export "memory") 1 1)
5193 (data (i32.const 128) "{{\"op\":\"response\",\"status\":200,\"headers\":[[\"content-type\",\"text/plain\"]],\"body_b64\":\"b2s=\"}}\0a")
5194 (func (export "_start")
5195 (if (i32.ne (call $seek (i32.const {fd}) (i64.const 0) (i32.const 0) (i32.const 0)) (i32.const {errno}))
5196 (then unreachable))
5197 (i32.store (i32.const 0) (i32.const 128))
5198 (i32.store (i32.const 4) (i32.const 91))
5199 (drop (call $write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 8)))))"#
5200 )
5201 }
5202
5203 #[test]
5204 fn seeking_an_unknown_descriptor_is_denied_and_recorded() {
5205 let outcome = host(&seek_guest(99, 8)).run(&get("/hello/greet"));
5206 assert_eq!(outcome.result.as_ref().expect("answers").status, 200);
5207 assert_eq!(
5208 denied(&outcome, DeniedCapability::Filesystem),
5209 vec!["fd_seek".to_owned()]
5210 );
5211 }
5212
5213 #[test]
5214 fn seeking_a_negative_descriptor_is_denied_too() {
5215 let outcome = host(&seek_guest(-1, 8)).run(&get("/hello/greet"));
5216 assert_eq!(outcome.result.as_ref().expect("answers").status, 200);
5217 assert_eq!(
5218 denied(&outcome, DeniedCapability::Filesystem),
5219 vec!["fd_seek".to_owned()]
5220 );
5221 }
5222
5223 #[test]
5224 fn seeking_stdio_is_a_pipe_and_not_a_denial() {
5225 let outcome = host(&seek_guest(1, 70)).run(&get("/hello/greet"));
5226 assert_eq!(outcome.result.as_ref().expect("answers").status, 200);
5227 assert!(outcome.denials.is_empty(), "{:?}", outcome.denials);
5228 }
5229
5230 #[test]
5231 fn outbound_network_is_denied() {
5232 let outcome = host(guests::NETWORK).run(&get("/hello/greet"));
5233 assert_eq!(
5234 denied(&outcome, DeniedCapability::Network),
5235 vec!["sock_send".to_owned()]
5236 );
5237 }
5238
5239 #[test]
5240 fn the_environment_is_empty_and_the_attempt_is_logged() {
5241 let outcome = host(guests::ENVIRONMENT).run(&get("/hello/greet"));
5242 let ops = denied(&outcome, DeniedCapability::Environment);
5243 assert_eq!(outcome.result.expect("still answers").status, 200);
5244 assert!(ops.contains(&"environ_sizes_get".to_owned()), "{ops:?}");
5245 assert!(ops.contains(&"environ_get".to_owned()), "{ops:?}");
5246 }
5247
5248 #[test]
5249 fn process_arguments_are_empty_and_the_attempt_is_logged() {
5250 let outcome = host(guests::ARGUMENTS).run(&get("/hello/greet"));
5251 assert_eq!(
5252 denied(&outcome, DeniedCapability::Environment),
5253 vec!["args_sizes_get".to_owned()]
5254 );
5255 }
5256
5257 #[test]
5258 fn blocking_the_host_on_a_poll_is_denied() {
5259 let outcome = host(guests::POLL).run(&get("/hello/greet"));
5260 assert_eq!(
5261 denied(&outcome, DeniedCapability::ProcessControl),
5262 vec!["poll_oneoff".to_owned()]
5263 );
5264 }
5265
5266 #[test]
5267 fn entropy_is_answered_deterministically_rather_than_denied() {
5268 let host = host(guests::ENTROPY);
5269 let first = host.run(&get("/hello/greet"));
5270 let second = host.run(&get("/hello/greet"));
5271 assert!(first.denials.is_empty(), "{:?}", first.denials);
5272 assert_eq!(
5273 first.result.expect("answers"),
5274 second.result.expect("answers")
5275 );
5276 }
5277
5278 #[test]
5279 fn a_database_seam_the_host_never_defined_is_refused_at_load() {
5280 let err = try_host(guests::DATABASE).expect_err("must be refused");
5281 let SandboxLoadError::ForbiddenImports(denials) = err else {
5282 panic!("expected a forbidden-import refusal, got {err}");
5283 };
5284 assert_eq!(denials.len(), 1);
5285 assert_eq!(denials[0].capability, DeniedCapability::UnknownImport);
5286 assert!(denials[0].operation.contains("autumn_db"), "{denials:?}");
5287 }
5288
5289 #[test]
5290 fn a_host_escape_from_an_invented_namespace_is_refused_at_load() {
5291 let err = try_host(guests::HOST_COMMAND).expect_err("must be refused");
5292 assert!(
5293 matches!(err, SandboxLoadError::ForbiddenImports(_)),
5294 "{err}"
5295 );
5296 }
5297
5298 #[test]
5299 fn a_wasi_function_this_shim_does_not_implement_is_refused_at_load() {
5300 let err = try_host(guests::UNDEFINED_WASI).expect_err("must be refused");
5301 let SandboxLoadError::ForbiddenImports(denials) = err else {
5302 panic!("expected a forbidden-import refusal, got {err}");
5303 };
5304 assert!(denials[0].operation.contains("sock_connect"), "{denials:?}");
5305 }
5306
5307 // ── the response is not a trusted channel either ─────────────────
5308
5309 #[test]
5310 fn a_forged_session_cookie_is_stripped_and_logged() {
5311 let outcome = host(guests::FORGE_COOKIE).run(&get("/hello/greet"));
5312 assert_eq!(
5313 denied(&outcome, DeniedCapability::ResponseHeader),
5314 vec!["set-cookie".to_owned()]
5315 );
5316 let response = outcome.result.expect("answers");
5317 assert!(
5318 !response
5319 .headers
5320 .iter()
5321 .any(|(name, _)| name.eq_ignore_ascii_case("set-cookie")),
5322 "{:?}",
5323 response.headers
5324 );
5325 }
5326
5327 #[test]
5328 fn a_plugin_cannot_borrow_the_reverse_proxy_s_filesystem() {
5329 let outcome = host(guests::PROXY_REDIRECT).run(&get("/hello/greet"));
5330 let denied = denied(&outcome, DeniedCapability::ResponseHeader);
5331 assert!(
5332 denied.contains(&"x-accel-redirect".to_owned()),
5333 "{denied:?}"
5334 );
5335 assert!(denied.contains(&"x-sendfile".to_owned()), "{denied:?}");
5336 let response = outcome.result.expect("answers without them");
5337 assert!(
5338 response
5339 .headers
5340 .iter()
5341 .all(|(name, _)| name == "content-type"),
5342 "{:?}",
5343 response.headers
5344 );
5345 }
5346
5347 #[test]
5348 fn a_response_splitting_header_never_reaches_a_response() {
5349 // Two locks on this door: the header allowlist drops the name before
5350 // anything looks at its value, and `SandboxResponse::validate` refuses a
5351 // value carrying CRLF for a name that *is* allowed (asserted directly in
5352 // `wire`). Here the first lock holds, so the guest is served — minus the
5353 // header, with the attempt on the record.
5354 let outcome = host(guests::SPLIT_RESPONSE).run(&get("/hello/greet"));
5355 assert_eq!(
5356 denied(&outcome, DeniedCapability::ResponseHeader),
5357 vec!["x-evil".to_owned()]
5358 );
5359 let response = outcome.result.expect("answers without the header");
5360 assert!(
5361 response
5362 .headers
5363 .iter()
5364 .all(|(name, _)| name == "content-type"),
5365 "{:?}",
5366 response.headers
5367 );
5368 }
5369
5370 #[test]
5371 fn an_impossible_status_is_refused() {
5372 let outcome = host(guests::IMPOSSIBLE_STATUS).run(&get("/hello/greet"));
5373 assert!(
5374 matches!(outcome.result, Err(SandboxFailure::ResponseRefused(_))),
5375 "{:?}",
5376 outcome.result
5377 );
5378 }
5379
5380 #[test]
5381 fn a_guest_error_detail_can_neither_flood_nor_forge_a_log_line() {
5382 // A guest's `detail` is attacker-controlled text on its way to a log
5383 // the operator trusts. Two separate hazards: its *length* (a line can
5384 // be as large as the stdout budget, and a plugin that fails in a loop
5385 // writes one per request) and its *content* (a newline starts a record
5386 // the operator did not write; an ANSI escape repaints one they did).
5387 let outcome = host(guests::FORGE_LOG).run(&get("/hello/greet"));
5388 let Err(failure) = outcome.result else {
5389 panic!("the guest reported a failure");
5390 };
5391 let rendered = failure.to_string();
5392 assert!(
5393 rendered.len() < 4096,
5394 "a 2 KiB detail reached the log intact: {} bytes",
5395 rendered.len()
5396 );
5397 assert!(
5398 !rendered.contains('\n') && !rendered.contains('\r'),
5399 "the detail can start a log record of its own: {rendered:?}"
5400 );
5401 assert!(
5402 !rendered.contains('\u{1b}'),
5403 "the detail can repaint the operator's terminal: {rendered:?}"
5404 );
5405 assert!(
5406 rendered.contains("forged"),
5407 "the detail is still legible enough to debug with: {rendered:?}"
5408 );
5409 }
5410
5411 #[test]
5412 fn a_denied_response_header_name_is_bounded_before_it_is_logged() {
5413 // The sibling of the content-type cap, on the header *name* rather
5414 // than its value. A denied name is often denied precisely because it
5415 // is not a valid header name, so it can carry newlines and escapes as
5416 // well as megabytes — and `deny` both stores it and logs it.
5417 let name = "x-".to_owned() + &"y".repeat(20_000);
5418 let frame = format!(
5419 r#"{{"op":"response","status":200,"headers":[["content-type","text/plain"],["{name}","v"]],"body_b64":""}}"#
5420 );
5421 let len = frame.len() + 1;
5422 let wat = format!(
5423 r#"(module
5424 (import "wasi_snapshot_preview1" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
5425 (memory (export "memory") 2)
5426 (data (i32.const 1024) "{escaped}\0a")
5427 (func (export "_start")
5428 (i32.store (i32.const 0) (i32.const 1024))
5429 (i32.store (i32.const 4) (i32.const {len}))
5430 (drop (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 8)))))"#,
5431 escaped = frame.replace('"', "\\\""),
5432 );
5433
5434 let outcome = host(&wat).run(&get("/hello/greet"));
5435 let logged = outcome
5436 .denials
5437 .iter()
5438 .find(|denial| denial.capability == DeniedCapability::ResponseHeader)
5439 .expect("the stripped header is recorded as a denial");
5440 assert!(
5441 logged.operation.len() < 1_024,
5442 "the denial carries the guest's whole header name: {} bytes",
5443 logged.operation.len()
5444 );
5445 assert!(
5446 logged.operation.contains("truncated"),
5447 "{:?}",
5448 logged.operation
5449 );
5450 }
5451
5452 #[test]
5453 fn a_refused_content_type_is_bounded_before_it_is_logged() {
5454 // `refused_content_type` takes everything before the first `;`, so a
5455 // guest that writes no parameter hands back its entire header value —
5456 // capped only by the stdout ceiling, which is megabytes. That string
5457 // reached both the denial detail and the `ResponseRefused` text, and
5458 // both are logged, so a guest could flood an operator's log with a
5459 // header rather than with output.
5460 let essence = "x".repeat(20_000);
5461 let frame = format!(
5462 r#"{{"op":"response","status":200,"headers":[["content-type","{essence}"]],"body_b64":""}}"#
5463 );
5464 // The frame is ASCII, so its byte length is its char length; + 1 for
5465 // the newline that ends it. The WAT literal needs its quotes escaped,
5466 // which changes the *source* length but not the decoded bytes.
5467 let len = frame.len() + 1;
5468 let wat = format!(
5469 r#"(module
5470 (import "wasi_snapshot_preview1" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
5471 (memory (export "memory") 2)
5472 (data (i32.const 1024) "{escaped}\0a")
5473 (func (export "_start")
5474 (i32.store (i32.const 0) (i32.const 1024))
5475 (i32.store (i32.const 4) (i32.const {len}))
5476 (drop (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 8)))))"#,
5477 escaped = frame.replace('"', "\\\""),
5478 );
5479
5480 let outcome = host(&wat).run(&get("/hello/greet"));
5481 let Err(SandboxFailure::ResponseRefused(detail)) = outcome.result else {
5482 panic!(
5483 "an unsupported content type must be refused: {:?}",
5484 outcome.result
5485 );
5486 };
5487 assert!(
5488 detail.len() < 1_024,
5489 "the failure text carries the guest's whole header: {} bytes",
5490 detail.len()
5491 );
5492 assert!(detail.contains("truncated"), "{detail}");
5493
5494 let logged = outcome
5495 .denials
5496 .iter()
5497 .find(|denial| denial.capability == DeniedCapability::ResponseHeader)
5498 .expect("the refusal is recorded as a denial");
5499 assert!(
5500 logged.detail.len() < 1_024,
5501 "the denial detail carries the guest's whole header: {} bytes",
5502 logged.detail.len()
5503 );
5504 }
5505
5506 #[test]
5507 fn an_element_offset_this_cannot_evaluate_is_refused_like_a_data_offset() {
5508 // The data walk learned to refuse an active segment whose offset it
5509 // cannot evaluate; the element walk beside it still skipped one. Same
5510 // fail-open, same shape, in the sibling — an active element segment is
5511 // written into its table at instantiation whether or not this can work
5512 // out where, so skipping the check because the offset is unknown is the
5513 // check silently not running.
5514 let mut operands = String::new();
5515 let mut adds = String::new();
5516 for _ in 0..24 {
5517 operands.push_str("i32.const 0 ");
5518 adds.push_str("i32.add ");
5519 }
5520 let wat = format!(
5521 r#"(module
5522 (table 1 funcref)
5523 (memory (export "memory") 1)
5524 (func $f (export "_start") (nop))
5525 (elem (offset i32.const 65536 {operands} {adds}) $f)
5526)"#
5527 );
5528 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
5529
5530 let mut config = wasmi::Config::default();
5531 config.consume_fuel(true);
5532 wasmi::Module::new(&wasmi::Engine::new(&config), &wasm[..])
5533 .expect("wasmi compiles a deep extended-const offset; the finding depends on it");
5534
5535 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
5536 .expect_err("an active element segment with an unevaluable offset must be refused");
5537 assert!(
5538 matches!(
5539 err,
5540 SandboxLoadError::SegmentOutOfBounds {
5541 what: "table elements",
5542 ..
5543 }
5544 ),
5545 "{err:?}",
5546 );
5547
5548 // A passive element segment writes nothing at instantiation, so an
5549 // offset it has no need of cannot make it refusable.
5550 let passive = wat::parse_str(
5551 r#"(module
5552 (table 1 funcref)
5553 (memory (export "memory") 1)
5554 (func $f (export "_start") (nop))
5555 (elem funcref (ref.func $f))
5556)"#,
5557 )
5558 .expect("the fixture is valid WAT");
5559 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &passive)
5560 .expect("a passive element segment must still load");
5561 }
5562
5563 #[test]
5564 fn deciding_a_header_does_not_cross_costs_nothing_to_decide() {
5565 // The value clone was the obvious half. The name is the same trap one step
5566 // smaller: looking a header up in the allowlist by lower-casing it first
5567 // allocates a copy of a string the caller chose the length of, only to conclude
5568 // it is not wanted. And a dropped header is no longer charged against the
5569 // metadata ceiling — rightly, it never crosses — so nothing bounds how large
5570 // that name may be. The predicate now compares in place. Asserted by behaviour
5571 // rather than by allocation here, since `tests/sandbox_header_alloc_gate.rs`
5572 // does the measuring: a name that cannot be an allowlisted header must be
5573 // rejected without the length ever mattering.
5574 let enormous = "x".repeat(4 * 1024 * 1024);
5575 assert!(!super::super::wire::request_header_allowed(&enormous));
5576
5577 // Case-insensitive, because HTTP header names are — which is what lets
5578 // the caller skip the lower-casing rather than merely defer it.
5579 assert!(super::super::wire::request_header_allowed("Accept"));
5580 assert!(super::super::wire::request_header_allowed("ACCEPT"));
5581 assert!(super::super::wire::request_header_allowed("accept"));
5582 assert!(!super::super::wire::request_header_allowed("Cookie"));
5583
5584 // And the canonical form is still what reaches the guest, so accepting
5585 // a mixed-case name does not mean forwarding one.
5586 let canonical = super::super::wire::canonicalize_headers(&[(
5587 "AcCePt".to_owned(),
5588 "text/plain".to_owned(),
5589 )]);
5590 assert_eq!(
5591 canonical,
5592 vec![("accept".to_owned(), "text/plain".to_owned())],
5593 );
5594 }
5595
5596 #[test]
5597 fn encoding_fuel_prices_the_sizing_pass_of_the_line() {
5598 // `to_line` serialises twice: once to count, once to write. The count
5599 // still encodes and escapes everything, so it is host work the guest's
5600 // fuel must pay for. Three raw walks and five expanded ones (encode and
5601 // escape-scan in each pass, plus the seed scan) make 9 2/3 of the body.
5602 let mut request = get("/hello/greet");
5603 request.body = vec![b'x'; 60_000];
5604 let charged_bytes = encoding_fuel(&request).saturating_mul(BYTES_PER_FUEL);
5605 assert!(
5606 charged_bytes >= 60_000 * 29 / 3,
5607 "the charge ({charged_bytes}) leaves the sizing pass free"
5608 );
5609 }
5610
5611 #[test]
5612 fn encoding_fuel_prices_the_line_that_is_written_not_the_bytes_handed_in() {
5613 // A flat multiplier over the raw input under-charged, because most of the walks
5614 // are over the expanded form: base64 grows the body by 4/3 and every pass after
5615 // the encode is over that, while `serde_json` writes a control character as six
5616 // bytes and `seed_from` then reads the whole finished line. A request could buy
5617 // host work the ceiling never saw. Measured against what the serialiser actually
5618 // writes, so the factors cannot drift from the encoding they claim to cover.
5619 let granted = [SandboxCapability::HttpRequest];
5620
5621 let mut request = get("/hello/greet");
5622 request.body = vec![b'x'; 60_000];
5623 let line = crate::plugin_sandbox::wire::to_line(
5624 &crate::plugin_sandbox::wire::HostFrame::request(&request, &granted),
5625 )
5626 .expect("serialises")
5627 .len();
5628
5629 // The charge has to cover writing that line and reading it back at
5630 // least once — the seed scan — on top of the copies before it.
5631 let charged_bytes = encoding_fuel(&request).saturating_mul(BYTES_PER_FUEL);
5632 assert!(
5633 charged_bytes >= (line as u64).saturating_mul(2),
5634 "the charge ({charged_bytes}) is under writing and re-reading a \
5635 {line}-byte line",
5636 );
5637
5638 // The metadata side expands further than the body does, so it must be
5639 // charged at a higher rate rather than at one flat factor for both.
5640 let mut escaped = get("/hello/greet");
5641 escaped.query = "\u{0}".repeat(10_000);
5642 let mut plain = get("/hello/greet");
5643 plain.query = "a".repeat(10_000);
5644 assert!(
5645 encoding_fuel(&escaped) == encoding_fuel(&plain),
5646 "the charge is computed from raw sizes, so it must not vary with \
5647 content — it is an upper bound taken before the line exists",
5648 );
5649 let escaped_line = crate::plugin_sandbox::wire::to_line(
5650 &crate::plugin_sandbox::wire::HostFrame::request(&escaped, &granted),
5651 )
5652 .expect("serialises")
5653 .len();
5654 let escaped_charge = encoding_fuel(&escaped).saturating_mul(BYTES_PER_FUEL);
5655 assert!(
5656 escaped_charge >= (escaped_line as u64).saturating_mul(2),
5657 "the charge ({escaped_charge}) is under writing and re-reading a \
5658 JSON-escaped {escaped_line}-byte line",
5659 );
5660 }
5661
5662 #[test]
5663 fn direct_run_does_not_charge_for_headers_the_frame_will_drop() {
5664 // `HostFrame::request` filters headers through the allowlist, so a
5665 // `Cookie` or `Authorization` never reaches the guest. Counting those
5666 // bytes against the metadata ceiling refused a request that would have
5667 // cost nothing — and made `SandboxHost::run`, which is public, stricter
5668 // than the adapter that is merely its politest caller.
5669 let host = host(guests::HELLO);
5670
5671 let mut credential_heavy = get("/hello/greet");
5672 credential_heavy.headers = vec![
5673 ("cookie".to_owned(), "s=".repeat(MAX_REQUEST_METADATA_BYTES)),
5674 (
5675 "authorization".to_owned(),
5676 "t=".repeat(MAX_REQUEST_METADATA_BYTES),
5677 ),
5678 ];
5679 assert!(
5680 request_metadata_bytes(&credential_heavy) < MAX_REQUEST_METADATA_BYTES,
5681 "headers the frame drops are still being charged for",
5682 );
5683 assert!(
5684 host.run(&credential_heavy).result.is_ok(),
5685 "a request whose only bulk is dropped headers must still be served",
5686 );
5687
5688 // And the ceiling still holds for headers that *do* cross, or it would
5689 // have stopped bounding anything.
5690 let mut allowed_heavy = get("/hello/greet");
5691 allowed_heavy.headers = vec![(
5692 "accept".to_owned(),
5693 "text/plain, ".repeat(MAX_REQUEST_METADATA_BYTES),
5694 )];
5695 let err = host
5696 .run(&allowed_heavy)
5697 .result
5698 .expect_err("oversized allowlisted metadata must still be refused");
5699 assert!(
5700 matches!(err, SandboxFailure::RequestMetadataBudget { .. }),
5701 "{err:?}",
5702 );
5703 }
5704
5705 #[test]
5706 fn a_module_of_nothing_but_custom_sections_is_refused_without_reading_them_all() {
5707 // Every other shape ceiling is read from a section's leading count.
5708 // A custom section has no count — a name and opaque bytes — so it was
5709 // excluded from `declared_entries` and contributed to nothing, while
5710 // costing three bytes to encode. The refusal that is supposed to be
5711 // cheap became the expensive part.
5712 let mut wasm = Vec::from(b"\0asm\x01\0\0\0".as_slice());
5713 // Empty custom section: id 0, size 1, a zero-length name.
5714 for _ in 0..=MAX_SECTIONS {
5715 wasm.extend_from_slice(&[CUSTOM_SECTION, 0x01, 0x00]);
5716 }
5717 let shape = module_shape(&wasm).expect("the header walk must not fail on legal sections");
5718
5719 // The walk stopped at the ceiling rather than reading every header.
5720 assert_eq!(
5721 shape.sections,
5722 MAX_SECTIONS + 1,
5723 "the walk read past the ceiling instead of stopping at it",
5724 );
5725
5726 let err = refuse_unbounded_shape(&wasm)
5727 .expect_err("a module past the section ceiling must be refused");
5728 assert!(
5729 matches!(
5730 err,
5731 SandboxLoadError::InstantiationTooExpensive {
5732 what: "sections",
5733 ..
5734 }
5735 ),
5736 "{err:?}",
5737 );
5738
5739 // And an ordinary module, which carries a handful of sections, is not
5740 // caught by this — or the ceiling would refuse every real plugin.
5741 let hello = wat::parse_str(guests::HELLO).expect("the fixture is valid WAT");
5742 let shape = module_shape(&hello).expect("a real module walks");
5743 assert!(
5744 shape.sections <= MAX_SECTIONS,
5745 "a real module carries {} sections, at or over the ceiling",
5746 shape.sections,
5747 );
5748 refuse_unbounded_shape(&hello).expect("a real module must still load");
5749 }
5750
5751 #[test]
5752 fn a_module_declaring_a_second_memory_is_refused_at_load_not_once_per_request() {
5753 // wasmi enables the multi-memory proposal by default, so `Module::new`
5754 // compiles this happily and only the `MemoryLimiter` objects — and it
5755 // objects at instantiation, which is per request. The artifact would
5756 // pass `autumn plugin inspect` and then answer every request with a
5757 // gateway error, which is the shape this file already refuses
5758 // elsewhere: a passing verdict on an artifact that can only ever 504 is
5759 // worse than no verdict, because an operator installs on it.
5760 let wat = r#"(module
5761 (memory (export "memory") 1)
5762 (memory 1)
5763 (func (export "_start") (nop))
5764)"#;
5765 let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
5766
5767 // First, that the engine really does accept it — a fix for a module the
5768 // compiler already rejects would be guarding nothing.
5769 let mut config = wasmi::Config::default();
5770 config.consume_fuel(true);
5771 let engine = wasmi::Engine::new(&config);
5772 wasmi::Module::new(&engine, &wasm[..])
5773 .expect("wasmi enables multi-memory by default; if this fails the ceiling is moot");
5774
5775 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
5776 .expect_err("a second memory must be refused at load");
5777 assert!(
5778 matches!(
5779 err,
5780 SandboxLoadError::InstantiationTooExpensive {
5781 what: "linear memories",
5782 ..
5783 }
5784 ),
5785 "{err:?}",
5786 );
5787
5788 // And refused *before* `Module::new`, not after it. The pre-compilation
5789 // gate is a separate function precisely so that a shape guaranteed to
5790 // be rejected does not first get a representation built for every
5791 // declaration in it — so the ceiling has to live there rather than
5792 // merely be reached eventually. Asserted by calling that gate directly:
5793 // it is what runs ahead of the compiler.
5794 let err = refuse_unbounded_shape(&wasm)
5795 .expect_err("the pre-compilation gate must refuse it, not a later one");
5796 assert!(
5797 matches!(
5798 err,
5799 SandboxLoadError::InstantiationTooExpensive {
5800 what: "linear memories",
5801 ..
5802 }
5803 ),
5804 "{err:?}",
5805 );
5806
5807 // One memory is the normal case and still loads.
5808 let single = wat::parse_str(
5809 r#"(module (memory (export "memory") 1) (func (export "_start") (nop)))"#,
5810 )
5811 .expect("the fixture is valid WAT");
5812 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &single)
5813 .expect("a single-memory module must still load");
5814 }
5815
5816 #[test]
5817 fn a_long_import_name_is_not_copied_whole_in_order_to_refuse_it() {
5818 // `MAX_IMPORTS` bounds how many names a module may declare; nothing on this
5819 // side bounded how long one may be. wasmparser caps a single name at 100 KB, so
5820 // this is not module-sized — but `MAX_DENIALS` is 64, and the denial copied the
5821 // whole name, `Display` cloned it again, and the join built a third copy. That
5822 // is megabytes of host memory to refuse one artifact, repeatable, in the process
5823 // of trying to reject it. Observable in what comes out: a bounded operation
5824 // cannot be longer than the bound, however long the name was.
5825 let huge = "z".repeat(99_000);
5826 let wat = format!(
5827 r#"(module
5828 (import "not_wasi" "{huge}" (func))
5829 (memory (export "memory") 1)
5830 (func (export "_start") (nop))
5831)"#
5832 );
5833 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
5834
5835 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
5836 .expect_err("an import the sandbox does not provide must be refused");
5837 let SandboxLoadError::ForbiddenImports(denials) = &err else {
5838 panic!("{err:?}");
5839 };
5840 assert_eq!(denials.len(), 1);
5841 assert!(
5842 denials[0].operation.len() <= DENIAL_RECORD_BYTES,
5843 "the denial carries {} bytes of a {}-byte name — it was copied whole \
5844 in order to reject it",
5845 denials[0].operation.len(),
5846 huge.len(),
5847 );
5848 assert!(
5849 denials[0].operation.starts_with("not_wasi::"),
5850 "the operation must still say what was imported: {}",
5851 &denials[0].operation[..denials[0].operation.len().min(64)],
5852 );
5853
5854 // And rendering the error does not rebuild it either. `Display` is what
5855 // `autumn plugin package`/`inspect` call before excerpting, so a copy
5856 // made here lands in the process trying to refuse the artifact.
5857 let rendered = err.to_string();
5858 assert!(
5859 rendered.len() <= DENIAL_RECORD_BYTES.saturating_mul(2),
5860 "rendering the refusal produced {} bytes from a {}-byte name",
5861 rendered.len(),
5862 huge.len(),
5863 );
5864 }
5865
5866 #[test]
5867 fn a_table_the_instance_fills_is_charged_like_the_memory_it_fills() {
5868 // The memory term exists because a module can declare an initial size
5869 // with no data segments, so the init-section terms price none of it
5870 // while the host still zero-fills it every instantiation. A table's
5871 // minimum is the same shape and was not charged: `segments` and
5872 // `init_bytes` see nothing, and wasmi still allocates and initialises
5873 // every slot before the guest runs.
5874 let bare = instantiation_fuel(0, 0, 0, 0, 0, 0);
5875 let with_table = instantiation_fuel(0, 0, 0, 0, 0, u64::from(MAX_TABLE_ELEMENTS));
5876 assert!(
5877 with_table > bare,
5878 "a full table cost the same as no table at all ({with_table} vs {bare})",
5879 );
5880 assert_eq!(
5881 with_table - bare,
5882 u64::from(MAX_TABLE_ELEMENTS),
5883 "the charge should be one unit per slot the instance initialises",
5884 );
5885
5886 // End to end: a module whose only weight is its table must be refused
5887 // when its fuel cannot cover filling it. Zero-page memory and no
5888 // segments, so every other fixed term is at its floor.
5889 let wat = format!(
5890 r#"(module
5891 (memory (export "memory") 0)
5892 (table {MAX_TABLE_ELEMENTS} funcref)
5893 (func (export "_start") (nop))
5894)"#
5895 );
5896 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
5897 let limits = ResourceLimits {
5898 fuel: u64::from(MAX_TABLE_ELEMENTS) / 2,
5899 ..ResourceLimits::default()
5900 };
5901 let err = SandboxHost::from_module(manifest_with(limits), &wasm)
5902 .expect_err("fuel below the cost of filling the table must be refused");
5903 assert!(
5904 matches!(err, SandboxLoadError::FuelBelowFixedCharges { .. }),
5905 "{err:?}",
5906 );
5907
5908 // And the same module loads once its budget covers the table, so the
5909 // charge bounds rather than forbids.
5910 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
5911 .expect("a table within a normal fuel budget must still load");
5912 }
5913
5914 #[test]
5915 fn the_review_list_bounds_an_import_name_where_it_builds_it() {
5916 // The sibling of the denial-path fix, and the site it did not reach.
5917 // `reported_imports` bounded how *many* names it formatted and not how
5918 // long each one was, so 256 entries could each be as long as an
5919 // artifact cared to make them. The review surface does excerpt them —
5920 // afterwards, which is one copy too late to be the bound.
5921 let huge = "z".repeat(99_000);
5922 let wat = format!(
5923 r#"(module
5924 (import "not_wasi" "{huge}" (func))
5925 (memory (export "memory") 1)
5926 (func (export "_start") (nop))
5927)"#
5928 );
5929 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
5930
5931 let imports = SandboxHost::imports_of(&wasm).expect("the module's shape is readable");
5932 assert_eq!(imports.len(), 1);
5933 assert!(
5934 imports[0].len() <= DENIAL_RECORD_BYTES,
5935 "the review list carries {} bytes of a {}-byte name",
5936 imports[0].len(),
5937 huge.len(),
5938 );
5939 assert!(
5940 imports[0].starts_with("not_wasi::"),
5941 "the entry must still say what is imported: {}",
5942 &imports[0][..imports[0].len().min(64)],
5943 );
5944 }
5945
5946 #[test]
5947 fn one_oversized_element_section_is_not_walked_before_it_is_refused() {
5948 // The count ceiling and the byte ceiling each refuse a module on their own, so
5949 // the walk should be skipped when either is exceeded. Only the count gated it,
5950 // and a single segment keeps the count at one, so the walk ran over the section
5951 // on the way to a refusal the byte ceiling had already decided. Observable
5952 // through what the walk reports: the segment here overflows its table, so a walk
5953 // that runs finds `Some(..)` and a walk that is skipped leaves `None`. The
5954 // module is refused either way, which is why the verdict cannot be what this
5955 // test looks at.
5956 let mut wasm = Vec::from(b"\0asm\x01\0\0\0".as_slice());
5957 // One table, funcref, minimum 1 — so five elements written at offset 0
5958 // is an overflow the walk would report.
5959 wasm.extend_from_slice(&[TABLE_SECTION, 0x04, 0x01, 0x70, 0x00, 0x01]);
5960
5961 let body_len = MAX_INIT_SECTION_BYTES + 1;
5962 wasm.push(ELEMENT_SECTION);
5963 let mut size = body_len;
5964 while size >= 0x80 {
5965 let low = u8::try_from(size & 0x7f).expect("masked to seven bits");
5966 wasm.push(low | 0x80);
5967 size >>= 7;
5968 }
5969 wasm.push(u8::try_from(size).expect("the loop leaves under 0x80"));
5970 let before_body = wasm.len();
5971 // count = 1; then an active segment: table 0, offset `i32.const 0`,
5972 // five function indices. The rest of the section is padding the walk
5973 // never reads — it is there only to carry the section over the byte
5974 // ceiling.
5975 wasm.extend_from_slice(&[0x01, 0x00, 0x41, 0x00, 0x0b, 0x05, 0, 0, 0, 0, 0]);
5976 wasm.resize(before_body + body_len, 0);
5977
5978 let shape = module_shape(&wasm).expect("the header walk must not fail");
5979 assert_eq!(
5980 shape.segments, 1,
5981 "the count ceiling is not what refuses it"
5982 );
5983 assert!(
5984 shape.init_bytes > MAX_INIT_SECTION_BYTES,
5985 "the fixture should be over the byte ceiling",
5986 );
5987 assert!(
5988 shape.element_overflow.is_none(),
5989 "the segment walk ran on a section the byte ceiling already refuses: {:?}",
5990 shape.element_overflow,
5991 );
5992
5993 let err =
5994 refuse_unbounded_shape(&wasm).expect_err("an oversized init section must be refused");
5995 assert!(
5996 matches!(
5997 err,
5998 SandboxLoadError::InstantiationTooExpensive {
5999 what: "bytes of data and element sections",
6000 ..
6001 }
6002 ),
6003 "{err:?}",
6004 );
6005 }
6006
6007 #[test]
6008 fn one_segment_cannot_declare_more_entries_than_the_ceiling_allows() {
6009 // `declared_entries` sums each section's leading count, one LEB128 per section,
6010 // which is what makes reading the shape cheap. The element section is the one
6011 // place that undercounts: its leading count is the number of segments, and a
6012 // segment carries its own count of items. So one passive segment holding
6013 // millions of function indices added exactly one to the ceiling that exists to
6014 // bound declarations before anything allocates per declaration.
6015 //
6016 // Nothing else caught it. `MAX_TABLE_ELEMENTS` bounds what a table starts with,
6017 // and a passive segment is never written to a table; `MAX_INIT_SEGMENTS` counts
6018 // segments, and there is one; and the byte ceiling admits a 16 MiB section,
6019 // which is millions of one-byte indices. The walk then read every one of them,
6020 // and `Module::new` expanded the whole initializer list after it.
6021 fn uleb(mut value: usize, out: &mut Vec<u8>) {
6022 while value >= 0x80 {
6023 let low = u8::try_from(value & 0x7f).expect("masked to seven bits");
6024 out.push(low | 0x80);
6025 value >>= 7;
6026 }
6027 out.push(u8::try_from(value).expect("the loop leaves under 0x80"));
6028 }
6029
6030 // One passive segment (flags 1, elemkind 0x00) declaring one item more
6031 // than the ceiling admits, each a one-byte `funcidx 0`.
6032 let items = MAX_DECLARED_ENTRIES + 1;
6033 let mut body = vec![0x01, 0x01, 0x00];
6034 uleb(items, &mut body);
6035 body.resize(body.len() + items, 0x00);
6036
6037 let mut wasm = Vec::from(b"\0asm\x01\0\0\0".as_slice());
6038 wasm.extend_from_slice(&[0x01, 0x04, 0x01, 0x60, 0x00, 0x00]); // type () -> ()
6039 wasm.extend_from_slice(&[0x03, 0x02, 0x01, 0x00]); // one function
6040 wasm.extend_from_slice(&[TABLE_SECTION, 0x04, 0x01, 0x70, 0x00, 0x01]);
6041 wasm.push(ELEMENT_SECTION);
6042 uleb(body.len(), &mut wasm);
6043 wasm.extend_from_slice(&body);
6044
6045 // The fixture has to clear every *other* ceiling, or it would be
6046 // refused for a reason that has nothing to do with the nested count.
6047 let shape = module_shape(&wasm).expect("the header walk must not fail");
6048 assert!(
6049 shape.segments <= MAX_INIT_SEGMENTS,
6050 "the segment count is not what refuses this fixture",
6051 );
6052 assert!(
6053 shape.init_bytes <= MAX_INIT_SECTION_BYTES,
6054 "the byte ceiling is not what refuses this fixture: {}",
6055 shape.init_bytes,
6056 );
6057 assert!(
6058 shape.table_elements <= u64::from(MAX_TABLE_ELEMENTS),
6059 "the table ceiling is not what refuses this fixture",
6060 );
6061 assert!(
6062 shape.declared_entries > MAX_DECLARED_ENTRIES,
6063 "the segment's own items never reached the ceiling: {}",
6064 shape.declared_entries,
6065 );
6066
6067 let err = refuse_unbounded_shape(&wasm)
6068 .expect_err("a segment over the entry ceiling must be refused");
6069 assert!(
6070 matches!(
6071 err,
6072 SandboxLoadError::InstantiationTooExpensive {
6073 what: "declared section entries",
6074 ..
6075 }
6076 ),
6077 "{err:?}",
6078 );
6079
6080 // And the count is read *before* the items are, which is what keeps the
6081 // refusal from costing what it refuses. Here the section claims far
6082 // more items than it carries: a walk that reads them runs off the end
6083 // of the section and reports nothing, leaving the module to be refused
6084 // for some later reason — or not at all. One that stops at the ceiling
6085 // still refuses it, from the count alone.
6086 let mut truncated = Vec::from(b"\0asm\x01\0\0\0".as_slice());
6087 truncated.extend_from_slice(&[TABLE_SECTION, 0x04, 0x01, 0x70, 0x00, 0x01]);
6088 let mut claim = vec![0x01, 0x01, 0x00];
6089 uleb(MAX_DECLARED_ENTRIES * 16, &mut claim);
6090 claim.extend_from_slice(&[0x00, 0x00, 0x00]);
6091 truncated.push(ELEMENT_SECTION);
6092 uleb(claim.len(), &mut truncated);
6093 truncated.extend_from_slice(&claim);
6094
6095 let err = refuse_unbounded_shape(&truncated)
6096 .expect_err("a section claiming more entries than it carries must be refused");
6097 assert!(
6098 matches!(
6099 err,
6100 SandboxLoadError::InstantiationTooExpensive {
6101 what: "declared section entries",
6102 ..
6103 }
6104 ),
6105 "the count was not consulted before the items were walked: {err:?}",
6106 );
6107 }
6108
6109 #[test]
6110 fn the_engine_refuses_a_heap_type_this_walk_would_have_to_guess_at() {
6111 // `skip_const_expr` reads `ref.null`'s heap type as a signed LEB rather than a
6112 // byte. Today that is the same one byte for every heap type the engine accepts
6113 // — this test is what says so, and what would notice if it stopped being true.
6114 // A non-minimal encoding of `funcref` (0xf0 0x7f decodes to -16, the same value
6115 // as 0x70) is the case a byte-wide read would desynchronise on. wasmi refuses it
6116 // outright, so no such module reaches instantiation: the walk cannot be led out
6117 // of step by it, and the load-time bounds check cannot be skipped through it.
6118 let mut module = Vec::from(b"\0asm\x01\0\0\0".as_slice());
6119 module.extend_from_slice(&[0x01, 0x04, 0x01, 0x60, 0x00, 0x00]); // type () -> ()
6120 module.extend_from_slice(&[0x03, 0x02, 0x01, 0x00]); // one function
6121 module.extend_from_slice(&[0x04, 0x04, 0x01, 0x70, 0x00, 0x01]); // table funcref, min 1
6122 module.extend_from_slice(&[0x05, 0x03, 0x01, 0x00, 0x01]); // memory, min 1
6123 module.extend_from_slice(&[0x07, 0x13, 0x02, 0x06]);
6124 module.extend_from_slice(b"memory");
6125 module.extend_from_slice(&[0x02, 0x00, 0x06]);
6126 module.extend_from_slice(b"_start");
6127 module.extend_from_slice(&[0x00, 0x00]);
6128 let code = [0x0a, 0x05, 0x01, 0x03, 0x00, 0x01, 0x0b];
6129
6130 let mut config = wasmi::Config::default();
6131 config.consume_fuel(true);
6132 let engine = wasmi::Engine::new(&config);
6133
6134 // The minimal form compiles, which is what makes the comparison mean
6135 // something: the fixture is well formed apart from the heap type.
6136 let mut minimal = module.clone();
6137 minimal.extend_from_slice(&[
6138 0x09, 0x09, 0x01, 0x04, 0x41, 0x00, 0x0b, 0x01, 0xd0, 0x70, 0x0b,
6139 ]);
6140 minimal.extend_from_slice(&code);
6141 wasmi::Module::new(&engine, &minimal[..]).expect("the minimal heap type compiles");
6142
6143 // The non-minimal form does not.
6144 let mut non_minimal = module;
6145 non_minimal.extend_from_slice(&[
6146 0x09, 0x0a, 0x01, 0x04, 0x41, 0x00, 0x0b, 0x01, 0xd0, 0xf0, 0x7f, 0x0b,
6147 ]);
6148 non_minimal.extend_from_slice(&code);
6149 let err = wasmi::Module::new(&engine, &non_minimal[..])
6150 .expect_err("a non-minimal heap type must not compile");
6151 assert!(
6152 err.to_string().contains("heap type"),
6153 "expected the engine to name the heap type: {err}",
6154 );
6155 }
6156
6157 #[test]
6158 fn a_swarm_of_empty_tables_is_refused_before_anything_is_compiled() {
6159 // The sibling of the memory case, and the reason all three ceilings
6160 // moved together. A module can declare far more tables than
6161 // `MAX_TABLES` while staying under `MAX_DECLARED_ENTRIES`, so nothing
6162 // else refuses it — and while the table ceiling did refuse it, it
6163 // refused after `Module::new` had already built a representation of
6164 // every declaration. The right answer at the wrong time.
6165 let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
6166 for _ in 0..=MAX_TABLES {
6167 wat.push_str(" (table 0 funcref)\n");
6168 }
6169 wat.push_str(" (func (export \"_start\") (nop))\n)");
6170 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
6171
6172 // Under the aggregate ceiling, so `MAX_DECLARED_ENTRIES` is not what
6173 // catches this — otherwise the test would pass for the wrong reason.
6174 let shape = module_shape(&wasm).expect("the header walk must not fail");
6175 assert!(
6176 shape.declared_entries <= MAX_DECLARED_ENTRIES,
6177 "the fixture is caught by the aggregate ceiling, not the table one",
6178 );
6179
6180 let err = refuse_unbounded_shape(&wasm)
6181 .expect_err("the pre-compilation gate must refuse too many tables");
6182 assert!(
6183 matches!(
6184 err,
6185 SandboxLoadError::InstantiationTooExpensive { what: "tables", .. }
6186 ),
6187 "{err:?}",
6188 );
6189
6190 // And the third of the three: tables that are already over the element
6191 // ceiling at rest, also refused before compiling.
6192 let heavy = wat::parse_str(format!(
6193 "(module (memory (export \"memory\") 1) (table {} funcref) \
6194 (func (export \"_start\") (nop)))",
6195 u64::from(MAX_TABLE_ELEMENTS) + 1,
6196 ))
6197 .expect("the fixture is valid WAT");
6198 let err = refuse_unbounded_shape(&heavy)
6199 .expect_err("the pre-compilation gate must refuse oversized tables");
6200 assert!(
6201 matches!(
6202 err,
6203 SandboxLoadError::InstantiationTooExpensive {
6204 what: "initial table elements",
6205 ..
6206 }
6207 ),
6208 "{err:?}",
6209 );
6210 }
6211
6212 #[test]
6213 fn the_metadata_walk_stops_at_the_ceiling_instead_of_finishing_the_list() {
6214 // Charging every entry made an oversized list fail. It did not bound the work of
6215 // discovering that it fails — the fold visited every pair first, before the
6216 // permit was taken and before the guest spent any fuel — so an unbounded list
6217 // still bought an unbounded scan per concurrent caller. Fixing the verdict is
6218 // not fixing the cost. Observable because a stopped walk returns a floor: run to
6219 // the end and the answer is the whole sum, stop at the ceiling and it is barely
6220 // past it. Same instrument as the section walk.
6221 let over = MAX_REQUEST_METADATA_BYTES / DROPPED_PAIR_BYTES * 16;
6222 let mut swarm = get("/hello/greet");
6223 swarm.headers = std::iter::repeat_with(|| ("cookie".to_owned(), String::new()))
6224 .take(over)
6225 .collect();
6226
6227 let counted = request_metadata_bytes(&swarm);
6228 let whole_list = over.saturating_mul(DROPPED_PAIR_BYTES);
6229 assert!(
6230 counted <= MAX_REQUEST_METADATA_BYTES + DROPPED_PAIR_BYTES,
6231 "the walk counted {counted} bytes, past the ceiling plus the entry \
6232 that crossed it — it finished the list instead of stopping",
6233 );
6234 assert!(
6235 counted < whole_list / 2,
6236 "the walk counted {counted} of a {whole_list}-byte list, so it did \
6237 not stop early",
6238 );
6239
6240 // Stopping early must not stop it refusing.
6241 let host = host(guests::HELLO);
6242 let err = host
6243 .run(&swarm)
6244 .result
6245 .expect_err("a list past the ceiling must still be refused");
6246 assert!(
6247 matches!(err, SandboxFailure::RequestMetadataBudget { .. }),
6248 "{err:?}",
6249 );
6250
6251 // And a request under the ceiling is still counted in full, or the
6252 // charge the ceiling is made of would be wrong for every real request.
6253 let mut ordinary = get("/hello/greet");
6254 ordinary.headers = vec![("accept".to_owned(), "text/plain".to_owned())];
6255 assert_eq!(
6256 request_metadata_bytes(&ordinary),
6257 ordinary.method.len()
6258 + ordinary.path.len()
6259 + ordinary.query.len()
6260 + ordinary.route.len()
6261 + metadata_pair_bytes("accept", "text/plain"),
6262 );
6263 }
6264
6265 #[test]
6266 fn a_header_the_frame_drops_still_costs_its_place_in_the_list() {
6267 // Not charging a dropped header's *contents* is right — they never
6268 // cross. Not charging it at all left the count unbounded, which is a
6269 // different resource: the list is walked once for the ceiling, once to
6270 // price the encoding, and once to filter it out, all before a permit is
6271 // taken and before the guest spends any fuel. So a million empty
6272 // `Cookie` pairs passed a byte ceiling for free and bought three
6273 // unbounded scans per request, per concurrent caller.
6274 let host = host(guests::HELLO);
6275
6276 // Still admitted: bulk in a dropped header is bulk that costs the guest
6277 // nothing, and refusing it would make withholding a credential into a
6278 // reason to fail the request.
6279 let mut one_huge_cookie = get("/hello/greet");
6280 one_huge_cookie.headers =
6281 vec![("cookie".to_owned(), "s=".repeat(MAX_REQUEST_METADATA_BYTES))];
6282 assert!(
6283 host.run(&one_huge_cookie).result.is_ok(),
6284 "one large dropped header must still be served",
6285 );
6286
6287 // Refused: the same total, spread across entries instead of into one
6288 // value. Nothing here reaches the guest either, but the host walks
6289 // every one of them to establish that.
6290 let many_empty_cookies: Vec<(String, String)> =
6291 std::iter::repeat_with(|| ("cookie".to_owned(), String::new()))
6292 .take(MAX_REQUEST_METADATA_BYTES / DROPPED_PAIR_BYTES + 1)
6293 .collect();
6294 let mut swarm = get("/hello/greet");
6295 swarm.headers = many_empty_cookies;
6296 let err = host
6297 .run(&swarm)
6298 .result
6299 .expect_err("an unbounded list of dropped headers must be refused");
6300 assert!(
6301 matches!(err, SandboxFailure::RequestMetadataBudget { .. }),
6302 "{err:?}",
6303 );
6304 }
6305
6306 #[test]
6307 fn an_active_offset_this_cannot_evaluate_is_refused_rather_than_skipped() {
6308 // The extended-const fix closed the door where the walk failed. This is the
6309 // other door: the walk succeeds and an active segment's offset cannot be
6310 // evaluated. The old code reported that as "no active write to measure",
6311 // indistinguishable from a passive-only section, so the segment was copied in
6312 // at instantiation with nothing having checked where it lands. The expression
6313 // below is flat rather than folded: every operand is pushed before any add
6314 // runs, so it is the stack that is exceeded, not the parser. wasmi compiles it
6315 // happily.
6316 let mut operands = String::new();
6317 for _ in 0..24 {
6318 operands.push_str("i32.const 0 ");
6319 }
6320 let mut adds = String::new();
6321 for _ in 0..24 {
6322 adds.push_str("i32.add ");
6323 }
6324 let wat = format!(
6325 r#"(module
6326 (memory (export "memory") 1)
6327 (data (offset i32.const 65536 {operands} {adds}) "xx")
6328 (func (export "_start") (nop))
6329)"#
6330 );
6331 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
6332
6333 let mut config = wasmi::Config::default();
6334 config.consume_fuel(true);
6335 wasmi::Module::new(&wasmi::Engine::new(&config), &wasm[..])
6336 .expect("wasmi compiles a deep extended-const offset; the finding depends on it");
6337
6338 let shape = module_shape(&wasm).expect("the section still walks cleanly");
6339 assert_eq!(
6340 shape.data_end,
6341 u64::MAX,
6342 "an offset that cannot be evaluated must be treated as one that cannot fit",
6343 );
6344 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
6345 .expect_err("an active segment with an unevaluable offset must be refused");
6346 assert!(
6347 matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
6348 "{err:?}",
6349 );
6350
6351 // And the refusal is about *active* segments, not about the evaluator
6352 // giving up: the same unevaluable expression on a passive segment is
6353 // copied by nothing at instantiation, so it still loads.
6354 let passive = wat::parse_str(
6355 r#"(module
6356 (memory (export "memory") 1)
6357 (data "passive, and its offset is not a question")
6358 (func (export "_start") (nop))
6359)"#,
6360 )
6361 .expect("the fixture is valid WAT");
6362 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &passive)
6363 .expect("a passive segment must still load");
6364 }
6365
6366 #[test]
6367 fn a_passive_only_data_section_is_not_a_walk_that_failed() {
6368 // `data_section_end` has two ways of saying nothing: the walk failed, or the
6369 // walk succeeded and there was no active write to measure. The fail-closed
6370 // fallback added for extended-const offsets read both as the first and refused
6371 // a perfectly runnable module — a passive segment is copied only by an explicit
6372 // `memory.init`, never at instantiation, so it cannot be out of bounds there.
6373 // The three outcomes are pinned together here because the bug was exactly their
6374 // collapse into two.
6375 let passive_only = wat::parse_str(
6376 r#"(module
6377 (memory (export "memory") 1)
6378 (data "passive bytes")
6379 (func (export "_start") (nop))
6380)"#,
6381 )
6382 .expect("the fixture is valid WAT");
6383 let shape = module_shape(&passive_only).expect("the section walks cleanly");
6384 assert_eq!(
6385 shape.data_end, 0,
6386 "a passive segment measures nothing, so it must contribute nothing",
6387 );
6388 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &passive_only)
6389 .expect("a passive-only module must still load");
6390
6391 // An active segment beside it is still measured, so "nothing to
6392 // measure" cannot become "measure nothing".
6393 let mixed = wat::parse_str(
6394 r#"(module
6395 (memory (export "memory") 1)
6396 (data "passive bytes")
6397 (data (offset (i32.const 16)) "active")
6398 (func (export "_start") (nop))
6399)"#,
6400 )
6401 .expect("the fixture is valid WAT");
6402 let shape = module_shape(&mixed).expect("the section walks cleanly");
6403 assert_eq!(
6404 shape.data_end, 22,
6405 "the active segment's end must survive the passive one beside it",
6406 );
6407
6408 // And an active segment past the memory is still refused, so the
6409 // assertions above cannot be satisfied by simply never refusing.
6410 let out_of_bounds = wat::parse_str(
6411 r#"(module
6412 (memory (export "memory") 1)
6413 (data "passive bytes")
6414 (data (offset (i32.const 65535)) "spills")
6415 (func (export "_start") (nop))
6416)"#,
6417 )
6418 .expect("the fixture is valid WAT");
6419 let err =
6420 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &out_of_bounds)
6421 .expect_err("an active segment past the end of memory must be refused");
6422 assert!(
6423 matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
6424 "{err:?}",
6425 );
6426 }
6427
6428 #[test]
6429 fn an_extended_const_segment_offset_is_evaluated_rather_than_waved_through() {
6430 // wasmi enables the extended-const proposal by default, so an active
6431 // data offset need not be a bare `i32.const`. Against a reader that
6432 // knew only the bare form this was not merely unevaluable: the walk did
6433 // not know `i32.add` either, so it desynced, the whole data section
6434 // returned nothing, and `data_end` stayed at zero. The bounds check did
6435 // not run — and packaging and `plugin inspect` then approved an
6436 // artifact whose every request would fail at instantiation, which is
6437 // precisely what checking at load is for.
6438 let out_of_bounds = wat::parse_str(
6439 r#"(module
6440 (memory (export "memory") 1)
6441 (data (offset (i32.add (i32.const 65535) (i32.const 2))) "xx")
6442 (func (export "_start") (nop))
6443)"#,
6444 )
6445 .expect("the fixture is valid WAT");
6446
6447 // The engine accepts it, which is what makes the gap reachable: if
6448 // wasmi refused the module this walk's silence would cost nothing.
6449 let mut config = wasmi::Config::default();
6450 config.consume_fuel(true);
6451 wasmi::Module::new(&wasmi::Engine::new(&config), &out_of_bounds[..])
6452 .expect("wasmi compiles an extended-const offset; the finding depends on it");
6453
6454 let err =
6455 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &out_of_bounds)
6456 .expect_err("a segment past the end of memory must be refused at load");
6457 // Refused for the true reason, with the offset the arithmetic actually
6458 // produces — 65535 + 2, plus the two bytes copied there.
6459 assert!(
6460 matches!(
6461 err,
6462 SandboxLoadError::SegmentOutOfBounds { end, capacity, .. }
6463 if end == 65539 && capacity == WASM_PAGE_BYTES
6464 ),
6465 "{err:?}",
6466 );
6467
6468 // And the arithmetic is *evaluated*, not merely refused for being
6469 // unfamiliar: the same shape landing inside the memory still loads.
6470 // Refusing every extended-const module would pass the assertion above
6471 // and fail this one.
6472 let in_bounds = wat::parse_str(
6473 r#"(module
6474 (memory (export "memory") 1)
6475 (data (offset (i32.add (i32.const 16) (i32.const 8))) "xx")
6476 (func (export "_start") (nop))
6477)"#,
6478 )
6479 .expect("the fixture is valid WAT");
6480 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &in_bounds)
6481 .expect("an extended-const offset inside the memory must still load");
6482 }
6483
6484 #[test]
6485 fn a_bidi_override_cannot_reorder_the_record_it_appears_in() {
6486 // `is_control` covers the C0/C1 codes and stops there, so the Unicode
6487 // formatting characters went into the log verbatim. They do an ESC's job by
6488 // other means: U+202E reverses everything after it, so a guest can write a
6489 // detail that reads as a different record than the one the host wrote —
6490 // including reading as though the denial were an allow. The operator reads that
6491 // line to decide what happened. The consent screen already refuses these in a
6492 // route path; a detail is evidence rather than a mount, so here they are
6493 // escaped instead, and the attempt survives legibly as an attempt.
6494 let forged = guest_text("denied \u{202E}dewolla\u{202D} by policy");
6495 assert!(
6496 !forged.contains('\u{202E}') && !forged.contains('\u{202D}'),
6497 "a bidi override reached the log verbatim: {forged:?}",
6498 );
6499 assert!(
6500 forged.contains("\\u{202e}"),
6501 "the attempt must survive as an escape, not be silently dropped: {forged:?}",
6502 );
6503 assert!(
6504 forged.contains("denied") && forged.contains("by policy"),
6505 "the host's own words must survive: {forged:?}",
6506 );
6507
6508 // The whole family, not just the one character: isolates and the
6509 // zero-width joiners hide a boundary rather than reversing a run, and
6510 // the log is just as unreadable either way.
6511 for ch in [
6512 '\u{061C}', '\u{200B}', '\u{200E}', '\u{200F}', '\u{2060}', '\u{2066}', '\u{2069}',
6513 '\u{FEFF}',
6514 ] {
6515 let out = guest_text(&format!("before{ch}after"));
6516 assert!(
6517 !out.contains(ch),
6518 "U+{:04X} reached the log verbatim: {out:?}",
6519 ch as u32,
6520 );
6521 }
6522
6523 // And ordinary non-ASCII is still kept — an author debugging a plugin
6524 // that reports errors in their own language has to be able to read them.
6525 let readable = guest_text("le module n'a pas démarré — 起動しませんでした");
6526 assert!(
6527 readable.contains("démarré") && readable.contains("起動しませんでした"),
6528 "real text was escaped along with the formatting characters: {readable:?}",
6529 );
6530 }
6531
6532 #[test]
6533 fn a_unicode_line_break_cannot_forge_a_second_log_record() {
6534 // The line breaks that are not control characters. U+2028 and U+2029 are
6535 // general category Zl and Zp, so `is_control` — which answers for Cc —
6536 // says no, and they are neither format nor default-ignorable, so the
6537 // reordering predicate says no as well. A log consumer that splits on
6538 // Unicode line breaks still renders them as a newline, which hands the
6539 // guest the forged record that escaping C0 exists to deny.
6540 for ch in ['\u{2028}', '\u{2029}'] {
6541 assert!(
6542 !ch.is_control(),
6543 "U+{:04X} is a control character after all; this test guards the wrong gap",
6544 ch as u32,
6545 );
6546 let split = guest_text(&format!("denied{ch}allowed by policy"));
6547 assert!(
6548 !split.contains(ch),
6549 "U+{:04X} reached the log verbatim: {split:?}",
6550 ch as u32,
6551 );
6552 assert!(
6553 split.contains(&format!("\\u{{{:x}}}", ch as u32)),
6554 "the attempt must survive as an escape, not be dropped: {split:?}",
6555 );
6556 }
6557 }
6558
6559 #[test]
6560 fn generating_a_random_byte_costs_more_than_copying_one() {
6561 // `BYTES_PER_FUEL` is a bulk-copy rate: a memcpy moves 64 bytes for about what
6562 // one instruction costs, so charging them to a unit is honest. `random_get` does
6563 // not copy — every byte is a whole SplitMix64 step, an add, two multiplies, and
6564 // four shift-XOR pairs. Billing that at the copy rate sold the mixer's work at a
6565 // memcpy's price while the guest held a blocking worker for it. Measured through
6566 // the fuel the host actually charges, because that is the thing that was wrong:
6567 // an earlier version of this test compared two constants it computed itself,
6568 // passed against its own revert, and never ran `random_get` at all.
6569 fn drawing(bytes: u32) -> u64 {
6570 let wat = format!(
6571 r#"(module
6572 (import "wasi_snapshot_preview1" "random_get" (func $random_get (param i32 i32) (result i32)))
6573 (memory (export "memory") 2)
6574 (func (export "_start")
6575 (drop (call $random_get (i32.const 4096) (i32.const {bytes}))))
6576)"#
6577 );
6578 // The guest never answers, which is fine: the outcome carries the
6579 // fuel spent either way, and the answer is not what is being
6580 // measured.
6581 host(&wat).run(&request("GET", "/hello/greet")).fuel_used
6582 }
6583
6584 let none = drawing(0);
6585 let many = drawing(65536);
6586 let charged = many.saturating_sub(none);
6587
6588 // At the copy rate 64 KiB is about 1,024 units; per byte it is 65,536.
6589 // Halfway between separates them without pinning either rate.
6590 assert!(
6591 charged > 32768,
6592 "drawing 64 KiB of entropy cost {charged} fuel — the bulk-copy rate \
6593 would charge about 1,024, so the mixer's work is being sold at a \
6594 memcpy's price",
6595 );
6596 }
6597
6598 #[test]
6599 fn the_pending_frame_never_allocates_past_the_budget_it_is_bounded_by() {
6600 // The pending-line ceiling bounds the buffer's *length*. Its
6601 // *capacity* is what the host actually holds, and `Vec`'s doubling puts
6602 // that at the next power of two above the ceiling — nearly twice the
6603 // `2 × max_response_bytes` `request_footprint_bytes` reserves for this
6604 // line, at every concurrent request at once. A bound the allocation can
6605 // exceed is not the bound the concurrency product was validated
6606 // against.
6607 let limits = ResourceLimits {
6608 // Small enough to fill in a test, and deliberately not a power of
6609 // two: an unclamped `Vec` lands on 65,536 for this budget, which a
6610 // ceiling of exactly 65,536 would have let pass.
6611 max_response_bytes: 20_000,
6612 ..ResourceLimits::default()
6613 };
6614 let budget = limits.max_response_bytes * 2 + 4096;
6615 let mut state = bare_state(limits, b"");
6616
6617 // One byte at a time, because that is the growth pattern that doubles:
6618 // a guest writing its frame through a byte-at-a-time formatter is
6619 // ordinary, and `write_stdout` sees the bytes either way.
6620 for _ in 0..budget {
6621 assert!(state.write_stdout(b"x"), "the budget refused a legal frame");
6622 }
6623 assert_eq!(state.stdout_line.len(), budget, "the buffer did not fill");
6624 assert!(
6625 state.stdout_line.capacity() <= budget,
6626 "a {budget}-byte ceiling holds {} bytes of allocation",
6627 state.stdout_line.capacity(),
6628 );
6629
6630 // …and the ceiling still stops the next byte, so bounding the
6631 // allocation did not cost the refusal it exists for.
6632 assert!(
6633 !state.write_stdout(b"x"),
6634 "a frame past the ceiling was accepted",
6635 );
6636 }
6637
6638 #[test]
6639 fn one_read_cannot_copy_the_whole_frame_out_of_the_queue() {
6640 // `fd_write` and `random_get` both bound their scratch to `HOST_IO_CHUNK_BYTES`,
6641 // and `FIXED_HOST_BUFFER_BYTES` budgets exactly one such buffer. The read path
6642 // did not: the iovec length is the guest's to choose, so one iovec spanning the
6643 // whole frame made a second copy of it, live beside the queue it was copied out
6644 // of, which keeps its allocation as it drains. A frame is metadata plus a base64
6645 // body with JSON escaping over both, so that copy is several times the raw
6646 // request, and none of it was in the footprint the concurrency product is
6647 // validated against. Returning less than was asked for is what the call already
6648 // promises: the queue running dry short-reads today, so a guest that does not
6649 // loop is already broken.
6650 let mut state = bare_state(ResourceLimits::default(), b"");
6651 state.stdin = VecDeque::from(vec![b'x'; HOST_IO_CHUNK_BYTES * 4]);
6652
6653 let chunk = state.take_stdin(HOST_IO_CHUNK_BYTES * 4);
6654 assert_eq!(
6655 chunk.len(),
6656 HOST_IO_CHUNK_BYTES,
6657 "one read copied more than the scratch the footprint budgets",
6658 );
6659
6660 // And the rest is still there to be read, one bounded chunk at a time,
6661 // so bounding the copy did not lose the frame.
6662 let mut drained = chunk.len();
6663 while !state.stdin.is_empty() {
6664 let next = state.take_stdin(HOST_IO_CHUNK_BYTES * 4);
6665 assert!(
6666 !next.is_empty(),
6667 "the queue stopped yielding with bytes left"
6668 );
6669 drained += next.len();
6670 }
6671 assert_eq!(
6672 drained,
6673 HOST_IO_CHUNK_BYTES * 4,
6674 "the frame did not survive being read in chunks",
6675 );
6676
6677 // A read smaller than the bound is unaffected — the cap is a ceiling,
6678 // not a quantum.
6679 let mut small = bare_state(ResourceLimits::default(), b"");
6680 small.stdin = VecDeque::from(b"{\"op\":\"request\"}".to_vec());
6681 assert_eq!(small.take_stdin(4).len(), 4, "a short read must stay short");
6682 }
6683
6684 #[test]
6685 fn a_cut_stderr_excerpt_says_that_it_was_cut() {
6686 // `guest_text` appends " … (truncated)" when it meets a 513th
6687 // character, and `stderr_excerpt` hands it at most 512 — so on this
6688 // path the marker could never fire, and a flood that lost its most
6689 // recent output read as though the guest had simply stopped there. The
6690 // suffix is usually the interesting part of a failure.
6691 let mut state = bare_state(ResourceLimits::default(), b"");
6692 state.write_stderr("a".repeat(STDERR_EXCERPT * 2).as_bytes());
6693 let excerpt = state.stderr_excerpt();
6694 assert!(
6695 excerpt.ends_with(" … (truncated)"),
6696 "a cut excerpt must say so: {:?}",
6697 excerpt.get(excerpt.len().saturating_sub(40)..),
6698 );
6699
6700 // And an excerpt that fits says nothing of the kind — the marker has to
6701 // mean something.
6702 let mut short = bare_state(ResourceLimits::default(), b"");
6703 short.write_stderr(b"panicked at the disco");
6704 let whole = short.stderr_excerpt();
6705 assert_eq!(
6706 whole, "panicked at the disco",
6707 "an excerpt that fits is untouched"
6708 );
6709 }
6710
6711 #[test]
6712 fn the_streamed_lossy_decode_says_what_from_utf8_lossy_says() {
6713 // The excerpt is now decoded lazily so a 64 KiB stderr of invalid bytes
6714 // never becomes a 192 KiB replacement string. That is only an
6715 // improvement if it decodes to the same characters, and the sharp edge
6716 // is *grouping*: `from_utf8_lossy` emits one U+FFFD per maximal invalid
6717 // subpart, not per byte, so a truncated three-byte sequence is one
6718 // replacement and two stray continuation bytes are two.
6719 for case in [
6720 b"".as_slice(),
6721 b"plain ascii",
6722 b"\xff",
6723 b"\xff\xff\xff",
6724 // A truncated 3-byte sequence: one maximal subpart.
6725 b"\xe2\x82",
6726 // The same, followed by a valid character.
6727 b"\xe2\x82x",
6728 // Stray continuation bytes: one subpart each.
6729 b"\x80\x80",
6730 // Valid multi-byte text either side of a bad byte.
6731 "é日本".as_bytes(),
6732 b"caf\xe9 latte",
6733 // An overlong encoding, which is invalid however it looks.
6734 b"\xc0\xaf",
6735 ] {
6736 let streamed: String = lossy_chars(case).collect();
6737 assert_eq!(
6738 streamed,
6739 String::from_utf8_lossy(case),
6740 "streamed decode diverged on {case:?}",
6741 );
6742 }
6743 }
6744
6745 #[test]
6746 fn a_stderr_of_invalid_bytes_is_still_bounded_at_the_excerpt() {
6747 // The flood the streaming exists for: a full budget of bytes that are
6748 // each their own invalid subpart, so the lossy form is three times the
6749 // buffer. What is kept is still one excerpt.
6750 let mut state = bare_state(ResourceLimits::default(), b"");
6751 state.write_stderr(&vec![0xff; STDERR_BUDGET_BYTES * 2]);
6752 assert_eq!(
6753 state.stderr.len(),
6754 STDERR_BUDGET_BYTES,
6755 "the buffer itself must still be capped",
6756 );
6757 let excerpt = state.stderr_excerpt();
6758 assert!(
6759 excerpt.chars().count() <= DETAIL_EXCERPT + " … (truncated)".len(),
6760 "the excerpt outgrew its bound: {} characters",
6761 excerpt.chars().count(),
6762 );
6763 // Kept verbatim rather than escaped, and deliberately so: U+FFFD is
6764 // neither a control nor default-ignorable nor display-reordering — it
6765 // is a visible glyph that says "there was a byte here that was not
6766 // text", which is exactly what the operator should see.
6767 assert!(
6768 excerpt.contains(char::REPLACEMENT_CHARACTER),
6769 "the replacement characters did not survive: {excerpt:?}",
6770 );
6771 }
6772
6773 #[test]
6774 fn stderr_is_escaped_as_well_as_bounded() {
6775 // Same hazard, same answer: stderr was truncated but not neutralised,
6776 // so a guest could forge a record inside its 512-character excerpt.
6777 let mut state = bare_state(ResourceLimits::default(), b"");
6778 state.write_stderr(b"panicked\n2026-01-01 INFO forged\x1b[2K");
6779 let excerpt = state.stderr_excerpt();
6780 assert!(
6781 !excerpt.contains('\n') && !excerpt.contains('\u{1b}'),
6782 "{excerpt:?}"
6783 );
6784 assert!(excerpt.contains("panicked"), "{excerpt:?}");
6785 }
6786
6787 #[test]
6788 fn a_manifest_mutated_after_validation_is_refused_rather_than_trusted() {
6789 // `SandboxManifest`'s fields are public and `from_module` is public, so
6790 // "the manifest was validated when it was parsed" is an invariant a
6791 // caller can step around without meaning to. The values that matter
6792 // here are the ones that panic something downstream rather than merely
6793 // misbehaving: a concurrency past the semaphore's ceiling, and a route
6794 // path axum refuses to build.
6795 let wasm = wat::parse_str(guests::HELLO).expect("the fixture is valid WAT");
6796
6797 let mut manifest = manifest_with(ResourceLimits::default());
6798 manifest.limits.max_concurrency = usize::MAX;
6799 let err = SandboxHost::from_module(manifest, &wasm)
6800 .expect_err("an invalid manifest must not produce a host");
6801 assert!(matches!(err, SandboxLoadError::InvalidManifest(_)), "{err}");
6802
6803 let mut manifest = manifest_with(ResourceLimits::default());
6804 manifest.routes[0].path = "/{".to_owned();
6805 let err = SandboxHost::from_module(manifest, &wasm)
6806 .expect_err("an unbuildable route must not produce a host");
6807 assert!(matches!(err, SandboxLoadError::InvalidManifest(_)), "{err}");
6808 }
6809
6810 #[test]
6811 fn encoding_the_request_frame_is_priced_before_it_is_performed() {
6812 // The body is cloned into the frame and base64-expanded into the NDJSON
6813 // line before a single guest instruction runs. With a large body
6814 // ceiling and a small fuel budget that is megabytes of host CPU outside
6815 // the declared ceiling, repeatable for as long as a client keeps
6816 // sending.
6817 let host = host(guests::HELLO);
6818 let mut request = get("/hello/greet");
6819 request.body = vec![b'x'; 200_000];
6820
6821 // Just above the load-time floor, so the host exists — and far below
6822 // what encoding this body costs, which is the point.
6823 let starved = try_host_with(
6824 guests::HELLO,
6825 ResourceLimits {
6826 fuel: host.instantiation_fuel() + 1,
6827 ..ResourceLimits::default()
6828 },
6829 )
6830 .expect("the fixture loads");
6831 let outcome = starved.run(&request);
6832 assert!(
6833 matches!(outcome.result, Err(SandboxFailure::FuelExhausted { .. })),
6834 "{:?}",
6835 outcome.result
6836 );
6837
6838 // And an honest request pays for it rather than getting it free.
6839 let outcome = host.run(&request);
6840 assert!(
6841 outcome.fuel_used >= 200_000 / BYTES_PER_FUEL,
6842 "the encoding was not charged: {} units",
6843 outcome.fuel_used
6844 );
6845 }
6846
6847 #[test]
6848 fn a_direct_caller_cannot_start_more_instances_than_the_manifest_allows() {
6849 // `SandboxedPlugin::serve` has a semaphore of its own, so HTTP traffic
6850 // is bounded. `run` is public, though, and an embedder calling it
6851 // directly used to bypass admission entirely — while the manifest
6852 // validator accepts limits on the premise that
6853 // `request_footprint_bytes() × max_concurrency` bounds the plugin.
6854 let host = try_host_with(
6855 guests::HELLO,
6856 ResourceLimits {
6857 max_concurrency: 1,
6858 ..ResourceLimits::default()
6859 },
6860 )
6861 .expect("the fixture loads");
6862
6863 // Hold the only permit, exactly as an in-flight request would, rather
6864 // than racing a real one: the property is the admission, not the race.
6865 let held = host
6866 .permits
6867 .try_acquire()
6868 .expect("the first permit is free");
6869 assert_eq!(
6870 host.run(&get("/hello/greet")).result,
6871 Err(SandboxFailure::AtCapacity { max: 1 }),
6872 "a second concurrent run was admitted past max_concurrency = 1"
6873 );
6874
6875 // And the permit comes back: the ceiling is on requests executing at
6876 // once, not a budget the plugin spends down.
6877 drop(held);
6878 assert!(
6879 host.run(&get("/hello/greet")).result.is_ok(),
6880 "the permit was not returned when the run finished"
6881 );
6882 }
6883
6884 #[test]
6885 fn a_fuel_budget_that_cannot_reach_start_is_refused_at_load() {
6886 // `fuel = 1` passes the manifest's own range check, and every request
6887 // then spends the instantiation charge before `_start` — so every route
6888 // the manifest declares answers 504, always. `inspect` reporting that
6889 // artifact as loadable is worse than reporting nothing, because an
6890 // operator installs on the strength of the verdict.
6891 let err = try_host_with(
6892 guests::HELLO,
6893 ResourceLimits {
6894 fuel: 1,
6895 ..ResourceLimits::default()
6896 },
6897 )
6898 .expect_err("a budget below the fixed charges must not produce a host");
6899 let SandboxLoadError::FuelBelowFixedCharges {
6900 fuel,
6901 instantiation,
6902 } = err
6903 else {
6904 panic!("expected the fuel refusal, got {err:?}");
6905 };
6906 assert_eq!(fuel, 1);
6907 assert!(
6908 instantiation >= 1,
6909 "the charge must be real to refuse against"
6910 );
6911
6912 // And a budget that clears the charge still loads: this is a floor, not
6913 // a demand for the default.
6914 assert!(
6915 try_host_with(
6916 guests::HELLO,
6917 ResourceLimits {
6918 fuel: instantiation + 1,
6919 ..ResourceLimits::default()
6920 },
6921 )
6922 .is_ok(),
6923 "a budget just above the fixed charge is legal"
6924 );
6925 }
6926
6927 #[test]
6928 fn the_import_list_a_review_surface_shows_is_bounded() {
6929 // Each name is excerpted where it is rendered, but the *count* is a
6930 // separate amplification: a legal module can carry far more imports
6931 // than a person will read, and formatting each into its own `String`
6932 // turns the artifact into memory in the process refusing it.
6933 let imports = (0..MAX_REPORTED_IMPORTS + 50)
6934 .map(|n| format!(r#" (import "env" "f{n}" (func))"#))
6935 .collect::<Vec<_>>()
6936 .join("\n");
6937 let wat = format!("(module\n{imports}\n (func (export \"_start\") (nop)))");
6938 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
6939
6940 let listed = SandboxHost::imports_of(&wasm).expect("a module's imports can be read");
6941 assert!(
6942 listed.len() <= MAX_REPORTED_IMPORTS + 1,
6943 "the list is unbounded: {} entries",
6944 listed.len()
6945 );
6946 // The operator must not read a truncated list as the whole of it.
6947 let last = listed.last().expect("a non-empty list");
6948 assert!(last.contains("truncated") && last.contains("50"), "{last}");
6949 }
6950
6951 #[test]
6952 fn a_module_declaring_more_imports_than_the_ceiling_is_refused_at_load() {
6953 // The reporting cap bounds the *review surface*; it does nothing for the
6954 // runtime path, where every import is resolved and retained per
6955 // instance — per request. A module repeating one allowlisted import
6956 // makes every request pay for those resolutions, and neither the fuel
6957 // charge nor `request_footprint_bytes` knew about them.
6958 let imports = (0..=MAX_IMPORTS)
6959 .map(|_| {
6960 format!(
6961 r#" (import "{WASI}" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#
6962 )
6963 })
6964 .collect::<Vec<_>>()
6965 .join("\n");
6966 let wat = format!(
6967 "(module\n{imports}\n (memory (export \"memory\") 1)\n (func (export \"_start\") (nop)))"
6968 );
6969 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
6970
6971 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
6972 .expect_err("a module past the import ceiling must not load");
6973 assert!(
6974 matches!(
6975 err,
6976 SandboxLoadError::InstantiationTooExpensive {
6977 what: "imports",
6978 ..
6979 }
6980 ),
6981 "{err:?}"
6982 );
6983 // Every import here is allowlisted, so nothing but the *count* refuses
6984 // it: the ceiling is structural, not a second spelling of the
6985 // capability gate.
6986 }
6987
6988 #[test]
6989 fn the_import_ceiling_does_not_weaken_the_forbidden_import_gate() {
6990 // A module hiding one forbidden import behind a crowd is still refused;
6991 // the count check running first changes why, never whether.
6992 let mut lines = (0..=MAX_IMPORTS)
6993 .map(|_| {
6994 format!(
6995 r#" (import "{WASI}" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#
6996 )
6997 })
6998 .collect::<Vec<_>>();
6999 lines.push(r#" (import "env" "escape" (func))"#.to_owned());
7000 let wat = format!(
7001 "(module\n{}\n (memory (export \"memory\") 1)\n (func (export \"_start\") (nop)))",
7002 lines.join("\n")
7003 );
7004 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
7005 assert!(
7006 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm).is_err(),
7007 "a module carrying a forbidden import must never load"
7008 );
7009 }
7010
7011 #[test]
7012 fn resolving_imports_is_priced_into_the_instantiation_charge() {
7013 // Per-instance work the guest never executes but every request pays
7014 // for. Unpriced, it is host CPU bought for free — the same defect the
7015 // host-side copying charge exists to close.
7016 let bare = wat::parse_str(
7017 "(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
7018 )
7019 .expect("the fixture is valid WAT");
7020 let imports = (0..32)
7021 .map(|_| {
7022 format!(
7023 r#" (import "{WASI}" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#
7024 )
7025 })
7026 .collect::<Vec<_>>()
7027 .join("\n");
7028 let with_imports = wat::parse_str(format!(
7029 "(module\n{imports}\n (memory (export \"memory\") 1)\n (func (export \"_start\") (nop)))"
7030 ))
7031 .expect("the fixture is valid WAT");
7032
7033 let cheap = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &bare)
7034 .expect("loads")
7035 .instantiation_fuel();
7036 let dear =
7037 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &with_imports)
7038 .expect("loads")
7039 .instantiation_fuel();
7040 assert_eq!(
7041 dear,
7042 cheap + 32,
7043 "each admitted import must cost a unit: {cheap} then {dear}"
7044 );
7045 }
7046
7047 #[test]
7048 fn a_module_pays_instantiation_fuel_for_the_memory_it_starts_with() {
7049 // A module can declare a large initial linear memory and no data segments at
7050 // all, so every init-section term prices none of it — and the host still
7051 // allocates and zero-fills the whole thing on each request, before the guest
7052 // runs an instruction. That is host work proportional to a guest-declared
7053 // quantity, exactly what the copying charge exists to make cost something. The
7054 // limiter already bounds how much memory; it cannot bound how often a client
7055 // asks for it to be zeroed.
7056 let limits = ResourceLimits {
7057 memory_bytes: 64 * 1024 * 1024,
7058 ..ResourceLimits::default()
7059 };
7060 let one_page = wat::parse_str(
7061 r#"(module (memory (export "memory") 1) (func (export "_start") (nop)))"#,
7062 )
7063 .expect("the fixture is valid WAT");
7064 // 512 pages = 32 MiB, under the ceiling above and carrying no segments.
7065 let many_pages = wat::parse_str(
7066 r#"(module (memory (export "memory") 512) (func (export "_start") (nop)))"#,
7067 )
7068 .expect("the fixture is valid WAT");
7069
7070 let small = SandboxHost::from_module(manifest_with(limits), &one_page)
7071 .expect("loads")
7072 .instantiation_fuel();
7073 let large = SandboxHost::from_module(manifest_with(limits), &many_pages)
7074 .expect("loads")
7075 .instantiation_fuel();
7076
7077 // 511 extra pages of memory to zero, at the same rate as every other
7078 // host-side byte.
7079 let extra = (511 * WASM_PAGE_BYTES) / BYTES_PER_FUEL;
7080 assert_eq!(
7081 large,
7082 small + extra,
7083 "initial memory is not charged: {small} then {large}"
7084 );
7085 assert!(
7086 extra > 0,
7087 "the fixture must differ by something the charge can see"
7088 );
7089 }
7090
7091 #[test]
7092 fn a_metadata_list_of_empty_pairs_is_charged_for_its_structure() {
7093 // Summing only the contents leaves a million empty pairs weighing
7094 // nothing: past a byte ceiling for free, then cloned into the frame and
7095 // expanded into real JSON syntax anyway. The same shape as a response
7096 // frame full of `["",""]`, and it has to be priced on this side too.
7097 let host = host(guests::HELLO);
7098 let mut request = get("/hello/greet");
7099 request.path_params = vec![(String::new(), String::new()); 100_000];
7100
7101 let err = host
7102 .run(&request)
7103 .result
7104 .expect_err("a list of empty pairs must not be free");
7105 assert!(
7106 matches!(err, SandboxFailure::RequestMetadataBudget { .. }),
7107 "{err:?}"
7108 );
7109 }
7110
7111 #[test]
7112 fn a_module_whose_tables_start_over_the_ceiling_is_refused_at_load() {
7113 // The limiter enforces the table ceiling at instantiation, which is per
7114 // request — so a module already over it at rest loaded cleanly and then
7115 // failed every request. Same defect as a fuel budget below the fixed
7116 // charges: a passing verdict on an artifact that can never answer.
7117 let over = MAX_TABLE_ELEMENTS + 1;
7118 let wasm = wat::parse_str(format!(
7119 "(module (table {over} funcref) (memory (export \"memory\") 1) (func (export \"_start\") (nop)))"
7120 ))
7121 .expect("the fixture is valid WAT");
7122
7123 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7124 .expect_err("a module over the table ceiling must not load");
7125 assert!(
7126 matches!(
7127 err,
7128 SandboxLoadError::InstantiationTooExpensive {
7129 what: "initial table elements",
7130 ..
7131 }
7132 ),
7133 "{err:?}"
7134 );
7135 }
7136
7137 #[test]
7138 fn an_element_expression_whose_operand_is_the_end_opcode_still_bounds_the_segment() {
7139 // `ref.func 11` encodes its immediate as 0x0b, the same byte that ends a
7140 // constant expression. A walk that scans for the terminator instead of
7141 // decoding stops on that operand, reads every later byte at the wrong
7142 // offset, bails, and silently drops the bounds check — so a segment past
7143 // the end of its table sails through. Twelve functions, so index 11
7144 // exists and the expression form is used.
7145 use std::fmt::Write as _;
7146
7147 let mut wat =
7148 String::from("(module\n (memory (export \"memory\") 1)\n (table 1 funcref)\n");
7149 for i in 0..12 {
7150 let _ = writeln!(wat, " (func $f{i} (nop))");
7151 }
7152 // Past the end of a one-element table, with a `ref.func 11` item.
7153 wat.push_str(" (elem (i32.const 1) funcref (ref.func $f11))\n");
7154 wat.push_str(" (func (export \"_start\") (nop))\n)");
7155 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
7156
7157 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7158 .expect_err("the operand must not be mistaken for the terminator");
7159 assert!(
7160 matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
7161 "{err:?}"
7162 );
7163 }
7164
7165 #[test]
7166 fn an_element_segment_past_the_end_of_its_table_is_refused_at_load() {
7167 // The exact sibling of the data-segment case: an active element segment
7168 // is written into its table during instantiation, so one that does not
7169 // fit compiles clean and fails every request. Fixing segments into
7170 // memory without fixing segments into tables would have left half the
7171 // defect in place.
7172 let wat = r#"(module
7173 (memory (export "memory") 1)
7174 (table 1 funcref)
7175 (func $f (nop))
7176 (elem (i32.const 1) $f)
7177 (func (export "_start") (nop)))"#;
7178 let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
7179
7180 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7181 .expect_err("a segment past the end of its table must not load");
7182 assert!(
7183 matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
7184 "{err:?}"
7185 );
7186 }
7187
7188 #[test]
7189 fn a_module_carrying_a_start_section_is_refused() {
7190 // The engine runs a `start` function at instantiation, before any
7191 // request. If it traps the plugin can never answer, and the only way to
7192 // find out at load would be to execute an unaudited artifact's code
7193 // while inspecting it. A sandboxed plugin answers through the exported
7194 // `_start` the shim calls, so the section has no legitimate use here.
7195 let wat = r#"(module
7196 (memory (export "memory") 1)
7197 (func $init (nop))
7198 (start $init)
7199 (func (export "_start") (nop)))"#;
7200 let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
7201
7202 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7203 .expect_err("a start section must not load");
7204 assert!(
7205 matches!(err, SandboxLoadError::StartSectionForbidden),
7206 "{err:?}"
7207 );
7208 }
7209
7210 #[test]
7211 fn a_negative_segment_offset_is_refused_rather_than_ignored() {
7212 // A wasm offset is unsigned: `i32.const -1` means 4294967295, which is
7213 // out of bounds for anything this sandbox admits. Reading it as a
7214 // *signed* value and dropping it when the conversion failed meant the
7215 // one offset guaranteed to be invalid was the one the walk called
7216 // unevaluable — a fail-open in the check meant to catch exactly this.
7217 let wat = r#"(module
7218 (memory (export "memory") 1)
7219 (data (i32.const -1) "x")
7220 (func (export "_start") (nop)))"#;
7221 let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
7222
7223 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7224 .expect_err("a negative offset must not load");
7225 assert!(
7226 matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
7227 "{err:?}"
7228 );
7229 }
7230
7231 #[test]
7232 fn a_data_segment_past_the_end_of_memory_is_refused_at_load() {
7233 // An active segment is copied in during instantiation, which is per
7234 // request. One that does not fit the module's own initial memory
7235 // compiles clean and then fails every instantiation, so the artifact
7236 // inspects green and 502s forever — the same shape as a fuel budget
7237 // below the fixed charges.
7238 let wat = r#"(module
7239 (memory (export "memory") 1)
7240 (data (i32.const 65536) "x")
7241 (func (export "_start") (nop)))"#;
7242 let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
7243
7244 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7245 .expect_err("a segment past the end of memory must not load");
7246 assert!(
7247 matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
7248 "{err:?}"
7249 );
7250 }
7251
7252 #[test]
7253 fn the_segment_ceiling_is_enforced_by_the_pre_compilation_gate() {
7254 // Not merely "the module is refused" — where it is refused is the point.
7255 // `from_module` calls `refuse_unbounded_shape` before `Module::new` precisely
7256 // because compiling is what builds a representation of every declaration, so a
7257 // ceiling checked after it is checked too late. These two ceilings were checked
7258 // after it, under a comment saying they must not be. A module can sit over
7259 // `MAX_INIT_SEGMENTS` (4,096) and still be far under `MAX_DECLARED_ENTRIES`
7260 // (1,000,000), so nothing else refused it first and wasmi expanded every segment
7261 // before the answer came back. Asserting through `refuse_unbounded_shape`
7262 // directly is what makes this about ordering: the gate that runs first has to be
7263 // the one that says no.
7264 use std::fmt::Write as _;
7265
7266 let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
7267 for offset in 0..=MAX_INIT_SEGMENTS {
7268 let _ = writeln!(wat, " (data (i32.const {offset}) \"x\")");
7269 }
7270 wat.push_str(" (func (export \"_start\") (nop))\n)");
7271 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
7272
7273 let err = refuse_unbounded_shape(&wasm)
7274 .expect_err("the pre-compilation gate must refuse the segment count");
7275 assert!(
7276 matches!(
7277 err,
7278 SandboxLoadError::InstantiationTooExpensive {
7279 what: "data and element segments",
7280 ..
7281 }
7282 ),
7283 "{err:?}"
7284 );
7285 // And the fixture really is under the aggregate ceiling, so it is this
7286 // check refusing it and not the entry one standing in.
7287 let shape = module_shape(&wasm).expect("the fixture walks");
7288 assert!(
7289 shape.declared_entries <= MAX_DECLARED_ENTRIES,
7290 "the fixture would have been refused for its entry count anyway"
7291 );
7292 }
7293
7294 #[test]
7295 fn a_module_past_the_segment_ceiling_is_refused_without_walking_its_segments() {
7296 // The count check runs before the per-segment walks now, because the walk was
7297 // work an artifact could buy at two bytes a segment purely to reach the refusal
7298 // that exists to bound it. The risk in skipping a walk is that the walk was also
7299 // doing a bounds check — the fail-open shape this file has been bitten by twice.
7300 // So the case that matters is a module over the ceiling that also carries a
7301 // segment past the end of its memory: it must still be refused. The sibling
7302 // tests above cover the other side, that a module under the ceiling is still
7303 // walked and still caught.
7304 use std::fmt::Write as _;
7305
7306 let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
7307 // Past the end of the single page this module declares.
7308 let _ = writeln!(wat, " (data (i32.const 65536) \"x\")");
7309 for offset in 0..=MAX_INIT_SEGMENTS {
7310 let _ = writeln!(wat, " (data (i32.const {offset}) \"x\")");
7311 }
7312 wat.push_str(" (func (export \"_start\") (nop))\n)");
7313 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
7314
7315 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7316 .expect_err("a module past the segment ceiling must not load");
7317 assert!(
7318 matches!(
7319 err,
7320 SandboxLoadError::InstantiationTooExpensive {
7321 what: "data and element segments",
7322 ..
7323 }
7324 ),
7325 "{err:?}"
7326 );
7327 }
7328
7329 #[test]
7330 fn a_module_declaring_more_tables_than_the_ceiling_is_refused_on_the_count() {
7331 // The same ordering, on the table walk — which the review named only
7332 // for elements and data, but which this loop shares. Its own comment
7333 // already said a 64 MiB artifact of empty table declarations must not
7334 // expand here; bounding the per-entry *allocation* left the per-entry
7335 // *iteration* unbounded, at three bytes an entry.
7336 use std::fmt::Write as _;
7337
7338 let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
7339 for _ in 0..=MAX_TABLES {
7340 let _ = writeln!(wat, " (table 1 funcref)");
7341 }
7342 wat.push_str(" (func (export \"_start\") (nop))\n)");
7343 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
7344
7345 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7346 .expect_err("a module past the table ceiling must not load");
7347 assert!(
7348 matches!(
7349 err,
7350 SandboxLoadError::InstantiationTooExpensive { what: "tables", .. }
7351 ),
7352 "{err:?}"
7353 );
7354 }
7355
7356 #[test]
7357 fn a_module_whose_code_section_is_too_large_is_refused_before_compilation() {
7358 // One function declares one entry however long its body is, so the
7359 // declaration ceilings never saw this: a single body filling the file
7360 // allowance still hands the compiler tens of megabytes to translate.
7361 // The section header carries the size, so the refusal costs one LEB128
7362 // and never touches the instruction stream.
7363 let mut wasm = wat::parse_str(
7364 "(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
7365 )
7366 .expect("the fixture is valid WAT");
7367
7368 // Rewrite the code section's declared size to just over the ceiling
7369 // rather than building a module that large: the check reads the header,
7370 // and a fixture of real instructions would cost the test minutes.
7371 let over = u32::try_from(MAX_CODE_BYTES + 1).expect("fits");
7372 let mut header = vec![10u8]; // the code section id
7373 let mut size = over;
7374 loop {
7375 let mut byte = (size & 0x7f) as u8;
7376 size >>= 7;
7377 if size != 0 {
7378 byte |= 0x80;
7379 }
7380 header.push(byte);
7381 if size == 0 {
7382 break;
7383 }
7384 }
7385 // The bytes have to actually be there: a header claiming a length the
7386 // file does not carry is refused as malformed, which is a correct
7387 // refusal but a different one from the ceiling under test.
7388 header.extend(std::iter::repeat_n(0u8, over as usize));
7389 wasm.extend_from_slice(&header);
7390
7391 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7392 .expect_err("an oversized code section must not reach the compiler");
7393 assert!(
7394 matches!(
7395 err,
7396 SandboxLoadError::InstantiationTooExpensive {
7397 what: "code section bytes",
7398 ..
7399 }
7400 ),
7401 "{err:?}"
7402 );
7403 }
7404
7405 #[test]
7406 fn a_module_whose_global_section_is_too_large_is_refused_before_compilation() {
7407 // The global section's initializers are an instruction stream —
7408 // extended-const lets one global's expression run to arbitrary
7409 // length — but the scanner only recorded the entry count, so a
7410 // single global with a ceiling-scale initializer sat under every
7411 // check while handing the compiler that much code to translate.
7412 // The section header carries the size, so the refusal costs one
7413 // LEB128 and never touches the instruction stream: the fixture
7414 // rewrites the global section's declared size rather than building
7415 // a module that large, the way the code-section test above does.
7416 let mut wasm = wat::parse_str(
7417 "(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
7418 )
7419 .expect("the fixture is valid WAT");
7420
7421 // One global, claiming just over the instruction-volume ceiling.
7422 let over = u32::try_from(MAX_CODE_BYTES + 1).expect("fits");
7423 let mut header = vec![6u8]; // the global section id
7424 let mut size = over;
7425 loop {
7426 let mut byte = (size & 0x7f) as u8;
7427 size >>= 7;
7428 if size != 0 {
7429 byte |= 0x80;
7430 }
7431 header.push(byte);
7432 if size == 0 {
7433 break;
7434 }
7435 }
7436 // A count of one, then padding: the bytes have to actually be there,
7437 // or the header is refused as malformed — a correct refusal, but a
7438 // different one from the ceiling under test.
7439 header.push(1u8);
7440 header.extend(std::iter::repeat_n(0u8, over as usize - 1));
7441 wasm.extend_from_slice(&header);
7442
7443 // First the gate itself: this is about ordering, and the gate that
7444 // runs before `Module::new` has to be the one that says no.
7445 let err = refuse_unbounded_shape(&wasm)
7446 .expect_err("an oversized global section must not reach the compiler");
7447 assert!(
7448 matches!(
7449 err,
7450 SandboxLoadError::InstantiationTooExpensive {
7451 what: "code and global section bytes",
7452 ..
7453 }
7454 ),
7455 "{err:?}"
7456 );
7457
7458 // Then both public doors onto the same bytes.
7459 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7460 .expect_err("an oversized global section must not load");
7461 assert!(
7462 matches!(
7463 err,
7464 SandboxLoadError::InstantiationTooExpensive {
7465 what: "code and global section bytes",
7466 ..
7467 }
7468 ),
7469 "{err:?}"
7470 );
7471 let err =
7472 SandboxHost::imports_of(&wasm).expect_err("imports_of must apply the same ceiling");
7473 assert!(
7474 matches!(
7475 err,
7476 SandboxLoadError::InstantiationTooExpensive {
7477 what: "code and global section bytes",
7478 ..
7479 }
7480 ),
7481 "expected the ceiling on imports_of, got: {err:?}"
7482 );
7483 }
7484
7485 #[test]
7486 fn a_global_section_at_exactly_the_instruction_ceiling_still_passes_the_gate() {
7487 // The ceiling is a strict greater-than, so exactly at it must pass.
7488 // Asserted through the gate directly: `from_module` would then hand
7489 // the padding to `Module::new`, which refuses the malformed bytes
7490 // for its own reasons — a correct refusal, but a different one from
7491 // the ceiling under test.
7492 let mut wasm = wat::parse_str(
7493 "(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
7494 )
7495 .expect("the fixture is valid WAT");
7496
7497 // The fixture's own code section counts toward the same ceiling, so
7498 // the global section fills exactly what it leaves.
7499 let code_bytes = refuse_unbounded_shape(&wasm)
7500 .expect("the fixture passes the gate")
7501 .code_bytes;
7502 let global_len = MAX_CODE_BYTES - code_bytes;
7503 let at = u32::try_from(global_len).expect("fits");
7504 let mut header = vec![6u8]; // the global section id
7505 let mut size = at;
7506 loop {
7507 let mut byte = (size & 0x7f) as u8;
7508 size >>= 7;
7509 if size != 0 {
7510 byte |= 0x80;
7511 }
7512 header.push(byte);
7513 if size == 0 {
7514 break;
7515 }
7516 }
7517 header.push(1u8);
7518 header.extend(std::iter::repeat_n(0u8, at as usize - 1));
7519 wasm.extend_from_slice(&header);
7520
7521 let shape = refuse_unbounded_shape(&wasm)
7522 .expect("a global section at exactly the ceiling must pass the gate");
7523 assert_eq!(
7524 shape.global_bytes, global_len,
7525 "the gate must see the section's full size"
7526 );
7527 assert_eq!(
7528 shape.code_bytes.saturating_add(shape.global_bytes),
7529 MAX_CODE_BYTES,
7530 "the combined instruction volume sits exactly on the ceiling"
7531 );
7532 }
7533
7534 #[test]
7535 fn a_module_past_the_byte_ceiling_is_refused_on_the_direct_path_too() {
7536 // `check_module` applies MAX_MODULE_BYTES to a module the container
7537 // reader unpacked, but `from_module` and `imports_of` are public and
7538 // take the bytes directly, so on that path nothing had imposed it. The
7539 // count ceilings do not close the gap — a module can sit under every
7540 // one of them and still be enormous, and `Module::new` touches the
7541 // bytes before any of this build's import checks can refuse them.
7542 //
7543 // Padded with a well-formed custom section rather than junk, so the
7544 // module stays parseable and it is provably the byte ceiling doing the
7545 // refusing rather than the wasm decoder.
7546 let base = wat::parse_str(
7547 "(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
7548 )
7549 .expect("the fixture is valid WAT");
7550
7551 let padding = super::super::MAX_MODULE_BYTES + 1 - base.len();
7552 let mut section = vec![0x00u8]; // custom section id
7553 let mut payload = vec![0x04u8]; // name length
7554 payload.extend_from_slice(b"pad\0");
7555 payload.resize(payload.len() + padding, 0);
7556 let mut len = payload.len();
7557 loop {
7558 // LEB128 of the payload length.
7559 let mut byte = u8::try_from(len & 0x7f).expect("masked to 7 bits");
7560 len >>= 7;
7561 if len != 0 {
7562 byte |= 0x80;
7563 }
7564 section.push(byte);
7565 if len == 0 {
7566 break;
7567 }
7568 }
7569 section.extend_from_slice(&payload);
7570
7571 let mut wasm = base;
7572 wasm.extend_from_slice(§ion);
7573 assert!(
7574 wasm.len() > super::super::MAX_MODULE_BYTES,
7575 "the fixture must exceed the ceiling: {} bytes",
7576 wasm.len(),
7577 );
7578
7579 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7580 .expect_err("a module past the byte ceiling must not load");
7581 assert!(
7582 matches!(err, SandboxLoadError::ModuleTooLarge { .. }),
7583 "expected the byte ceiling to refuse it, got: {err}",
7584 );
7585
7586 // The review surface is the other public door onto the same bytes, and
7587 // is exactly where an unaudited artifact would be pointed first.
7588 let err =
7589 SandboxHost::imports_of(&wasm).expect_err("imports_of must apply the same ceiling");
7590 assert!(
7591 matches!(err, SandboxLoadError::ModuleTooLarge { .. }),
7592 "expected the byte ceiling on imports_of, got: {err}",
7593 );
7594 }
7595
7596 #[test]
7597 fn a_module_defining_more_functions_than_the_ceiling_is_refused_at_load() {
7598 // Neither general ceiling bounds these: a flood of tiny functions sits
7599 // under both the aggregate declared-entry cap and the code-section byte
7600 // cap, because each is a couple of bytes of body and one byte of type
7601 // index — while every instance still allocates an entry per function.
7602 // The section header carries the count, so the refusal reads one LEB128.
7603 use std::fmt::Write as _;
7604
7605 let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
7606 for i in 0..=MAX_FUNCTIONS {
7607 let _ = writeln!(wat, " (func $f{i} (nop))");
7608 }
7609 wat.push_str(" (func (export \"_start\") (nop))\n)");
7610 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
7611
7612 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7613 .expect_err("a module past the function ceiling must not load");
7614 assert!(
7615 matches!(
7616 err,
7617 SandboxLoadError::InstantiationTooExpensive {
7618 what: "functions",
7619 ..
7620 }
7621 ),
7622 "{err:?}"
7623 );
7624 }
7625
7626 #[test]
7627 fn a_module_declaring_more_globals_than_the_ceiling_is_refused_at_load() {
7628 // Every instance allocates and initialises its own copy of each global,
7629 // and an instance is per request. The aggregate declared-entry ceiling
7630 // is far too generous to bound them: a module can sit well under a
7631 // million total entries and still carry hundreds of thousands of
7632 // globals, which no fuel charge priced and no footprint counted.
7633 use std::fmt::Write as _;
7634
7635 let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
7636 for _ in 0..=MAX_GLOBALS {
7637 let _ = writeln!(wat, " (global i32 (i32.const 0))");
7638 }
7639 wat.push_str(" (func (export \"_start\") (nop))\n)");
7640 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
7641
7642 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7643 .expect_err("a module past the globals ceiling must not load");
7644 assert!(
7645 matches!(
7646 err,
7647 SandboxLoadError::InstantiationTooExpensive {
7648 what: "globals",
7649 ..
7650 }
7651 ),
7652 "{err:?}"
7653 );
7654 }
7655
7656 #[test]
7657 fn globals_are_priced_into_the_instantiation_charge() {
7658 // Per-instance work the guest never executes but every request pays
7659 // for — the same reason segments and imports are charged.
7660 use std::fmt::Write as _;
7661
7662 let bare = wat::parse_str(
7663 "(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
7664 )
7665 .expect("the fixture is valid WAT");
7666 let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
7667 for _ in 0..64 {
7668 let _ = writeln!(wat, " (global i32 (i32.const 0))");
7669 }
7670 wat.push_str(" (func (export \"_start\") (nop))\n)");
7671 let with_globals = wat::parse_str(&wat).expect("the fixture is valid WAT");
7672
7673 let cheap = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &bare)
7674 .expect("loads")
7675 .instantiation_fuel();
7676 let dear =
7677 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &with_globals)
7678 .expect("loads")
7679 .instantiation_fuel();
7680 assert_eq!(
7681 dear,
7682 cheap + 64,
7683 "each admitted global must cost a unit: {cheap} then {dear}"
7684 );
7685 }
7686
7687 #[test]
7688 fn a_flood_of_table_declarations_is_refused_without_collecting_them() {
7689 // The walk collects each table's initial size so element segments can be
7690 // bounded against the right table. Collecting them into a growing vector
7691 // put an unbounded per-entry allocation inside the code whose entire job
7692 // is to refuse before anything allocates per entry — so a module of
7693 // empty table declarations expanded the process trying to reject it.
7694 // Only the first `MAX_TABLES` can ever be admitted, so only those are
7695 // kept, and the refusal names the count.
7696 use std::fmt::Write as _;
7697
7698 let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
7699 for _ in 0..50_000 {
7700 let _ = writeln!(wat, " (table 0 funcref)");
7701 }
7702 wat.push_str(" (func (export \"_start\") (nop))\n)");
7703 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
7704
7705 // Refused — by wasmi's own hundred-table limit here, since `module_shape`
7706 // runs before `Module::new` and hands off to it. That the walk no longer
7707 // grows a vector per declaration on the way is what this change is for,
7708 // and it is not observable from a test: the allocation was never visible
7709 // in the result, only in the memory used to produce it.
7710 assert!(
7711 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm).is_err(),
7712 "a flood of table declarations must not load"
7713 );
7714 }
7715
7716 #[test]
7717 fn a_module_with_more_tables_than_the_store_will_build_is_refused_at_load() {
7718 // Sizes and count are separate ceilings, and the element sum says
7719 // nothing about the second: five *empty* tables cost no elements at all
7720 // and still exceed what the limiter will build, so the artifact loaded,
7721 // inspected clean, and failed every request at instantiation.
7722 use std::fmt::Write as _;
7723
7724 let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
7725 for _ in 0..=MAX_TABLES {
7726 let _ = writeln!(wat, " (table 0 funcref)");
7727 }
7728 wat.push_str(" (func (export \"_start\") (nop))\n)");
7729 let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
7730
7731 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7732 .expect_err("a module past the table-count ceiling must not load");
7733 assert!(
7734 matches!(
7735 err,
7736 SandboxLoadError::InstantiationTooExpensive { what: "tables", .. }
7737 ),
7738 "{err:?}"
7739 );
7740 }
7741
7742 #[test]
7743 fn tables_within_the_ceiling_still_load() {
7744 // The ceiling is a ceiling, not a ban: a module with real tables must
7745 // still mount, and the sum is across the module rather than per table.
7746 let half = MAX_TABLE_ELEMENTS / 4;
7747 let wasm = wat::parse_str(format!(
7748 "(module (table {half} funcref) (table {half} funcref) (memory (export \"memory\") 1) (func (export \"_start\") (nop)))"
7749 ))
7750 .expect("the fixture is valid WAT");
7751 assert!(
7752 SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm).is_ok(),
7753 "tables under the ceiling must load"
7754 );
7755 }
7756
7757 #[test]
7758 fn a_module_declaring_more_entries_than_the_ceiling_never_reaches_the_compiler() {
7759 // `Module::new` builds a representation of every declaration before any
7760 // later ceiling runs, so the file's size is not a bound on what
7761 // compiling it costs. The section headers carry the counts, so the
7762 // shape is knowable first.
7763 //
7764 // A header claiming far more entries than the section holds is refused
7765 // either way — by this ceiling if the claim is large, by wasmi if the
7766 // bytes do not back it. Both are a refusal before anything allocates
7767 // per entry.
7768 let mut wasm = wat::parse_str("(module (func (export \"_start\") (nop)))")
7769 .expect("the fixture is valid WAT");
7770 // Rewrite the type section's declared count to something no 64 MiB file
7771 // could honestly carry.
7772 let forged = {
7773 let mut bytes = vec![0x01u8, 0x05]; // type section, 5 bytes
7774 bytes.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0x07]); // count: ~2^31
7775 bytes
7776 };
7777 wasm.splice(8..8, forged);
7778
7779 let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
7780 .expect_err("a module past the declared-entry ceiling must not load");
7781 assert!(
7782 matches!(
7783 err,
7784 SandboxLoadError::InstantiationTooExpensive {
7785 what: "declared section entries",
7786 ..
7787 }
7788 ),
7789 "{err:?}"
7790 );
7791
7792 // And the review surface refuses it too: `inspect` runs this on
7793 // artifacts nobody has audited, so it must not be the way one exhausts
7794 // the process reviewing it.
7795 assert!(
7796 SandboxHost::imports_of(&wasm).is_err(),
7797 "the import listing compiled a module the loader would not"
7798 );
7799 }
7800
7801 #[test]
7802 fn the_metadata_footprint_covers_what_json_escaping_expands_it_to() {
7803 // The footprint charged metadata at `4 ×`: the caller's strings, the
7804 // frame's clone of them, and the serialised line priced at the raw byte
7805 // count. But JSON is an *escaping* encoding — `serde_json` writes a
7806 // control character as `\u0000`, six bytes for one — and every byte of
7807 // a metadata field can be one. An HTTP request cannot carry them, but
7808 // `SandboxHost::run` is public and an embedder builds the request by
7809 // hand, so the bound has to hold for the API rather than for the
7810 // adapter that is merely its politest caller.
7811 //
7812 // Measured, not asserted from arithmetic: the constant has to track
7813 // what the serialiser actually writes, so if that ever changes this
7814 // fails rather than quietly understating the product again.
7815 let granted = [SandboxCapability::HttpRequest];
7816 let filler = "\u{0}".repeat(4096);
7817
7818 let mut request = get("/hello/greet");
7819 request.query = filler.clone();
7820 let with = crate::plugin_sandbox::wire::to_line(
7821 &crate::plugin_sandbox::wire::HostFrame::request(&request, &granted),
7822 )
7823 .expect("serialises")
7824 .len();
7825
7826 request.query = String::new();
7827 let without = crate::plugin_sandbox::wire::to_line(
7828 &crate::plugin_sandbox::wire::HostFrame::request(&request, &granted),
7829 )
7830 .expect("serialises")
7831 .len();
7832
7833 // What one raw metadata byte becomes in the line.
7834 let expansion = with.saturating_sub(without) / filler.len();
7835 assert!(
7836 expansion > 4,
7837 "the fixture does not exceed the old factor, so it proves nothing: {expansion}"
7838 );
7839 assert!(
7840 expansion <= 6,
7841 "escaping expands further than the footprint charges: {expansion}"
7842 );
7843
7844 // …so the term must cover the caller's copy, the frame's clone, and the
7845 // expanded line — all three live while the line is built.
7846 //
7847 // Isolated by subtraction rather than compared against the whole
7848 // footprint: at the default limits the other terms come to ~58 MiB, so
7849 // `footprint >= 2 MiB` would hold at *any* metadata factor and prove
7850 // nothing. Zeroing the three manifest-driven terms leaves only the
7851 // fixed ones, which are subtracted here by name.
7852 let bare = ResourceLimits {
7853 memory_bytes: 0,
7854 max_request_body_bytes: 0,
7855 max_response_bytes: 0,
7856 ..ResourceLimits::default()
7857 };
7858 let fixed = u128::from(MAX_TABLE_ELEMENTS) * 16
7859 + MAX_GLOBALS as u128 * 16
7860 + MAX_FUNCTIONS as u128 * 32
7861 + FIXED_HOST_BUFFER_BYTES as u128;
7862 let charged_for_metadata = bare.request_footprint_bytes().saturating_sub(fixed);
7863 assert_eq!(
7864 charged_for_metadata,
7865 MAX_REQUEST_METADATA_BYTES as u128 * (2 + expansion as u128),
7866 "the metadata term does not match what the serialiser was measured to write"
7867 );
7868 }
7869
7870 #[test]
7871 fn request_metadata_over_the_ceiling_is_refused_before_the_frame_is_built() {
7872 // The body ceiling was the manifest's and only ever covered the body.
7873 // Everything else on a `SandboxRequest` — query, headers, path params —
7874 // is cloned into the frame and serialised into the NDJSON line just the
7875 // same, and `request_footprint_bytes` budgets for none of it. The
7876 // encoding charge prices those bytes, but a manifest may declare fuel
7877 // enough to buy more than a terabyte of them, so pricing is not a bound.
7878 let host = host(guests::HELLO);
7879 let mut request = get("/hello/greet");
7880 request.query = "x=".repeat(MAX_REQUEST_METADATA_BYTES);
7881
7882 let outcome = host.run(&request);
7883 let err = outcome
7884 .result
7885 .expect_err("oversized metadata must be refused");
7886 assert!(
7887 matches!(err, SandboxFailure::RequestMetadataBudget { .. }),
7888 "{err:?}"
7889 );
7890 // The caller's request was refused, not the plugin's answer: 413, the
7891 // same door the body ceiling answers through.
7892 assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
7893 assert_eq!(
7894 outcome.fuel_used, 0,
7895 "nothing was built, so nothing was spent"
7896 );
7897 }
7898
7899 #[test]
7900 fn the_metadata_ceiling_leaves_an_ordinary_request_alone() {
7901 // A ceiling that a real request could reach would be a bug of its own:
7902 // every HTTP server in front of this caps a URI and a header block far
7903 // below it.
7904 let host = host(guests::HELLO);
7905 let mut request = get("/hello/greet");
7906 request.query = "x=1&".repeat(64);
7907 request
7908 .headers
7909 .push(("x-trace".to_owned(), "a".repeat(1024)));
7910 assert!(
7911 host.run(&request).result.is_ok(),
7912 "an ordinary request must not be caught by the metadata ceiling"
7913 );
7914 }
7915
7916 #[test]
7917 fn a_body_over_the_ceiling_is_refused_by_run_itself() {
7918 // The Axum adapter applies the ceiling while reading, so nothing
7919 // oversized reaches `run` on that path. But `run` is public: an
7920 // embedder builds the `SandboxRequest` itself, and a manifest with
7921 // generous fuel would otherwise buy an arbitrarily large host-side
7922 // copy — cloned into the frame and base64-expanded — of a body the
7923 // manifest said it would never accept.
7924 let host = try_host_with(
7925 guests::HELLO,
7926 ResourceLimits {
7927 max_request_body_bytes: 1_024,
7928 // Deliberately generous — the largest a manifest may declare.
7929 // The encoding price must not be what saves us here, or the
7930 // ceiling is decorative on this path.
7931 fuel: 100_000_000_000,
7932 ..ResourceLimits::default()
7933 },
7934 )
7935 .expect("the fixture loads");
7936
7937 let mut request = get("/hello/greet");
7938 request.body = vec![b'x'; 1_025];
7939 let outcome = host.run(&request);
7940 assert_eq!(
7941 outcome.result,
7942 Err(SandboxFailure::RequestBudget {
7943 max: 1_024,
7944 len: 1_025,
7945 }),
7946 "a body over the ceiling must be refused, not encoded"
7947 );
7948 // Refused before the request was priced or walked at all.
7949 assert_eq!(outcome.fuel_used, 0);
7950 assert_eq!(outcome.peak_memory_bytes, 0);
7951 // The same answer the adapter gives for the same condition.
7952 assert_eq!(
7953 outcome.result.unwrap_err().status(),
7954 http::StatusCode::PAYLOAD_TOO_LARGE
7955 );
7956
7957 // And a body exactly at the ceiling is still served: the check is a
7958 // ceiling, not an off-by-one that costs the last byte.
7959 request.body = vec![b'x'; 1_024];
7960 let outcome = host.run(&request);
7961 assert!(outcome.result.is_ok(), "{:?}", outcome.result);
7962 }
7963
7964 #[test]
7965 fn a_malformed_frame_is_refused() {
7966 let outcome = host(guests::MALFORMED_FRAME).run(&get("/hello/greet"));
7967 assert!(
7968 matches!(outcome.result, Err(SandboxFailure::MalformedFrame(_))),
7969 "{:?}",
7970 outcome.result
7971 );
7972 }
7973
7974 #[test]
7975 fn an_op_the_wire_does_not_define_is_refused() {
7976 let outcome = host(guests::UNKNOWN_OP).run(&get("/hello/greet"));
7977 assert!(
7978 matches!(outcome.result, Err(SandboxFailure::MalformedFrame(_))),
7979 "{:?}",
7980 outcome.result
7981 );
7982 }
7983
7984 #[test]
7985 fn the_first_answer_is_the_answer() {
7986 let outcome = host(guests::DOUBLE_ANSWER).run(&get("/hello/greet"));
7987 assert_eq!(
7988 outcome.result.expect("answers").body,
7989 b"hello from the sandbox"
7990 );
7991 }
7992
7993 #[test]
7994 fn every_refusal_stub_matches_the_wasi_signature_it_stands_in_for() {
7995 // A wrong signature would not weaken the sandbox — it would stop an
7996 // honest guest from linking at all. This builds a module importing
7997 // every refusal with the shape the table declares and proves it
7998 // instantiates and runs.
7999 use std::fmt::Write as _;
8000
8001 let mut wat = String::from("(module\n");
8002 for (name, _, _, signature) in DENIED_IMPORTS {
8003 let params: Vec<&str> = signature
8004 .chars()
8005 .map(|ch| if ch == 'l' { "i64" } else { "i32" })
8006 .collect();
8007 let _ = writeln!(
8008 wat,
8009 " (import \"wasi_snapshot_preview1\" \"{name}\" (func (param {params}) (result i32)))",
8010 params = params.join(" ")
8011 );
8012 }
8013 wat.push_str(" (memory (export \"memory\") 1)\n (func (export \"_start\") (nop))\n)");
8014 let outcome = host(&wat).run(&get("/hello/greet"));
8015 assert!(
8016 matches!(outcome.result, Err(SandboxFailure::NoAnswer)),
8017 "every stub must link: {:?}",
8018 outcome.result
8019 );
8020 }
8021
8022 #[test]
8023 fn an_import_with_the_wrong_signature_is_refused_at_load() {
8024 // Name-checking alone lets a module through packaging and inspection
8025 // and then fails it on every request, as a gateway error nobody can
8026 // explain from outside.
8027 let wat = r#"(module
8028 (import "wasi_snapshot_preview1" "fd_write"
8029 (func (param i32 i32) (result i32)))
8030 (memory (export "memory") 1)
8031 (func (export "_start") (nop)))"#;
8032 let err = try_host(wat).expect_err("must be refused");
8033 let SandboxLoadError::ForbiddenImports(denials) = err else {
8034 panic!("expected a forbidden-import refusal, got {err}");
8035 };
8036 assert!(denials[0].operation.contains("fd_write"), "{denials:?}");
8037 assert!(denials[0].detail.contains("signature"), "{denials:?}");
8038 }
8039
8040 #[test]
8041 fn a_non_function_import_is_refused_at_load() {
8042 let wat = r#"(module
8043 (import "wasi_snapshot_preview1" "fd_write" (memory 1))
8044 (memory (export "memory") 1)
8045 (func (export "_start") (nop)))"#;
8046 assert!(matches!(
8047 try_host(wat),
8048 Err(SandboxLoadError::ForbiddenImports(_))
8049 ));
8050 }
8051
8052 #[test]
8053 fn a_start_that_takes_arguments_is_not_a_start() {
8054 // The host looks `_start` up as `() -> ()`, so anything else loads and
8055 // then fails on every request.
8056 let wat = r#"(module
8057 (memory (export "memory") 1)
8058 (func (export "_start") (param i32) (nop)))"#;
8059 assert!(matches!(try_host(wat), Err(SandboxLoadError::MissingStart)));
8060
8061 let wat = r#"(module
8062 (memory (export "memory") 1)
8063 (func (export "_start") (result i32) (i32.const 0)))"#;
8064 assert!(matches!(try_host(wat), Err(SandboxLoadError::MissingStart)));
8065 }
8066
8067 #[test]
8068 fn the_load_gate_admits_exactly_what_the_shim_defines() {
8069 for (name, ..) in SERVED_IMPORTS {
8070 assert!(is_shim_function(name), "{name} is served but not admitted");
8071 }
8072 for (name, ..) in DENIED_IMPORTS {
8073 assert!(is_shim_function(name), "{name} is refused but not admitted");
8074 }
8075 for name in ["sock_connect", "path_open_v2", "fd_write2"] {
8076 assert!(!is_shim_function(name), "{name} must not be admitted");
8077 }
8078 }
8079
8080 #[test]
8081 fn the_import_list_is_readable_for_review() {
8082 let imports = host(guests::HELLO).imports();
8083 assert!(
8084 imports.contains(&"wasi_snapshot_preview1::fd_read".to_owned()),
8085 "{imports:?}"
8086 );
8087 }
8088
8089 // ── Reply room is reserved before a call commits (issue #2523) ───
8090
8091 fn outbound_state(
8092 queued: usize,
8093 ) -> (
8094 HostState,
8095 std::sync::Arc<crate::plugin_sandbox::RecordingHttp>,
8096 ) {
8097 let limits = ResourceLimits::default();
8098 let manifest = crate::plugin_sandbox::manifest::SandboxManifest::parse(&format!(
8099 r#"
8100name = "hello"
8101version = "0.1.0"
8102wire_version = 1
8103prefix = "/hello"
8104capabilities = ["http-request", "http-outbound", "kv"]
8105sha256 = "{digest}"
8106
8107[[routes]]
8108method = "GET"
8109path = "/hello/greet"
8110
8111[grants]
8112hosts = ["api.example.com"]
8113"#,
8114 digest = "a".repeat(64)
8115 ))
8116 .expect("valid manifest");
8117 let http = crate::plugin_sandbox::RecordingHttp::new();
8118 let services = crate::plugin_sandbox::CapabilityServices {
8119 http: Some(std::sync::Arc::clone(&http)
8120 as std::sync::Arc<dyn crate::plugin_sandbox::OutboundHttp>),
8121 kv: Some(crate::plugin_sandbox::MemoryKvStore::new()
8122 as std::sync::Arc<dyn crate::plugin_sandbox::KvStore>),
8123 ..crate::plugin_sandbox::CapabilityServices::none()
8124 };
8125 let mut state = HostState::new(
8126 "hello".to_owned(),
8127 limits,
8128 b"",
8129 Exchange::Request,
8130 crate::plugin_sandbox::capability::CapabilityRuntime::new(&manifest, services),
8131 );
8132 state.queued_replies = queued;
8133 (state, http)
8134 }
8135
8136 fn post() -> crate::plugin_sandbox::CapabilityCall {
8137 crate::plugin_sandbox::CapabilityCall::HttpFetch {
8138 id: 1,
8139 method: "POST".to_owned(),
8140 url: "https://api.example.com/orders".to_owned(),
8141 headers: Vec::new(),
8142 body: String::new(),
8143 }
8144 }
8145
8146 #[test]
8147 fn a_call_is_not_committed_when_its_reply_could_not_be_queued() {
8148 // One MiB queue, nearly full of unread replies: a POST could commit and
8149 // then fail the request on its own reply.
8150 let (mut state, http) = outbound_state(MAX_QUEUED_REPLY_BYTES - 1024);
8151 state.service(&post(), u64::MAX);
8152 assert!(http.seen().is_empty(), "the POST left the host");
8153 assert!(state.answer.is_none(), "the request must go on");
8154 let queued =
8155 String::from_utf8_lossy(&state.stdin.iter().copied().collect::<Vec<_>>()).into_owned();
8156 assert!(queued.contains("quota-exceeded"), "{queued}");
8157 }
8158
8159 #[test]
8160 fn a_call_is_not_committed_when_the_fuel_could_not_pay_for_its_reply() {
8161 let (mut state, http) = outbound_state(0);
8162 state.service(&post(), 1_000);
8163 assert!(http.seen().is_empty(), "the POST left the host");
8164 assert!(state.answer.is_none(), "the request must go on");
8165 }
8166
8167 #[test]
8168 fn a_call_with_room_and_fuel_is_dispatched() {
8169 let (mut state, http) = outbound_state(0);
8170 state.service(&post(), u64::MAX);
8171 assert_eq!(http.seen().len(), 1);
8172 }
8173 /// Fuel to price `call`'s worst-case reply and write the answer.
8174 fn priced(call: &crate::plugin_sandbox::CapabilityCall) -> u64 {
8175 let allowance = FINISH_ALLOWANCE_FUEL.min(ResourceLimits::default().fuel / 4);
8176 allowance + (call.reply_reserve() as u64).div_ceil(BYTES_PER_FUEL)
8177 }
8178
8179 fn get_call() -> crate::plugin_sandbox::CapabilityCall {
8180 crate::plugin_sandbox::CapabilityCall::HttpFetch {
8181 id: 1,
8182 method: "GET".to_owned(),
8183 url: "https://api.example.com/".to_owned(),
8184 headers: Vec::new(),
8185 body: String::new(),
8186 }
8187 }
8188
8189 #[test]
8190 fn the_fuel_edge_for_a_write_includes_the_guest_s_read() {
8191 // #2523 finding 7. Fuel to price the reply is not fuel to read it. A
8192 // POST that commits and then runs out of fuel in the guest fails, and a
8193 // retry sends it again.
8194 let need = priced(&post()) + post().reply_reserve() as u64 * GUEST_READ_FUEL_PER_BYTE;
8195 let (mut state, http) = outbound_state(0);
8196 state.service(&post(), priced(&post()));
8197 assert!(http.seen().is_empty(), "the guest could not read the reply");
8198 let (mut state, http) = outbound_state(0);
8199 state.service(&post(), need - 1);
8200 assert!(http.seen().is_empty(), "one unit short must not dispatch");
8201 let (mut state, http) = outbound_state(0);
8202 state.service(&post(), need);
8203 assert_eq!(http.seen().len(), 1);
8204 }
8205
8206 #[test]
8207 fn the_fuel_edge_for_a_read_is_its_price() {
8208 // A read that fails commits nothing, so it keeps no fuel to be read.
8209 let (mut state, http) = outbound_state(0);
8210 state.service(&get_call(), priced(&get_call()) - 1);
8211 assert!(http.seen().is_empty(), "one unit short must not dispatch");
8212 let (mut state, http) = outbound_state(0);
8213 state.service(&get_call(), priced(&get_call()));
8214 assert_eq!(http.seen().len(), 1);
8215 }
8216
8217 /// What the host queued for the guest, as text.
8218 fn queued_text(state: &HostState) -> String {
8219 String::from_utf8_lossy(&state.stdin.iter().copied().collect::<Vec<_>>()).into_owned()
8220 }
8221
8222 /// Fuel to price `call`, write the answer, and read `unread` bytes plus the
8223 /// reply.
8224 fn with_read(call: &crate::plugin_sandbox::CapabilityCall, unread: usize) -> u64 {
8225 priced(call) + (call.reply_reserve() + unread) as u64 * GUEST_READ_FUEL_PER_BYTE
8226 }
8227
8228 #[test]
8229 fn a_fuel_refusal_and_a_queue_refusal_say_different_things() {
8230 let (mut state, http) = outbound_state(0);
8231 state.service(&post(), priced(&post()));
8232 assert!(http.seen().is_empty());
8233 assert!(state.answer.is_none(), "the request must go on");
8234 let text = queued_text(&state);
8235 assert!(text.contains("quota-exceeded"), "{text}");
8236 assert!(text.contains("increase `fuel`"), "{text}");
8237 assert!(!text.contains("pending replies"), "{text}");
8238
8239 let (mut state, _) = outbound_state(MAX_QUEUED_REPLY_BYTES - 1024);
8240 state.service(&post(), u64::MAX);
8241 let text = queued_text(&state);
8242 assert!(text.contains("pending replies"), "{text}");
8243 assert!(!text.contains("fuel"), "{text}");
8244 }
8245
8246 #[test]
8247 fn a_write_keeps_fuel_to_read_the_replies_not_yet_read() {
8248 let unread = 10_000;
8249 let (mut state, http) = outbound_state(unread);
8250 state.service(&post(), with_read(&post(), unread) - 1);
8251 assert!(http.seen().is_empty(), "one unit short must not dispatch");
8252 let (mut state, http) = outbound_state(unread);
8253 state.service(&post(), with_read(&post(), unread));
8254 assert_eq!(http.seen().len(), 1);
8255 }
8256
8257 #[test]
8258 fn a_read_after_a_write_keeps_fuel_to_read_its_reply() {
8259 // The write has run. A read that then fails the request makes a retry
8260 // do the write again.
8261 let (mut state, http) = outbound_state(0);
8262 state.service(&post(), u64::MAX);
8263 assert_eq!(http.seen().len(), 1);
8264 state.service(
8265 &get_call(),
8266 with_read(&get_call(), state.queued_replies) - 1,
8267 );
8268 assert_eq!(http.seen().len(), 1, "one unit short must not dispatch");
8269 state.service(&get_call(), with_read(&get_call(), state.queued_replies));
8270 assert_eq!(http.seen().len(), 2);
8271 }
8272
8273 #[test]
8274 fn a_write_refused_before_it_runs_does_not_make_reads_keep_fuel() {
8275 let (mut state, http) = outbound_state(0);
8276 state.service(&post(), priced(&post()));
8277 assert!(http.seen().is_empty());
8278 state.service(&get_call(), priced(&get_call()));
8279 assert_eq!(http.seen().len(), 1);
8280 }
8281
8282 #[test]
8283 fn the_fuel_edge_for_a_kv_write_is_about_0_6_million() {
8284 let set = crate::plugin_sandbox::CapabilityCall::KvSet {
8285 id: 1,
8286 key: "cart".to_owned(),
8287 value: crate::plugin_sandbox::PluginValue::Text("one".to_owned()),
8288 };
8289 // The guide quotes this number.
8290 assert_eq!(with_read(&set, 0), 624_352);
8291 let (mut state, _) = outbound_state(0);
8292 state.service(&set, with_read(&set, 0) - 1);
8293 assert!(queued_text(&state).contains("increase `fuel`"));
8294 let (mut state, _) = outbound_state(0);
8295 state.service(&set, with_read(&set, 0));
8296 let text = queued_text(&state);
8297 assert!(text.contains(r#""status":"ok""#), "{text}");
8298 }
8299
8300 #[test]
8301 fn a_get_is_denied_not_failed_when_the_fuel_cannot_price_its_reply() {
8302 let read = || crate::plugin_sandbox::CapabilityCall::HttpFetch {
8303 id: 1,
8304 method: "GET".to_owned(),
8305 url: "https://api.example.com/".to_owned(),
8306 headers: Vec::new(),
8307 body: String::new(),
8308 };
8309 let queued = |state: &HostState| {
8310 String::from_utf8_lossy(&state.stdin.iter().copied().collect::<Vec<_>>()).into_owned()
8311 };
8312 let (mut state, _) = outbound_state(0);
8313 state.service(&read(), 10);
8314 assert!(state.answer.is_none(), "the request must go on");
8315 assert!(
8316 queued(&state).contains("quota-exceeded"),
8317 "{}",
8318 queued(&state)
8319 );
8320 let (mut state, _) = outbound_state(0);
8321 state.service(&read(), 1_000_000);
8322 assert!(
8323 !queued(&state).contains("quota-exceeded"),
8324 "{}",
8325 queued(&state)
8326 );
8327 }
8328}