Skip to main content

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(&section);
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}