Skip to main content

autumn_web/plugin_sandbox/
plugin.rs

1//! Mounting a sandboxed artifact as an ordinary Autumn [`Plugin`].
2//!
3//! A [`SandboxedPlugin`] is installed exactly like a native one:
4//!
5//! ```rust,no_run
6//! # use std::path::Path;
7//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
8//! let plugin = autumn_web::plugin_sandbox::SandboxedPlugin::from_file(
9//!     Path::new("plugins/hello.autumn-plugin"),
10//! )?;
11//! let app = autumn_web::app().plugin(plugin);
12//! # let _ = app;
13//! # Ok(())
14//! # }
15//! ```
16//!
17//! …and from the application's point of view that is where the resemblance
18//! ends. A native plugin's `build` receives the whole `AppBuilder`. This one
19//! receives nothing: the mount is performed by the framework, from the
20//! manifest, and consists of exactly one `nest` at the declared prefix plus the
21//! declared route metadata.
22//!
23//! # The manifest is the mount
24//!
25//! The router is built from the manifest's `[[routes]]`, one axum route per
26//! declared `(method, path)`. A request to an undeclared path under the prefix
27//! is a 404 the guest never sees, and a request to a path *outside* the prefix
28//! never reaches this router at all. So "which routes it mounts" is a property
29//! the runtime enforces, not a claim the artifact makes.
30//!
31//! # What a misbehaving plugin costs
32//!
33//! | Its behaviour | What the caller gets | What the rest of the app gets |
34//! | --- | --- | --- |
35//! | answers | its answer, minus any header it may not set | nothing |
36//! | traps, exits, answers badly | 502 on its own prefix | nothing |
37//! | spins or floods output | 504 on its own prefix | nothing |
38//! | is already at its concurrency ceiling | 503 with `Retry-After` | nothing |
39//! | finds the shared guest pool full | 503 with `Retry-After` | nothing |
40//! | is sent a body over its ceiling | 413, guest never started | nothing |
41//!
42//! The last column is the point. Every failure mode is scoped to the plugin's
43//! own prefix, because the interpreter runs on a blocking worker with a bounded
44//! permit count and can only ever return a value.
45// autumn-panic-gate: request-path module — production code path must be panic-free.
46// See CONTRIBUTING.md "Request-path panic gate". Justify exceptions with
47// #[allow(clippy::<lint>, reason = "…")] at the narrowest scope.
48#![cfg_attr(
49    not(test),
50    deny(
51        clippy::unwrap_used,
52        clippy::expect_used,
53        clippy::panic,
54        clippy::unreachable,
55        clippy::todo,
56        clippy::unimplemented,
57        clippy::indexing_slicing,
58        clippy::string_slice,
59        clippy::arithmetic_side_effects,
60    )
61)]
62
63use std::borrow::Cow;
64use std::fmt;
65use std::path::Path;
66use std::sync::{Arc, OnceLock};
67
68use axum::body::Body;
69use axum::response::{IntoResponse as _, Response};
70use axum::routing::MethodFilter;
71use http::{HeaderName, HeaderValue, StatusCode};
72use tokio::sync::Semaphore;
73
74use super::artifact::{ArtifactError, SandboxArtifact};
75use super::capability::{CapabilityRateLimiter, CapabilityServices, PluginActivityLog};
76use super::host::{SandboxHost, SandboxLoadError};
77use super::manifest::SandboxManifest;
78use super::wire::SandboxRequest;
79use crate::app::AppBuilder;
80use crate::plugin::Plugin;
81
82/// Response header naming the sandboxed plugin that produced a response.
83///
84/// Sandboxed responses are otherwise indistinguishable from the host's own, and
85/// an operator reading a trace should never have to guess which side of the
86/// boundary a byte came from.
87pub const SANDBOX_ATTRIBUTION_HEADER: &str = "x-autumn-sandboxed";
88
89/// Why a sandboxed plugin could not be installed.
90#[derive(Debug, Clone, PartialEq, Eq)]
91#[non_exhaustive]
92pub enum SandboxPluginError {
93    /// The artifact could not be read or verified.
94    Artifact(ArtifactError),
95    /// The module could not be loaded into the sandbox.
96    Load(SandboxLoadError),
97}
98
99impl fmt::Display for SandboxPluginError {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        match self {
102            Self::Artifact(err) => write!(f, "{err}"),
103            Self::Load(err) => write!(f, "{err}"),
104        }
105    }
106}
107
108impl std::error::Error for SandboxPluginError {
109    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
110        match self {
111            Self::Artifact(err) => Some(err),
112            Self::Load(err) => Some(err),
113        }
114    }
115}
116
117impl From<ArtifactError> for SandboxPluginError {
118    fn from(err: ArtifactError) -> Self {
119        Self::Artifact(err)
120    }
121}
122
123impl From<SandboxLoadError> for SandboxPluginError {
124    fn from(err: SandboxLoadError) -> Self {
125        Self::Load(err)
126    }
127}
128
129/// Default number of guests that may run at once in the whole process.
130///
131/// A guest runs on Tokio's shared blocking pool. That pool has 512 threads by
132/// default. A quarter of it leaves the rest for host work, such as password
133/// hashing. Change the limit with [`set_guest_slots`].
134pub const DEFAULT_GUEST_SLOTS: usize = 128;
135
136static GUEST_SLOTS: OnceLock<(usize, Arc<Semaphore>)> = OnceLock::new();
137
138/// Set the process-wide guest limit. Call once, before any plugin is built.
139///
140/// Set it to at most a quarter of the runtime's `max_blocking_threads`. It
141/// caps all sandboxed plugins together, on top of each `max_concurrency`; a
142/// request over it gets 503.
143///
144/// The pool is first come, first served. A plugin with a high `max_concurrency`
145/// can take all of it, so keep each `max_concurrency` well under the limit.
146///
147/// # Errors
148///
149/// Returns the limit already in force if one was set. Returns 0 if `slots` is
150/// zero or too large.
151pub fn set_guest_slots(slots: usize) -> Result<(), usize> {
152    fix_slots(&GUEST_SLOTS, slots)
153}
154
155fn fix_slots(cell: &OnceLock<(usize, Arc<Semaphore>)>, slots: usize) -> Result<(), usize> {
156    if slots == 0 || slots > Semaphore::MAX_PERMITS {
157        return Err(0);
158    }
159    cell.set((slots, Arc::new(Semaphore::new(slots))))
160        .map_err(|_| cell.get().map_or(DEFAULT_GUEST_SLOTS, |(held, _)| *held))
161}
162
163fn guest_slots() -> Arc<Semaphore> {
164    let (_, pool) = GUEST_SLOTS.get_or_init(|| {
165        (
166            DEFAULT_GUEST_SLOTS,
167            Arc::new(Semaphore::new(DEFAULT_GUEST_SLOTS)),
168        )
169    });
170    Arc::clone(pool)
171}
172
173/// A sandboxed plugin, ready to mount.
174///
175/// `Clone` shares the compiled host, the concurrency permits, the capability
176/// backends and the activity log rather than copying them — so registering a
177/// clone with [`RenderSlots`](super::slots::RenderSlots) and then installing the
178/// original as a [`Plugin`] gives one plugin with one ceiling and one ledger,
179/// not two of each.
180#[derive(Clone)]
181pub struct SandboxedPlugin {
182    host: Arc<SandboxHost>,
183    /// One permit per concurrently-executing request, so `max_concurrency ×
184    /// memory_bytes` bounds what this plugin can cost the host at any instant.
185    permits: Arc<Semaphore>,
186    /// The process-wide pool of guest slots, shared by every sandboxed plugin.
187    guest_slots: Arc<Semaphore>,
188    /// The identity an operator reviewed, logged at mount so the grant in the
189    /// log can be compared with the one on the review screen. `None` when the
190    /// plugin was built from a bare host rather than an artifact — there is no
191    /// container to have an identity, and saying so beats inventing one.
192    artifact_sha256: Option<String>,
193    /// The capability backends, minus the tenant (issue #1632).
194    ///
195    /// Held without a tenant and bound to one per request, because one compiled
196    /// plugin serves every tenant: a `CapabilityServices` stored here with a
197    /// tenant in it would be one tenant's, for everybody.
198    services: CapabilityServices,
199    /// What this plugin has been doing, for the operator audit surface.
200    activity: Arc<PluginActivityLog>,
201}
202
203impl fmt::Debug for SandboxedPlugin {
204    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205        f.debug_struct("SandboxedPlugin")
206            .field("plugin", &self.host.manifest().name)
207            .field("prefix", &self.host.manifest().prefix)
208            .finish_non_exhaustive()
209    }
210}
211
212impl SandboxedPlugin {
213    /// Wrap an already-compiled host.
214    #[must_use]
215    pub fn new(host: SandboxHost) -> Self {
216        let permits = Arc::new(Semaphore::new(host.manifest().limits.max_concurrency));
217        // The rate limiter is built here rather than by the caller, and from
218        // the manifest rather than from a parameter, so a plugin that names a
219        // `calls_per_second` on its consent screen is limited by it whether or
220        // not whoever mounted it remembered to say so.
221        let services = CapabilityServices {
222            rate: Some(Arc::new(CapabilityRateLimiter::new(
223                host.manifest().quotas.calls_per_second,
224            ))),
225            ..CapabilityServices::none()
226        };
227        Self {
228            host: Arc::new(host),
229            permits,
230            guest_slots: guest_slots(),
231            artifact_sha256: None,
232            services,
233            activity: Arc::new(PluginActivityLog::new()),
234        }
235    }
236
237    /// Use a private guest pool. For tests.
238    #[cfg(test)]
239    fn with_guest_slots(mut self, slots: Arc<Semaphore>) -> Self {
240        self.guest_slots = slots;
241        self
242    }
243
244    /// Wire the backends this plugin's granted capabilities need (issue #1632).
245    ///
246    /// Anything left unwired stays unwired: a call to a capability with no
247    /// backend is answered
248    /// [`unavailable`](crate::plugin_sandbox::DenialReason::Unavailable) and
249    /// recorded, which is the same shape as every other refusal and not a
250    /// silent success.
251    ///
252    /// `services.tenant` is ignored — the tenant is the *request's*, resolved
253    /// per request — and `services.rate` may replace the limiter built from the
254    /// manifest, for an embedder that wants one shared across a fleet.
255    ///
256    /// *May*, not *does*: a replacement is taken only when it is no looser than
257    /// the manifest's. Sharing limiting state across instances is what this is
258    /// for; raising the ceiling is not, and silently allowing it would make the
259    /// `calls_per_second` on the consent screen a number the operator approved
260    /// and the plugin need not obey. A looser limiter is ignored and the
261    /// manifest's kept, so wiring one by mistake costs coordination rather than
262    /// containment.
263    #[must_use]
264    pub fn with_services(mut self, services: CapabilityServices) -> Self {
265        let approved = self
266            .services
267            .rate
268            .as_ref()
269            .map_or(u32::MAX, |rate| rate.per_second());
270        let rate = services
271            .rate
272            .clone()
273            .filter(|supplied| supplied.per_second() <= approved)
274            .or_else(|| self.services.rate.clone());
275        self.services = CapabilityServices {
276            tenant: None,
277            rate,
278            ..services
279        };
280        self
281    }
282
283    /// What this plugin has done, for the operator audit surface.
284    ///
285    /// Shared with every mounted route, so a caller that keeps this handle sees
286    /// activity as it happens rather than a copy taken at mount.
287    #[must_use]
288    pub fn activity(&self) -> Arc<PluginActivityLog> {
289        Arc::clone(&self.activity)
290    }
291
292    /// Load a verified artifact.
293    ///
294    /// # Errors
295    ///
296    /// Returns [`SandboxPluginError::Load`] if the module does not compile or
297    /// imports something the sandbox does not provide.
298    pub fn from_artifact(artifact: &SandboxArtifact) -> Result<Self, SandboxPluginError> {
299        // The whole container, not just the module: the grant an operator
300        // reviewed is in the manifest, and the module digest does not move when
301        // that does. See `SandboxArtifact::artifact_digest`.
302        let digest = artifact.artifact_digest()?;
303        let mut plugin = Self::new(SandboxHost::load(artifact)?);
304        plugin.artifact_sha256 = Some(digest);
305        Ok(plugin)
306    }
307
308    /// Read, verify and load a `.autumn-plugin` file.
309    ///
310    /// # Errors
311    ///
312    /// Returns [`SandboxPluginError::Artifact`] if the file is missing,
313    /// malformed, or does not match its declared digest, and
314    /// [`SandboxPluginError::Load`] if the module cannot be sandboxed.
315    pub fn from_file(path: &Path) -> Result<Self, SandboxPluginError> {
316        Self::from_artifact(&SandboxArtifact::read_file(path)?)
317    }
318
319    /// Count the requests that run now.
320    pub(super) fn in_flight(&self) -> usize {
321        self.manifest()
322            .limits
323            .max_concurrency
324            .saturating_sub(self.permits.available_permits())
325    }
326
327    /// Take one permit, like a running request. Tests use this.
328    #[cfg(test)]
329    pub(super) fn hold_permit_for_test(&self) -> tokio::sync::OwnedSemaphorePermit {
330        Arc::clone(&self.permits)
331            .try_acquire_owned()
332            .expect("a free permit")
333    }
334
335    /// The artifact digest, if the plugin came from an artifact.
336    pub(super) fn artifact_sha256(&self) -> Option<&str> {
337        self.artifact_sha256.as_deref()
338    }
339
340    /// Log the capability grant. `generation` is 0 for a boot-time mount.
341    pub(super) fn log_grant(&self, message: &str, generation: u64) {
342        let manifest = self.manifest();
343        // The grant reaches the operator's log before the plugin serves its
344        // first request, so "what did we agree to run" is answerable from a
345        // production log alone, without the artifact in hand.
346        tracing::info!(
347            plugin = manifest.name,
348            version = manifest.version,
349            prefix = manifest.prefix,
350            module_sha256 = manifest.sha256,
351            // The number to compare against the one `plugin inspect` printed:
352            // it covers the manifest too, so a grant rewritten after review
353            // does not match a log line from before it.
354            artifact_sha256 = self
355                .artifact_sha256
356                .as_deref()
357                .unwrap_or("(built from a host, not an artifact)"),
358            capabilities = manifest
359                .capabilities
360                .iter()
361                .map(|capability| capability.as_str())
362                .collect::<Vec<_>>()
363                .join(","),
364            generation,
365            routes = manifest.routes.len(),
366            fuel = manifest.limits.fuel,
367            memory_bytes = manifest.limits.memory_bytes,
368            max_concurrency = manifest.limits.max_concurrency,
369            "{message}"
370        );
371    }
372
373    /// The manifest this plugin is mounted under.
374    #[must_use]
375    pub fn manifest(&self) -> &SandboxManifest {
376        self.host.manifest()
377    }
378
379    /// Fill one render slot (issue #1632).
380    ///
381    /// Returns `None` — meaning *omit the fragment* — for every failure there
382    /// is: the slot was not granted, the guest trapped, it ran out of fuel, it
383    /// answered with something this build will not emit, or it overran the
384    /// `render_bytes` quota. A render hook is decoration on somebody else's
385    /// page, and there is no failure of a plugin's that should become a failure
386    /// of the page.
387    ///
388    /// Runs on a blocking worker for the same reason a request does, and holds
389    /// a concurrency permit for the same reason: a slot on a hot page is as
390    /// many concurrent interpreters as the page has readers.
391    pub async fn render_slot(&self, slot: &str, context: &[(String, String)]) -> Option<String> {
392        let plugin = self.manifest().name.clone();
393        let Ok(permit) = Arc::clone(&self.permits).try_acquire_owned() else {
394            tracing::warn!(
395                plugin,
396                slot,
397                "sandboxed plugin is at its concurrency ceiling; omitting its render fragment"
398            );
399            return None;
400        };
401        let Ok(slot_permit) = Arc::clone(&self.guest_slots).try_acquire_owned() else {
402            tracing::warn!(
403                plugin,
404                slot,
405                "the sandbox guest pool is full; omitting its render fragment"
406            );
407            return None;
408        };
409        let services = CapabilityServices {
410            // Read here rather than in the closure; see the note in `serve`.
411            tenant: crate::tenancy::CURRENT_TENANT
412                .try_with(Clone::clone)
413                .ok()
414                .flatten(),
415            ..self.services.clone()
416        };
417        let host = Arc::clone(&self.host);
418        let ingest_log = Arc::clone(&self.activity);
419        let ingest_plugin = plugin.clone();
420        let slot_owned = slot.to_owned();
421        // Bounded before it is cloned onto the worker. The context is the
422        // *host's* — a handler builds it — but "the host built it" is not the
423        // same as "the host chose its size": it is routinely assembled from a
424        // row, a query string or a form, and none of it appears in the
425        // per-request footprint `max_concurrency` is validated against. An
426        // unbounded `to_vec` duplicates every string of it per render, at every
427        // concurrent render at once. Truncated rather than refused: a fragment
428        // rendered with fewer context entries is a panel missing a value, and a
429        // page losing its panel over a caller's mistake is the outcome this
430        // whole path exists to avoid.
431        let context = super::host::bounded_context(context);
432        let outcome = crate::time::spawn_blocking(move || {
433            let outcome = host.render(&slot_owned, &context, services);
434            // Inside the closure for the same reason as `serve`: a reader who
435            // navigates away drops this future and its join handle while the
436            // blocking task runs on, and recording after the `.await` lost the
437            // whole record of what the hook did.
438            ingest_log.ingest(&ingest_plugin, outcome.activity.clone());
439            if let Some(dropped) = outcome.dropped_calls {
440                ingest_log.ingest_dropped_calls(&ingest_plugin, dropped);
441            }
442            drop(permit);
443            drop(slot_permit);
444            outcome
445        })
446        .await;
447
448        let outcome = match outcome {
449            Ok(outcome) => outcome,
450            Err(err) => {
451                tracing::error!(
452                    plugin,
453                    slot,
454                    error = %err,
455                    "the sandbox render worker did not complete; omitting the fragment"
456                );
457                return None;
458            }
459        };
460        // Already ingested inside the blocking task above.
461        match outcome.fragment {
462            Ok(fragment) => Some(fragment),
463            Err(failure) => {
464                tracing::warn!(
465                    plugin,
466                    slot,
467                    failure = %failure,
468                    stderr = outcome.stderr,
469                    fuel_used = outcome.fuel_used,
470                    "sandboxed plugin did not produce a render fragment; omitting it"
471                );
472                None
473            }
474        }
475    }
476
477    /// This plugin, mounted at the prefix its own manifest declares.
478    ///
479    /// The public door, for a test or an embedder that wants the routes without
480    /// an [`AppBuilder`]. It nests at `self.manifest().prefix` and nowhere else:
481    /// a caller that could choose the prefix could mount a plugin over a
482    /// namespace its manifest never named, and the prefix would stop being a
483    /// containment boundary.
484    ///
485    /// Generic over the router's state because the handlers use none — a
486    /// sandboxed plugin has no access to application state, which is the point.
487    ///
488    /// Prefer installing it as a [`Plugin`], which also logs the capability
489    /// grant and declares the routes for `autumn routes` and
490    /// `autumn plugin-check`.
491    pub fn mounted_router<S>(&self) -> axum::Router<S>
492    where
493        S: Clone + Send + Sync + 'static,
494    {
495        axum::Router::new().nest(&self.manifest().prefix.clone(), self.router())
496    }
497
498    /// The router for this plugin's declared routes, with paths **relative to
499    /// the declared prefix** — the shape [`AppBuilder::nest`] expects.
500    fn router<S>(&self) -> axum::Router<S>
501    where
502        S: Clone + Send + Sync + 'static,
503    {
504        let manifest = self.manifest();
505        let mut router = axum::Router::new();
506        for route in &manifest.routes {
507            let nested = nested_path(&route.path, &manifest.prefix);
508            // A declared HEAD on the same path mounts itself; adding the
509            // implied one here as well would be an overlapping method route,
510            // which axum refuses by panicking as the router is built.
511            let implies_head = route.method == "GET"
512                && !manifest
513                    .routes
514                    .iter()
515                    .any(|other| other.method == "HEAD" && other.path == route.path);
516            let Some(filter) = method_filter(&route.method, implies_head) else {
517                // `SandboxManifest` validation already refused every method
518                // outside the allowed set, so this is unreachable in practice.
519                // Skipping rather than panicking keeps a future manifest
520                // vocabulary from turning into a boot crash.
521                tracing::error!(
522                    plugin = manifest.name,
523                    method = route.method,
524                    "sandboxed plugin declared a method this build cannot mount; skipping it"
525                );
526                continue;
527            };
528            let host = Arc::clone(&self.host);
529            let permits = Arc::clone(&self.permits);
530            let guest_slots = Arc::clone(&self.guest_slots);
531            let services = self.services.clone();
532            let activity = Arc::clone(&self.activity);
533            let pattern = route.path.clone();
534            router = router.route(
535                &nested,
536                axum::routing::on(
537                    filter,
538                    move |params: axum::extract::RawPathParams, request: axum::extract::Request| {
539                        let mounted = Mounted {
540                            host: Arc::clone(&host),
541                            permits: Arc::clone(&permits),
542                            guest_slots: Arc::clone(&guest_slots),
543                            services: services.clone(),
544                            activity: Arc::clone(&activity),
545                        };
546                        let pattern = pattern.clone();
547                        async move { serve(mounted, pattern, params, request).await }
548                    },
549                ),
550            );
551        }
552        router
553    }
554}
555
556impl Plugin for SandboxedPlugin {
557    fn name(&self) -> Cow<'static, str> {
558        Cow::Owned(self.manifest().name.clone())
559    }
560
561    fn build(self, app: AppBuilder) -> AppBuilder {
562        let manifest = self.manifest().clone();
563        self.log_grant(
564            "mounting a sandboxed plugin under its declared capability grant",
565            0,
566        );
567        app.nest(&manifest.prefix, self.router())
568            .declare_plugin_routes(manifest.route_infos())
569    }
570}
571
572/// The path a declared route takes inside a router nested at `prefix`.
573///
574/// A route *at* the prefix nests as `/`, which is how axum spells "the mount
575/// point itself".
576fn nested_path(path: &str, prefix: &str) -> String {
577    match path.strip_prefix(prefix) {
578        Some("") | None => "/".to_owned(),
579        Some(rest) => rest.to_owned(),
580    }
581}
582
583/// The filter a declared method mounts under.
584///
585/// `GET` mounts as `GET | HEAD` when the manifest does not declare a HEAD of
586/// its own. HTTP defines HEAD as GET without a body, and axum's method router
587/// already dispatches a HEAD with no HEAD route to the GET one — so the
588/// alternative to naming it here is not "HEAD is refused", it is "HEAD is
589/// served by an accident the manifest never mentions". When the manifest *does*
590/// declare HEAD, that route mounts itself and the implication must be dropped:
591/// two overlapping method routes on one path is a panic as the router builds.
592/// [`SandboxManifest::route_infos`](crate::plugin_sandbox::SandboxManifest::route_infos)
593/// reports the implied HEAD for the same reason.
594fn method_filter(method: &str, implies_head: bool) -> Option<MethodFilter> {
595    match method {
596        "GET" if implies_head => Some(MethodFilter::GET.or(MethodFilter::HEAD)),
597        "GET" => Some(MethodFilter::GET),
598        "HEAD" => Some(MethodFilter::HEAD),
599        "POST" => Some(MethodFilter::POST),
600        "PUT" => Some(MethodFilter::PUT),
601        "PATCH" => Some(MethodFilter::PATCH),
602        "DELETE" => Some(MethodFilter::DELETE),
603        "OPTIONS" => Some(MethodFilter::OPTIONS),
604        _ => None,
605    }
606}
607
608/// Serve one request through the sandbox.
609/// Everything one mounted route needs to serve a request.
610///
611/// Gathered into a struct because the four move together into every route
612/// closure and then into `serve`, and a five-argument call whose first four are
613/// always the same four is a place for two of them to be swapped.
614struct Mounted {
615    host: Arc<SandboxHost>,
616    permits: Arc<Semaphore>,
617    guest_slots: Arc<Semaphore>,
618    services: CapabilityServices,
619    activity: Arc<PluginActivityLog>,
620}
621
622async fn serve(
623    mounted: Mounted,
624    pattern: String,
625    params: axum::extract::RawPathParams,
626    request: axum::extract::Request,
627) -> Response {
628    let route = crate::middleware::access_log::ResponseRoute(pattern.clone());
629    let mut response = serve_inner(mounted, pattern, params, request).await;
630    response.extensions_mut().insert(route);
631    response
632}
633
634async fn serve_inner(
635    mounted: Mounted,
636    pattern: String,
637    params: axum::extract::RawPathParams,
638    request: axum::extract::Request,
639) -> Response {
640    let Mounted {
641        host,
642        permits,
643        guest_slots,
644        services,
645        activity,
646    } = mounted;
647    let manifest = host.manifest();
648    let limits = manifest.limits;
649    let plugin = manifest.name.clone();
650
651    // Refuse rather than queue. An unbounded queue in front of a bounded
652    // interpreter converts a slow plugin into unbounded host memory; a 503 with
653    // `Retry-After` says exactly what happened and costs nothing to produce.
654    let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else {
655        tracing::warn!(
656            plugin,
657            max_concurrency = limits.max_concurrency,
658            "sandboxed plugin is at its concurrency ceiling; shedding the request"
659        );
660        let mut response = sandbox_error(&plugin, StatusCode::SERVICE_UNAVAILABLE);
661        response
662            .headers_mut()
663            .insert(http::header::RETRY_AFTER, HeaderValue::from_static("1"));
664        return response;
665    };
666
667    let request = match read_request(&plugin, limits, pattern, &params, request).await {
668        Ok(request) => request,
669        Err(response) => return *response,
670    };
671
672    // Taken after the body is read, so a slow client holds no slot. Held,
673    // like the plugin's permit, inside the blocking task. See
674    // `DEFAULT_GUEST_SLOTS`.
675    let Ok(slot) = guest_slots.try_acquire_owned() else {
676        tracing::warn!(
677            plugin,
678            "the sandbox guest pool is full; shedding the request"
679        );
680        let mut response = sandbox_error(&plugin, StatusCode::SERVICE_UNAVAILABLE);
681        response
682            .headers_mut()
683            .insert(http::header::RETRY_AFTER, HeaderValue::from_static("1"));
684        return response;
685    };
686
687    // `wasmi` is a synchronous interpreter: running it on the async runtime
688    // would block a worker for the whole fuel budget. `spawn_blocking` also
689    // means a panic anywhere in the host shim surfaces as a `JoinError` here
690    // instead of unwinding through the request task.
691    //
692    // The permit moves INTO the closure. A `spawn_blocking` task is never
693    // cancelled — dropping its handle detaches it — so a permit held by this
694    // future would be released the instant a client disconnects while the
695    // interpreter kept running. `max_concurrency` would then bound nothing: a
696    // client that connects and immediately resets, in a loop, would fill the
697    // shared blocking pool with interpreters nobody is waiting for.
698    // Captured before the request is handed to the interpreter: the response
699    // path needs to know a HEAD was asked for, and `request` moves.
700    let request_method_is_head = request.method.eq_ignore_ascii_case("HEAD");
701
702    // Read here, not inside the closure. `CURRENT_TENANT` is a task-local
703    // scoped by the tenancy middleware around *this* task; a `spawn_blocking`
704    // closure runs on a different thread with no task-local at all, so a
705    // capability resolved in there would silently be the single-tenant
706    // namespace for every tenant at once — the exact failure this whole
707    // subsystem exists to make impossible.
708    let services = CapabilityServices {
709        tenant: crate::tenancy::CURRENT_TENANT
710            .try_with(Clone::clone)
711            .ok()
712            .flatten(),
713        ..services
714    };
715
716    // The permit still moves INTO the closure — a detached `spawn_blocking`
717    // task must not outlive it — but it comes back out so the response body can
718    // keep holding it while hyper delivers the bytes.
719    //
720    // The activity is ingested in *here*, before the closure returns, and not
721    // after the `.await`. A client that disconnects drops this future and its
722    // join handle, and the blocking closure keeps running to completion — it
723    // has already been admitted and holds a permit. Recording afterwards meant
724    // the outcome of an abandoned request was dropped with the handle, so every
725    // capability call it made became invisible to the operator. The one request
726    // whose record is most worth having is the one nobody waited for.
727    let ingest_log = Arc::clone(&activity);
728    let ingest_plugin = plugin.clone();
729    let outcome = crate::time::spawn_blocking(move || {
730        let outcome = host.run_with(&request, services);
731        ingest_log.ingest(&ingest_plugin, outcome.activity.clone());
732        if let Some(dropped) = outcome.dropped_calls {
733            ingest_log.ingest_dropped_calls(&ingest_plugin, dropped);
734        }
735        drop(slot);
736        (outcome, permit)
737    })
738    .await;
739
740    let (outcome, permit) = match outcome {
741        Ok(pair) => pair,
742        Err(err) => {
743            tracing::error!(
744                plugin,
745                error = %err,
746                "the sandbox worker did not complete; serving 502 on the plugin's prefix"
747            );
748            return sandbox_error(&plugin, StatusCode::BAD_GATEWAY);
749        }
750    };
751
752    // Already ingested inside the blocking task above, so that a request whose
753    // client walked away still leaves the record of what it did — a plugin
754    // whose denials are the reason it failed is exactly the one an operator
755    // will ask about.
756
757    match outcome.result {
758        Ok(response) => {
759            let built = build_response(&plugin, &response, permit);
760            // Naming HEAD in the method filter is deliberate (see
761            // `method_filter`), but it costs axum's GET-to-HEAD fallback, and
762            // that fallback is what discards the body. Without this, a HEAD
763            // gets the whole GET payload — against both the documented
764            // behaviour and HTTP itself.
765            //
766            // The bodyless statuses go the same way. RFC 9110 forbids content
767            // on 204, 205 and 304, and hyper drops it on the way out — but only
768            // on the way out. `mounted_router` is public, so middleware and an
769            // embedder see this `Response` itself, and there they would see
770            // payload bytes on a status that cannot carry them. Discarding here
771            // rather than refusing keeps it consistent with the HEAD case and
772            // with what the wire would have done anyway.
773            //
774            // Dropping the body drops its share of the permit, not the permit:
775            // the other share rides in the extensions, which `into_parts`
776            // carries over, so the slot still lasts as long as the response.
777            discard_body_if_it_cannot_be_sent(built, request_method_is_head)
778        }
779        Err(failure) => {
780            tracing::warn!(
781                plugin,
782                failure = %failure,
783                stderr = outcome.stderr,
784                fuel_used = outcome.fuel_used,
785                "sandboxed plugin failed to answer; serving an error on its own prefix"
786            );
787            sandbox_error(&plugin, failure.status())
788        }
789    }
790}
791
792/// Charge `bytes` against a request's running metadata total, refusing before
793/// the caller clones anything more if the ceiling is crossed.
794///
795/// Three call sites charge into one running total — the URI, an allowlisted
796/// header, and a dropped header's entry — and each must refuse at the same
797/// point, so the charge and the refusal live together rather than being
798/// repeated beside each one.
799fn charge_metadata(
800    plugin: &str,
801    running: &mut usize,
802    bytes: usize,
803    over: &'static str,
804) -> Result<(), Box<Response>> {
805    *running = running.saturating_add(bytes);
806    if *running > super::host::MAX_REQUEST_METADATA_BYTES {
807        tracing::warn!(
808            plugin,
809            over,
810            max_request_metadata_bytes = super::host::MAX_REQUEST_METADATA_BYTES,
811            "request metadata over the sandbox ceiling; refusing before cloning it"
812        );
813        return Err(Box::new(sandbox_error(
814            plugin,
815            StatusCode::PAYLOAD_TOO_LARGE,
816        )));
817    }
818    Ok(())
819}
820
821/// Turn an axum request into the frame the guest will see, or the response the
822/// caller gets instead.
823///
824/// The error side is boxed: a `Response` is large enough that returning one by
825/// value makes every `Ok` of this function carry its footprint too, on the path
826/// every sandboxed request takes.
827async fn read_request(
828    plugin: &str,
829    limits: super::manifest::ResourceLimits,
830    pattern: String,
831    params: &axum::extract::RawPathParams,
832    request: axum::extract::Request,
833) -> Result<SandboxRequest, Box<Response>> {
834    let (parts, body) = request.into_parts();
835    // The nested router rewrote the URI, so the concrete path the caller asked
836    // for comes from the original.
837    let path_str = parts
838        .extensions
839        .get::<axum::extract::OriginalUri>()
840        .map_or_else(|| parts.uri.path(), |original| original.0.path());
841    let query_str = parts.uri.query().unwrap_or_default();
842
843    // Everything the frame will carry except the headers, measured on the
844    // borrowed values. Headers got this treatment first; the rest of the
845    // metadata is the same argument and was left cloning unconditionally — an
846    // in-process caller could hand `mounted_router` a megabyte of query string
847    // and have it duplicated, and held for the whole body-read deadline, before
848    // the ceiling that exists to refuse it ever ran.
849    let mut metadata = 0usize;
850    charge_metadata(
851        plugin,
852        &mut metadata,
853        parts
854            .method
855            .as_str()
856            .len()
857            .saturating_add(path_str.len())
858            .saturating_add(query_str.len())
859            .saturating_add(pattern.len())
860            .saturating_add(
861                params
862                    .iter()
863                    .map(|(name, value)| super::host::metadata_pair_bytes(name, value))
864                    .fold(0usize, usize::saturating_add),
865            ),
866        "uri",
867    )?;
868
869    let path = path_str.to_owned();
870    let query = query_str.to_owned();
871    let path_params: Vec<(String, String)> = params
872        .iter()
873        .map(|(name, value)| (name.to_owned(), value.to_owned()))
874        .collect();
875    // Charged as they are read, and refused before the next one is cloned. The
876    // ceiling used to be applied inside `run`, which is after every header has
877    // already been copied into owned strings — so an oversized set forced the
878    // duplicate allocation the ceiling exists to prevent, and then held it for
879    // the whole body-read deadline before being told no. The cost per pair comes
880    // from the same function the ceiling counts with, so the two agree.
881    let mut headers: Vec<(String, String)> = Vec::new();
882    for (name, value) in &parts.headers {
883        // A dropped header is charged for its entry but not its contents.
884        // `HostFrame::request` drops everything outside this allowlist, so
885        // counting the rest measured bytes no guest will ever see: a request
886        // carrying 256 KiB of `Cookie` — headers this sandbox promises to
887        // withhold silently — would be refused outright, turning a
888        // confidentiality guarantee into an availability one. But skipping it
889        // free leaves the *count* unbounded, and the count is what this loop
890        // and the two walks inside `run` each pay per entry. See
891        // `DROPPED_PAIR_BYTES`.
892        if !super::wire::request_header_allowed(name.as_str()) {
893            charge_metadata(
894                plugin,
895                &mut metadata,
896                super::host::DROPPED_PAIR_BYTES,
897                "dropped-headers",
898            )?;
899            continue;
900        }
901        let Ok(value) = value.to_str() else {
902            continue;
903        };
904        charge_metadata(
905            plugin,
906            &mut metadata,
907            super::host::metadata_pair_bytes(name.as_str(), value),
908            "headers",
909        )?;
910        headers.push((name.as_str().to_owned(), value.to_owned()));
911    }
912
913    // The ceiling is applied while reading, so an oversized body is refused
914    // without ever being buffered in full — and the *wait* is bounded too. The
915    // permit is already held at this point, deliberately: that is what makes the
916    // declared footprint a bound on the whole request rather than on the part a
917    // guest is running. The cost of that choice is that a client dribbling a
918    // body could otherwise hold a permit forever without starting a guest, so
919    // the read gets a deadline. Unlike the interpreter, an async body read is
920    // genuinely cancellable, so this is a real bound rather than a hopeful one.
921    let deadline = std::time::Duration::from_millis(limits.request_body_timeout_ms);
922    let read = tokio::time::timeout(
923        deadline,
924        axum::body::to_bytes(body, limits.max_request_body_bytes),
925    )
926    .await;
927    let body = match read {
928        Ok(Ok(body)) => body.to_vec(),
929        Ok(Err(_)) => {
930            tracing::warn!(
931                plugin,
932                max_request_body_bytes = limits.max_request_body_bytes,
933                "request body over the sandboxed plugin's declared ceiling; refusing it"
934            );
935            return Err(Box::new(sandbox_error(
936                plugin,
937                StatusCode::PAYLOAD_TOO_LARGE,
938            )));
939        }
940        Err(_) => {
941            tracing::warn!(
942                plugin,
943                request_body_timeout_ms = limits.request_body_timeout_ms,
944                "request body did not arrive within the plugin's deadline; releasing its permit"
945            );
946            return Err(Box::new(sandbox_error(plugin, StatusCode::REQUEST_TIMEOUT)));
947        }
948    };
949
950    Ok(SandboxRequest {
951        method: parts.method.as_str().to_owned(),
952        route: pattern,
953        path,
954        query,
955        path_params,
956        headers,
957        body,
958    })
959}
960
961/// Turn a sanitized guest answer into an HTTP response.
962/// The plugin's answer, owning the concurrency permit alongside its bytes.
963///
964/// The permit exists to bound what one plugin can keep resident: the decoded
965/// response is handed to hyper as a buffer, and a client that reads slowly — or
966/// stops reading — keeps it alive long after the interpreter returned. So the
967/// permit must be released exactly when *the bytes* are freed.
968///
969/// It took three tries to learn that "exactly when the bytes are freed" is not
970/// a moment any `poll_frame` can name. Releasing on drop of the body missed a
971/// consumer that drained and kept the value; releasing at end-of-stream missed
972/// a body that was already ended and never polled; releasing on the poll that
973/// ends the stream missed the fact that the same poll *hands the buffer away* —
974/// the body keeps nothing, so tying the permit to the body's state released it
975/// while the consumer still held every byte.
976///
977/// Each fix moved the release to a different point in the body's life, and the
978/// bytes do not live in the body for all of it. Owning the permit here, beneath
979/// the `Bytes`, is what makes the question answerable: the permit is dropped
980/// when the last handle to the buffer is, whether that is inside hyper, inside
981/// middleware, or in the response nobody ever polled.
982struct PermitBuf {
983    data: Vec<u8>,
984    /// Never read: it is held so that dropping this buffer returns the slot.
985    ///
986    /// A *share* of the permit rather than the permit, because the body is not
987    /// the only part of the response that occupies the footprint — see
988    /// [`PermitShare`].
989    _permit: PermitShare,
990}
991
992/// A share of one concurrency slot, released when the last share drops.
993///
994/// The slot has two holders, because the response has two halves that occupy
995/// the footprint and they are not dropped together. The body's bytes may
996/// outlive the response (hyper holds them by `Bytes`), which is why
997/// [`PermitBuf`] exists. But the *headers* may outlive the bytes: a guest may
998/// answer with an empty body, and `check_size` charges header bytes against the
999/// same response ceiling the body is charged against, so up to the whole
1000/// allowance can sit in `HeaderValue`s with no body at all. `Bytes::from_owner`
1001/// over an empty buffer drops its owner at construction, so a permit that lived
1002/// only in the buffer came back before such a response was even returned —
1003/// letting a caller retain header-only responses and admit another request for
1004/// each, outside `max_concurrency`.
1005///
1006/// Holding a share in each place makes the slot outlive whichever half is
1007/// dropped last, without either half needing to know about the other.
1008type PermitShare = Arc<tokio::sync::OwnedSemaphorePermit>;
1009
1010/// The response's share of the slot, parked in its extensions so it lives
1011/// exactly as long as the head does.
1012#[derive(Clone)]
1013struct PermitExtension(#[allow(dead_code)] PermitShare);
1014
1015impl AsRef<[u8]> for PermitBuf {
1016    fn as_ref(&self) -> &[u8] {
1017        &self.data
1018    }
1019}
1020
1021/// Drop the body from a response that may not carry one.
1022///
1023/// Two cases, and the reason they are one function is that the reason is the
1024/// same: the status line and the method already say there is no content, so
1025/// bytes hanging off the response are bytes nothing should ever read.
1026///
1027/// - **HEAD.** Naming HEAD in the method filter is deliberate (see
1028///   `method_filter`), but it costs axum's GET-to-HEAD fallback, and that
1029///   fallback is what discards the body. Without this a HEAD gets the whole GET
1030///   payload, against both the documented behaviour and HTTP itself.
1031/// - **204, 205, 304.** RFC 9110 forbids content on these, and hyper drops it
1032///   on the way out — but only on the way out. `mounted_router` is public, so
1033///   middleware and an embedder see this `Response` itself, and there they
1034///   would see payload bytes on a status that cannot carry them.
1035///
1036/// Discarding rather than refusing keeps both cases consistent with each other
1037/// and with what the wire would have done anyway.
1038///
1039/// Dropping the body drops *its share* of the concurrency permit, not the
1040/// permit: the other share rides in the extensions, which `into_parts` carries
1041/// over, so the slot still lasts as long as the response does.
1042fn discard_body_if_it_cannot_be_sent(response: Response, method_is_head: bool) -> Response {
1043    let bodyless = matches!(
1044        response.status(),
1045        StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED
1046    );
1047    if !method_is_head && !bodyless {
1048        return response;
1049    }
1050    let (parts, _body) = response.into_parts();
1051    Response::from_parts(parts, Body::empty())
1052}
1053
1054fn build_response(
1055    plugin: &str,
1056    response: &super::wire::SandboxResponse,
1057    permit: tokio::sync::OwnedSemaphorePermit,
1058) -> Response {
1059    let Ok(status) = StatusCode::from_u16(response.status) else {
1060        // `SandboxResponse::validate` already refused anything outside the HTTP
1061        // range, so this cannot happen; refusing again is cheaper than trusting.
1062        return sandbox_error(plugin, StatusCode::BAD_GATEWAY);
1063    };
1064
1065    let mut out = Response::builder().status(status);
1066    let mut saw_content_type = false;
1067    for (name, value) in &response.headers {
1068        let (Ok(name), Ok(value)) = (
1069            HeaderName::try_from(name.as_str()),
1070            HeaderValue::try_from(value.as_str()),
1071        ) else {
1072            return sandbox_error(plugin, StatusCode::BAD_GATEWAY);
1073        };
1074        if name == http::header::CONTENT_TYPE {
1075            saw_content_type = true;
1076        }
1077        out = out.header(name, value);
1078    }
1079    if !saw_content_type {
1080        // A body with no declared type is a body a browser will guess at.
1081        // Guessing is how a plugin that can only return bytes turns into one
1082        // that can return a script.
1083        out = out.header(http::header::CONTENT_TYPE, "application/octet-stream");
1084    }
1085    // The permit rides *inside* the buffer, not alongside the body that carries
1086    // it: hyper may hold these bytes long after `run` returned, and it holds
1087    // them by `Bytes` rather than by the body they came out of.
1088    //
1089    // It rides in the response's extensions too, because the headers are
1090    // charged against the same ceiling and are not dropped with the bytes. See
1091    // [`PermitShare`].
1092    let permit: PermitShare = Arc::new(permit);
1093    let body = Body::from(bytes::Bytes::from_owner(PermitBuf {
1094        data: response.body.clone(),
1095        _permit: Arc::clone(&permit),
1096    }));
1097    let Ok(mut built) = out.body(body) else {
1098        return sandbox_error(plugin, StatusCode::BAD_GATEWAY);
1099    };
1100    built.extensions_mut().insert(PermitExtension(permit));
1101    stamp_host_headers(&mut built, plugin);
1102    built
1103}
1104
1105fn attribution(plugin: &str) -> Option<HeaderValue> {
1106    HeaderValue::try_from(plugin).ok()
1107}
1108
1109/// Stamp the headers the host owns, replacing anything of the same name.
1110///
1111/// `Response::builder().header(..)` *appends*, and `HeaderMap::get` returns the
1112/// first value — so a guest that emitted `x-autumn-sandboxed` or
1113/// `x-content-type-options` of its own would win the lookup against the host's.
1114/// The response header allowlist already refuses both names; this is the second
1115/// lock on the same door, and it is what makes "every response on this prefix
1116/// is attributable" true of the 413 and 503 the guest never ran for.
1117fn stamp_host_headers(response: &mut Response, plugin: &str) {
1118    let headers = response.headers_mut();
1119    headers.insert(
1120        http::header::X_CONTENT_TYPE_OPTIONS,
1121        HeaderValue::from_static("nosniff"),
1122    );
1123    if let Some(value) = attribution(plugin) {
1124        headers.insert(HeaderName::from_static(SANDBOX_ATTRIBUTION_HEADER), value);
1125    }
1126}
1127
1128/// The error a sandboxed plugin's own prefix serves. Never leaks a guest's
1129/// stderr or trap text to the caller — that is for the operator's log.
1130fn sandbox_error(plugin: &str, status: StatusCode) -> Response {
1131    let mut response = (
1132        status,
1133        [(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
1134        "the sandboxed plugin could not serve this request\n",
1135    )
1136        .into_response();
1137    stamp_host_headers(&mut response, plugin);
1138    response
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143    use super::*;
1144    use crate::plugin_sandbox::manifest::ResourceLimits;
1145    use crate::plugin_sandbox::test_guests as guests;
1146    use axum::body::Body;
1147    use http::{Request, StatusCode};
1148    use tower::ServiceExt as _;
1149
1150    fn manifest_toml(routes: &str, limits: &str) -> String {
1151        format!(
1152            r#"
1153name = "autumn-plugin-hello"
1154version = "0.1.0"
1155wire_version = 1
1156prefix = "/hello"
1157capabilities = ["http-request"]
1158sha256 = "{digest}"
1159{routes}
1160[limits]
1161{limits}
1162"#,
1163            digest = "a".repeat(64)
1164        )
1165    }
1166
1167    fn plugin_from(wat: &str, routes: &str, limits: ResourceLimits) -> SandboxedPlugin {
1168        let mut manifest = SandboxManifest::parse(&manifest_toml(
1169            routes,
1170            "fuel = 200000000\nmemory_bytes = 33554432",
1171        ))
1172        .expect("valid manifest");
1173        manifest.limits = limits;
1174        let wasm = wat::parse_str(wat).expect("valid WAT");
1175        SandboxedPlugin::new(SandboxHost::from_module(manifest, &wasm).expect("loads"))
1176    }
1177
1178    const GREET_ROUTE: &str = "[[routes]]\nmethod = \"GET\"\npath = \"/hello/greet\"\n";
1179
1180    fn hello_plugin() -> SandboxedPlugin {
1181        plugin_from(guests::HELLO, GREET_ROUTE, ResourceLimits::default())
1182    }
1183
1184    #[test]
1185    fn a_supplied_rate_limiter_cannot_raise_the_approved_ceiling() {
1186        use crate::plugin_sandbox::capability::CapabilityRateLimiter;
1187
1188        let plugin = hello_plugin();
1189        let approved = plugin
1190            .services
1191            .rate
1192            .as_ref()
1193            .expect("the manifest limiter is always built")
1194            .per_second();
1195
1196        // Looser than the manifest: taken, this would let the plugin exceed the
1197        // `calls_per_second` the operator approved on the consent screen.
1198        let looser = plugin.clone().with_services(CapabilityServices {
1199            rate: Some(Arc::new(CapabilityRateLimiter::new(
1200                approved.saturating_mul(10).max(approved + 1),
1201            ))),
1202            ..CapabilityServices::none()
1203        });
1204        assert_eq!(
1205            looser
1206                .services
1207                .rate
1208                .as_ref()
1209                .expect("a limiter is always present")
1210                .per_second(),
1211            approved,
1212            "a looser limiter must be ignored and the manifest's kept"
1213        );
1214
1215        // At or below it: taken, because sharing limiting state across
1216        // instances is what the parameter is for.
1217        let shared = Arc::new(CapabilityRateLimiter::new(approved));
1218        let tighter = plugin.with_services(CapabilityServices {
1219            rate: Some(Arc::clone(&shared)),
1220            ..CapabilityServices::none()
1221        });
1222        assert!(
1223            Arc::ptr_eq(tighter.services.rate.as_ref().expect("present"), &shared),
1224            "an equal-or-tighter limiter must be the one actually used"
1225        );
1226    }
1227
1228    /// The app a test drives: a sandboxed plugin mounted beside an ordinary
1229    /// route, so "the rest of the app keeps serving" is something a request
1230    /// can actually prove.
1231    fn app(plugin: &SandboxedPlugin) -> axum::Router {
1232        axum::Router::new()
1233            .route("/healthz", axum::routing::get(|| async { "ok" }))
1234            .merge(plugin.mounted_router())
1235    }
1236
1237    async fn send(app: axum::Router, method: &str, path: &str) -> (StatusCode, String) {
1238        send_with_body(app, method, path, Body::empty()).await
1239    }
1240
1241    async fn send_raw(app: axum::Router, method: &str, path: &str) -> Response {
1242        let request = Request::builder()
1243            .method(method)
1244            .uri(path)
1245            .body(Body::empty())
1246            .expect("request");
1247        app.oneshot(request).await.expect("infallible")
1248    }
1249
1250    async fn send_with_body(
1251        app: axum::Router,
1252        method: &str,
1253        path: &str,
1254        body: Body,
1255    ) -> (StatusCode, String) {
1256        let request = Request::builder()
1257            .method(method)
1258            .uri(path)
1259            .body(body)
1260            .expect("request");
1261        let response = app.oneshot(request).await.expect("infallible");
1262        let status = response.status();
1263        let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
1264            .await
1265            .expect("body");
1266        (status, String::from_utf8_lossy(&bytes).into_owned())
1267    }
1268
1269    #[tokio::test]
1270    async fn a_sandboxed_plugin_serves_http_under_its_declared_prefix() {
1271        let plugin = hello_plugin();
1272        let (status, body) = send(app(&plugin), "GET", "/hello/greet").await;
1273        assert_eq!(status, StatusCode::OK);
1274        assert_eq!(body, "hello from the sandbox");
1275    }
1276
1277    #[tokio::test]
1278    async fn the_manifest_is_the_mount_not_a_description_of_it() {
1279        // An undeclared path under the prefix is a 404 from the *host*: the
1280        // guest is never started, so the manifest bounds what the artifact can
1281        // serve rather than merely documenting it.
1282        let plugin = hello_plugin();
1283        let (status, _) = send(app(&plugin), "GET", "/hello/undeclared").await;
1284        assert_eq!(status, StatusCode::NOT_FOUND);
1285        let (status, _) = send(app(&plugin), "POST", "/hello/greet").await;
1286        assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
1287    }
1288
1289    #[tokio::test]
1290    async fn a_head_on_a_declared_get_reaches_the_guest_rather_than_axum_s_405() {
1291        // axum's method router dispatches a HEAD with no HEAD route to the GET
1292        // one (`call!(req, HEAD, get)`), which is what HTTP requires — so the
1293        // guest really does see `method: "HEAD"`, and the manifest says so.
1294        let plugin = hello_plugin();
1295        let request = Request::builder()
1296            .method("HEAD")
1297            .uri("/hello/greet")
1298            .body(Body::empty())
1299            .expect("request");
1300        let response = app(&plugin).oneshot(request).await.expect("infallible");
1301        // This fixture answers 405 for anything but GET — which is the point:
1302        // the *guest* decided, so the request crossed the boundary. axum's own
1303        // 405 would carry no attribution.
1304        assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
1305        assert!(
1306            response.headers().contains_key(SANDBOX_ATTRIBUTION_HEADER),
1307            "the sandbox handler must have produced this"
1308        );
1309    }
1310
1311    #[tokio::test]
1312    async fn a_manifest_declaring_both_get_and_head_still_mounts() {
1313        // Mounting GET as `GET | HEAD` and then mounting the declared HEAD on
1314        // the same path is an overlapping method route, which axum refuses by
1315        // panicking while the router is built — a valid manifest taking the
1316        // application down at boot, which is the one thing this lane must never
1317        // do.
1318        let plugin = plugin_from(
1319            guests::HELLO,
1320            "[[routes]]\nmethod = \"GET\"\npath = \"/hello/greet\"\n\n\
1321             [[routes]]\nmethod = \"HEAD\"\npath = \"/hello/greet\"\n",
1322            ResourceLimits::default(),
1323        );
1324        let (status, body) = send(app(&plugin), "GET", "/hello/greet").await;
1325        assert_eq!(status, StatusCode::OK);
1326        assert_eq!(body, "hello from the sandbox");
1327        let (status, _) = send(app(&plugin), "HEAD", "/hello/greet").await;
1328        assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED, "the guest answered");
1329    }
1330
1331    #[test]
1332    fn the_route_manifest_names_the_head_a_get_route_also_serves() {
1333        let builder = crate::app::app().plugin(hello_plugin());
1334        let routes = builder.plugin_route_infos().expect("route manifest");
1335        assert!(
1336            routes
1337                .iter()
1338                .any(|route| route.method == "HEAD" && route.path == "/hello/greet"),
1339            "{routes:?}"
1340        );
1341    }
1342
1343    #[tokio::test]
1344    async fn responses_are_attributable_to_the_plugin_that_produced_them() {
1345        let plugin = hello_plugin();
1346        let request = Request::builder()
1347            .uri("/hello/greet")
1348            .body(Body::empty())
1349            .expect("request");
1350        let response = app(&plugin).oneshot(request).await.expect("infallible");
1351        assert_eq!(
1352            response
1353                .headers()
1354                .get(SANDBOX_ATTRIBUTION_HEADER)
1355                .and_then(|value| value.to_str().ok()),
1356            Some("autumn-plugin-hello")
1357        );
1358    }
1359
1360    #[tokio::test]
1361    async fn a_runaway_plugin_5xxs_on_its_own_prefix_while_the_app_keeps_serving() {
1362        let plugin = plugin_from(
1363            guests::CPU_SPIN,
1364            GREET_ROUTE,
1365            ResourceLimits {
1366                fuel: 5_000_000,
1367                ..ResourceLimits::default()
1368            },
1369        );
1370        let (status, _) = send(app(&plugin), "GET", "/hello/greet").await;
1371        assert!(status.is_server_error(), "{status}");
1372
1373        let (status, body) = send(app(&plugin), "GET", "/healthz").await;
1374        assert_eq!(status, StatusCode::OK);
1375        assert_eq!(body, "ok");
1376    }
1377
1378    #[tokio::test]
1379    async fn a_trapping_plugin_never_takes_the_host_with_it() {
1380        let plugin = plugin_from(guests::TRAP, GREET_ROUTE, ResourceLimits::default());
1381        let app = app(&plugin);
1382        let (status, _) = send(app.clone(), "GET", "/hello/greet").await;
1383        assert_eq!(status, StatusCode::BAD_GATEWAY);
1384        // The process is still here, and so is every other route.
1385        let (status, _) = send(app, "GET", "/healthz").await;
1386        assert_eq!(status, StatusCode::OK);
1387    }
1388
1389    #[tokio::test]
1390    async fn a_plugin_that_exits_never_exits_the_host() {
1391        let plugin = plugin_from(guests::EXIT, GREET_ROUTE, ResourceLimits::default());
1392        let app = app(&plugin);
1393        let (status, _) = send(app.clone(), "GET", "/hello/greet").await;
1394        assert!(status.is_server_error(), "{status}");
1395        let (status, _) = send(app, "GET", "/healthz").await;
1396        assert_eq!(status, StatusCode::OK);
1397    }
1398
1399    #[tokio::test]
1400    async fn a_body_over_the_declared_ceiling_never_reaches_the_guest() {
1401        let plugin = plugin_from(
1402            guests::HELLO,
1403            "[[routes]]\nmethod = \"POST\"\npath = \"/hello/greet\"\n",
1404            ResourceLimits {
1405                max_request_body_bytes: 16,
1406                ..ResourceLimits::default()
1407            },
1408        );
1409        let (status, _) = send_with_body(
1410            app(&plugin),
1411            "POST",
1412            "/hello/greet",
1413            Body::from(vec![b'x'; 4096]),
1414        )
1415        .await;
1416        assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
1417    }
1418
1419    #[tokio::test]
1420    async fn the_concurrency_ceiling_is_enforced_rather_than_queued() {
1421        let plugin = plugin_from(
1422            guests::HELLO,
1423            GREET_ROUTE,
1424            ResourceLimits {
1425                max_concurrency: 1,
1426                ..ResourceLimits::default()
1427            },
1428        );
1429        // Hold the plugin's only permit, exactly as an in-flight request would.
1430        let held = std::sync::Arc::clone(&plugin.permits)
1431            .try_acquire_owned()
1432            .expect("the first permit is free");
1433        let (status, _) = send(app(&plugin), "GET", "/hello/greet").await;
1434        assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
1435        drop(held);
1436        let (status, _) = send(app(&plugin), "GET", "/hello/greet").await;
1437        assert_eq!(status, StatusCode::OK);
1438    }
1439
1440    #[tokio::test]
1441    async fn the_process_wide_guest_slots_shed_a_request_the_plugin_would_admit() {
1442        // The plugin's own ceiling is wide open; only the shared pool is full.
1443        let plugin = plugin_from(
1444            guests::HELLO,
1445            GREET_ROUTE,
1446            ResourceLimits {
1447                max_concurrency: 8,
1448                ..ResourceLimits::default()
1449            },
1450        )
1451        .with_guest_slots(Arc::new(Semaphore::new(1)));
1452        let held = Arc::clone(&plugin.guest_slots)
1453            .try_acquire_owned()
1454            .expect("the only slot is free");
1455        let response = send_raw(app(&plugin), "GET", "/hello/greet").await;
1456        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
1457        assert!(response.headers().contains_key(http::header::RETRY_AFTER));
1458        // The refused request gave its plugin permit back.
1459        assert_eq!(plugin.permits.available_permits(), 8);
1460        assert!(plugin.render_slot("any", &[]).await.is_none());
1461        drop(held);
1462        let (status, _) = send(app(&plugin), "GET", "/hello/greet").await;
1463        assert_eq!(status, StatusCode::OK);
1464        // The slot came back after the request: a second one still serves.
1465        assert_eq!(plugin.guest_slots.available_permits(), 1);
1466    }
1467
1468    #[test]
1469    fn the_public_setter_refuses_bad_limits_and_leaves_the_pool_alone() {
1470        // Refused before the process-wide cell is touched.
1471        assert_eq!(set_guest_slots(0), Err(0));
1472        assert_eq!(set_guest_slots(usize::MAX), Err(0));
1473    }
1474
1475    #[tokio::test]
1476    async fn one_guest_pool_caps_two_plugins_together() {
1477        let pool = Arc::new(Semaphore::new(1));
1478        let one = hello_plugin().with_guest_slots(Arc::clone(&pool));
1479        let two = hello_plugin().with_guest_slots(Arc::clone(&pool));
1480        let held = Arc::clone(&pool).try_acquire_owned().expect("free");
1481        for plugin in [&one, &two] {
1482            let (status, _) = send(app(plugin), "GET", "/hello/greet").await;
1483            assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
1484        }
1485        drop(held);
1486        let (status, _) = send(app(&two), "GET", "/hello/greet").await;
1487        assert_eq!(status, StatusCode::OK);
1488    }
1489
1490    #[test]
1491    fn the_default_guest_slots_are_a_fraction_of_tokios_blocking_pool() {
1492        // Tokio's default pool is 512 threads; host work must keep most of it.
1493        const { assert!(DEFAULT_GUEST_SLOTS <= 512 / 4) };
1494        const { assert!(DEFAULT_GUEST_SLOTS >= 1) };
1495    }
1496
1497    #[test]
1498    fn the_guest_slot_limit_is_fixed_by_the_first_caller() {
1499        let cell = std::sync::OnceLock::new();
1500        assert_eq!(fix_slots(&cell, 3), Ok(()));
1501        assert_eq!(fix_slots(&cell, 9), Err(3), "a later caller is refused");
1502        assert_eq!(cell.get().map(|(n, _)| *n), Some(3));
1503        assert_eq!(fix_slots(&std::sync::OnceLock::new(), 0), Err(0));
1504        assert_eq!(fix_slots(&std::sync::OnceLock::new(), usize::MAX), Err(0));
1505    }
1506
1507    #[tokio::test(start_paused = true)]
1508    async fn a_body_that_never_arrives_does_not_pin_a_permit_forever() {
1509        // The permit is held from admission, which is what makes the declared
1510        // footprint a bound on the whole request. The cost of that is a client
1511        // dribbling a body, so the read has a deadline — and unlike the
1512        // interpreter, an async body read is genuinely cancellable.
1513        let plugin = plugin_from(
1514            guests::HELLO,
1515            "[[routes]]\nmethod = \"POST\"\npath = \"/hello/greet\"\n",
1516            ResourceLimits {
1517                max_concurrency: 1,
1518                request_body_timeout_ms: 250,
1519                ..ResourceLimits::default()
1520            },
1521        );
1522        let stalled = Body::from_stream(futures::stream::pending::<
1523            Result<bytes::Bytes, std::io::Error>,
1524        >());
1525        let (status, _) = send_with_body(app(&plugin), "POST", "/hello/greet", stalled).await;
1526        assert_eq!(status, StatusCode::REQUEST_TIMEOUT);
1527
1528        // …and the permit came back: the next request reaches the guest, which
1529        // answers 405 because this fixture only handles GET. Any guest-produced
1530        // answer proves the point — a pinned permit would have been a 503.
1531        let (status, _) =
1532            send_with_body(app(&plugin), "POST", "/hello/greet", Body::from("hi")).await;
1533        assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
1534    }
1535
1536    #[tokio::test]
1537    async fn an_empty_answer_returns_its_slot_on_drop_without_being_polled() {
1538        // Release must not ride on `poll_frame` reaching the end of the stream:
1539        // a body that is *already* ended need never be polled at all — a
1540        // consumer is entitled to read `is_end_stream` and stop there. Every
1541        // 204, and every empty error page a guest returns, takes that path.
1542        //
1543        // This test used to assert that such an answer held no slot at all,
1544        // which was the defect rather than the design: header bytes are charged
1545        // against the same response ceiling as body bytes, so "empty body" does
1546        // not mean "occupies nothing". The slot is now held until the response
1547        // is dropped, and — the part that still matters here — it comes back
1548        // then without anything having polled the body.
1549        use crate::plugin_sandbox::wire::SandboxResponse;
1550
1551        let permits = Arc::new(Semaphore::new(1));
1552        let empty = SandboxResponse {
1553            status: 204,
1554            headers: Vec::new(),
1555            body: Vec::new(),
1556        };
1557        let response = build_response(
1558            "autumn-plugin-hello",
1559            &empty,
1560            Arc::clone(&permits).acquire_owned().await.expect("permit"),
1561        );
1562        assert!(
1563            http_body::Body::is_end_stream(response.body()),
1564            "the fixture must be the never-polled shape this test is about",
1565        );
1566        assert_eq!(
1567            permits.available_permits(),
1568            0,
1569            "the response is still resident, so its slot is still spent",
1570        );
1571        drop(response);
1572        assert_eq!(
1573            permits.available_permits(),
1574            1,
1575            "the slot must come back on drop, with no poll anywhere",
1576        );
1577
1578        // The converse, so the assertion above is about the response being gone
1579        // and not about the permit having been dropped on the floor: bytes still
1580        // resident do hold a slot, until they are.
1581        let full = SandboxResponse {
1582            status: 200,
1583            headers: Vec::new(),
1584            body: b"hi".to_vec(),
1585        };
1586        let response = build_response(
1587            "autumn-plugin-hello",
1588            &full,
1589            Arc::clone(&permits).acquire_owned().await.expect("permit"),
1590        );
1591        assert_eq!(
1592            permits.available_permits(),
1593            0,
1594            "undelivered bytes keep their slot"
1595        );
1596        drop(response);
1597        assert_eq!(permits.available_permits(), 1, "and give it back on drop");
1598    }
1599
1600    #[tokio::test]
1601    async fn a_bodyless_status_carries_no_body_to_an_in_process_caller() {
1602        // RFC 9110 forbids content on 204, 205 and 304. Hyper drops it on the
1603        // way out, so on a network connection this is invisible — but
1604        // `mounted_router` is public, and middleware or an embedder reads this
1605        // `Response` directly. There, payload bytes on a status that cannot
1606        // carry them are bytes a consumer may well act on.
1607        //
1608        // The permit is the reason to be careful here rather than just correct:
1609        // discarding the body drops its share of the slot, so this also pins
1610        // that the extensions share is what keeps the accounting honest.
1611        use crate::plugin_sandbox::wire::SandboxResponse;
1612        use http_body_util::BodyExt as _;
1613
1614        for status in [204u16, 205, 304] {
1615            let permits = Arc::new(Semaphore::new(1));
1616            let answered = SandboxResponse {
1617                status,
1618                headers: Vec::new(),
1619                body: b"content a bodyless status may not carry".to_vec(),
1620            };
1621            let built = build_response(
1622                "autumn-plugin-hello",
1623                &answered,
1624                Arc::clone(&permits).acquire_owned().await.expect("permit"),
1625            );
1626            let response = discard_body_if_it_cannot_be_sent(built, false);
1627
1628            let collected = response
1629                .into_body()
1630                .collect()
1631                .await
1632                .expect("the body collects")
1633                .to_bytes();
1634            assert!(
1635                collected.is_empty(),
1636                "{status} handed {} body bytes to an in-process caller",
1637                collected.len(),
1638            );
1639        }
1640
1641        // The converse, so the loop above is about the status and not about
1642        // this function emptying everything it is given.
1643        let permits = Arc::new(Semaphore::new(1));
1644        let ok = SandboxResponse {
1645            status: 200,
1646            headers: Vec::new(),
1647            body: b"hi".to_vec(),
1648        };
1649        let built = build_response(
1650            "autumn-plugin-hello",
1651            &ok,
1652            Arc::clone(&permits).acquire_owned().await.expect("permit"),
1653        );
1654        let kept = discard_body_if_it_cannot_be_sent(built, false)
1655            .into_body()
1656            .collect()
1657            .await
1658            .expect("the body collects")
1659            .to_bytes();
1660        assert_eq!(kept.as_ref(), b"hi", "a 200 must keep the answer it gave");
1661    }
1662
1663    #[tokio::test]
1664    async fn discarding_a_body_does_not_return_the_slot_the_headers_still_hold() {
1665        // `into_parts` carries the extensions over, so the response's share of
1666        // the permit survives its body being thrown away. Without that, every
1667        // HEAD and every bodyless status would release its slot early — the
1668        // header-only defect again, reached through the discard path.
1669        use crate::plugin_sandbox::wire::SandboxResponse;
1670
1671        let permits = Arc::new(Semaphore::new(1));
1672        let answered = SandboxResponse {
1673            status: 204,
1674            headers: vec![("x-plugin-note".to_string(), "v".repeat(4096))],
1675            body: b"discarded".to_vec(),
1676        };
1677        let built = build_response(
1678            "autumn-plugin-hello",
1679            &answered,
1680            Arc::clone(&permits).acquire_owned().await.expect("permit"),
1681        );
1682        let response = discard_body_if_it_cannot_be_sent(built, false);
1683        assert_eq!(
1684            permits.available_permits(),
1685            0,
1686            "the slot came back when the body was discarded, while the headers remain",
1687        );
1688        drop(response);
1689        assert_eq!(
1690            permits.available_permits(),
1691            1,
1692            "and back once nothing holds it"
1693        );
1694    }
1695
1696    #[tokio::test]
1697    async fn a_header_only_response_holds_its_permit_until_the_headers_go() {
1698        // `PermitBuf` tied the slot to the response *bytes*, which is right for
1699        // every answer that has some. A guest may answer with none: headers are
1700        // charged against the same response ceiling (`check_size` sums header
1701        // bytes and body bytes against one `max`), so up to the whole allowance
1702        // can sit in `HeaderValue`s with an empty body. `Bytes::from_owner`
1703        // drops its owner at construction for an empty buffer, so the slot came
1704        // back before the response was even returned — and a caller retaining
1705        // header-only responses could admit another request for each, holding
1706        // response-sized allocations outside `max_concurrency`.
1707        use crate::plugin_sandbox::wire::SandboxResponse;
1708
1709        let permits = Arc::new(Semaphore::new(1));
1710        let header_only = SandboxResponse {
1711            status: 200,
1712            headers: vec![("x-plugin-note".to_string(), "v".repeat(4096))],
1713            body: Vec::new(),
1714        };
1715        let response = build_response(
1716            "autumn-plugin-hello",
1717            &header_only,
1718            Arc::clone(&permits).acquire_owned().await.expect("permit"),
1719        );
1720
1721        // The body is empty and end-of-stream from the start, so nothing about
1722        // the *bytes* can be holding the slot here.
1723        assert!(http_body::Body::is_end_stream(response.body()));
1724        assert!(
1725            response.headers().contains_key("x-plugin-note"),
1726            "the header this response is made of must survive to be held",
1727        );
1728        assert_eq!(
1729            permits.available_permits(),
1730            0,
1731            "the slot came back while the response headers were still held",
1732        );
1733
1734        // And it is the headers keeping it: dropping them returns it.
1735        drop(response);
1736        assert_eq!(
1737            permits.available_permits(),
1738            1,
1739            "the slot must come back once nothing holds the response",
1740        );
1741    }
1742
1743    #[tokio::test]
1744    async fn the_permit_outlives_the_body_and_dies_with_the_bytes() {
1745        // The release point moved three times before it moved to the right
1746        // *place*. This is the case that showed the place was wrong: the poll
1747        // that ends the stream also hands the buffer away, so a permit tied to
1748        // the body's state came back while the consumer still held every byte —
1749        // admitting another full-sized response outside `max_concurrency`.
1750        //
1751        // Now the permit lives under the `Bytes`, so this test follows the
1752        // bytes rather than the body.
1753        use crate::plugin_sandbox::wire::SandboxResponse;
1754        use http_body_util::BodyExt as _;
1755
1756        let permits = Arc::new(Semaphore::new(1));
1757        let full = SandboxResponse {
1758            status: 200,
1759            headers: Vec::new(),
1760            body: b"hi".to_vec(),
1761        };
1762        let mut response = build_response(
1763            "autumn-plugin-hello",
1764            &full,
1765            Arc::clone(&permits).acquire_owned().await.expect("permit"),
1766        );
1767        assert_eq!(permits.available_permits(), 0, "the bytes are undelivered");
1768
1769        let frame = response
1770            .body_mut()
1771            .frame()
1772            .await
1773            .expect("a frame")
1774            .expect("not an error");
1775        let data = frame.into_data().expect("a data frame");
1776        assert_eq!(data.as_ref(), b"hi");
1777
1778        // The body is exhausted and holds nothing…
1779        assert!(http_body::Body::is_end_stream(response.body()));
1780        // …and dropping it must not return the slot, because the bytes are
1781        // still here, in `data`.
1782        drop(response);
1783        assert_eq!(
1784            permits.available_permits(),
1785            0,
1786            "the slot came back while the response bytes were still held"
1787        );
1788
1789        // Only the last handle to the buffer returns it.
1790        drop(data);
1791        assert_eq!(
1792            permits.available_permits(),
1793            1,
1794            "the slot did not come back when the bytes were freed"
1795        );
1796    }
1797
1798    #[test]
1799    fn the_plugin_is_named_by_its_manifest() {
1800        assert_eq!(
1801            crate::plugin::Plugin::name(&hello_plugin()),
1802            "autumn-plugin-hello"
1803        );
1804    }
1805
1806    #[test]
1807    fn mounting_declares_the_manifest_routes_with_plugin_attribution() {
1808        let builder = crate::app::app().plugin(hello_plugin());
1809        let routes = builder.plugin_route_infos().expect("route manifest");
1810        let sandboxed: Vec<_> = routes
1811            .iter()
1812            .filter(|route| {
1813                route.source
1814                    == crate::route_listing::RouteSource::Plugin("autumn-plugin-hello".to_owned())
1815            })
1816            .collect();
1817        // GET and the HEAD it also serves.
1818        assert_eq!(sandboxed.len(), 2, "{sandboxed:?}");
1819        assert!(sandboxed.iter().all(|route| route.path == "/hello/greet"));
1820        assert_eq!(sandboxed[0].method, "GET");
1821        assert_eq!(sandboxed[1].method, "HEAD");
1822    }
1823
1824    #[test]
1825    fn a_mounted_sandboxed_plugin_passes_the_conformance_checks() {
1826        let builder = crate::app::app().plugin(hello_plugin());
1827        let routes = builder.plugin_route_infos().expect("route manifest");
1828        let report = crate::plugin_conformance::run_conformance(
1829            &crate::plugin_conformance::ConformanceConfig::new("autumn-plugin-hello")
1830                .prefix("/hello"),
1831            &routes,
1832        );
1833        assert!(report.passed(), "{}", report.to_text_report());
1834    }
1835
1836    #[test]
1837    fn a_render_context_is_bounded_before_it_is_cloned_onto_a_worker() {
1838        // The context is the host's, but "the host built it" is not "the host
1839        // chose its size": it is routinely assembled from a row or a query
1840        // string, and none of it is in the per-request footprint
1841        // `max_concurrency` is validated against.
1842        let many: Vec<(String, String)> = (0
1843            ..crate::plugin_sandbox::host::MAX_RENDER_CONTEXT_ENTRIES * 4)
1844            .map(|index| (format!("k{index}"), String::new()))
1845            .collect();
1846        assert_eq!(
1847            crate::plugin_sandbox::host::bounded_context(&many).len(),
1848            crate::plugin_sandbox::host::MAX_RENDER_CONTEXT_ENTRIES,
1849            "empty pairs still cost allocation, so the count alone has to bound them"
1850        );
1851
1852        let heavy: Vec<(String, String)> = (0
1853            ..crate::plugin_sandbox::host::MAX_RENDER_CONTEXT_ENTRIES)
1854            .map(|index| {
1855                (
1856                    format!("k{index}"),
1857                    "x".repeat(crate::plugin_sandbox::host::MAX_RENDER_CONTEXT_BYTES),
1858                )
1859            })
1860            .collect();
1861        let kept = crate::plugin_sandbox::host::bounded_context(&heavy);
1862        let carried: usize = kept
1863            .iter()
1864            .map(|(name, value)| name.len().saturating_add(value.len()))
1865            .sum();
1866        assert!(
1867            carried <= crate::plugin_sandbox::host::MAX_RENDER_CONTEXT_BYTES,
1868            "the worker was handed {carried} bytes"
1869        );
1870
1871        // What fits is passed through unchanged, which is every real caller.
1872        let ordinary = vec![
1873            ("id".to_owned(), "42".to_owned()),
1874            ("locale".to_owned(), "en".to_owned()),
1875        ];
1876        assert_eq!(
1877            crate::plugin_sandbox::host::bounded_context(&ordinary),
1878            ordinary
1879        );
1880    }
1881}