#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::borrow::Cow;
use std::fmt;
use std::path::Path;
use std::sync::{Arc, OnceLock};
use axum::body::Body;
use axum::response::{IntoResponse as _, Response};
use axum::routing::MethodFilter;
use http::{HeaderName, HeaderValue, StatusCode};
use tokio::sync::Semaphore;
use super::artifact::{ArtifactError, SandboxArtifact};
use super::capability::{CapabilityRateLimiter, CapabilityServices, PluginActivityLog};
use super::host::{SandboxHost, SandboxLoadError};
use super::manifest::SandboxManifest;
use super::wire::SandboxRequest;
use crate::app::AppBuilder;
use crate::plugin::Plugin;
pub const SANDBOX_ATTRIBUTION_HEADER: &str = "x-autumn-sandboxed";
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SandboxPluginError {
Artifact(ArtifactError),
Load(SandboxLoadError),
}
impl fmt::Display for SandboxPluginError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Artifact(err) => write!(f, "{err}"),
Self::Load(err) => write!(f, "{err}"),
}
}
}
impl std::error::Error for SandboxPluginError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Artifact(err) => Some(err),
Self::Load(err) => Some(err),
}
}
}
impl From<ArtifactError> for SandboxPluginError {
fn from(err: ArtifactError) -> Self {
Self::Artifact(err)
}
}
impl From<SandboxLoadError> for SandboxPluginError {
fn from(err: SandboxLoadError) -> Self {
Self::Load(err)
}
}
pub const DEFAULT_GUEST_SLOTS: usize = 128;
static GUEST_SLOTS: OnceLock<(usize, Arc<Semaphore>)> = OnceLock::new();
pub fn set_guest_slots(slots: usize) -> Result<(), usize> {
fix_slots(&GUEST_SLOTS, slots)
}
fn fix_slots(cell: &OnceLock<(usize, Arc<Semaphore>)>, slots: usize) -> Result<(), usize> {
if slots == 0 || slots > Semaphore::MAX_PERMITS {
return Err(0);
}
cell.set((slots, Arc::new(Semaphore::new(slots))))
.map_err(|_| cell.get().map_or(DEFAULT_GUEST_SLOTS, |(held, _)| *held))
}
fn guest_slots() -> Arc<Semaphore> {
let (_, pool) = GUEST_SLOTS.get_or_init(|| {
(
DEFAULT_GUEST_SLOTS,
Arc::new(Semaphore::new(DEFAULT_GUEST_SLOTS)),
)
});
Arc::clone(pool)
}
#[derive(Clone)]
pub struct SandboxedPlugin {
host: Arc<SandboxHost>,
permits: Arc<Semaphore>,
guest_slots: Arc<Semaphore>,
artifact_sha256: Option<String>,
services: CapabilityServices,
activity: Arc<PluginActivityLog>,
}
impl fmt::Debug for SandboxedPlugin {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SandboxedPlugin")
.field("plugin", &self.host.manifest().name)
.field("prefix", &self.host.manifest().prefix)
.finish_non_exhaustive()
}
}
impl SandboxedPlugin {
#[must_use]
pub fn new(host: SandboxHost) -> Self {
let permits = Arc::new(Semaphore::new(host.manifest().limits.max_concurrency));
let services = CapabilityServices {
rate: Some(Arc::new(CapabilityRateLimiter::new(
host.manifest().quotas.calls_per_second,
))),
..CapabilityServices::none()
};
Self {
host: Arc::new(host),
permits,
guest_slots: guest_slots(),
artifact_sha256: None,
services,
activity: Arc::new(PluginActivityLog::new()),
}
}
#[cfg(test)]
fn with_guest_slots(mut self, slots: Arc<Semaphore>) -> Self {
self.guest_slots = slots;
self
}
#[must_use]
pub fn with_services(mut self, services: CapabilityServices) -> Self {
let approved = self
.services
.rate
.as_ref()
.map_or(u32::MAX, |rate| rate.per_second());
let rate = services
.rate
.clone()
.filter(|supplied| supplied.per_second() <= approved)
.or_else(|| self.services.rate.clone());
self.services = CapabilityServices {
tenant: None,
rate,
..services
};
self
}
#[must_use]
pub fn activity(&self) -> Arc<PluginActivityLog> {
Arc::clone(&self.activity)
}
pub fn from_artifact(artifact: &SandboxArtifact) -> Result<Self, SandboxPluginError> {
let digest = artifact.artifact_digest()?;
let mut plugin = Self::new(SandboxHost::load(artifact)?);
plugin.artifact_sha256 = Some(digest);
Ok(plugin)
}
pub fn from_file(path: &Path) -> Result<Self, SandboxPluginError> {
Self::from_artifact(&SandboxArtifact::read_file(path)?)
}
pub(super) fn in_flight(&self) -> usize {
self.manifest()
.limits
.max_concurrency
.saturating_sub(self.permits.available_permits())
}
#[cfg(test)]
pub(super) fn hold_permit_for_test(&self) -> tokio::sync::OwnedSemaphorePermit {
Arc::clone(&self.permits)
.try_acquire_owned()
.expect("a free permit")
}
pub(super) fn artifact_sha256(&self) -> Option<&str> {
self.artifact_sha256.as_deref()
}
pub(super) fn log_grant(&self, message: &str, generation: u64) {
let manifest = self.manifest();
tracing::info!(
plugin = manifest.name,
version = manifest.version,
prefix = manifest.prefix,
module_sha256 = manifest.sha256,
artifact_sha256 = self
.artifact_sha256
.as_deref()
.unwrap_or("(built from a host, not an artifact)"),
capabilities = manifest
.capabilities
.iter()
.map(|capability| capability.as_str())
.collect::<Vec<_>>()
.join(","),
generation,
routes = manifest.routes.len(),
fuel = manifest.limits.fuel,
memory_bytes = manifest.limits.memory_bytes,
max_concurrency = manifest.limits.max_concurrency,
"{message}"
);
}
#[must_use]
pub fn manifest(&self) -> &SandboxManifest {
self.host.manifest()
}
pub async fn render_slot(&self, slot: &str, context: &[(String, String)]) -> Option<String> {
let plugin = self.manifest().name.clone();
let Ok(permit) = Arc::clone(&self.permits).try_acquire_owned() else {
tracing::warn!(
plugin,
slot,
"sandboxed plugin is at its concurrency ceiling; omitting its render fragment"
);
return None;
};
let Ok(slot_permit) = Arc::clone(&self.guest_slots).try_acquire_owned() else {
tracing::warn!(
plugin,
slot,
"the sandbox guest pool is full; omitting its render fragment"
);
return None;
};
let services = CapabilityServices {
tenant: crate::tenancy::CURRENT_TENANT
.try_with(Clone::clone)
.ok()
.flatten(),
..self.services.clone()
};
let host = Arc::clone(&self.host);
let ingest_log = Arc::clone(&self.activity);
let ingest_plugin = plugin.clone();
let slot_owned = slot.to_owned();
let context = super::host::bounded_context(context);
let outcome = crate::time::spawn_blocking(move || {
let outcome = host.render(&slot_owned, &context, services);
ingest_log.ingest(&ingest_plugin, outcome.activity.clone());
if let Some(dropped) = outcome.dropped_calls {
ingest_log.ingest_dropped_calls(&ingest_plugin, dropped);
}
drop(permit);
drop(slot_permit);
outcome
})
.await;
let outcome = match outcome {
Ok(outcome) => outcome,
Err(err) => {
tracing::error!(
plugin,
slot,
error = %err,
"the sandbox render worker did not complete; omitting the fragment"
);
return None;
}
};
match outcome.fragment {
Ok(fragment) => Some(fragment),
Err(failure) => {
tracing::warn!(
plugin,
slot,
failure = %failure,
stderr = outcome.stderr,
fuel_used = outcome.fuel_used,
"sandboxed plugin did not produce a render fragment; omitting it"
);
None
}
}
}
pub fn mounted_router<S>(&self) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
axum::Router::new().nest(&self.manifest().prefix.clone(), self.router())
}
fn router<S>(&self) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
let manifest = self.manifest();
let mut router = axum::Router::new();
for route in &manifest.routes {
let nested = nested_path(&route.path, &manifest.prefix);
let implies_head = route.method == "GET"
&& !manifest
.routes
.iter()
.any(|other| other.method == "HEAD" && other.path == route.path);
let Some(filter) = method_filter(&route.method, implies_head) else {
tracing::error!(
plugin = manifest.name,
method = route.method,
"sandboxed plugin declared a method this build cannot mount; skipping it"
);
continue;
};
let host = Arc::clone(&self.host);
let permits = Arc::clone(&self.permits);
let guest_slots = Arc::clone(&self.guest_slots);
let services = self.services.clone();
let activity = Arc::clone(&self.activity);
let pattern = route.path.clone();
router = router.route(
&nested,
axum::routing::on(
filter,
move |params: axum::extract::RawPathParams, request: axum::extract::Request| {
let mounted = Mounted {
host: Arc::clone(&host),
permits: Arc::clone(&permits),
guest_slots: Arc::clone(&guest_slots),
services: services.clone(),
activity: Arc::clone(&activity),
};
let pattern = pattern.clone();
async move { serve(mounted, pattern, params, request).await }
},
),
);
}
router
}
}
impl Plugin for SandboxedPlugin {
fn name(&self) -> Cow<'static, str> {
Cow::Owned(self.manifest().name.clone())
}
fn build(self, app: AppBuilder) -> AppBuilder {
let manifest = self.manifest().clone();
self.log_grant(
"mounting a sandboxed plugin under its declared capability grant",
0,
);
app.nest(&manifest.prefix, self.router())
.declare_plugin_routes(manifest.route_infos())
}
}
fn nested_path(path: &str, prefix: &str) -> String {
match path.strip_prefix(prefix) {
Some("") | None => "/".to_owned(),
Some(rest) => rest.to_owned(),
}
}
fn method_filter(method: &str, implies_head: bool) -> Option<MethodFilter> {
match method {
"GET" if implies_head => Some(MethodFilter::GET.or(MethodFilter::HEAD)),
"GET" => Some(MethodFilter::GET),
"HEAD" => Some(MethodFilter::HEAD),
"POST" => Some(MethodFilter::POST),
"PUT" => Some(MethodFilter::PUT),
"PATCH" => Some(MethodFilter::PATCH),
"DELETE" => Some(MethodFilter::DELETE),
"OPTIONS" => Some(MethodFilter::OPTIONS),
_ => None,
}
}
struct Mounted {
host: Arc<SandboxHost>,
permits: Arc<Semaphore>,
guest_slots: Arc<Semaphore>,
services: CapabilityServices,
activity: Arc<PluginActivityLog>,
}
async fn serve(
mounted: Mounted,
pattern: String,
params: axum::extract::RawPathParams,
request: axum::extract::Request,
) -> Response {
let route = crate::middleware::access_log::ResponseRoute(pattern.clone());
let mut response = serve_inner(mounted, pattern, params, request).await;
response.extensions_mut().insert(route);
response
}
async fn serve_inner(
mounted: Mounted,
pattern: String,
params: axum::extract::RawPathParams,
request: axum::extract::Request,
) -> Response {
let Mounted {
host,
permits,
guest_slots,
services,
activity,
} = mounted;
let manifest = host.manifest();
let limits = manifest.limits;
let plugin = manifest.name.clone();
let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else {
tracing::warn!(
plugin,
max_concurrency = limits.max_concurrency,
"sandboxed plugin is at its concurrency ceiling; shedding the request"
);
let mut response = sandbox_error(&plugin, StatusCode::SERVICE_UNAVAILABLE);
response
.headers_mut()
.insert(http::header::RETRY_AFTER, HeaderValue::from_static("1"));
return response;
};
let request = match read_request(&plugin, limits, pattern, ¶ms, request).await {
Ok(request) => request,
Err(response) => return *response,
};
let Ok(slot) = guest_slots.try_acquire_owned() else {
tracing::warn!(
plugin,
"the sandbox guest pool is full; shedding the request"
);
let mut response = sandbox_error(&plugin, StatusCode::SERVICE_UNAVAILABLE);
response
.headers_mut()
.insert(http::header::RETRY_AFTER, HeaderValue::from_static("1"));
return response;
};
let request_method_is_head = request.method.eq_ignore_ascii_case("HEAD");
let services = CapabilityServices {
tenant: crate::tenancy::CURRENT_TENANT
.try_with(Clone::clone)
.ok()
.flatten(),
..services
};
let ingest_log = Arc::clone(&activity);
let ingest_plugin = plugin.clone();
let outcome = crate::time::spawn_blocking(move || {
let outcome = host.run_with(&request, services);
ingest_log.ingest(&ingest_plugin, outcome.activity.clone());
if let Some(dropped) = outcome.dropped_calls {
ingest_log.ingest_dropped_calls(&ingest_plugin, dropped);
}
drop(slot);
(outcome, permit)
})
.await;
let (outcome, permit) = match outcome {
Ok(pair) => pair,
Err(err) => {
tracing::error!(
plugin,
error = %err,
"the sandbox worker did not complete; serving 502 on the plugin's prefix"
);
return sandbox_error(&plugin, StatusCode::BAD_GATEWAY);
}
};
match outcome.result {
Ok(response) => {
let built = build_response(&plugin, &response, permit);
discard_body_if_it_cannot_be_sent(built, request_method_is_head)
}
Err(failure) => {
tracing::warn!(
plugin,
failure = %failure,
stderr = outcome.stderr,
fuel_used = outcome.fuel_used,
"sandboxed plugin failed to answer; serving an error on its own prefix"
);
sandbox_error(&plugin, failure.status())
}
}
}
fn charge_metadata(
plugin: &str,
running: &mut usize,
bytes: usize,
over: &'static str,
) -> Result<(), Box<Response>> {
*running = running.saturating_add(bytes);
if *running > super::host::MAX_REQUEST_METADATA_BYTES {
tracing::warn!(
plugin,
over,
max_request_metadata_bytes = super::host::MAX_REQUEST_METADATA_BYTES,
"request metadata over the sandbox ceiling; refusing before cloning it"
);
return Err(Box::new(sandbox_error(
plugin,
StatusCode::PAYLOAD_TOO_LARGE,
)));
}
Ok(())
}
async fn read_request(
plugin: &str,
limits: super::manifest::ResourceLimits,
pattern: String,
params: &axum::extract::RawPathParams,
request: axum::extract::Request,
) -> Result<SandboxRequest, Box<Response>> {
let (parts, body) = request.into_parts();
let path_str = parts
.extensions
.get::<axum::extract::OriginalUri>()
.map_or_else(|| parts.uri.path(), |original| original.0.path());
let query_str = parts.uri.query().unwrap_or_default();
let mut metadata = 0usize;
charge_metadata(
plugin,
&mut metadata,
parts
.method
.as_str()
.len()
.saturating_add(path_str.len())
.saturating_add(query_str.len())
.saturating_add(pattern.len())
.saturating_add(
params
.iter()
.map(|(name, value)| super::host::metadata_pair_bytes(name, value))
.fold(0usize, usize::saturating_add),
),
"uri",
)?;
let path = path_str.to_owned();
let query = query_str.to_owned();
let path_params: Vec<(String, String)> = params
.iter()
.map(|(name, value)| (name.to_owned(), value.to_owned()))
.collect();
let mut headers: Vec<(String, String)> = Vec::new();
for (name, value) in &parts.headers {
if !super::wire::request_header_allowed(name.as_str()) {
charge_metadata(
plugin,
&mut metadata,
super::host::DROPPED_PAIR_BYTES,
"dropped-headers",
)?;
continue;
}
let Ok(value) = value.to_str() else {
continue;
};
charge_metadata(
plugin,
&mut metadata,
super::host::metadata_pair_bytes(name.as_str(), value),
"headers",
)?;
headers.push((name.as_str().to_owned(), value.to_owned()));
}
let deadline = std::time::Duration::from_millis(limits.request_body_timeout_ms);
let read = tokio::time::timeout(
deadline,
axum::body::to_bytes(body, limits.max_request_body_bytes),
)
.await;
let body = match read {
Ok(Ok(body)) => body.to_vec(),
Ok(Err(_)) => {
tracing::warn!(
plugin,
max_request_body_bytes = limits.max_request_body_bytes,
"request body over the sandboxed plugin's declared ceiling; refusing it"
);
return Err(Box::new(sandbox_error(
plugin,
StatusCode::PAYLOAD_TOO_LARGE,
)));
}
Err(_) => {
tracing::warn!(
plugin,
request_body_timeout_ms = limits.request_body_timeout_ms,
"request body did not arrive within the plugin's deadline; releasing its permit"
);
return Err(Box::new(sandbox_error(plugin, StatusCode::REQUEST_TIMEOUT)));
}
};
Ok(SandboxRequest {
method: parts.method.as_str().to_owned(),
route: pattern,
path,
query,
path_params,
headers,
body,
})
}
struct PermitBuf {
data: Vec<u8>,
_permit: PermitShare,
}
type PermitShare = Arc<tokio::sync::OwnedSemaphorePermit>;
#[derive(Clone)]
struct PermitExtension(#[allow(dead_code)] PermitShare);
impl AsRef<[u8]> for PermitBuf {
fn as_ref(&self) -> &[u8] {
&self.data
}
}
fn discard_body_if_it_cannot_be_sent(response: Response, method_is_head: bool) -> Response {
let bodyless = matches!(
response.status(),
StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED
);
if !method_is_head && !bodyless {
return response;
}
let (parts, _body) = response.into_parts();
Response::from_parts(parts, Body::empty())
}
fn build_response(
plugin: &str,
response: &super::wire::SandboxResponse,
permit: tokio::sync::OwnedSemaphorePermit,
) -> Response {
let Ok(status) = StatusCode::from_u16(response.status) else {
return sandbox_error(plugin, StatusCode::BAD_GATEWAY);
};
let mut out = Response::builder().status(status);
let mut saw_content_type = false;
for (name, value) in &response.headers {
let (Ok(name), Ok(value)) = (
HeaderName::try_from(name.as_str()),
HeaderValue::try_from(value.as_str()),
) else {
return sandbox_error(plugin, StatusCode::BAD_GATEWAY);
};
if name == http::header::CONTENT_TYPE {
saw_content_type = true;
}
out = out.header(name, value);
}
if !saw_content_type {
out = out.header(http::header::CONTENT_TYPE, "application/octet-stream");
}
let permit: PermitShare = Arc::new(permit);
let body = Body::from(bytes::Bytes::from_owner(PermitBuf {
data: response.body.clone(),
_permit: Arc::clone(&permit),
}));
let Ok(mut built) = out.body(body) else {
return sandbox_error(plugin, StatusCode::BAD_GATEWAY);
};
built.extensions_mut().insert(PermitExtension(permit));
stamp_host_headers(&mut built, plugin);
built
}
fn attribution(plugin: &str) -> Option<HeaderValue> {
HeaderValue::try_from(plugin).ok()
}
fn stamp_host_headers(response: &mut Response, plugin: &str) {
let headers = response.headers_mut();
headers.insert(
http::header::X_CONTENT_TYPE_OPTIONS,
HeaderValue::from_static("nosniff"),
);
if let Some(value) = attribution(plugin) {
headers.insert(HeaderName::from_static(SANDBOX_ATTRIBUTION_HEADER), value);
}
}
fn sandbox_error(plugin: &str, status: StatusCode) -> Response {
let mut response = (
status,
[(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
"the sandboxed plugin could not serve this request\n",
)
.into_response();
stamp_host_headers(&mut response, plugin);
response
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin_sandbox::manifest::ResourceLimits;
use crate::plugin_sandbox::test_guests as guests;
use axum::body::Body;
use http::{Request, StatusCode};
use tower::ServiceExt as _;
fn manifest_toml(routes: &str, limits: &str) -> String {
format!(
r#"
name = "autumn-plugin-hello"
version = "0.1.0"
wire_version = 1
prefix = "/hello"
capabilities = ["http-request"]
sha256 = "{digest}"
{routes}
[limits]
{limits}
"#,
digest = "a".repeat(64)
)
}
fn plugin_from(wat: &str, routes: &str, limits: ResourceLimits) -> SandboxedPlugin {
let mut manifest = SandboxManifest::parse(&manifest_toml(
routes,
"fuel = 200000000\nmemory_bytes = 33554432",
))
.expect("valid manifest");
manifest.limits = limits;
let wasm = wat::parse_str(wat).expect("valid WAT");
SandboxedPlugin::new(SandboxHost::from_module(manifest, &wasm).expect("loads"))
}
const GREET_ROUTE: &str = "[[routes]]\nmethod = \"GET\"\npath = \"/hello/greet\"\n";
fn hello_plugin() -> SandboxedPlugin {
plugin_from(guests::HELLO, GREET_ROUTE, ResourceLimits::default())
}
#[test]
fn a_supplied_rate_limiter_cannot_raise_the_approved_ceiling() {
use crate::plugin_sandbox::capability::CapabilityRateLimiter;
let plugin = hello_plugin();
let approved = plugin
.services
.rate
.as_ref()
.expect("the manifest limiter is always built")
.per_second();
let looser = plugin.clone().with_services(CapabilityServices {
rate: Some(Arc::new(CapabilityRateLimiter::new(
approved.saturating_mul(10).max(approved + 1),
))),
..CapabilityServices::none()
});
assert_eq!(
looser
.services
.rate
.as_ref()
.expect("a limiter is always present")
.per_second(),
approved,
"a looser limiter must be ignored and the manifest's kept"
);
let shared = Arc::new(CapabilityRateLimiter::new(approved));
let tighter = plugin.with_services(CapabilityServices {
rate: Some(Arc::clone(&shared)),
..CapabilityServices::none()
});
assert!(
Arc::ptr_eq(tighter.services.rate.as_ref().expect("present"), &shared),
"an equal-or-tighter limiter must be the one actually used"
);
}
fn app(plugin: &SandboxedPlugin) -> axum::Router {
axum::Router::new()
.route("/healthz", axum::routing::get(|| async { "ok" }))
.merge(plugin.mounted_router())
}
async fn send(app: axum::Router, method: &str, path: &str) -> (StatusCode, String) {
send_with_body(app, method, path, Body::empty()).await
}
async fn send_raw(app: axum::Router, method: &str, path: &str) -> Response {
let request = Request::builder()
.method(method)
.uri(path)
.body(Body::empty())
.expect("request");
app.oneshot(request).await.expect("infallible")
}
async fn send_with_body(
app: axum::Router,
method: &str,
path: &str,
body: Body,
) -> (StatusCode, String) {
let request = Request::builder()
.method(method)
.uri(path)
.body(body)
.expect("request");
let response = app.oneshot(request).await.expect("infallible");
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
.await
.expect("body");
(status, String::from_utf8_lossy(&bytes).into_owned())
}
#[tokio::test]
async fn a_sandboxed_plugin_serves_http_under_its_declared_prefix() {
let plugin = hello_plugin();
let (status, body) = send(app(&plugin), "GET", "/hello/greet").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "hello from the sandbox");
}
#[tokio::test]
async fn the_manifest_is_the_mount_not_a_description_of_it() {
let plugin = hello_plugin();
let (status, _) = send(app(&plugin), "GET", "/hello/undeclared").await;
assert_eq!(status, StatusCode::NOT_FOUND);
let (status, _) = send(app(&plugin), "POST", "/hello/greet").await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn a_head_on_a_declared_get_reaches_the_guest_rather_than_axum_s_405() {
let plugin = hello_plugin();
let request = Request::builder()
.method("HEAD")
.uri("/hello/greet")
.body(Body::empty())
.expect("request");
let response = app(&plugin).oneshot(request).await.expect("infallible");
assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED);
assert!(
response.headers().contains_key(SANDBOX_ATTRIBUTION_HEADER),
"the sandbox handler must have produced this"
);
}
#[tokio::test]
async fn a_manifest_declaring_both_get_and_head_still_mounts() {
let plugin = plugin_from(
guests::HELLO,
"[[routes]]\nmethod = \"GET\"\npath = \"/hello/greet\"\n\n\
[[routes]]\nmethod = \"HEAD\"\npath = \"/hello/greet\"\n",
ResourceLimits::default(),
);
let (status, body) = send(app(&plugin), "GET", "/hello/greet").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "hello from the sandbox");
let (status, _) = send(app(&plugin), "HEAD", "/hello/greet").await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED, "the guest answered");
}
#[test]
fn the_route_manifest_names_the_head_a_get_route_also_serves() {
let builder = crate::app::app().plugin(hello_plugin());
let routes = builder.plugin_route_infos().expect("route manifest");
assert!(
routes
.iter()
.any(|route| route.method == "HEAD" && route.path == "/hello/greet"),
"{routes:?}"
);
}
#[tokio::test]
async fn responses_are_attributable_to_the_plugin_that_produced_them() {
let plugin = hello_plugin();
let request = Request::builder()
.uri("/hello/greet")
.body(Body::empty())
.expect("request");
let response = app(&plugin).oneshot(request).await.expect("infallible");
assert_eq!(
response
.headers()
.get(SANDBOX_ATTRIBUTION_HEADER)
.and_then(|value| value.to_str().ok()),
Some("autumn-plugin-hello")
);
}
#[tokio::test]
async fn a_runaway_plugin_5xxs_on_its_own_prefix_while_the_app_keeps_serving() {
let plugin = plugin_from(
guests::CPU_SPIN,
GREET_ROUTE,
ResourceLimits {
fuel: 5_000_000,
..ResourceLimits::default()
},
);
let (status, _) = send(app(&plugin), "GET", "/hello/greet").await;
assert!(status.is_server_error(), "{status}");
let (status, body) = send(app(&plugin), "GET", "/healthz").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "ok");
}
#[tokio::test]
async fn a_trapping_plugin_never_takes_the_host_with_it() {
let plugin = plugin_from(guests::TRAP, GREET_ROUTE, ResourceLimits::default());
let app = app(&plugin);
let (status, _) = send(app.clone(), "GET", "/hello/greet").await;
assert_eq!(status, StatusCode::BAD_GATEWAY);
let (status, _) = send(app, "GET", "/healthz").await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn a_plugin_that_exits_never_exits_the_host() {
let plugin = plugin_from(guests::EXIT, GREET_ROUTE, ResourceLimits::default());
let app = app(&plugin);
let (status, _) = send(app.clone(), "GET", "/hello/greet").await;
assert!(status.is_server_error(), "{status}");
let (status, _) = send(app, "GET", "/healthz").await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn a_body_over_the_declared_ceiling_never_reaches_the_guest() {
let plugin = plugin_from(
guests::HELLO,
"[[routes]]\nmethod = \"POST\"\npath = \"/hello/greet\"\n",
ResourceLimits {
max_request_body_bytes: 16,
..ResourceLimits::default()
},
);
let (status, _) = send_with_body(
app(&plugin),
"POST",
"/hello/greet",
Body::from(vec![b'x'; 4096]),
)
.await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn the_concurrency_ceiling_is_enforced_rather_than_queued() {
let plugin = plugin_from(
guests::HELLO,
GREET_ROUTE,
ResourceLimits {
max_concurrency: 1,
..ResourceLimits::default()
},
);
let held = std::sync::Arc::clone(&plugin.permits)
.try_acquire_owned()
.expect("the first permit is free");
let (status, _) = send(app(&plugin), "GET", "/hello/greet").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
drop(held);
let (status, _) = send(app(&plugin), "GET", "/hello/greet").await;
assert_eq!(status, StatusCode::OK);
}
#[tokio::test]
async fn the_process_wide_guest_slots_shed_a_request_the_plugin_would_admit() {
let plugin = plugin_from(
guests::HELLO,
GREET_ROUTE,
ResourceLimits {
max_concurrency: 8,
..ResourceLimits::default()
},
)
.with_guest_slots(Arc::new(Semaphore::new(1)));
let held = Arc::clone(&plugin.guest_slots)
.try_acquire_owned()
.expect("the only slot is free");
let response = send_raw(app(&plugin), "GET", "/hello/greet").await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(response.headers().contains_key(http::header::RETRY_AFTER));
assert_eq!(plugin.permits.available_permits(), 8);
assert!(plugin.render_slot("any", &[]).await.is_none());
drop(held);
let (status, _) = send(app(&plugin), "GET", "/hello/greet").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(plugin.guest_slots.available_permits(), 1);
}
#[test]
fn the_public_setter_refuses_bad_limits_and_leaves_the_pool_alone() {
assert_eq!(set_guest_slots(0), Err(0));
assert_eq!(set_guest_slots(usize::MAX), Err(0));
}
#[tokio::test]
async fn one_guest_pool_caps_two_plugins_together() {
let pool = Arc::new(Semaphore::new(1));
let one = hello_plugin().with_guest_slots(Arc::clone(&pool));
let two = hello_plugin().with_guest_slots(Arc::clone(&pool));
let held = Arc::clone(&pool).try_acquire_owned().expect("free");
for plugin in [&one, &two] {
let (status, _) = send(app(plugin), "GET", "/hello/greet").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
}
drop(held);
let (status, _) = send(app(&two), "GET", "/hello/greet").await;
assert_eq!(status, StatusCode::OK);
}
#[test]
fn the_default_guest_slots_are_a_fraction_of_tokios_blocking_pool() {
const { assert!(DEFAULT_GUEST_SLOTS <= 512 / 4) };
const { assert!(DEFAULT_GUEST_SLOTS >= 1) };
}
#[test]
fn the_guest_slot_limit_is_fixed_by_the_first_caller() {
let cell = std::sync::OnceLock::new();
assert_eq!(fix_slots(&cell, 3), Ok(()));
assert_eq!(fix_slots(&cell, 9), Err(3), "a later caller is refused");
assert_eq!(cell.get().map(|(n, _)| *n), Some(3));
assert_eq!(fix_slots(&std::sync::OnceLock::new(), 0), Err(0));
assert_eq!(fix_slots(&std::sync::OnceLock::new(), usize::MAX), Err(0));
}
#[tokio::test(start_paused = true)]
async fn a_body_that_never_arrives_does_not_pin_a_permit_forever() {
let plugin = plugin_from(
guests::HELLO,
"[[routes]]\nmethod = \"POST\"\npath = \"/hello/greet\"\n",
ResourceLimits {
max_concurrency: 1,
request_body_timeout_ms: 250,
..ResourceLimits::default()
},
);
let stalled = Body::from_stream(futures::stream::pending::<
Result<bytes::Bytes, std::io::Error>,
>());
let (status, _) = send_with_body(app(&plugin), "POST", "/hello/greet", stalled).await;
assert_eq!(status, StatusCode::REQUEST_TIMEOUT);
let (status, _) =
send_with_body(app(&plugin), "POST", "/hello/greet", Body::from("hi")).await;
assert_eq!(status, StatusCode::METHOD_NOT_ALLOWED);
}
#[tokio::test]
async fn an_empty_answer_returns_its_slot_on_drop_without_being_polled() {
use crate::plugin_sandbox::wire::SandboxResponse;
let permits = Arc::new(Semaphore::new(1));
let empty = SandboxResponse {
status: 204,
headers: Vec::new(),
body: Vec::new(),
};
let response = build_response(
"autumn-plugin-hello",
&empty,
Arc::clone(&permits).acquire_owned().await.expect("permit"),
);
assert!(
http_body::Body::is_end_stream(response.body()),
"the fixture must be the never-polled shape this test is about",
);
assert_eq!(
permits.available_permits(),
0,
"the response is still resident, so its slot is still spent",
);
drop(response);
assert_eq!(
permits.available_permits(),
1,
"the slot must come back on drop, with no poll anywhere",
);
let full = SandboxResponse {
status: 200,
headers: Vec::new(),
body: b"hi".to_vec(),
};
let response = build_response(
"autumn-plugin-hello",
&full,
Arc::clone(&permits).acquire_owned().await.expect("permit"),
);
assert_eq!(
permits.available_permits(),
0,
"undelivered bytes keep their slot"
);
drop(response);
assert_eq!(permits.available_permits(), 1, "and give it back on drop");
}
#[tokio::test]
async fn a_bodyless_status_carries_no_body_to_an_in_process_caller() {
use crate::plugin_sandbox::wire::SandboxResponse;
use http_body_util::BodyExt as _;
for status in [204u16, 205, 304] {
let permits = Arc::new(Semaphore::new(1));
let answered = SandboxResponse {
status,
headers: Vec::new(),
body: b"content a bodyless status may not carry".to_vec(),
};
let built = build_response(
"autumn-plugin-hello",
&answered,
Arc::clone(&permits).acquire_owned().await.expect("permit"),
);
let response = discard_body_if_it_cannot_be_sent(built, false);
let collected = response
.into_body()
.collect()
.await
.expect("the body collects")
.to_bytes();
assert!(
collected.is_empty(),
"{status} handed {} body bytes to an in-process caller",
collected.len(),
);
}
let permits = Arc::new(Semaphore::new(1));
let ok = SandboxResponse {
status: 200,
headers: Vec::new(),
body: b"hi".to_vec(),
};
let built = build_response(
"autumn-plugin-hello",
&ok,
Arc::clone(&permits).acquire_owned().await.expect("permit"),
);
let kept = discard_body_if_it_cannot_be_sent(built, false)
.into_body()
.collect()
.await
.expect("the body collects")
.to_bytes();
assert_eq!(kept.as_ref(), b"hi", "a 200 must keep the answer it gave");
}
#[tokio::test]
async fn discarding_a_body_does_not_return_the_slot_the_headers_still_hold() {
use crate::plugin_sandbox::wire::SandboxResponse;
let permits = Arc::new(Semaphore::new(1));
let answered = SandboxResponse {
status: 204,
headers: vec![("x-plugin-note".to_string(), "v".repeat(4096))],
body: b"discarded".to_vec(),
};
let built = build_response(
"autumn-plugin-hello",
&answered,
Arc::clone(&permits).acquire_owned().await.expect("permit"),
);
let response = discard_body_if_it_cannot_be_sent(built, false);
assert_eq!(
permits.available_permits(),
0,
"the slot came back when the body was discarded, while the headers remain",
);
drop(response);
assert_eq!(
permits.available_permits(),
1,
"and back once nothing holds it"
);
}
#[tokio::test]
async fn a_header_only_response_holds_its_permit_until_the_headers_go() {
use crate::plugin_sandbox::wire::SandboxResponse;
let permits = Arc::new(Semaphore::new(1));
let header_only = SandboxResponse {
status: 200,
headers: vec![("x-plugin-note".to_string(), "v".repeat(4096))],
body: Vec::new(),
};
let response = build_response(
"autumn-plugin-hello",
&header_only,
Arc::clone(&permits).acquire_owned().await.expect("permit"),
);
assert!(http_body::Body::is_end_stream(response.body()));
assert!(
response.headers().contains_key("x-plugin-note"),
"the header this response is made of must survive to be held",
);
assert_eq!(
permits.available_permits(),
0,
"the slot came back while the response headers were still held",
);
drop(response);
assert_eq!(
permits.available_permits(),
1,
"the slot must come back once nothing holds the response",
);
}
#[tokio::test]
async fn the_permit_outlives_the_body_and_dies_with_the_bytes() {
use crate::plugin_sandbox::wire::SandboxResponse;
use http_body_util::BodyExt as _;
let permits = Arc::new(Semaphore::new(1));
let full = SandboxResponse {
status: 200,
headers: Vec::new(),
body: b"hi".to_vec(),
};
let mut response = build_response(
"autumn-plugin-hello",
&full,
Arc::clone(&permits).acquire_owned().await.expect("permit"),
);
assert_eq!(permits.available_permits(), 0, "the bytes are undelivered");
let frame = response
.body_mut()
.frame()
.await
.expect("a frame")
.expect("not an error");
let data = frame.into_data().expect("a data frame");
assert_eq!(data.as_ref(), b"hi");
assert!(http_body::Body::is_end_stream(response.body()));
drop(response);
assert_eq!(
permits.available_permits(),
0,
"the slot came back while the response bytes were still held"
);
drop(data);
assert_eq!(
permits.available_permits(),
1,
"the slot did not come back when the bytes were freed"
);
}
#[test]
fn the_plugin_is_named_by_its_manifest() {
assert_eq!(
crate::plugin::Plugin::name(&hello_plugin()),
"autumn-plugin-hello"
);
}
#[test]
fn mounting_declares_the_manifest_routes_with_plugin_attribution() {
let builder = crate::app::app().plugin(hello_plugin());
let routes = builder.plugin_route_infos().expect("route manifest");
let sandboxed: Vec<_> = routes
.iter()
.filter(|route| {
route.source
== crate::route_listing::RouteSource::Plugin("autumn-plugin-hello".to_owned())
})
.collect();
assert_eq!(sandboxed.len(), 2, "{sandboxed:?}");
assert!(sandboxed.iter().all(|route| route.path == "/hello/greet"));
assert_eq!(sandboxed[0].method, "GET");
assert_eq!(sandboxed[1].method, "HEAD");
}
#[test]
fn a_mounted_sandboxed_plugin_passes_the_conformance_checks() {
let builder = crate::app::app().plugin(hello_plugin());
let routes = builder.plugin_route_infos().expect("route manifest");
let report = crate::plugin_conformance::run_conformance(
&crate::plugin_conformance::ConformanceConfig::new("autumn-plugin-hello")
.prefix("/hello"),
&routes,
);
assert!(report.passed(), "{}", report.to_text_report());
}
#[test]
fn a_render_context_is_bounded_before_it_is_cloned_onto_a_worker() {
let many: Vec<(String, String)> = (0
..crate::plugin_sandbox::host::MAX_RENDER_CONTEXT_ENTRIES * 4)
.map(|index| (format!("k{index}"), String::new()))
.collect();
assert_eq!(
crate::plugin_sandbox::host::bounded_context(&many).len(),
crate::plugin_sandbox::host::MAX_RENDER_CONTEXT_ENTRIES,
"empty pairs still cost allocation, so the count alone has to bound them"
);
let heavy: Vec<(String, String)> = (0
..crate::plugin_sandbox::host::MAX_RENDER_CONTEXT_ENTRIES)
.map(|index| {
(
format!("k{index}"),
"x".repeat(crate::plugin_sandbox::host::MAX_RENDER_CONTEXT_BYTES),
)
})
.collect();
let kept = crate::plugin_sandbox::host::bounded_context(&heavy);
let carried: usize = kept
.iter()
.map(|(name, value)| name.len().saturating_add(value.len()))
.sum();
assert!(
carried <= crate::plugin_sandbox::host::MAX_RENDER_CONTEXT_BYTES,
"the worker was handed {carried} bytes"
);
let ordinary = vec![
("id".to_owned(), "42".to_owned()),
("locale".to_owned(), "en".to_owned()),
];
assert_eq!(
crate::plugin_sandbox::host::bounded_context(&ordinary),
ordinary
);
}
}