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, ¶ms, 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}