#![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::collections::VecDeque;
use std::fmt;
use tokio::sync::Semaphore;
use wasmi::{Caller, Config, Engine, Linker, Module, Store};
use super::artifact::SandboxArtifact;
use super::manifest::{ResourceLimits, SandboxManifest};
use super::wire::{
GuestFrame, HostFrame, SandboxRequest, SandboxResponse, from_guest_line, to_exact_line, to_line,
};
const WASI: &str = "wasi_snapshot_preview1";
mod errno {
pub(super) const SUCCESS: i32 = 0;
pub(super) const BADF: i32 = 8;
pub(super) const INVAL: i32 = 28;
pub(super) const SPIPE: i32 = 70;
pub(super) const NOTCAPABLE: i32 = 76;
}
const WASM_PAGE_BYTES: u64 = 64 * 1024;
const IOVEC_SIZE: usize = 8;
const FDSTAT_SIZE: usize = 24;
const HOST_IO_CHUNK_BYTES: usize = 64 * 1024;
const STDERR_BUDGET_BYTES: usize = 64 * 1024;
const MAX_IOVECS: i32 = 64;
pub const MAX_TABLE_ELEMENTS: u32 = 16_384;
pub const MAX_TABLES: usize = 4;
pub const MAX_MEMORIES: usize = 1;
pub const MAX_GLOBALS: usize = 4096;
pub const MAX_FUNCTIONS: usize = 65_536;
pub const MAX_CODE_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_INIT_SECTION_BYTES: usize = 16 * 1024 * 1024;
pub const MAX_INIT_SEGMENTS: usize = 4096;
const BYTES_PER_FUEL: u64 = 64;
const STDERR_EXCERPT: usize = 512;
const DETAIL_EXCERPT: usize = 512;
pub(super) const TRUNCATION_MARKER: &str = " … (truncated)";
pub(super) fn guest_text(text: &str) -> String {
bounded_guest_text(text.chars(), text.len())
}
fn bounded_guest_text(chars: impl Iterator<Item = char>, hint: usize) -> String {
let mut out = String::with_capacity(hint.min(DETAIL_EXCERPT));
for (kept, ch) in chars.enumerate() {
if kept == DETAIL_EXCERPT {
out.push_str(TRUNCATION_MARKER);
break;
}
if ch.is_control() || is_line_separator(ch) || super::manifest::is_display_reordering(ch) {
out.extend(ch.escape_debug());
} else {
out.push(ch);
}
}
out
}
const fn is_line_separator(ch: char) -> bool {
matches!(ch, '\u{2028}' | '\u{2029}')
}
fn lossy_chars(bytes: &[u8]) -> impl Iterator<Item = char> + '_ {
bytes.utf8_chunks().flat_map(|chunk| {
chunk
.valid()
.chars()
.chain((!chunk.invalid().is_empty()).then_some(char::REPLACEMENT_CHARACTER))
})
}
fn import_operation(module: &str, name: &str) -> String {
bounded_guest_text(
module.chars().chain("::".chars()).chain(name.chars()),
module.len().saturating_add(name.len()).saturating_add(2),
)
}
const DENIAL_RECORD_BYTES: usize = DETAIL_EXCERPT * 10 + 256;
pub const FIXED_HOST_BUFFER_BYTES: usize = STDERR_BUDGET_BYTES
.saturating_add(HOST_IO_CHUNK_BYTES)
.saturating_add(MAX_DENIALS.saturating_mul(DENIAL_RECORD_BYTES))
.saturating_add(MAX_QUEUED_REPLY_BYTES)
.saturating_add(
crate::plugin_sandbox::capability::MAX_EVENTS.saturating_mul(CAPABILITY_EVENT_BYTES),
)
.saturating_add(4096);
pub const MAX_QUEUED_REPLY_BYTES: usize = 1024 * 1024;
const CAPABILITY_EVENT_BYTES: usize =
crate::plugin_sandbox::capability::MAX_TARGET_CHARS * 10 + 256;
const CAPABILITY_CALL_FUEL: u64 = 2_000;
const FINISH_ALLOWANCE_FUEL: u64 = 100_000;
const GUEST_READ_FUEL_PER_BYTE: u64 = 128;
pub const MAX_REQUEST_METADATA_BYTES: usize = 256 * 1024;
pub(crate) const fn metadata_pair_bytes(name: &str, value: &str) -> usize {
const ENTRY: usize = 56;
name.len().saturating_add(value.len()).saturating_add(ENTRY)
}
pub(crate) const DROPPED_PAIR_BYTES: usize = metadata_pair_bytes("", "");
fn request_metadata_bytes(request: &SandboxRequest) -> usize {
let mut total = request
.method
.len()
.saturating_add(request.path.len())
.saturating_add(request.query.len())
.saturating_add(request.route.len());
if total > MAX_REQUEST_METADATA_BYTES {
return total;
}
for (name, value) in &request.headers {
total = total.saturating_add(if super::wire::request_header_allowed(name) {
metadata_pair_bytes(name, value)
} else {
DROPPED_PAIR_BYTES
});
if total > MAX_REQUEST_METADATA_BYTES {
return total;
}
}
for (name, value) in &request.path_params {
total = total.saturating_add(metadata_pair_bytes(name, value));
if total > MAX_REQUEST_METADATA_BYTES {
return total;
}
}
total
}
fn refuse_oversized_request(
request: &SandboxRequest,
limits: ResourceLimits,
) -> Option<SandboxOutcome> {
if request.body.len() > limits.max_request_body_bytes {
return Some(SandboxOutcome::refused(
SandboxFailure::RequestBudget {
max: limits.max_request_body_bytes,
len: request.body.len(),
},
0,
));
}
let metadata = request_metadata_bytes(request);
(metadata > MAX_REQUEST_METADATA_BYTES).then(|| {
SandboxOutcome::refused(
SandboxFailure::RequestMetadataBudget {
max: MAX_REQUEST_METADATA_BYTES,
len: metadata,
},
0,
)
})
}
pub const MAX_DECLARED_ENTRIES: usize = 1_000_000;
pub const MAX_SECTIONS: usize = 1_024;
pub const MAX_IMPORTS: usize = 1024;
const MAX_REPORTED_IMPORTS: usize = 256;
fn reported_imports<'a>(imports: impl Iterator<Item = wasmi::ImportType<'a>>) -> Vec<String> {
let mut names: Vec<String> = Vec::new();
let mut total: usize = 0;
for import in imports {
total = total.saturating_add(1);
if names.len() < MAX_REPORTED_IMPORTS {
names.push(import_operation(import.module(), import.name()));
}
}
if let Some(hidden) = total.checked_sub(names.len()).filter(|more| *more > 0) {
names.push(format!("… and {hidden} more (truncated)"));
}
names
}
fn encoding_fuel(request: &SandboxRequest) -> u64 {
const BODY_PASSES: u64 = 10;
const METADATA_PASSES: u64 = 21;
let charge = |bytes: usize, passes: u64| {
u64::try_from(bytes)
.unwrap_or(u64::MAX)
.saturating_mul(passes)
};
charge(request.body.len(), BODY_PASSES)
.saturating_add(charge(request_metadata_bytes(request), METADATA_PASSES))
.checked_div(BYTES_PER_FUEL)
.unwrap_or(u64::MAX)
.saturating_add(1)
}
const MAX_DENIALS: usize = 64;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DeniedCapability {
Filesystem,
Network,
Environment,
ProcessControl,
Memory,
ResponseHeader,
UnknownImport,
}
impl DeniedCapability {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Filesystem => "filesystem",
Self::Network => "network",
Self::Environment => "environment",
Self::ProcessControl => "process-control",
Self::Memory => "memory",
Self::ResponseHeader => "response-header",
Self::UnknownImport => "unknown-import",
}
}
}
impl fmt::Display for DeniedCapability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CapabilityDenial {
pub capability: DeniedCapability,
pub operation: String,
pub detail: String,
}
impl fmt::Display for CapabilityDenial {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"{capability}: {operation} — {detail}",
capability = self.capability,
operation = self.operation,
detail = self.detail
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SandboxFailure {
Instantiation(String),
FuelExhausted {
budget: u64,
},
Trap(String),
Exited(i32),
NoAnswer,
PartialFrame,
MalformedFrame(String),
GuestError(String),
ResponseRefused(String),
OutputBudget {
max: usize,
},
AtCapacity {
max: usize,
},
RequestBudget {
max: usize,
len: usize,
},
RequestMetadataBudget {
max: usize,
len: usize,
},
}
impl SandboxFailure {
#[must_use]
pub const fn status(&self) -> http::StatusCode {
match self {
Self::FuelExhausted { .. } | Self::OutputBudget { .. } => {
http::StatusCode::GATEWAY_TIMEOUT
}
Self::RequestBudget { .. } | Self::RequestMetadataBudget { .. } => {
http::StatusCode::PAYLOAD_TOO_LARGE
}
Self::AtCapacity { .. } => http::StatusCode::SERVICE_UNAVAILABLE,
_ => http::StatusCode::BAD_GATEWAY,
}
}
}
impl fmt::Display for SandboxFailure {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Instantiation(detail) => {
write!(f, "the plugin could not be instantiated: {detail}")
}
Self::FuelExhausted { budget } => write!(
f,
"the plugin exhausted its {budget}-unit CPU budget without answering"
),
Self::Trap(detail) => write!(f, "the plugin trapped: {detail}"),
Self::Exited(code) => write!(f, "the plugin called proc_exit({code})"),
Self::NoAnswer => write!(f, "the plugin returned without answering"),
Self::PartialFrame => write!(
f,
"the plugin wrote a partial frame with no terminating newline; a frame is one \
NDJSON line, so it must end with `\\n` (use `println!`, not `print!`)"
),
Self::MalformedFrame(detail) => {
write!(f, "the plugin wrote a malformed frame: {detail}")
}
Self::GuestError(detail) => write!(f, "the plugin reported a failure: {detail}"),
Self::ResponseRefused(detail) => {
write!(f, "the plugin's answer was refused: {detail}")
}
Self::OutputBudget { max } => write!(
f,
"the plugin wrote more than its {max}-byte response ceiling without ending a frame"
),
Self::AtCapacity { max } => write!(
f,
"the plugin already has its {max} permitted requests executing"
),
Self::RequestBudget { max, len } => write!(
f,
"the request body is {len} bytes, over the plugin's {max}-byte request ceiling"
),
Self::RequestMetadataBudget { max, len } => write!(
f,
"the request's path, query, headers and parameters are {len} bytes, over the \
host's {max}-byte ceiling"
),
}
}
}
impl std::error::Error for SandboxFailure {}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SandboxLoadError {
InvalidManifest(String),
Wasm(String),
ForbiddenImports(Vec<CapabilityDenial>),
FuelBelowFixedCharges {
fuel: u64,
instantiation: u64,
},
InstantiationTooExpensive {
what: &'static str,
found: usize,
max: usize,
},
ModuleTooLarge {
found: usize,
max: usize,
},
MissingStart,
MissingMemory,
StartSectionForbidden,
SegmentOutOfBounds {
what: &'static str,
end: u64,
capacity: u64,
},
MemoryTooLarge {
found: u64,
max: usize,
},
Engine(String),
}
impl fmt::Display for SandboxLoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidManifest(detail) => {
write!(f, "the manifest is not one this host will serve: {detail}")
}
Self::Wasm(detail) => write!(f, "the plugin module could not be loaded: {detail}"),
Self::ForbiddenImports(denials) => {
write!(
f,
"the plugin imports {count} host function(s) the sandbox does not provide, \
so it is refused before it runs: ",
count = denials.len(),
)?;
for (written, denial) in denials.iter().enumerate() {
if written > 0 {
f.write_str(", ")?;
}
f.write_str(&denial.operation)?;
}
Ok(())
}
Self::FuelBelowFixedCharges {
fuel,
instantiation,
} => write!(
f,
"the plugin's {fuel}-unit fuel budget cannot cover the {instantiation} units \
instantiating this module costs before `_start` runs, so every request would \
exhaust the budget without the guest executing an instruction"
),
Self::ModuleTooLarge { found, max } => write!(
f,
"sandboxed plugin module is {found} bytes, over the {max}-byte ceiling"
),
Self::InstantiationTooExpensive { what, found, max } => write!(
f,
"the plugin declares {found} {what}, over the {max} ceiling: every request \
re-instantiates the module, and that work happens before the first guest \
instruction — so it is bounded here rather than priced per request"
),
Self::MissingStart => write!(
f,
"the plugin exports no `_start` of type `() -> ()`; it must be built as a \
wasm32-wasip1 *command*"
),
Self::MissingMemory => write!(
f,
"the plugin exports no linear memory named `memory`; every host function reads \
and writes through it, so a plugin without one can never answer"
),
Self::MemoryTooLarge { found, max } => write!(
f,
"the plugin's initial linear memory is {found} bytes, over the manifest's \
{max}-byte ceiling; no request could instantiate it"
),
Self::StartSectionForbidden => write!(
f,
"the plugin carries a WebAssembly `start` section; a sandboxed plugin answers \
through its exported `_start` and runs no code at instantiation"
),
Self::SegmentOutOfBounds {
what,
end,
capacity,
} => write!(
f,
"an active segment writes up to {end} of the plugin's {what}, past the \
{capacity} it starts with; every instantiation would fail on it"
),
Self::Engine(detail) => write!(f, "the sandbox engine could not be built: {detail}"),
}
}
}
impl std::error::Error for SandboxLoadError {}
#[derive(Debug)]
#[non_exhaustive]
pub struct SandboxOutcome {
pub result: Result<SandboxResponse, SandboxFailure>,
pub denials: Vec<CapabilityDenial>,
pub fuel_used: u64,
pub peak_memory_bytes: usize,
pub stderr: String,
pub activity: Vec<crate::plugin_sandbox::capability::CapabilityEvent>,
pub dropped_activity: u64,
pub dropped_calls: Option<crate::plugin_sandbox::capability::DroppedCalls>,
}
impl SandboxOutcome {
const fn refused(failure: SandboxFailure, fuel_used: u64) -> Self {
Self {
result: Err(failure),
denials: Vec::new(),
fuel_used,
peak_memory_bytes: 0,
stderr: String::new(),
activity: Vec::new(),
dropped_activity: 0,
dropped_calls: None,
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct SandboxRenderOutcome {
pub fragment: Result<String, SandboxFailure>,
pub denials: Vec<CapabilityDenial>,
pub fuel_used: u64,
pub stderr: String,
pub peak_memory_bytes: usize,
pub activity: Vec<crate::plugin_sandbox::capability::CapabilityEvent>,
pub dropped_events: u64,
pub dropped_calls: Option<crate::plugin_sandbox::capability::DroppedCalls>,
}
pub struct SandboxHost {
engine: Engine,
module: Module,
permits: Semaphore,
artifact_sha256: Option<String>,
owned_routes: super::wire::OwnedRoutes,
instantiation_fuel: u64,
manifest: SandboxManifest,
}
impl fmt::Debug for SandboxHost {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SandboxHost")
.field("plugin", &self.manifest.name)
.field("prefix", &self.manifest.prefix)
.finish_non_exhaustive()
}
}
pub const RENDER_CONTEXT_ENTRY_OVERHEAD: usize = 64;
pub const MAX_RENDER_CONTEXT_ENTRIES: usize = 32;
pub const MAX_RENDER_CONTEXT_BYTES: usize = 16 * 1024;
#[must_use]
pub fn bounded_context(context: &[(String, String)]) -> Vec<(String, String)> {
let mut out = Vec::with_capacity(context.len().min(MAX_RENDER_CONTEXT_ENTRIES));
let mut total = 0_usize;
for (name, value) in context {
if out.len() >= MAX_RENDER_CONTEXT_ENTRIES {
break;
}
let weight = name
.len()
.saturating_add(value.len())
.saturating_add(RENDER_CONTEXT_ENTRY_OVERHEAD);
if total.saturating_add(weight) > MAX_RENDER_CONTEXT_BYTES {
break;
}
total = total.saturating_add(weight);
out.push((name.clone(), value.clone()));
}
out
}
impl SandboxHost {
pub fn load(artifact: &SandboxArtifact) -> Result<Self, SandboxLoadError> {
let digest = artifact
.artifact_digest()
.map_err(|err| SandboxLoadError::InvalidManifest(err.to_string()))?;
Ok(
Self::from_module(artifact.manifest().clone(), artifact.module())?
.with_artifact_sha256(digest),
)
}
pub fn from_module(manifest: SandboxManifest, wasm: &[u8]) -> Result<Self, SandboxLoadError> {
manifest
.validate()
.map_err(|err| SandboxLoadError::InvalidManifest(err.to_string()))?;
let shape = refuse_unbounded_shape(wasm)?;
let mut config = Config::default();
config.consume_fuel(true);
let engine = Engine::new(&config);
let module =
Module::new(&engine, wasm).map_err(|err| SandboxLoadError::Wasm(err.to_string()))?;
let import_count = module.imports().count();
if import_count > MAX_IMPORTS {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "imports",
found: import_count,
max: MAX_IMPORTS,
});
}
let forbidden = forbidden_imports(&module);
if !forbidden.is_empty() {
return Err(SandboxLoadError::ForbiddenImports(forbidden));
}
let memory = module
.exports()
.find(|export| export.name() == "memory")
.and_then(|export| export.ty().memory().copied());
let Some(memory) = memory else {
return Err(SandboxLoadError::MissingMemory);
};
let initial_bytes =
u64::from(u32::from(memory.initial_pages())).saturating_mul(WASM_PAGE_BYTES);
if initial_bytes > manifest.limits.memory_bytes as u64 {
return Err(SandboxLoadError::MemoryTooLarge {
found: initial_bytes,
max: manifest.limits.memory_bytes,
});
}
let (segments, init_bytes) = (shape.segments, shape.init_bytes);
let instantiation = instantiation_fuel(
segments,
init_bytes,
import_count,
shape.global_count,
initial_bytes,
shape.table_elements,
)
.saturating_add(u64::try_from(shape.function_count).unwrap_or(u64::MAX));
if manifest.limits.fuel <= instantiation {
return Err(SandboxLoadError::FuelBelowFixedCharges {
fuel: manifest.limits.fuel,
instantiation,
});
}
let start_is_callable = module.exports().any(|export| {
export.name() == "_start"
&& export
.ty()
.func()
.is_some_and(|ty| ty.params().is_empty() && ty.results().is_empty())
});
if !start_is_callable {
return Err(SandboxLoadError::MissingStart);
}
refuse_unrunnable_shape(&shape, initial_bytes)?;
Ok(Self {
engine,
module,
permits: Semaphore::new(manifest.limits.max_concurrency),
artifact_sha256: None,
instantiation_fuel: instantiation,
owned_routes: super::wire::OwnedRoutes::from_routes(
manifest
.routes
.iter()
.map(|route| (route.method.as_str(), route.path.as_str())),
),
manifest,
})
}
#[must_use]
pub(super) fn with_artifact_sha256(mut self, digest: String) -> Self {
self.artifact_sha256 = Some(digest);
self
}
#[must_use]
pub const fn manifest(&self) -> &SandboxManifest {
&self.manifest
}
pub fn imports_of(wasm: &[u8]) -> Result<Vec<String>, SandboxLoadError> {
refuse_unbounded_shape(wasm)?;
let engine = Engine::default();
let module =
Module::new(&engine, wasm).map_err(|err| SandboxLoadError::Wasm(err.to_string()))?;
Ok(reported_imports(module.imports()))
}
#[must_use]
pub const fn instantiation_fuel(&self) -> u64 {
self.instantiation_fuel
}
#[must_use]
pub fn imports(&self) -> Vec<String> {
reported_imports(self.module.imports())
}
#[must_use]
pub fn run(&self, request: &SandboxRequest) -> SandboxOutcome {
self.run_with(request, crate::plugin_sandbox::CapabilityServices::none())
}
#[must_use]
pub fn run_with(
&self,
request: &SandboxRequest,
services: crate::plugin_sandbox::CapabilityServices,
) -> SandboxOutcome {
let limits = self.manifest.limits;
let rules = ResponseRules {
prefix: self.manifest.prefix.as_str(),
owned: &self.owned_routes,
requested: &request.method,
};
if let Some(refusal) = refuse_oversized_request(request, limits) {
return refusal;
}
let capabilities = &self.manifest.capabilities;
match self.execute(
|| HostFrame::request(request, capabilities),
encoding_fuel(request),
Exchange::Request,
services,
) {
Execution::Refused(failure, fuel_used) => SandboxOutcome::refused(failure, fuel_used),
Execution::Ran(store, permit, result) => {
let outcome = finish(store, limits, &rules, result);
drop(permit);
outcome
}
}
}
#[must_use]
pub fn render(
&self,
slot: &str,
context: &[(String, String)],
services: crate::plugin_sandbox::CapabilityServices,
) -> SandboxRenderOutcome {
let limits = self.manifest.limits;
let refused = |failure, fuel_used| SandboxRenderOutcome {
fragment: Err(failure),
denials: Vec::new(),
fuel_used,
peak_memory_bytes: 0,
stderr: String::new(),
activity: Vec::new(),
dropped_events: 0,
dropped_calls: None,
};
if !self
.manifest
.is_granted(super::manifest::SandboxCapability::Render)
|| !self
.manifest
.grants
.allows(super::manifest::SandboxCapability::Render, slot)
{
return refused(
SandboxFailure::GuestError(format!(
"this plugin was not granted the render slot {slot:?}",
slot = super::manifest::rejected(slot)
)),
0,
);
}
let context = &bounded_context(context);
let encoding = context
.iter()
.map(|(name, value)| {
u64::try_from(
name.len()
.saturating_add(value.len())
.saturating_add(RENDER_CONTEXT_ENTRY_OVERHEAD),
)
.unwrap_or(u64::MAX)
})
.fold(
u64::try_from(slot.len()).unwrap_or(u64::MAX),
u64::saturating_add,
)
/ BYTES_PER_FUEL;
let capabilities = &self.manifest.capabilities;
match self.execute(
|| HostFrame::render(slot, context, capabilities),
encoding,
Exchange::Render,
services,
) {
Execution::Refused(failure, fuel_used) => refused(failure, fuel_used),
Execution::Ran(store, permit, result) => {
let fuel_used = store
.get_fuel()
.map_or(limits.fuel, |left| limits.fuel.saturating_sub(left));
let mut state = store.into_data();
let peak_memory_bytes = record_limiter_refusals(&mut state, limits);
let activity = state.runtime.take_events();
let dropped_events = state.runtime.dropped_events();
let dropped_calls = state.runtime.dropped_calls();
let max_bytes = state.runtime.quotas().render_bytes as usize;
let fragment = match result {
Ok(GuestAnswer::Fragment(nodes)) => {
super::capability::render::render(&nodes, max_bytes).map_err(|err| {
SandboxFailure::ResponseRefused(guest_text(&err.to_string()))
})
}
Ok(GuestAnswer::Response(_)) => Err(SandboxFailure::MalformedFrame(
"a render slot is answered with a `fragment` frame, not a `response`"
.to_owned(),
)),
Err(failure) => Err(failure),
};
let stderr = state.stderr_excerpt();
drop(permit);
SandboxRenderOutcome {
fragment,
denials: state.denials,
fuel_used,
peak_memory_bytes,
stderr,
activity,
dropped_events,
dropped_calls,
}
}
}
}
fn execute(
&self,
frame: impl FnOnce() -> HostFrame,
encoding: u64,
exchange: Exchange,
services: crate::plugin_sandbox::CapabilityServices,
) -> Execution<'_> {
let limits = self.manifest.limits;
let Ok(permit) = self.permits.try_acquire() else {
return Execution::Refused(
SandboxFailure::AtCapacity {
max: limits.max_concurrency,
},
0,
);
};
let Some(after_encoding) = limits.fuel.checked_sub(encoding) else {
return Execution::Refused(
SandboxFailure::FuelExhausted {
budget: limits.fuel,
},
limits.fuel,
);
};
let frame = frame();
let line = match if matches!(frame, HostFrame::Request { .. }) {
to_exact_line(&frame)
} else {
to_line(&frame)
} {
Ok(line) => line,
Err(err) => {
return Execution::Refused(
SandboxFailure::Instantiation(guest_text(&err.to_string())),
0,
);
}
};
let mut state = HostState::new(
self.manifest.name.clone(),
limits,
line.as_bytes(),
exchange,
crate::plugin_sandbox::capability::CapabilityRuntime::new(&self.manifest, services)
.with_artifact_sha256(
self.artifact_sha256
.clone()
.unwrap_or_else(|| self.manifest.sha256.clone()),
),
);
state.stdin = VecDeque::from(line.into_bytes());
let mut store = Store::new(&self.engine, state);
store.limiter(|state| &mut state.limiter);
if let Err(err) = store.set_fuel(after_encoding) {
return Execution::Refused(
SandboxFailure::Instantiation(guest_text(&err.to_string())),
0,
);
}
let Some(left) = after_encoding.checked_sub(self.instantiation_fuel) else {
return Execution::Ran(
store,
permit,
Err(SandboxFailure::FuelExhausted {
budget: limits.fuel,
}),
);
};
if let Err(err) = store.set_fuel(left) {
return Execution::Refused(
SandboxFailure::Instantiation(guest_text(&err.to_string())),
0,
);
}
let mut linker = <Linker<HostState>>::new(&self.engine);
if let Err(err) = define_wasi_shim(&mut linker) {
return Execution::Refused(
SandboxFailure::Instantiation(guest_text(&err.to_string())),
0,
);
}
let started = linker
.instantiate(&mut store, &self.module)
.and_then(|pre| pre.start(&mut store));
let instance = match started {
Ok(instance) => instance,
Err(err) => {
return Execution::Ran(store, permit, Err(instantiation_failure(&err, limits)));
}
};
let Ok(start) = instance.get_typed_func::<(), ()>(&store, "_start") else {
return Execution::Ran(store, permit, Err(SandboxFailure::NoAnswer));
};
let trap = start.call(&mut store, ()).err();
let partial = !store.data().stdout_line.is_empty();
let result = match (store.data().answer.clone(), trap) {
(Some(answer), _) => answer,
(None, Some(err)) => Err(guest_failure(&err, limits)),
(None, None) if partial => Err(SandboxFailure::PartialFrame),
(None, None) => Err(SandboxFailure::NoAnswer),
};
Execution::Ran(store, permit, result)
}
}
#[allow(
clippy::large_enum_variant,
reason = "`Ran` carries the wasmi `Store` itself, which is large by construction and is the \
common path; boxing it would add an allocation per request without shrinking the \
store, and this value is a return value moved once, never held in a collection"
)]
enum Execution<'a> {
Refused(SandboxFailure, u64),
Ran(
Store<HostState>,
tokio::sync::SemaphorePermit<'a>,
Result<GuestAnswer, SandboxFailure>,
),
}
struct ResponseRules<'a> {
prefix: &'a str,
owned: &'a super::wire::OwnedRoutes,
requested: &'a str,
}
fn finish(
store: Store<HostState>,
limits: ResourceLimits,
rules: &ResponseRules<'_>,
result: Result<GuestAnswer, SandboxFailure>,
) -> SandboxOutcome {
let fuel_used = store
.get_fuel()
.map_or(limits.fuel, |left| limits.fuel.saturating_sub(left));
let mut state = store.into_data();
let peak_memory_bytes = record_limiter_refusals(&mut state, limits);
let activity = state.runtime.take_events();
let dropped_activity = state.runtime.dropped_events();
let dropped_calls = state.runtime.dropped_calls();
let result = match result {
Ok(GuestAnswer::Fragment(_)) => Err(SandboxFailure::MalformedFrame(
"a request is answered with a `response` frame, not a `fragment`".to_owned(),
)),
Ok(GuestAnswer::Response(response)) => {
let (response, denied) = response.sanitize(rules.prefix, rules.owned, rules.requested);
for name in denied {
state.deny(
DeniedCapability::ResponseHeader,
&guest_text(&name),
"a sandboxed plugin may not set this response header",
);
}
response.refused_content_type().map_or_else(
|| {
response
.validate()
.and_then(|()| response.check_size(limits.max_response_bytes))
.map_or_else(
|err| {
Err(SandboxFailure::ResponseRefused(guest_text(
&err.to_string(),
)))
},
|()| Ok(response.clone()),
)
},
|essence| {
let essence = guest_text(&essence);
let detail = format!(
"a sandboxed plugin may not serve `{essence}`: a document or a script \
from the host's own origin would carry the host's authority"
);
state.deny(DeniedCapability::ResponseHeader, "content-type", &detail);
Err(SandboxFailure::ResponseRefused(
super::wire::WireError::UnsupportedContentType(essence).to_string(),
))
},
)
}
Err(failure) => Err(failure),
};
let stderr = state.stderr_excerpt();
SandboxOutcome {
result,
denials: state.denials,
fuel_used,
peak_memory_bytes,
stderr,
activity,
dropped_activity,
dropped_calls,
}
}
fn record_limiter_refusals(state: &mut HostState, limits: ResourceLimits) -> usize {
let peak_memory_bytes = state.limiter.peak;
let (memory_refusals, table_refusals) =
(state.limiter.memory_refusals, state.limiter.table_refusals);
if memory_refusals > 0 {
let detail = format!(
"{memory_refusals} allocation(s) over the plugin's {max}-byte memory ceiling were refused",
max = limits.memory_bytes,
);
state.deny(DeniedCapability::Memory, "memory.grow", &detail);
}
if table_refusals > 0 {
let detail = format!(
"{table_refusals} table growth(s) over the sandbox's {MAX_TABLE_ELEMENTS}-element table ceiling were refused",
);
state.deny(DeniedCapability::Memory, "table.grow", &detail);
}
peak_memory_bytes
}
fn instantiation_failure(err: &wasmi::Error, limits: ResourceLimits) -> SandboxFailure {
if err.as_trap_code() == Some(wasmi::core::TrapCode::OutOfFuel) {
SandboxFailure::FuelExhausted {
budget: limits.fuel,
}
} else {
SandboxFailure::Instantiation(guest_text(&err.to_string()))
}
}
fn guest_failure(err: &wasmi::Error, limits: ResourceLimits) -> SandboxFailure {
if let Some(code) = err.i32_exit_status() {
return SandboxFailure::Exited(code);
}
if err.as_trap_code() == Some(wasmi::core::TrapCode::OutOfFuel) {
return SandboxFailure::FuelExhausted {
budget: limits.fuel,
};
}
if err.downcast_ref::<OutputBudgetExhausted>().is_some() {
return SandboxFailure::OutputBudget {
max: limits.max_response_bytes,
};
}
SandboxFailure::Trap(guest_text(&err.to_string()))
}
#[derive(Debug, Clone, Copy)]
struct ModuleShape {
memory_count: usize,
sections: usize,
segments: usize,
init_bytes: usize,
declared_entries: usize,
element_overflow: Option<(u64, u64)>,
data_end: u64,
import_count: usize,
function_count: usize,
has_start: bool,
code_bytes: usize,
global_count: usize,
global_bytes: usize,
table_count: usize,
table_elements: u64,
}
struct ElementSectionShape {
overflow: Option<(u64, u64)>,
items: usize,
}
fn element_section_shape(
wasm: &[u8],
start: usize,
section_end: usize,
table_minimums: &[u64],
) -> Option<ElementSectionShape> {
let (count, mut at) = leb128(wasm, start)?;
let mut overflow = None;
let mut declared = 0usize;
for _ in 0..count {
let (flags, next) = leb128(wasm, at)?;
at = next;
let active = matches!(flags, 0 | 2 | 4 | 6);
let mut table_index = 0usize;
if flags == 2 || flags == 6 {
let (index, next) = leb128(wasm, at)?;
table_index = index;
at = next;
}
let mut offset = None;
if active {
let (value, next) = const_i32(wasm, at, section_end)?;
offset = value;
at = next;
}
if !matches!(flags, 0 | 4) {
at = at.checked_add(1)?;
}
let (items, next) = leb128(wasm, at)?;
at = next;
declared = declared.saturating_add(items);
if declared > MAX_DECLARED_ENTRIES {
return Some(ElementSectionShape {
overflow,
items: declared,
});
}
if flags >= 4 {
for _ in 0..items {
at = skip_const_expr(wasm, at, section_end)?;
}
} else {
for _ in 0..items {
let (_, next) = leb128(wasm, at)?;
at = next;
}
}
if at > section_end {
return None;
}
if active {
let capacity = table_minimums.get(table_index).copied().unwrap_or(0);
let end = match offset {
Some(offset) => offset.checked_add(items as u64)?,
None => u64::MAX,
};
if end > capacity && overflow.is_none() {
overflow = Some((end, capacity));
}
}
}
Some(ElementSectionShape {
overflow,
items: declared,
})
}
fn skip_const_expr(wasm: &[u8], mut at: usize, limit: usize) -> Option<usize> {
loop {
if at > limit {
return None;
}
let opcode = *wasm.get(at)?;
at = at.checked_add(1)?;
match opcode {
0x0b => return Some(at),
0x41 | 0x42 | 0xd0 => at = sleb128(wasm, at)?.1,
0x43 => at = at.checked_add(4)?,
0x44 => at = at.checked_add(8)?,
0xd2 | 0x23 => at = leb128(wasm, at)?.1,
0x6a..=0x6c | 0x7c..=0x7e => {}
_ => return None,
}
}
}
fn const_i32(wasm: &[u8], at: usize, limit: usize) -> Option<(Option<u64>, usize)> {
let mut stack = [0i32; 16];
let mut depth = 0usize;
let mut evaluable = true;
let mut at = at;
loop {
if at > limit {
return None;
}
let opcode = *wasm.get(at)?;
at = at.checked_add(1)?;
match opcode {
0x0b => break,
0x41 => {
let (value, next) = sleb128(wasm, at)?;
at = next;
match (i32::try_from(value), stack.get_mut(depth)) {
(Ok(narrow), Some(slot)) => {
*slot = narrow;
depth = depth.saturating_add(1);
}
_ => evaluable = false,
}
}
0x6a..=0x6c => {
let folded = depth.checked_sub(2).and_then(|left_at| {
let right = *stack.get(depth.checked_sub(1)?)?;
let left = *stack.get(left_at)?;
let value = match opcode {
0x6a => left.wrapping_add(right),
0x6b => left.wrapping_sub(right),
_ => left.wrapping_mul(right),
};
Some((left_at, value))
});
match folded {
Some((left_at, value)) => {
if let Some(slot) = stack.get_mut(left_at) {
*slot = value;
}
depth = left_at.saturating_add(1);
}
None => evaluable = false,
}
}
0x42 => {
at = sleb128(wasm, at)?.1;
evaluable = false;
}
0x7c..=0x7e => evaluable = false,
0x43 => {
at = at.checked_add(4)?;
evaluable = false;
}
0x44 => {
at = at.checked_add(8)?;
evaluable = false;
}
0xd0 => {
at = at.checked_add(1)?;
evaluable = false;
}
0xd2 | 0x23 => {
at = leb128(wasm, at)?.1;
evaluable = false;
}
_ => return None,
}
}
if let (true, 1, Some(&result)) = (evaluable, depth, stack.first()) {
let unsigned = u64::from(u32::from_ne_bytes(result.to_ne_bytes()));
return Some((Some(unsigned), at));
}
Some((None, at))
}
fn leb128(wasm: &[u8], at: usize) -> Option<(usize, usize)> {
let mut value: usize = 0;
let mut shift: u32 = 0;
let mut cursor = at;
loop {
let byte = *wasm.get(cursor)?;
cursor = cursor.checked_add(1)?;
value = value.checked_add(usize::from(byte & 0x7f).checked_shl(shift)?)?;
if byte & 0x80 == 0 {
return Some((value, cursor));
}
shift = shift.checked_add(7)?;
if shift > 63 {
return None;
}
}
}
fn sleb128(wasm: &[u8], at: usize) -> Option<(i64, usize)> {
let mut value: i64 = 0;
let mut shift: u32 = 0;
let mut cursor = at;
loop {
let byte = *wasm.get(cursor)?;
cursor = cursor.checked_add(1)?;
value |= i64::from(byte & 0x7f).checked_shl(shift)?;
shift = shift.checked_add(7)?;
if byte & 0x80 == 0 {
if shift < 64 && byte & 0x40 != 0 {
value |= -1i64 << shift;
}
return Some((value, cursor));
}
if shift > 63 {
return None;
}
}
}
enum DataSectionEnd {
Unreadable,
NothingActive,
ActiveUnevaluable,
Furthest(u64),
}
impl DataSectionEnd {
const fn contribution(&self) -> u64 {
match *self {
Self::Unreadable | Self::ActiveUnevaluable => u64::MAX,
Self::NothingActive => 0,
Self::Furthest(end) => end,
}
}
}
fn data_section_end(wasm: &[u8], start: usize, section_end: usize) -> DataSectionEnd {
walk_data_section(wasm, start, section_end).unwrap_or(DataSectionEnd::Unreadable)
}
fn walk_data_section(wasm: &[u8], start: usize, section_end: usize) -> Option<DataSectionEnd> {
let (count, mut at) = leb128(wasm, start)?;
let mut furthest: Option<u64> = None;
let mut active_unevaluable = false;
for _ in 0..count {
let (flags, next) = leb128(wasm, at)?;
at = next;
let active = flags != 1;
if flags == 2 {
let (_memory_index, next) = leb128(wasm, at)?;
at = next;
}
let mut offset: Option<u64> = None;
if active {
let (value, next) = const_i32(wasm, at, section_end)?;
offset = value;
at = next;
}
let (len, next) = leb128(wasm, at)?;
at = next.checked_add(len)?;
if at > section_end {
return None;
}
match (active, offset) {
(true, Some(offset)) => {
let segment_end = offset.checked_add(len as u64)?;
furthest = Some(furthest.map_or(segment_end, |f: u64| f.max(segment_end)));
}
(true, None) => active_unevaluable = true,
(false, _) => {}
}
}
if active_unevaluable {
return Some(DataSectionEnd::ActiveUnevaluable);
}
Some(furthest.map_or(DataSectionEnd::NothingActive, DataSectionEnd::Furthest))
}
const ELEMENT_SECTION: u8 = 9;
const DATA_SECTION: u8 = 11;
const TABLE_SECTION: u8 = 4;
const MEMORY_SECTION: u8 = 5;
const GLOBAL_SECTION: u8 = 6;
const IMPORT_SECTION: u8 = 2;
const FUNCTION_SECTION: u8 = 3;
const START_SECTION: u8 = 8;
const CODE_SECTION: u8 = 10;
const CUSTOM_SECTION: u8 = 0;
const fn refuse_unrunnable_shape(
shape: &ModuleShape,
initial_bytes: u64,
) -> Result<(), SandboxLoadError> {
if shape.has_start {
return Err(SandboxLoadError::StartSectionForbidden);
}
if shape.data_end > initial_bytes {
return Err(SandboxLoadError::SegmentOutOfBounds {
what: "linear memory",
end: shape.data_end,
capacity: initial_bytes,
});
}
if let Some((end, capacity)) = shape.element_overflow {
return Err(SandboxLoadError::SegmentOutOfBounds {
what: "table elements",
end,
capacity,
});
}
Ok(())
}
fn table_section_shape(
wasm: &[u8],
after_size: usize,
end: usize,
already_counted: usize,
minimums: &mut [u64; MAX_TABLES],
seen: &mut usize,
) -> Option<(usize, u64)> {
let (count, mut at) = leb128(wasm, after_size)?;
let walked = if already_counted.saturating_add(count) <= MAX_TABLES {
count
} else {
0
};
let mut elements = 0u64;
for _ in 0..walked {
at = at.checked_add(1)?; let flag = *wasm.get(at)?;
at = at.checked_add(1)?;
let (minimum, next) = leb128(wasm, at)?;
at = next;
elements = elements.saturating_add(minimum as u64);
if let Some(slot) = minimums.get_mut(*seen) {
*slot = minimum as u64;
*seen = seen.saturating_add(1);
}
if flag == 0x01 {
let (_, after_max) = leb128(wasm, at)?;
at = after_max;
}
if at > end {
return None;
}
}
Some((count, elements))
}
fn module_shape(wasm: &[u8]) -> Option<ModuleShape> {
let mut cursor = 8usize; let mut sections = 0usize;
let mut memory_count = 0usize;
let mut segments = 0usize;
let mut bytes = 0usize;
let mut declared_entries = 0usize;
let mut table_elements = 0u64;
let mut table_count = 0usize;
let mut global_count = 0usize;
let mut global_bytes = 0usize;
let mut code_bytes = 0usize;
let mut has_start = false;
let mut function_count = 0usize;
let mut import_count = 0usize;
let mut data_end = 0u64;
let mut table_minimums = [0u64; MAX_TABLES];
let mut table_count_seen = 0usize;
let mut element_overflow: Option<(u64, u64)> = None;
while cursor < wasm.len() {
let id = *wasm.get(cursor)?;
let (size, after_size) = leb128(wasm, cursor.checked_add(1)?)?;
let end = after_size.checked_add(size)?;
if end > wasm.len() {
return None;
}
sections = sections.saturating_add(1);
if sections > MAX_SECTIONS {
break;
}
if id != CUSTOM_SECTION {
let (count, _) = leb128(wasm, after_size)?;
declared_entries = declared_entries.saturating_add(count);
}
if id == ELEMENT_SECTION || id == DATA_SECTION {
let (count, _) = leb128(wasm, after_size)?;
segments = segments.saturating_add(count);
bytes = bytes.saturating_add(size);
}
let within_segment_ceiling =
segments <= MAX_INIT_SEGMENTS && bytes <= MAX_INIT_SECTION_BYTES;
if id == ELEMENT_SECTION && within_segment_ceiling {
if let Some(element) = element_section_shape(
wasm,
after_size,
end,
table_minimums.get(..table_count_seen).unwrap_or_default(),
) {
element_overflow = element.overflow;
declared_entries = declared_entries.saturating_add(element.items);
}
}
if id == DATA_SECTION && within_segment_ceiling {
data_end = data_end.max(data_section_end(wasm, after_size, end).contribution());
}
let leading_count = || leb128(wasm, after_size).map(|(count, _)| count);
match id {
IMPORT_SECTION => import_count = import_count.saturating_add(leading_count()?),
FUNCTION_SECTION => function_count = function_count.saturating_add(leading_count()?),
GLOBAL_SECTION => {
global_count = global_count.saturating_add(leading_count()?);
global_bytes = global_bytes.saturating_add(size);
}
MEMORY_SECTION => memory_count = memory_count.saturating_add(leading_count()?),
CODE_SECTION => code_bytes = code_bytes.saturating_add(size),
START_SECTION => has_start = true,
_ => {}
}
if id == TABLE_SECTION {
let (count, elements) = table_section_shape(
wasm,
after_size,
end,
table_count,
&mut table_minimums,
&mut table_count_seen,
)?;
table_count = table_count.saturating_add(count);
table_elements = table_elements.saturating_add(elements);
}
cursor = end;
}
Some(ModuleShape {
sections,
memory_count,
segments,
init_bytes: bytes,
declared_entries,
import_count,
function_count,
has_start,
element_overflow,
data_end,
code_bytes,
global_count,
global_bytes,
table_count,
table_elements,
})
}
fn refuse_unbounded_shape(wasm: &[u8]) -> Result<ModuleShape, SandboxLoadError> {
if wasm.len() > super::MAX_MODULE_BYTES {
return Err(SandboxLoadError::ModuleTooLarge {
found: wasm.len(),
max: super::MAX_MODULE_BYTES,
});
}
let Some(shape) = module_shape(wasm) else {
return Err(SandboxLoadError::Wasm(
"the module's section stream could not be walked".to_owned(),
));
};
if shape.sections > MAX_SECTIONS {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "sections",
found: shape.sections,
max: MAX_SECTIONS,
});
}
if shape.segments > MAX_INIT_SEGMENTS {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "data and element segments",
found: shape.segments,
max: MAX_INIT_SEGMENTS,
});
}
if shape.init_bytes > MAX_INIT_SECTION_BYTES {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "bytes of data and element sections",
found: shape.init_bytes,
max: MAX_INIT_SECTION_BYTES,
});
}
if shape.code_bytes > MAX_CODE_BYTES {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "code section bytes",
found: shape.code_bytes,
max: MAX_CODE_BYTES,
});
}
let instruction_bytes = shape.code_bytes.saturating_add(shape.global_bytes);
if instruction_bytes > MAX_CODE_BYTES {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "code and global section bytes",
found: instruction_bytes,
max: MAX_CODE_BYTES,
});
}
if shape.import_count > MAX_IMPORTS {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "imports",
found: shape.import_count,
max: MAX_IMPORTS,
});
}
if shape.function_count > MAX_FUNCTIONS {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "functions",
found: shape.function_count,
max: MAX_FUNCTIONS,
});
}
if shape.global_count > MAX_GLOBALS {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "globals",
found: shape.global_count,
max: MAX_GLOBALS,
});
}
if shape.memory_count > MAX_MEMORIES {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "linear memories",
found: shape.memory_count,
max: MAX_MEMORIES,
});
}
if shape.table_count > MAX_TABLES {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "tables",
found: shape.table_count,
max: MAX_TABLES,
});
}
if shape.table_elements > u64::from(MAX_TABLE_ELEMENTS) {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "initial table elements",
found: usize::try_from(shape.table_elements).unwrap_or(usize::MAX),
max: MAX_TABLE_ELEMENTS as usize,
});
}
if shape.declared_entries > MAX_DECLARED_ENTRIES {
return Err(SandboxLoadError::InstantiationTooExpensive {
what: "declared section entries",
found: shape.declared_entries,
max: MAX_DECLARED_ENTRIES,
});
}
Ok(shape)
}
fn forbidden_imports(module: &Module) -> Vec<CapabilityDenial> {
module
.imports()
.filter_map(|import| {
let operation = import_operation(import.module(), import.name());
if import.module() != WASI {
return Some((operation, "the sandbox defines no such host module"));
}
let Some(declared) = import.ty().func() else {
return Some((
operation,
"the sandbox provides host functions only, not memories, tables or globals",
));
};
let Some((params, results)) = shim_signature(import.name()) else {
return Some((operation, "the sandbox defines no such host function"));
};
match signature_type(params, results) {
Some(expected) if expected == *declared => None,
_ => Some((
operation,
"the sandbox defines this host function with a different signature, so it \
could never link",
)),
}
})
.map(|(operation, why)| CapabilityDenial {
capability: DeniedCapability::UnknownImport,
operation,
detail: format!("{why}, so the plugin is refused before it runs"),
})
.take(MAX_DENIALS)
.collect()
}
#[derive(Debug)]
struct OutputBudgetExhausted;
#[derive(Debug)]
struct AnswerComplete;
impl fmt::Display for AnswerComplete {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("the plugin answered")
}
}
impl wasmi::core::HostError for AnswerComplete {}
impl fmt::Display for OutputBudgetExhausted {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("the plugin exceeded its response ceiling")
}
}
impl wasmi::core::HostError for OutputBudgetExhausted {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Exchange {
Request,
Render,
}
#[derive(Debug, Clone)]
enum GuestAnswer {
Response(SandboxResponse),
Fragment(Vec<super::capability::FragmentNode>),
}
#[derive(Debug)]
struct MemoryLimiter {
max: usize,
peak: usize,
memory_refusals: usize,
table_refusals: usize,
table_elements: u32,
}
impl wasmi::ResourceLimiter for MemoryLimiter {
fn memory_growing(
&mut self,
_current: usize,
desired: usize,
_maximum: Option<usize>,
) -> Result<bool, wasmi::errors::MemoryError> {
if desired > self.max {
self.memory_refusals = self.memory_refusals.saturating_add(1);
return Ok(false);
}
self.peak = self.peak.max(desired);
Ok(true)
}
fn table_growing(
&mut self,
current: u32,
desired: u32,
_maximum: Option<u32>,
) -> Result<bool, wasmi::errors::TableError> {
let total = self
.table_elements
.saturating_sub(current)
.saturating_add(desired);
if total > MAX_TABLE_ELEMENTS {
self.table_refusals = self.table_refusals.saturating_add(1);
return Ok(false);
}
self.table_elements = total;
Ok(true)
}
fn instances(&self) -> usize {
1
}
fn tables(&self) -> usize {
MAX_TABLES
}
fn memories(&self) -> usize {
1
}
}
struct HostState {
plugin: String,
stdin: VecDeque<u8>,
stdout_line: Vec<u8>,
stderr: Vec<u8>,
answer: Option<Result<GuestAnswer, SandboxFailure>>,
exchange: Exchange,
runtime: crate::plugin_sandbox::capability::CapabilityRuntime,
pending_charge: u64,
pending_calls: VecDeque<super::capability::CapabilityCall>,
queued_replies: usize,
committed: bool,
denials: Vec<CapabilityDenial>,
random_state: u64,
limiter: MemoryLimiter,
limits: ResourceLimits,
}
impl HostState {
const RANDOM_SEED: u64 = 0x2545_F491_4F6C_DD1D;
fn seed_from(frame: &[u8]) -> u64 {
let mut seed = Self::RANDOM_SEED;
for byte in frame {
seed = seed.rotate_left(7) ^ u64::from(*byte);
seed = seed.wrapping_mul(0x0000_0100_0000_01B3);
}
seed
}
fn new(
plugin: String,
limits: ResourceLimits,
frame: &[u8],
exchange: Exchange,
runtime: crate::plugin_sandbox::capability::CapabilityRuntime,
) -> Self {
Self {
plugin,
stdin: VecDeque::new(),
stdout_line: Vec::new(),
stderr: Vec::new(),
answer: None,
exchange,
runtime,
pending_charge: 0,
pending_calls: VecDeque::new(),
queued_replies: 0,
committed: false,
denials: Vec::new(),
random_state: Self::seed_from(frame),
limiter: MemoryLimiter {
max: limits.memory_bytes,
peak: 0,
memory_refusals: 0,
table_refusals: 0,
table_elements: 0,
},
limits,
}
}
fn deny(&mut self, capability: DeniedCapability, operation: &str, detail: &str) {
let already = self
.denials
.iter()
.any(|denial| denial.capability == capability && denial.operation == operation);
if already || self.denials.len() >= MAX_DENIALS {
return;
}
let denial = CapabilityDenial {
capability,
operation: operation.to_owned(),
detail: detail.to_owned(),
};
tracing::warn!(
plugin = self.plugin,
capability = capability.as_str(),
operation,
detail,
"sandboxed plugin was denied a capability it reached for"
);
self.denials.push(denial);
}
fn on_guest_line(&mut self, line: &str) {
if self.answer.is_some() {
return;
}
self.answer = Some(match from_guest_line(line) {
Ok(GuestFrame::Call(call)) => {
self.pending_calls.push_back(call);
return;
}
Ok(GuestFrame::Response(response)) => match self.exchange {
Exchange::Request => Ok(GuestAnswer::Response(response)),
Exchange::Render => Err(SandboxFailure::MalformedFrame(
"a render slot is answered with a `fragment` frame, not a `response`"
.to_owned(),
)),
},
Ok(GuestFrame::Fragment { nodes }) => match self.exchange {
Exchange::Render => Ok(GuestAnswer::Fragment(nodes)),
Exchange::Request => Err(SandboxFailure::MalformedFrame(
"a request is answered with a `response` frame, not a `fragment`".to_owned(),
)),
},
Ok(GuestFrame::Error { detail }) => {
Err(SandboxFailure::GuestError(guest_text(&detail)))
}
Err(err) => Err(SandboxFailure::MalformedFrame(guest_text(&err.to_string()))),
});
}
fn service(&mut self, call: &super::capability::CapabilityCall, fuel_left: u64) {
let queue_room = MAX_QUEUED_REPLY_BYTES.saturating_sub(self.queued_replies);
let allowance = FINISH_ALLOWANCE_FUEL.min(self.limits.fuel / 4);
let keep_read = self.committed || call.commits();
let unread = u64::try_from(self.queued_replies).unwrap_or(u64::MAX);
let fuel_covers = |reserve: usize| {
let reserve = u64::try_from(reserve).unwrap_or(u64::MAX);
let read = if keep_read {
reserve
.saturating_add(unread)
.saturating_mul(GUEST_READ_FUEL_PER_BYTE)
} else {
0
};
fuel_left
>= allowance
.saturating_add(read)
.saturating_add(reserve.div_ceil(BYTES_PER_FUEL))
};
let result = self.runtime.dispatch_checked(call, queue_room, fuel_covers);
if call.commits() && reached_backend(&result) {
self.committed = true;
}
let line = match to_line(&HostFrame::CallResult(result)) {
Ok(line) => line,
Err(err) => {
self.answer = Some(Err(SandboxFailure::Instantiation(guest_text(
&err.to_string(),
))));
return;
}
};
if self.queued_replies.saturating_add(line.len()) > MAX_QUEUED_REPLY_BYTES {
self.answer = Some(Err(SandboxFailure::MalformedFrame(format!(
"the guest has left over {MAX_QUEUED_REPLY_BYTES} bytes of capability replies \
unread; it must read each answer before making the next call"
))));
return;
}
self.pending_charge = self
.pending_charge
.saturating_add(u64::try_from(line.len()).unwrap_or(u64::MAX) / BYTES_PER_FUEL);
self.queued_replies = self.queued_replies.saturating_add(line.len());
self.stdin.extend(line.as_bytes());
}
fn service_next(&mut self, fuel_left: u64) -> bool {
let Some(call) = self.pending_calls.pop_front() else {
return false;
};
self.service(&call, fuel_left);
true
}
fn has_pending_call(&self) -> bool {
!self.pending_calls.is_empty()
}
const fn take_pending_charge(&mut self) -> u64 {
std::mem::replace(&mut self.pending_charge, 0)
}
#[must_use]
fn write_stdout(&mut self, bytes: &[u8]) -> bool {
let budget = self
.limits
.max_response_bytes
.saturating_mul(2)
.saturating_add(4096);
for byte in bytes {
if *byte == b'\n' {
let line = std::mem::take(&mut self.stdout_line);
match String::from_utf8(line) {
Ok(line) => self.on_guest_line(&line),
Err(_) => {
self.answer = Some(Err(SandboxFailure::MalformedFrame(
"the frame is not valid UTF-8".to_owned(),
)));
}
}
} else {
if self.stdout_line.len() >= budget {
return false;
}
if self.stdout_line.len() == self.stdout_line.capacity() {
let want = self
.stdout_line
.capacity()
.saturating_mul(2)
.clamp(64, budget);
self.stdout_line
.reserve_exact(want.saturating_sub(self.stdout_line.len()));
}
self.stdout_line.push(*byte);
}
}
true
}
fn write_stderr(&mut self, bytes: &[u8]) {
let room = STDERR_BUDGET_BYTES.saturating_sub(self.stderr.len());
if let Some(kept) = bytes.get(..bytes.len().min(room)) {
self.stderr.extend_from_slice(kept);
}
}
const fn stderr_is_full(&self) -> bool {
self.stderr.len() >= STDERR_BUDGET_BYTES
}
fn stderr_excerpt(&self) -> String {
let mut chars = lossy_chars(&self.stderr).skip_while(|ch| ch.is_whitespace());
let mut kept: String = chars.by_ref().take(STDERR_EXCERPT).collect();
let cut = chars.next().is_some();
if !cut {
while kept.ends_with(char::is_whitespace) {
kept.pop();
}
}
let mut excerpt = guest_text(&kept);
if cut {
excerpt.push_str(TRUNCATION_MARKER);
}
excerpt
}
const fn next_random_byte(&mut self) -> u8 {
self.random_state = self.random_state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.random_state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
#[allow(
clippy::cast_possible_truncation,
reason = "taking the low byte of the mixed state is the point"
)]
{
(z ^ (z >> 31)) as u8
}
}
fn take_stdin(&mut self, len: usize) -> Vec<u8> {
let len = len.min(HOST_IO_CHUNK_BYTES);
let head = self.stdin.len().saturating_sub(self.queued_replies);
let mut out = Vec::with_capacity(len.min(self.stdin.len()));
while out.len() < len {
match self.stdin.pop_front() {
Some(byte) => out.push(byte),
None => break,
}
}
let from_replies = out.len().saturating_sub(head);
self.queued_replies = self.queued_replies.saturating_sub(from_replies);
out
}
}
type Shim = Linker<HostState>;
fn memory_of(caller: &Caller<'_, HostState>) -> Option<wasmi::Memory> {
caller
.get_export("memory")
.and_then(wasmi::Extern::into_memory)
}
fn read_u32(caller: &Caller<'_, HostState>, memory: wasmi::Memory, at: usize) -> Option<u32> {
let mut buffer = [0u8; 4];
memory.read(caller, at, &mut buffer).ok()?;
Some(u32::from_le_bytes(buffer))
}
fn write_u32(
caller: &mut Caller<'_, HostState>,
memory: wasmi::Memory,
at: usize,
value: u32,
) -> Option<()> {
memory.write(caller, at, &value.to_le_bytes()).ok()
}
fn iovec(
caller: &Caller<'_, HostState>,
memory: wasmi::Memory,
base: i32,
index: i32,
) -> Option<(usize, usize)> {
let base = usize::try_from(base).ok()?;
let offset = usize::try_from(index).ok()?.checked_mul(IOVEC_SIZE)?;
let at = base.checked_add(offset)?;
let pointer = read_u32(caller, memory, at)?;
let length = read_u32(caller, memory, at.checked_add(4)?)?;
Some((
usize::try_from(pointer).ok()?,
usize::try_from(length).ok()?,
))
}
const SERVED_IMPORTS: &[(&str, &str, &str)] = &[
("args_get", "ii", "i"),
("args_sizes_get", "ii", "i"),
("clock_res_get", "ii", "i"),
("clock_time_get", "ili", "i"),
("environ_get", "ii", "i"),
("environ_sizes_get", "ii", "i"),
("fd_close", "i", "i"),
("fd_fdstat_get", "ii", "i"),
("fd_read", "iiii", "i"),
("fd_seek", "ilii", "i"),
("fd_tell", "ii", "i"),
("fd_write", "iiii", "i"),
("proc_exit", "i", ""),
("random_get", "ii", "i"),
("sched_yield", "", "i"),
];
const DENIED_IMPORTS: &[(&str, DeniedCapability, &str, &str)] = &[
("fd_advise", DeniedCapability::Filesystem, FS_DETAIL, "illi"),
(
"fd_allocate",
DeniedCapability::Filesystem,
FS_DETAIL,
"ill",
),
("fd_datasync", DeniedCapability::Filesystem, FS_DETAIL, "i"),
(
"fd_fdstat_set_flags",
DeniedCapability::Filesystem,
FS_DETAIL,
"ii",
),
(
"fd_fdstat_set_rights",
DeniedCapability::Filesystem,
FS_DETAIL,
"ill",
),
(
"fd_filestat_get",
DeniedCapability::Filesystem,
FS_DETAIL,
"ii",
),
(
"fd_filestat_set_size",
DeniedCapability::Filesystem,
FS_DETAIL,
"il",
),
(
"fd_filestat_set_times",
DeniedCapability::Filesystem,
FS_DETAIL,
"illi",
),
("fd_pread", DeniedCapability::Filesystem, FS_DETAIL, "iiili"),
(
"fd_prestat_dir_name",
DeniedCapability::Filesystem,
FS_DETAIL,
"iii",
),
(
"fd_prestat_get",
DeniedCapability::Filesystem,
FS_DETAIL,
"ii",
),
(
"fd_pwrite",
DeniedCapability::Filesystem,
FS_DETAIL,
"iiili",
),
(
"fd_readdir",
DeniedCapability::Filesystem,
FS_DETAIL,
"iiili",
),
("fd_renumber", DeniedCapability::Filesystem, FS_DETAIL, "ii"),
("fd_sync", DeniedCapability::Filesystem, FS_DETAIL, "i"),
(
"path_create_directory",
DeniedCapability::Filesystem,
FS_DETAIL,
"iii",
),
(
"path_filestat_get",
DeniedCapability::Filesystem,
FS_DETAIL,
"iiiii",
),
(
"path_filestat_set_times",
DeniedCapability::Filesystem,
FS_DETAIL,
"iiiilli",
),
(
"path_link",
DeniedCapability::Filesystem,
FS_DETAIL,
"iiiiiii",
),
(
"path_open",
DeniedCapability::Filesystem,
FS_DETAIL,
"iiiiillii",
),
(
"path_readlink",
DeniedCapability::Filesystem,
FS_DETAIL,
"iiiiii",
),
(
"path_remove_directory",
DeniedCapability::Filesystem,
FS_DETAIL,
"iii",
),
(
"path_rename",
DeniedCapability::Filesystem,
FS_DETAIL,
"iiiiii",
),
(
"path_symlink",
DeniedCapability::Filesystem,
FS_DETAIL,
"iiiii",
),
(
"path_unlink_file",
DeniedCapability::Filesystem,
FS_DETAIL,
"iii",
),
("sock_accept", DeniedCapability::Network, NET_DETAIL, "iii"),
("sock_recv", DeniedCapability::Network, NET_DETAIL, "iiiiii"),
("sock_send", DeniedCapability::Network, NET_DETAIL, "iiiii"),
("sock_shutdown", DeniedCapability::Network, NET_DETAIL, "ii"),
(
"poll_oneoff",
DeniedCapability::ProcessControl,
"a sandboxed plugin may not block the host on external events",
"iiii",
),
(
"proc_raise",
DeniedCapability::ProcessControl,
"a sandboxed plugin may not signal the host process",
"i",
),
];
const FS_DETAIL: &str = "a sandboxed plugin has no filesystem";
const NET_DETAIL: &str = "a sandboxed plugin has no outbound network";
fn shim_signature(name: &str) -> Option<(&'static str, &'static str)> {
if let Some((_, params, results)) = SERVED_IMPORTS.iter().find(|(known, ..)| *known == name) {
return Some((params, results));
}
DENIED_IMPORTS
.iter()
.find(|(known, ..)| *known == name)
.map(|(_, _, _, params)| (*params, "i"))
}
#[cfg(test)]
fn is_shim_function(name: &str) -> bool {
shim_signature(name).is_some()
}
fn signature_type(params: &str, results: &str) -> Option<wasmi::FuncType> {
fn types(descriptor: &str) -> Option<Vec<wasmi::core::ValType>> {
descriptor
.chars()
.map(|ch| match ch {
'i' => Some(wasmi::core::ValType::I32),
'l' => Some(wasmi::core::ValType::I64),
_ => None,
})
.collect()
}
Some(wasmi::FuncType::new(types(params)?, types(results)?))
}
fn instantiation_fuel(
segments: usize,
init_bytes: usize,
imports: usize,
globals: usize,
initial_memory_bytes: u64,
table_elements: u64,
) -> u64 {
let bytes = u64::try_from(init_bytes).unwrap_or(u64::MAX);
let segments = u64::try_from(segments).unwrap_or(u64::MAX);
let imports = u64::try_from(imports).unwrap_or(u64::MAX);
let globals = u64::try_from(globals).unwrap_or(u64::MAX);
let memory = initial_memory_bytes
.checked_div(BYTES_PER_FUEL)
.unwrap_or(u64::MAX);
bytes
.checked_div(BYTES_PER_FUEL)
.unwrap_or(u64::MAX)
.saturating_add(memory)
.saturating_add(table_elements)
.saturating_add(segments)
.saturating_add(imports)
.saturating_add(globals)
.saturating_add(1)
}
const fn reached_backend(result: &super::capability::CallResult) -> bool {
!matches!(
result.denial(),
Some(
super::capability::DenialReason::CapabilityNotGranted
| super::capability::DenialReason::NotInGrant
| super::capability::DenialReason::QuotaExceeded
| super::capability::DenialReason::Malformed
| super::capability::DenialReason::Unavailable
)
)
}
fn charge_bytes(caller: &mut Caller<'_, HostState>, bytes: usize) -> Result<(), wasmi::Error> {
let units = u64::try_from(bytes)
.unwrap_or(u64::MAX)
.checked_div(BYTES_PER_FUEL)
.unwrap_or(u64::MAX)
.saturating_add(1);
charge_units(caller, units)
}
fn charge_units(caller: &mut Caller<'_, HostState>, units: u64) -> Result<(), wasmi::Error> {
let left = caller.get_fuel()?;
let remaining = left
.checked_sub(units)
.ok_or_else(|| wasmi::Error::from(wasmi::core::TrapCode::OutOfFuel))?;
caller.set_fuel(remaining)
}
fn charge_units_saturating(
caller: &mut Caller<'_, HostState>,
units: u64,
) -> Result<(), wasmi::Error> {
let left = caller.get_fuel()?;
caller.set_fuel(left.saturating_sub(units))
}
#[allow(
clippy::too_many_lines,
reason = "splitting this up would hide the fact that this list IS the sandbox"
)]
fn define_wasi_shim(linker: &mut Shim) -> Result<(), SandboxLoadError> {
fn engine_error(err: impl fmt::Display) -> SandboxLoadError {
SandboxLoadError::Engine(err.to_string())
}
linker
.func_wrap(
WASI,
"fd_read",
|mut caller: Caller<'_, HostState>, fd: i32, iovs: i32, iovs_len: i32, nread: i32| {
if fd != 0 {
caller.data_mut().deny(
DeniedCapability::Filesystem,
"fd_read",
"a sandboxed plugin has no descriptors beyond the request dialogue",
);
return Ok(errno::BADF);
}
let Some(memory) = memory_of(&caller) else {
return Ok(errno::INVAL);
};
if iovs_len > MAX_IOVECS {
return Ok(errno::INVAL);
}
let mut total: u32 = 0;
for index in 0..iovs_len {
let Some((pointer, length)) = iovec(&caller, memory, iovs, index) else {
return Ok(errno::INVAL);
};
let in_bounds = pointer
.checked_add(length)
.is_some_and(|end| end <= memory.data_size(&caller));
if !in_bounds {
return Ok(errno::INVAL);
}
let take = HOST_IO_CHUNK_BYTES
.min(length)
.min(caller.data().stdin.len());
charge_bytes(&mut caller, take)?;
let chunk = caller.data_mut().take_stdin(take);
if memory.write(&mut caller, pointer, &chunk).is_err() {
return Ok(errno::INVAL);
}
let Ok(read) = u32::try_from(chunk.len()) else {
return Ok(errno::INVAL);
};
let Some(sum) = total.checked_add(read) else {
return Ok(errno::INVAL);
};
total = sum;
if chunk.len() < length {
break;
}
}
let Ok(at) = usize::try_from(nread) else {
return Ok(errno::INVAL);
};
if write_u32(&mut caller, memory, at, total).is_none() {
return Ok(errno::INVAL);
}
Ok(errno::SUCCESS)
},
)
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"fd_write",
|mut caller: Caller<'_, HostState>,
fd: i32,
iovs: i32,
iovs_len: i32,
nwritten: i32|
-> Result<i32, wasmi::Error> {
if fd != 1 && fd != 2 {
caller.data_mut().deny(
DeniedCapability::Filesystem,
"fd_write",
"a sandboxed plugin may only write its response frame and diagnostics",
);
return Ok(errno::BADF);
}
let Some(memory) = memory_of(&caller) else {
return Ok(errno::INVAL);
};
if iovs_len > MAX_IOVECS {
return Ok(errno::INVAL);
}
let mut total: u32 = 0;
for index in 0..iovs_len {
charge_units(&mut caller, 1)?;
let Some((pointer, length)) = iovec(&caller, memory, iovs, index) else {
return Ok(errno::INVAL);
};
let in_bounds = pointer
.checked_add(length)
.is_some_and(|end| end <= memory.data_size(&caller));
if !in_bounds {
return Ok(errno::INVAL);
}
let Ok(written) = u32::try_from(length) else {
return Ok(errno::INVAL);
};
let Some(sum) = total.checked_add(written) else {
return Ok(errno::INVAL);
};
total = sum;
let mut offset = 0usize;
while offset < length {
if fd == 2 && caller.data().stderr_is_full() {
break;
}
let take = HOST_IO_CHUNK_BYTES.min(length.saturating_sub(offset));
charge_bytes(&mut caller, take)?;
if fd == 1 {
charge_units(&mut caller, u64::try_from(take).unwrap_or(u64::MAX))?;
}
let mut scratch = vec![0u8; take];
let at = pointer.saturating_add(offset);
if memory.read(&caller, at, &mut scratch).is_err() {
return Ok(errno::INVAL);
}
if fd == 1 {
let accepted = caller.data_mut().write_stdout(&scratch);
if accepted {
while caller.data().has_pending_call()
&& caller.data().answer.is_none()
{
charge_units(&mut caller, CAPABILITY_CALL_FUEL)?;
let fuel_left = caller.get_fuel()?;
let _ = caller.data_mut().service_next(fuel_left);
let owed = caller.data_mut().take_pending_charge();
charge_units_saturating(&mut caller, owed)?;
}
}
let owed = caller.data_mut().take_pending_charge();
charge_units(&mut caller, owed)?;
if !accepted {
return Err(wasmi::Error::host(OutputBudgetExhausted));
}
if caller.data().answer.is_some() {
return Err(wasmi::Error::host(AnswerComplete));
}
} else {
caller.data_mut().write_stderr(&scratch);
}
offset = offset.saturating_add(take);
}
}
let Ok(at) = usize::try_from(nwritten) else {
return Ok(errno::INVAL);
};
if write_u32(&mut caller, memory, at, total).is_none() {
return Ok(errno::INVAL);
}
Ok(errno::SUCCESS)
},
)
.map_err(engine_error)?;
linker
.func_wrap(WASI, "fd_close", |_: Caller<'_, HostState>, fd: i32| {
if (0..=2).contains(&fd) {
errno::SUCCESS
} else {
errno::BADF
}
})
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"fd_seek",
|mut caller: Caller<'_, HostState>, fd: i32, _offset: i64, _whence: i32, _out: i32| {
if !(0..=2).contains(&fd) {
caller.data_mut().deny(
DeniedCapability::Filesystem,
"fd_seek",
"a sandboxed plugin has no descriptors beyond the request dialogue",
);
return errno::BADF;
}
errno::SPIPE
},
)
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"fd_tell",
|_: Caller<'_, HostState>, _fd: i32, _out: i32| errno::SPIPE,
)
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"fd_fdstat_get",
|mut caller: Caller<'_, HostState>, fd: i32, out: i32| {
if !(0..=2).contains(&fd) {
caller.data_mut().deny(
DeniedCapability::Filesystem,
"fd_fdstat_get",
"a sandboxed plugin has no descriptors beyond the request dialogue",
);
return errno::BADF;
}
let Some(memory) = memory_of(&caller) else {
return errno::INVAL;
};
let Ok(at) = usize::try_from(out) else {
return errno::INVAL;
};
let mut stat = [0u8; FDSTAT_SIZE];
stat[0] = 2;
if memory.write(&mut caller, at, &stat).is_err() {
return errno::INVAL;
}
errno::SUCCESS
},
)
.map_err(engine_error)?;
linker
.func_wrap(WASI, "sched_yield", |_: Caller<'_, HostState>| {
errno::SUCCESS
})
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"clock_time_get",
|mut caller: Caller<'_, HostState>, _id: i32, _precision: i64, out: i32| {
let Some(memory) = memory_of(&caller) else {
return errno::INVAL;
};
let Ok(at) = usize::try_from(out) else {
return errno::INVAL;
};
if memory.write(&mut caller, at, &0u64.to_le_bytes()).is_err() {
return errno::INVAL;
}
errno::SUCCESS
},
)
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"clock_res_get",
|mut caller: Caller<'_, HostState>, _id: i32, out: i32| {
let Some(memory) = memory_of(&caller) else {
return errno::INVAL;
};
let Ok(at) = usize::try_from(out) else {
return errno::INVAL;
};
if memory.write(&mut caller, at, &1u64.to_le_bytes()).is_err() {
return errno::INVAL;
}
errno::SUCCESS
},
)
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"random_get",
|mut caller: Caller<'_, HostState>, buf: i32, len: i32| -> Result<i32, wasmi::Error> {
let Some(memory) = memory_of(&caller) else {
return Ok(errno::INVAL);
};
let (Ok(at), Ok(len)) = (usize::try_from(buf), usize::try_from(len)) else {
return Ok(errno::INVAL);
};
let in_bounds = at
.checked_add(len)
.is_some_and(|end| end <= memory.data_size(&caller));
if !in_bounds {
return Ok(errno::INVAL);
}
let mut written = 0usize;
while written < len {
let take = HOST_IO_CHUNK_BYTES.min(len.saturating_sub(written));
charge_units(&mut caller, u64::try_from(take).unwrap_or(u64::MAX))?;
let mut scratch = vec![0u8; take];
for slot in &mut scratch {
*slot = caller.data_mut().next_random_byte();
}
if memory
.write(&mut caller, at.saturating_add(written), &scratch)
.is_err()
{
return Ok(errno::INVAL);
}
written = written.saturating_add(take);
}
Ok(errno::SUCCESS)
},
)
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"proc_exit",
|_: Caller<'_, HostState>, code: i32| -> Result<(), wasmi::Error> {
Err(wasmi::Error::i32_exit(code))
},
)
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"environ_sizes_get",
|mut caller: Caller<'_, HostState>, count: i32, size: i32| {
caller.data_mut().deny(
DeniedCapability::Environment,
"environ_sizes_get",
"a sandboxed plugin sees an empty environment",
);
write_two_zeroes(&mut caller, count, size)
},
)
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"environ_get",
|mut caller: Caller<'_, HostState>, _ptrs: i32, _buf: i32| {
caller.data_mut().deny(
DeniedCapability::Environment,
"environ_get",
"a sandboxed plugin sees an empty environment",
);
errno::SUCCESS
},
)
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"args_sizes_get",
|mut caller: Caller<'_, HostState>, count: i32, size: i32| {
caller.data_mut().deny(
DeniedCapability::Environment,
"args_sizes_get",
"a sandboxed plugin sees no process arguments",
);
write_two_zeroes(&mut caller, count, size)
},
)
.map_err(engine_error)?;
linker
.func_wrap(
WASI,
"args_get",
|mut caller: Caller<'_, HostState>, _ptrs: i32, _buf: i32| {
caller.data_mut().deny(
DeniedCapability::Environment,
"args_get",
"a sandboxed plugin sees no process arguments",
);
errno::SUCCESS
},
)
.map_err(engine_error)?;
for (name, capability, detail, signature) in DENIED_IMPORTS {
deny(linker, name, *capability, detail, signature)?;
}
Ok(())
}
fn write_two_zeroes(caller: &mut Caller<'_, HostState>, first: i32, second: i32) -> i32 {
let Some(memory) = memory_of(caller) else {
return errno::INVAL;
};
let (Ok(first), Ok(second)) = (usize::try_from(first), usize::try_from(second)) else {
return errno::INVAL;
};
if write_u32(caller, memory, first, 0).is_none()
|| write_u32(caller, memory, second, 0).is_none()
{
return errno::INVAL;
}
errno::SUCCESS
}
fn deny(
linker: &mut Shim,
name: &'static str,
capability: DeniedCapability,
detail: &'static str,
signature: &str,
) -> Result<(), SandboxLoadError> {
let mut params = Vec::with_capacity(signature.len());
for ch in signature.chars() {
params.push(match ch {
'i' => wasmi::core::ValType::I32,
'l' => wasmi::core::ValType::I64,
other => {
return Err(SandboxLoadError::Engine(format!(
"unknown signature character `{other}` for {name}"
)));
}
});
}
let ty = wasmi::FuncType::new(params, [wasmi::core::ValType::I32]);
linker
.func_new(
WASI,
name,
ty,
move |mut caller: Caller<'_, HostState>, _args: &[wasmi::Val], results| {
caller.data_mut().deny(capability, name, detail);
if let Some(slot) = results.first_mut() {
*slot = wasmi::Val::I32(errno::NOTCAPABLE);
}
Ok(())
},
)
.map_err(|err| SandboxLoadError::Engine(err.to_string()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin_sandbox::manifest::{ResourceLimits, SandboxCapability, SandboxManifest};
use crate::plugin_sandbox::test_guests as guests;
fn manifest_with(limits: ResourceLimits) -> SandboxManifest {
let mut manifest = SandboxManifest::parse(&format!(
r#"
name = "autumn-plugin-hello"
version = "0.1.0"
wire_version = 1
prefix = "/hello"
capabilities = ["http-request"]
sha256 = "{digest}"
[[routes]]
method = "GET"
path = "/hello/greet"
"#,
digest = "a".repeat(64)
))
.expect("valid manifest");
manifest.limits = limits;
manifest
}
fn bare_state(limits: ResourceLimits, frame: &[u8]) -> HostState {
HostState::new(
"hello".to_owned(),
limits,
frame,
Exchange::Request,
crate::plugin_sandbox::capability::CapabilityRuntime::new(
&manifest_with(limits),
crate::plugin_sandbox::CapabilityServices::none(),
),
)
}
fn try_host(wat: &str) -> Result<SandboxHost, SandboxLoadError> {
try_host_with(wat, ResourceLimits::default())
}
fn try_host_with(wat: &str, limits: ResourceLimits) -> Result<SandboxHost, SandboxLoadError> {
let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
SandboxHost::from_module(manifest_with(limits), &wasm)
}
fn host(wat: &str) -> SandboxHost {
try_host(wat).expect("the fixture loads")
}
fn request(method: &str, path: &str) -> SandboxRequest {
SandboxRequest {
method: method.to_owned(),
route: path.to_owned(),
path: path.to_owned(),
query: String::new(),
path_params: vec![],
headers: vec![("accept".to_owned(), "text/plain".to_owned())],
body: vec![],
}
}
fn get(path: &str) -> SandboxRequest {
request("GET", path)
}
fn denied(outcome: &SandboxOutcome, capability: DeniedCapability) -> Vec<String> {
outcome
.denials
.iter()
.filter(|denial| denial.capability == capability)
.map(|denial| denial.operation.clone())
.collect()
}
#[test]
fn a_well_behaved_guest_answers_the_request() {
let outcome = host(guests::HELLO).run(&get("/hello/greet"));
let response = outcome.result.expect("answers");
assert_eq!(response.status, 200);
assert_eq!(response.body, b"hello from the sandbox");
assert!(outcome.denials.is_empty(), "{:?}", outcome.denials);
assert!(outcome.fuel_used > 0);
}
#[test]
fn the_guest_sees_the_request_it_was_sent() {
let host = host(guests::HELLO);
assert_eq!(
host.run(&get("/hello/other"))
.result
.expect("answers")
.status,
404
);
assert_eq!(
host.run(&request("POST", "/hello/greet"))
.result
.expect("answers")
.status,
405
);
}
#[test]
fn each_request_gets_a_fresh_instance() {
let host = host(guests::HELLO);
let first = host.run(&get("/hello/greet"));
let second = host.run(&get("/hello/greet"));
assert_eq!(
first.result.expect("answers"),
second.result.expect("answers")
);
assert_eq!(first.fuel_used, second.fuel_used);
}
#[test]
fn a_guest_that_never_stops_is_stopped() {
let limits = ResourceLimits {
fuel: 5_000_000,
..ResourceLimits::default()
};
let outcome = try_host_with(guests::CPU_SPIN, limits)
.expect("loads")
.run(&get("/hello/greet"));
let failure = outcome.result.expect_err("must not answer");
assert!(
matches!(failure, SandboxFailure::FuelExhausted { .. }),
"{failure}"
);
assert!(failure.status().is_server_error(), "{failure}");
}
#[test]
fn a_guest_that_allocates_without_bound_is_capped() {
let limits = ResourceLimits {
fuel: 20_000_000,
memory_bytes: 1024 * 1024,
..ResourceLimits::default()
};
let outcome = try_host_with(guests::MEMORY_BOMB, limits)
.expect("loads")
.run(&get("/hello/greet"));
assert!(outcome.result.is_err(), "a memory bomb must not answer");
assert!(
outcome.peak_memory_bytes <= 1024 * 1024,
"peak {} exceeded the ceiling",
outcome.peak_memory_bytes
);
assert!(
!denied(&outcome, DeniedCapability::Memory).is_empty(),
"the refused growth must be observable: {:?}",
outcome.denials
);
}
#[test]
fn a_guest_that_floods_stdout_without_a_newline_is_cut_off() {
let limits = ResourceLimits {
max_response_bytes: 4096,
fuel: 50_000_000,
..ResourceLimits::default()
};
let outcome = try_host_with(guests::OUTPUT_FLOOD, limits)
.expect("loads")
.run(&get("/hello/greet"));
let failure = outcome.result.expect_err("must not answer");
assert!(
matches!(failure, SandboxFailure::OutputBudget { .. }),
"{failure}"
);
}
#[test]
fn host_side_copying_is_charged_against_the_guest_s_fuel() {
const COPIED: u64 = 1024 * 1024;
let bulk = host(guests::STDOUT_BULK).run(&get("/hello/greet"));
assert!(
bulk.fuel_used >= COPIED / 64,
"1 MiB of host-side copying cost only {} fuel units",
bulk.fuel_used
);
let honest = host(guests::HELLO).run(&get("/hello/greet"));
assert!(
honest.fuel_used < COPIED / 64,
"the baseline is not a baseline: {} units",
honest.fuel_used
);
}
#[test]
fn walking_an_iovec_vector_is_charged_even_when_it_moves_no_bytes() {
const CALLS: u32 = 8192;
const WIDE: u32 = MAX_IOVECS as u32;
let wide = host(&guests::empty_iovecs(WIDE, CALLS)).run(&get("/hello/greet"));
let narrow = host(&guests::empty_iovecs(1, CALLS)).run(&get("/hello/greet"));
assert!(wide.result.is_ok(), "{:?}", wide.result);
assert!(narrow.result.is_ok(), "{:?}", narrow.result);
let extra = wide.fuel_used.saturating_sub(narrow.fuel_used);
let expected = u64::from(CALLS) * u64::from(WIDE - 1) / 2;
assert!(
extra >= expected,
"sixty-four empty descriptors per call cost {extra} more fuel than one, \
short of the {expected} that walking them is worth"
);
}
#[test]
fn parsing_a_stdout_line_is_charged_per_byte_not_at_the_copy_rate() {
const PAD: u32 = 65_536;
let padded = host(&guests::padded_frame(PAD)).run(&get("/hello/greet"));
let plain = host(&guests::padded_frame(0)).run(&get("/hello/greet"));
assert!(padded.result.is_ok(), "{:?}", padded.result);
assert!(plain.result.is_ok(), "{:?}", plain.result);
let extra = padded.fuel_used.saturating_sub(plain.fuel_used);
assert!(
extra > u64::from(PAD) / 2,
"{PAD} bytes of padding cost {extra} more fuel than none, \
short of the per-byte price of parsing them"
);
}
#[test]
fn instantiating_the_module_is_priced_against_the_budget() {
let charged = host(guests::HELLO).instantiation_fuel();
assert!(charged > 1, "the fixture carries data segments");
let err = try_host_with(
guests::HELLO,
ResourceLimits {
fuel: charged / 2,
..ResourceLimits::default()
},
)
.expect_err("a budget below the fixed charge must not produce a host");
assert!(
matches!(err, SandboxLoadError::FuelBelowFixedCharges { .. }),
"{err:?}"
);
let outcome = try_host_with(
guests::HELLO,
ResourceLimits {
fuel: charged + 1,
..ResourceLimits::default()
},
)
.expect("a budget above the fixed charge loads")
.run(&get("/hello/greet"));
assert!(
matches!(outcome.result, Err(SandboxFailure::FuelExhausted { .. })),
"{:?}",
outcome.result
);
let outcome = host(guests::HELLO).run(&get("/hello/greet"));
assert!(outcome.result.is_ok());
assert!(
outcome.fuel_used >= charged,
"instantiation cost {} of a {charged}-unit module",
outcome.fuel_used
);
}
#[test]
fn a_frame_that_is_not_utf8_is_refused_rather_than_expanded() {
let outcome = host(guests::INVALID_UTF8).run(&get("/hello/greet"));
let failure = outcome.result.expect_err("must not answer");
assert!(
matches!(failure, SandboxFailure::MalformedFrame(ref detail) if detail.contains("UTF-8")),
"{failure}"
);
}
#[test]
fn a_module_without_the_memory_the_shim_needs_is_refused_at_load() {
let wat = r#"(module (func (export "_start") (nop)))"#;
assert!(matches!(
try_host(wat),
Err(SandboxLoadError::MissingMemory)
));
}
#[test]
fn a_module_whose_initial_memory_exceeds_the_ceiling_is_refused_at_load() {
let wat = r#"(module (memory (export "memory") 32) (func (export "_start") (nop)))"#;
let limits = ResourceLimits {
memory_bytes: 1024 * 1024,
..ResourceLimits::default()
};
let err = try_host_with(wat, limits).expect_err("must be refused");
assert!(
matches!(err, SandboxLoadError::MemoryTooLarge { found, .. } if found == 2 * 1024 * 1024),
"{err}"
);
}
#[test]
fn table_growth_past_the_ceiling_is_refused_while_the_guest_runs() {
let wat = format!(
r#"(module
(memory (export "memory") 1)
(table 1 funcref)
(func (export "_start")
(drop (table.grow 0 (ref.null func) (i32.const {MAX_TABLE_ELEMENTS})))))"#
);
let outcome = host(&wat).run(&get("/hello/greet"));
assert!(outcome.result.is_err(), "{:?}", outcome.result);
let operations = denied(&outcome, DeniedCapability::Memory);
assert!(
!operations.is_empty(),
"the refusal was not recorded: {:?}",
outcome.denials
);
assert!(
operations.iter().any(|op| op == "table.grow"),
"a table refusal was not reported as one: {operations:?}"
);
assert!(
!operations.iter().any(|op| op == "memory.grow"),
"a table refusal was reported against the byte ceiling: {operations:?}"
);
let detail = outcome
.denials
.iter()
.find(|denial| denial.operation == "table.grow")
.map(|denial| denial.detail.clone())
.expect("the table denial is present");
assert!(
detail.contains(&MAX_TABLE_ELEMENTS.to_string()),
"the detail does not name the ceiling that applied: {detail}"
);
}
#[test]
fn a_module_that_is_expensive_to_instantiate_is_refused_at_load() {
use std::fmt::Write as _;
let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
for offset in 0..=MAX_INIT_SEGMENTS {
let _ = writeln!(wat, " (data (i32.const {offset}) \"x\")");
}
wat.push_str(" (func (export \"_start\") (nop))\n)");
let err = try_host(&wat).expect_err("must be refused");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive { max, .. } if max == MAX_INIT_SEGMENTS
),
"{err}"
);
assert!(err.to_string().contains("re-instantiates"), "{err}");
}
#[test]
fn a_guest_that_floods_stderr_runs_out_of_fuel_rather_than_time() {
let limits = ResourceLimits {
fuel: 2_000_000,
..ResourceLimits::default()
};
let outcome = try_host_with(guests::STDERR_FLOOD, limits)
.expect("loads")
.run(&get("/hello/greet"));
let failure = outcome.result.expect_err("must not answer");
assert!(
matches!(failure, SandboxFailure::FuelExhausted { .. }),
"{failure}"
);
}
#[test]
fn a_guest_that_answers_and_then_spins_does_not_hold_its_whole_budget() {
let limits = ResourceLimits {
fuel: 500_000_000,
..ResourceLimits::default()
};
let outcome = try_host_with(guests::ANSWER_THEN_SPIN, limits)
.expect("loads")
.run(&get("/hello/greet"));
assert_eq!(outcome.result.as_ref().expect("answers").status, 200);
assert!(
outcome.fuel_used < 1_000_000,
"the answer cost {} fuel; the guest was allowed to keep spinning",
outcome.fuel_used
);
}
#[test]
fn a_frame_without_its_newline_says_what_the_author_did() {
let outcome = host(guests::PARTIAL_FRAME).run(&get("/hello/greet"));
let failure = outcome.result.expect_err("must not answer");
assert!(matches!(failure, SandboxFailure::PartialFrame), "{failure}");
assert!(failure.to_string().contains("println!"), "{failure}");
}
#[test]
fn a_guest_may_not_forge_the_host_s_own_response_headers() {
let outcome = host(guests::FORGE_ATTRIBUTION).run(&get("/hello/greet"));
let denied = denied(&outcome, DeniedCapability::ResponseHeader);
assert!(
denied.contains(&"x-autumn-sandboxed".to_owned()),
"{denied:?}"
);
assert!(
denied.contains(&"x-content-type-options".to_owned()),
"{denied:?}"
);
let response = outcome.result.expect("answers");
assert!(
response
.headers
.iter()
.all(|(name, _)| name.starts_with("content-"))
);
}
#[test]
fn a_document_content_type_is_refused_rather_than_served_from_the_host_s_origin() {
for essence in [
"text/html",
"application/javascript",
"image/svg+xml",
"text/css",
] {
let response = SandboxResponse {
status: 200,
headers: vec![("content-type".to_owned(), essence.to_owned())],
body: b"<script>".to_vec(),
};
assert_eq!(
response.refused_content_type().as_deref(),
Some(essence),
"{essence} must be refused"
);
}
for essence in ["text/plain; charset=utf-8", "application/json", "image/png"] {
let response = SandboxResponse {
status: 200,
headers: vec![("Content-Type".to_owned(), essence.to_owned())],
body: vec![],
};
assert_eq!(
response.refused_content_type(),
None,
"{essence} must be served"
);
}
}
#[test]
fn entropy_is_deterministic_per_request_without_being_a_published_constant() {
let host = host(guests::ENTROPY);
let first = host.run(&get("/hello/greet")).result.expect("answers");
let again = host.run(&get("/hello/greet")).result.expect("answers");
assert_eq!(first.status, again.status, "the same request must replay");
let other = host.run(&get("/hello/other")).result.expect("answers");
assert!(
(200..=207).contains(&other.status),
"unexpected status {}",
other.status
);
}
#[test]
fn the_entropy_seed_is_a_function_of_the_request() {
assert_ne!(
HostState::seed_from(b"one request"),
HostState::seed_from(b"another request")
);
assert_eq!(
HostState::seed_from(b"one request"),
HostState::seed_from(b"one request")
);
}
#[test]
fn a_trap_is_an_error_value_not_a_dead_process() {
let outcome = host(guests::TRAP).run(&get("/hello/greet"));
let failure = outcome.result.expect_err("must not answer");
assert!(matches!(failure, SandboxFailure::Trap(_)), "{failure}");
}
#[test]
fn proc_exit_does_not_exit_the_host() {
let outcome = host(guests::EXIT).run(&get("/hello/greet"));
let failure = outcome.result.expect_err("must not answer");
assert!(matches!(failure, SandboxFailure::Exited(3)), "{failure}");
}
#[test]
fn a_guest_that_never_answers_is_a_failure_not_a_hang() {
let outcome = host(guests::SILENT).run(&get("/hello/greet"));
assert!(
matches!(outcome.result, Err(SandboxFailure::NoAnswer)),
"{:?}",
outcome.result
);
}
#[test]
fn a_module_without_a_start_is_refused_at_load() {
let err = try_host(guests::NO_START).expect_err("must be refused");
assert!(matches!(err, SandboxLoadError::MissingStart), "{err}");
}
#[test]
fn a_file_read_is_denied_and_logged() {
let outcome = host(guests::READ_FILE).run(&get("/hello/greet"));
assert_eq!(
denied(&outcome, DeniedCapability::Filesystem),
vec!["path_open".to_owned()]
);
assert_eq!(outcome.result.expect("still answers").status, 200);
}
#[derive(Default)]
struct DenialLog(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
impl tracing::Subscriber for DenialLog {
fn enabled(&self, _: &tracing::Metadata<'_>) -> bool {
true
}
fn max_level_hint(&self) -> Option<tracing::level_filters::LevelFilter> {
Some(tracing::level_filters::LevelFilter::TRACE)
}
fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id {
tracing::span::Id::from_u64(1)
}
fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {}
fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {}
fn event(&self, event: &tracing::Event<'_>) {
struct Fields<'a>(&'a mut Vec<String>);
impl tracing::field::Visit for Fields<'_> {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
self.0.push(format!("{}={value:?}", field.name()));
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.0.push(format!("{}={value}", field.name()));
}
}
let mut fields = Vec::new();
event.record(&mut Fields(&mut fields));
if let Ok(mut recorded) = self.0.lock() {
recorded.push(format!(
"{} [{}]",
event.metadata().level(),
fields.join(" ")
));
}
}
fn enter(&self, _: &tracing::span::Id) {}
fn exit(&self, _: &tracing::span::Id) {}
}
use rusty_fork::rusty_fork_test;
rusty_fork_test! {
#[test]
fn a_denial_reaches_the_log_and_not_only_the_ledger() {
let recorded = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
tracing::subscriber::with_default(
DenialLog(std::sync::Arc::clone(&recorded)),
|| {
let _ = host(guests::READ_FILE).run(&get("/hello/greet"));
},
);
let lines = recorded.lock().expect("not poisoned").clone();
let denial = lines
.iter()
.find(|line| line.contains("operation=path_open"))
.unwrap_or_else(|| panic!("no denial line in {lines:#?}"));
assert!(denial.starts_with("WARN"), "{denial}");
assert!(denial.contains("capability=filesystem"), "{denial}");
assert!(denial.contains("plugin=autumn-plugin-hello"), "{denial}");
assert!(denial.contains("no filesystem"), "{denial}");
}
}
#[test]
fn there_are_no_preopened_directories_to_discover() {
let outcome = host(guests::DISCOVER_PREOPENS).run(&get("/hello/greet"));
assert_eq!(
denied(&outcome, DeniedCapability::Filesystem),
vec!["fd_prestat_get".to_owned()]
);
}
#[test]
fn a_stray_descriptor_read_is_denied() {
let outcome = host(guests::READ_STRAY_FD).run(&get("/hello/greet"));
assert_eq!(
denied(&outcome, DeniedCapability::Filesystem),
vec!["fd_read".to_owned()]
);
}
fn seek_guest(fd: i32, errno: i32) -> String {
format!(
r#"(module
(import "wasi_snapshot_preview1" "fd_seek" (func $seek (param i32 i64 i32 i32) (result i32)))
(import "wasi_snapshot_preview1" "fd_write" (func $write (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1 1)
(data (i32.const 128) "{{\"op\":\"response\",\"status\":200,\"headers\":[[\"content-type\",\"text/plain\"]],\"body_b64\":\"b2s=\"}}\0a")
(func (export "_start")
(if (i32.ne (call $seek (i32.const {fd}) (i64.const 0) (i32.const 0) (i32.const 0)) (i32.const {errno}))
(then unreachable))
(i32.store (i32.const 0) (i32.const 128))
(i32.store (i32.const 4) (i32.const 91))
(drop (call $write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 8)))))"#
)
}
#[test]
fn seeking_an_unknown_descriptor_is_denied_and_recorded() {
let outcome = host(&seek_guest(99, 8)).run(&get("/hello/greet"));
assert_eq!(outcome.result.as_ref().expect("answers").status, 200);
assert_eq!(
denied(&outcome, DeniedCapability::Filesystem),
vec!["fd_seek".to_owned()]
);
}
#[test]
fn seeking_a_negative_descriptor_is_denied_too() {
let outcome = host(&seek_guest(-1, 8)).run(&get("/hello/greet"));
assert_eq!(outcome.result.as_ref().expect("answers").status, 200);
assert_eq!(
denied(&outcome, DeniedCapability::Filesystem),
vec!["fd_seek".to_owned()]
);
}
#[test]
fn seeking_stdio_is_a_pipe_and_not_a_denial() {
let outcome = host(&seek_guest(1, 70)).run(&get("/hello/greet"));
assert_eq!(outcome.result.as_ref().expect("answers").status, 200);
assert!(outcome.denials.is_empty(), "{:?}", outcome.denials);
}
#[test]
fn outbound_network_is_denied() {
let outcome = host(guests::NETWORK).run(&get("/hello/greet"));
assert_eq!(
denied(&outcome, DeniedCapability::Network),
vec!["sock_send".to_owned()]
);
}
#[test]
fn the_environment_is_empty_and_the_attempt_is_logged() {
let outcome = host(guests::ENVIRONMENT).run(&get("/hello/greet"));
let ops = denied(&outcome, DeniedCapability::Environment);
assert_eq!(outcome.result.expect("still answers").status, 200);
assert!(ops.contains(&"environ_sizes_get".to_owned()), "{ops:?}");
assert!(ops.contains(&"environ_get".to_owned()), "{ops:?}");
}
#[test]
fn process_arguments_are_empty_and_the_attempt_is_logged() {
let outcome = host(guests::ARGUMENTS).run(&get("/hello/greet"));
assert_eq!(
denied(&outcome, DeniedCapability::Environment),
vec!["args_sizes_get".to_owned()]
);
}
#[test]
fn blocking_the_host_on_a_poll_is_denied() {
let outcome = host(guests::POLL).run(&get("/hello/greet"));
assert_eq!(
denied(&outcome, DeniedCapability::ProcessControl),
vec!["poll_oneoff".to_owned()]
);
}
#[test]
fn entropy_is_answered_deterministically_rather_than_denied() {
let host = host(guests::ENTROPY);
let first = host.run(&get("/hello/greet"));
let second = host.run(&get("/hello/greet"));
assert!(first.denials.is_empty(), "{:?}", first.denials);
assert_eq!(
first.result.expect("answers"),
second.result.expect("answers")
);
}
#[test]
fn a_database_seam_the_host_never_defined_is_refused_at_load() {
let err = try_host(guests::DATABASE).expect_err("must be refused");
let SandboxLoadError::ForbiddenImports(denials) = err else {
panic!("expected a forbidden-import refusal, got {err}");
};
assert_eq!(denials.len(), 1);
assert_eq!(denials[0].capability, DeniedCapability::UnknownImport);
assert!(denials[0].operation.contains("autumn_db"), "{denials:?}");
}
#[test]
fn a_host_escape_from_an_invented_namespace_is_refused_at_load() {
let err = try_host(guests::HOST_COMMAND).expect_err("must be refused");
assert!(
matches!(err, SandboxLoadError::ForbiddenImports(_)),
"{err}"
);
}
#[test]
fn a_wasi_function_this_shim_does_not_implement_is_refused_at_load() {
let err = try_host(guests::UNDEFINED_WASI).expect_err("must be refused");
let SandboxLoadError::ForbiddenImports(denials) = err else {
panic!("expected a forbidden-import refusal, got {err}");
};
assert!(denials[0].operation.contains("sock_connect"), "{denials:?}");
}
#[test]
fn a_forged_session_cookie_is_stripped_and_logged() {
let outcome = host(guests::FORGE_COOKIE).run(&get("/hello/greet"));
assert_eq!(
denied(&outcome, DeniedCapability::ResponseHeader),
vec!["set-cookie".to_owned()]
);
let response = outcome.result.expect("answers");
assert!(
!response
.headers
.iter()
.any(|(name, _)| name.eq_ignore_ascii_case("set-cookie")),
"{:?}",
response.headers
);
}
#[test]
fn a_plugin_cannot_borrow_the_reverse_proxy_s_filesystem() {
let outcome = host(guests::PROXY_REDIRECT).run(&get("/hello/greet"));
let denied = denied(&outcome, DeniedCapability::ResponseHeader);
assert!(
denied.contains(&"x-accel-redirect".to_owned()),
"{denied:?}"
);
assert!(denied.contains(&"x-sendfile".to_owned()), "{denied:?}");
let response = outcome.result.expect("answers without them");
assert!(
response
.headers
.iter()
.all(|(name, _)| name == "content-type"),
"{:?}",
response.headers
);
}
#[test]
fn a_response_splitting_header_never_reaches_a_response() {
let outcome = host(guests::SPLIT_RESPONSE).run(&get("/hello/greet"));
assert_eq!(
denied(&outcome, DeniedCapability::ResponseHeader),
vec!["x-evil".to_owned()]
);
let response = outcome.result.expect("answers without the header");
assert!(
response
.headers
.iter()
.all(|(name, _)| name == "content-type"),
"{:?}",
response.headers
);
}
#[test]
fn an_impossible_status_is_refused() {
let outcome = host(guests::IMPOSSIBLE_STATUS).run(&get("/hello/greet"));
assert!(
matches!(outcome.result, Err(SandboxFailure::ResponseRefused(_))),
"{:?}",
outcome.result
);
}
#[test]
fn a_guest_error_detail_can_neither_flood_nor_forge_a_log_line() {
let outcome = host(guests::FORGE_LOG).run(&get("/hello/greet"));
let Err(failure) = outcome.result else {
panic!("the guest reported a failure");
};
let rendered = failure.to_string();
assert!(
rendered.len() < 4096,
"a 2 KiB detail reached the log intact: {} bytes",
rendered.len()
);
assert!(
!rendered.contains('\n') && !rendered.contains('\r'),
"the detail can start a log record of its own: {rendered:?}"
);
assert!(
!rendered.contains('\u{1b}'),
"the detail can repaint the operator's terminal: {rendered:?}"
);
assert!(
rendered.contains("forged"),
"the detail is still legible enough to debug with: {rendered:?}"
);
}
#[test]
fn a_denied_response_header_name_is_bounded_before_it_is_logged() {
let name = "x-".to_owned() + &"y".repeat(20_000);
let frame = format!(
r#"{{"op":"response","status":200,"headers":[["content-type","text/plain"],["{name}","v"]],"body_b64":""}}"#
);
let len = frame.len() + 1;
let wat = format!(
r#"(module
(import "wasi_snapshot_preview1" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 2)
(data (i32.const 1024) "{escaped}\0a")
(func (export "_start")
(i32.store (i32.const 0) (i32.const 1024))
(i32.store (i32.const 4) (i32.const {len}))
(drop (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 8)))))"#,
escaped = frame.replace('"', "\\\""),
);
let outcome = host(&wat).run(&get("/hello/greet"));
let logged = outcome
.denials
.iter()
.find(|denial| denial.capability == DeniedCapability::ResponseHeader)
.expect("the stripped header is recorded as a denial");
assert!(
logged.operation.len() < 1_024,
"the denial carries the guest's whole header name: {} bytes",
logged.operation.len()
);
assert!(
logged.operation.contains("truncated"),
"{:?}",
logged.operation
);
}
#[test]
fn a_refused_content_type_is_bounded_before_it_is_logged() {
let essence = "x".repeat(20_000);
let frame = format!(
r#"{{"op":"response","status":200,"headers":[["content-type","{essence}"]],"body_b64":""}}"#
);
let len = frame.len() + 1;
let wat = format!(
r#"(module
(import "wasi_snapshot_preview1" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 2)
(data (i32.const 1024) "{escaped}\0a")
(func (export "_start")
(i32.store (i32.const 0) (i32.const 1024))
(i32.store (i32.const 4) (i32.const {len}))
(drop (call $fd_write (i32.const 1) (i32.const 0) (i32.const 1) (i32.const 8)))))"#,
escaped = frame.replace('"', "\\\""),
);
let outcome = host(&wat).run(&get("/hello/greet"));
let Err(SandboxFailure::ResponseRefused(detail)) = outcome.result else {
panic!(
"an unsupported content type must be refused: {:?}",
outcome.result
);
};
assert!(
detail.len() < 1_024,
"the failure text carries the guest's whole header: {} bytes",
detail.len()
);
assert!(detail.contains("truncated"), "{detail}");
let logged = outcome
.denials
.iter()
.find(|denial| denial.capability == DeniedCapability::ResponseHeader)
.expect("the refusal is recorded as a denial");
assert!(
logged.detail.len() < 1_024,
"the denial detail carries the guest's whole header: {} bytes",
logged.detail.len()
);
}
#[test]
fn an_element_offset_this_cannot_evaluate_is_refused_like_a_data_offset() {
let mut operands = String::new();
let mut adds = String::new();
for _ in 0..24 {
operands.push_str("i32.const 0 ");
adds.push_str("i32.add ");
}
let wat = format!(
r#"(module
(table 1 funcref)
(memory (export "memory") 1)
(func $f (export "_start") (nop))
(elem (offset i32.const 65536 {operands} {adds}) $f)
)"#
);
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let mut config = wasmi::Config::default();
config.consume_fuel(true);
wasmi::Module::new(&wasmi::Engine::new(&config), &wasm[..])
.expect("wasmi compiles a deep extended-const offset; the finding depends on it");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("an active element segment with an unevaluable offset must be refused");
assert!(
matches!(
err,
SandboxLoadError::SegmentOutOfBounds {
what: "table elements",
..
}
),
"{err:?}",
);
let passive = wat::parse_str(
r#"(module
(table 1 funcref)
(memory (export "memory") 1)
(func $f (export "_start") (nop))
(elem funcref (ref.func $f))
)"#,
)
.expect("the fixture is valid WAT");
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &passive)
.expect("a passive element segment must still load");
}
#[test]
fn deciding_a_header_does_not_cross_costs_nothing_to_decide() {
let enormous = "x".repeat(4 * 1024 * 1024);
assert!(!super::super::wire::request_header_allowed(&enormous));
assert!(super::super::wire::request_header_allowed("Accept"));
assert!(super::super::wire::request_header_allowed("ACCEPT"));
assert!(super::super::wire::request_header_allowed("accept"));
assert!(!super::super::wire::request_header_allowed("Cookie"));
let canonical = super::super::wire::canonicalize_headers(&[(
"AcCePt".to_owned(),
"text/plain".to_owned(),
)]);
assert_eq!(
canonical,
vec![("accept".to_owned(), "text/plain".to_owned())],
);
}
#[test]
fn encoding_fuel_prices_the_sizing_pass_of_the_line() {
let mut request = get("/hello/greet");
request.body = vec![b'x'; 60_000];
let charged_bytes = encoding_fuel(&request).saturating_mul(BYTES_PER_FUEL);
assert!(
charged_bytes >= 60_000 * 29 / 3,
"the charge ({charged_bytes}) leaves the sizing pass free"
);
}
#[test]
fn encoding_fuel_prices_the_line_that_is_written_not_the_bytes_handed_in() {
let granted = [SandboxCapability::HttpRequest];
let mut request = get("/hello/greet");
request.body = vec![b'x'; 60_000];
let line = crate::plugin_sandbox::wire::to_line(
&crate::plugin_sandbox::wire::HostFrame::request(&request, &granted),
)
.expect("serialises")
.len();
let charged_bytes = encoding_fuel(&request).saturating_mul(BYTES_PER_FUEL);
assert!(
charged_bytes >= (line as u64).saturating_mul(2),
"the charge ({charged_bytes}) is under writing and re-reading a \
{line}-byte line",
);
let mut escaped = get("/hello/greet");
escaped.query = "\u{0}".repeat(10_000);
let mut plain = get("/hello/greet");
plain.query = "a".repeat(10_000);
assert!(
encoding_fuel(&escaped) == encoding_fuel(&plain),
"the charge is computed from raw sizes, so it must not vary with \
content — it is an upper bound taken before the line exists",
);
let escaped_line = crate::plugin_sandbox::wire::to_line(
&crate::plugin_sandbox::wire::HostFrame::request(&escaped, &granted),
)
.expect("serialises")
.len();
let escaped_charge = encoding_fuel(&escaped).saturating_mul(BYTES_PER_FUEL);
assert!(
escaped_charge >= (escaped_line as u64).saturating_mul(2),
"the charge ({escaped_charge}) is under writing and re-reading a \
JSON-escaped {escaped_line}-byte line",
);
}
#[test]
fn direct_run_does_not_charge_for_headers_the_frame_will_drop() {
let host = host(guests::HELLO);
let mut credential_heavy = get("/hello/greet");
credential_heavy.headers = vec![
("cookie".to_owned(), "s=".repeat(MAX_REQUEST_METADATA_BYTES)),
(
"authorization".to_owned(),
"t=".repeat(MAX_REQUEST_METADATA_BYTES),
),
];
assert!(
request_metadata_bytes(&credential_heavy) < MAX_REQUEST_METADATA_BYTES,
"headers the frame drops are still being charged for",
);
assert!(
host.run(&credential_heavy).result.is_ok(),
"a request whose only bulk is dropped headers must still be served",
);
let mut allowed_heavy = get("/hello/greet");
allowed_heavy.headers = vec![(
"accept".to_owned(),
"text/plain, ".repeat(MAX_REQUEST_METADATA_BYTES),
)];
let err = host
.run(&allowed_heavy)
.result
.expect_err("oversized allowlisted metadata must still be refused");
assert!(
matches!(err, SandboxFailure::RequestMetadataBudget { .. }),
"{err:?}",
);
}
#[test]
fn a_module_of_nothing_but_custom_sections_is_refused_without_reading_them_all() {
let mut wasm = Vec::from(b"\0asm\x01\0\0\0".as_slice());
for _ in 0..=MAX_SECTIONS {
wasm.extend_from_slice(&[CUSTOM_SECTION, 0x01, 0x00]);
}
let shape = module_shape(&wasm).expect("the header walk must not fail on legal sections");
assert_eq!(
shape.sections,
MAX_SECTIONS + 1,
"the walk read past the ceiling instead of stopping at it",
);
let err = refuse_unbounded_shape(&wasm)
.expect_err("a module past the section ceiling must be refused");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "sections",
..
}
),
"{err:?}",
);
let hello = wat::parse_str(guests::HELLO).expect("the fixture is valid WAT");
let shape = module_shape(&hello).expect("a real module walks");
assert!(
shape.sections <= MAX_SECTIONS,
"a real module carries {} sections, at or over the ceiling",
shape.sections,
);
refuse_unbounded_shape(&hello).expect("a real module must still load");
}
#[test]
fn a_module_declaring_a_second_memory_is_refused_at_load_not_once_per_request() {
let wat = r#"(module
(memory (export "memory") 1)
(memory 1)
(func (export "_start") (nop))
)"#;
let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
let mut config = wasmi::Config::default();
config.consume_fuel(true);
let engine = wasmi::Engine::new(&config);
wasmi::Module::new(&engine, &wasm[..])
.expect("wasmi enables multi-memory by default; if this fails the ceiling is moot");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a second memory must be refused at load");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "linear memories",
..
}
),
"{err:?}",
);
let err = refuse_unbounded_shape(&wasm)
.expect_err("the pre-compilation gate must refuse it, not a later one");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "linear memories",
..
}
),
"{err:?}",
);
let single = wat::parse_str(
r#"(module (memory (export "memory") 1) (func (export "_start") (nop)))"#,
)
.expect("the fixture is valid WAT");
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &single)
.expect("a single-memory module must still load");
}
#[test]
fn a_long_import_name_is_not_copied_whole_in_order_to_refuse_it() {
let huge = "z".repeat(99_000);
let wat = format!(
r#"(module
(import "not_wasi" "{huge}" (func))
(memory (export "memory") 1)
(func (export "_start") (nop))
)"#
);
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("an import the sandbox does not provide must be refused");
let SandboxLoadError::ForbiddenImports(denials) = &err else {
panic!("{err:?}");
};
assert_eq!(denials.len(), 1);
assert!(
denials[0].operation.len() <= DENIAL_RECORD_BYTES,
"the denial carries {} bytes of a {}-byte name — it was copied whole \
in order to reject it",
denials[0].operation.len(),
huge.len(),
);
assert!(
denials[0].operation.starts_with("not_wasi::"),
"the operation must still say what was imported: {}",
&denials[0].operation[..denials[0].operation.len().min(64)],
);
let rendered = err.to_string();
assert!(
rendered.len() <= DENIAL_RECORD_BYTES.saturating_mul(2),
"rendering the refusal produced {} bytes from a {}-byte name",
rendered.len(),
huge.len(),
);
}
#[test]
fn a_table_the_instance_fills_is_charged_like_the_memory_it_fills() {
let bare = instantiation_fuel(0, 0, 0, 0, 0, 0);
let with_table = instantiation_fuel(0, 0, 0, 0, 0, u64::from(MAX_TABLE_ELEMENTS));
assert!(
with_table > bare,
"a full table cost the same as no table at all ({with_table} vs {bare})",
);
assert_eq!(
with_table - bare,
u64::from(MAX_TABLE_ELEMENTS),
"the charge should be one unit per slot the instance initialises",
);
let wat = format!(
r#"(module
(memory (export "memory") 0)
(table {MAX_TABLE_ELEMENTS} funcref)
(func (export "_start") (nop))
)"#
);
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let limits = ResourceLimits {
fuel: u64::from(MAX_TABLE_ELEMENTS) / 2,
..ResourceLimits::default()
};
let err = SandboxHost::from_module(manifest_with(limits), &wasm)
.expect_err("fuel below the cost of filling the table must be refused");
assert!(
matches!(err, SandboxLoadError::FuelBelowFixedCharges { .. }),
"{err:?}",
);
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect("a table within a normal fuel budget must still load");
}
#[test]
fn the_review_list_bounds_an_import_name_where_it_builds_it() {
let huge = "z".repeat(99_000);
let wat = format!(
r#"(module
(import "not_wasi" "{huge}" (func))
(memory (export "memory") 1)
(func (export "_start") (nop))
)"#
);
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let imports = SandboxHost::imports_of(&wasm).expect("the module's shape is readable");
assert_eq!(imports.len(), 1);
assert!(
imports[0].len() <= DENIAL_RECORD_BYTES,
"the review list carries {} bytes of a {}-byte name",
imports[0].len(),
huge.len(),
);
assert!(
imports[0].starts_with("not_wasi::"),
"the entry must still say what is imported: {}",
&imports[0][..imports[0].len().min(64)],
);
}
#[test]
fn one_oversized_element_section_is_not_walked_before_it_is_refused() {
let mut wasm = Vec::from(b"\0asm\x01\0\0\0".as_slice());
wasm.extend_from_slice(&[TABLE_SECTION, 0x04, 0x01, 0x70, 0x00, 0x01]);
let body_len = MAX_INIT_SECTION_BYTES + 1;
wasm.push(ELEMENT_SECTION);
let mut size = body_len;
while size >= 0x80 {
let low = u8::try_from(size & 0x7f).expect("masked to seven bits");
wasm.push(low | 0x80);
size >>= 7;
}
wasm.push(u8::try_from(size).expect("the loop leaves under 0x80"));
let before_body = wasm.len();
wasm.extend_from_slice(&[0x01, 0x00, 0x41, 0x00, 0x0b, 0x05, 0, 0, 0, 0, 0]);
wasm.resize(before_body + body_len, 0);
let shape = module_shape(&wasm).expect("the header walk must not fail");
assert_eq!(
shape.segments, 1,
"the count ceiling is not what refuses it"
);
assert!(
shape.init_bytes > MAX_INIT_SECTION_BYTES,
"the fixture should be over the byte ceiling",
);
assert!(
shape.element_overflow.is_none(),
"the segment walk ran on a section the byte ceiling already refuses: {:?}",
shape.element_overflow,
);
let err =
refuse_unbounded_shape(&wasm).expect_err("an oversized init section must be refused");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "bytes of data and element sections",
..
}
),
"{err:?}",
);
}
#[test]
fn one_segment_cannot_declare_more_entries_than_the_ceiling_allows() {
fn uleb(mut value: usize, out: &mut Vec<u8>) {
while value >= 0x80 {
let low = u8::try_from(value & 0x7f).expect("masked to seven bits");
out.push(low | 0x80);
value >>= 7;
}
out.push(u8::try_from(value).expect("the loop leaves under 0x80"));
}
let items = MAX_DECLARED_ENTRIES + 1;
let mut body = vec![0x01, 0x01, 0x00];
uleb(items, &mut body);
body.resize(body.len() + items, 0x00);
let mut wasm = Vec::from(b"\0asm\x01\0\0\0".as_slice());
wasm.extend_from_slice(&[0x01, 0x04, 0x01, 0x60, 0x00, 0x00]); wasm.extend_from_slice(&[0x03, 0x02, 0x01, 0x00]); wasm.extend_from_slice(&[TABLE_SECTION, 0x04, 0x01, 0x70, 0x00, 0x01]);
wasm.push(ELEMENT_SECTION);
uleb(body.len(), &mut wasm);
wasm.extend_from_slice(&body);
let shape = module_shape(&wasm).expect("the header walk must not fail");
assert!(
shape.segments <= MAX_INIT_SEGMENTS,
"the segment count is not what refuses this fixture",
);
assert!(
shape.init_bytes <= MAX_INIT_SECTION_BYTES,
"the byte ceiling is not what refuses this fixture: {}",
shape.init_bytes,
);
assert!(
shape.table_elements <= u64::from(MAX_TABLE_ELEMENTS),
"the table ceiling is not what refuses this fixture",
);
assert!(
shape.declared_entries > MAX_DECLARED_ENTRIES,
"the segment's own items never reached the ceiling: {}",
shape.declared_entries,
);
let err = refuse_unbounded_shape(&wasm)
.expect_err("a segment over the entry ceiling must be refused");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "declared section entries",
..
}
),
"{err:?}",
);
let mut truncated = Vec::from(b"\0asm\x01\0\0\0".as_slice());
truncated.extend_from_slice(&[TABLE_SECTION, 0x04, 0x01, 0x70, 0x00, 0x01]);
let mut claim = vec![0x01, 0x01, 0x00];
uleb(MAX_DECLARED_ENTRIES * 16, &mut claim);
claim.extend_from_slice(&[0x00, 0x00, 0x00]);
truncated.push(ELEMENT_SECTION);
uleb(claim.len(), &mut truncated);
truncated.extend_from_slice(&claim);
let err = refuse_unbounded_shape(&truncated)
.expect_err("a section claiming more entries than it carries must be refused");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "declared section entries",
..
}
),
"the count was not consulted before the items were walked: {err:?}",
);
}
#[test]
fn the_engine_refuses_a_heap_type_this_walk_would_have_to_guess_at() {
let mut module = Vec::from(b"\0asm\x01\0\0\0".as_slice());
module.extend_from_slice(&[0x01, 0x04, 0x01, 0x60, 0x00, 0x00]); module.extend_from_slice(&[0x03, 0x02, 0x01, 0x00]); module.extend_from_slice(&[0x04, 0x04, 0x01, 0x70, 0x00, 0x01]); module.extend_from_slice(&[0x05, 0x03, 0x01, 0x00, 0x01]); module.extend_from_slice(&[0x07, 0x13, 0x02, 0x06]);
module.extend_from_slice(b"memory");
module.extend_from_slice(&[0x02, 0x00, 0x06]);
module.extend_from_slice(b"_start");
module.extend_from_slice(&[0x00, 0x00]);
let code = [0x0a, 0x05, 0x01, 0x03, 0x00, 0x01, 0x0b];
let mut config = wasmi::Config::default();
config.consume_fuel(true);
let engine = wasmi::Engine::new(&config);
let mut minimal = module.clone();
minimal.extend_from_slice(&[
0x09, 0x09, 0x01, 0x04, 0x41, 0x00, 0x0b, 0x01, 0xd0, 0x70, 0x0b,
]);
minimal.extend_from_slice(&code);
wasmi::Module::new(&engine, &minimal[..]).expect("the minimal heap type compiles");
let mut non_minimal = module;
non_minimal.extend_from_slice(&[
0x09, 0x0a, 0x01, 0x04, 0x41, 0x00, 0x0b, 0x01, 0xd0, 0xf0, 0x7f, 0x0b,
]);
non_minimal.extend_from_slice(&code);
let err = wasmi::Module::new(&engine, &non_minimal[..])
.expect_err("a non-minimal heap type must not compile");
assert!(
err.to_string().contains("heap type"),
"expected the engine to name the heap type: {err}",
);
}
#[test]
fn a_swarm_of_empty_tables_is_refused_before_anything_is_compiled() {
let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
for _ in 0..=MAX_TABLES {
wat.push_str(" (table 0 funcref)\n");
}
wat.push_str(" (func (export \"_start\") (nop))\n)");
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let shape = module_shape(&wasm).expect("the header walk must not fail");
assert!(
shape.declared_entries <= MAX_DECLARED_ENTRIES,
"the fixture is caught by the aggregate ceiling, not the table one",
);
let err = refuse_unbounded_shape(&wasm)
.expect_err("the pre-compilation gate must refuse too many tables");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive { what: "tables", .. }
),
"{err:?}",
);
let heavy = wat::parse_str(format!(
"(module (memory (export \"memory\") 1) (table {} funcref) \
(func (export \"_start\") (nop)))",
u64::from(MAX_TABLE_ELEMENTS) + 1,
))
.expect("the fixture is valid WAT");
let err = refuse_unbounded_shape(&heavy)
.expect_err("the pre-compilation gate must refuse oversized tables");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "initial table elements",
..
}
),
"{err:?}",
);
}
#[test]
fn the_metadata_walk_stops_at_the_ceiling_instead_of_finishing_the_list() {
let over = MAX_REQUEST_METADATA_BYTES / DROPPED_PAIR_BYTES * 16;
let mut swarm = get("/hello/greet");
swarm.headers = std::iter::repeat_with(|| ("cookie".to_owned(), String::new()))
.take(over)
.collect();
let counted = request_metadata_bytes(&swarm);
let whole_list = over.saturating_mul(DROPPED_PAIR_BYTES);
assert!(
counted <= MAX_REQUEST_METADATA_BYTES + DROPPED_PAIR_BYTES,
"the walk counted {counted} bytes, past the ceiling plus the entry \
that crossed it — it finished the list instead of stopping",
);
assert!(
counted < whole_list / 2,
"the walk counted {counted} of a {whole_list}-byte list, so it did \
not stop early",
);
let host = host(guests::HELLO);
let err = host
.run(&swarm)
.result
.expect_err("a list past the ceiling must still be refused");
assert!(
matches!(err, SandboxFailure::RequestMetadataBudget { .. }),
"{err:?}",
);
let mut ordinary = get("/hello/greet");
ordinary.headers = vec![("accept".to_owned(), "text/plain".to_owned())];
assert_eq!(
request_metadata_bytes(&ordinary),
ordinary.method.len()
+ ordinary.path.len()
+ ordinary.query.len()
+ ordinary.route.len()
+ metadata_pair_bytes("accept", "text/plain"),
);
}
#[test]
fn a_header_the_frame_drops_still_costs_its_place_in_the_list() {
let host = host(guests::HELLO);
let mut one_huge_cookie = get("/hello/greet");
one_huge_cookie.headers =
vec![("cookie".to_owned(), "s=".repeat(MAX_REQUEST_METADATA_BYTES))];
assert!(
host.run(&one_huge_cookie).result.is_ok(),
"one large dropped header must still be served",
);
let many_empty_cookies: Vec<(String, String)> =
std::iter::repeat_with(|| ("cookie".to_owned(), String::new()))
.take(MAX_REQUEST_METADATA_BYTES / DROPPED_PAIR_BYTES + 1)
.collect();
let mut swarm = get("/hello/greet");
swarm.headers = many_empty_cookies;
let err = host
.run(&swarm)
.result
.expect_err("an unbounded list of dropped headers must be refused");
assert!(
matches!(err, SandboxFailure::RequestMetadataBudget { .. }),
"{err:?}",
);
}
#[test]
fn an_active_offset_this_cannot_evaluate_is_refused_rather_than_skipped() {
let mut operands = String::new();
for _ in 0..24 {
operands.push_str("i32.const 0 ");
}
let mut adds = String::new();
for _ in 0..24 {
adds.push_str("i32.add ");
}
let wat = format!(
r#"(module
(memory (export "memory") 1)
(data (offset i32.const 65536 {operands} {adds}) "xx")
(func (export "_start") (nop))
)"#
);
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let mut config = wasmi::Config::default();
config.consume_fuel(true);
wasmi::Module::new(&wasmi::Engine::new(&config), &wasm[..])
.expect("wasmi compiles a deep extended-const offset; the finding depends on it");
let shape = module_shape(&wasm).expect("the section still walks cleanly");
assert_eq!(
shape.data_end,
u64::MAX,
"an offset that cannot be evaluated must be treated as one that cannot fit",
);
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("an active segment with an unevaluable offset must be refused");
assert!(
matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
"{err:?}",
);
let passive = wat::parse_str(
r#"(module
(memory (export "memory") 1)
(data "passive, and its offset is not a question")
(func (export "_start") (nop))
)"#,
)
.expect("the fixture is valid WAT");
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &passive)
.expect("a passive segment must still load");
}
#[test]
fn a_passive_only_data_section_is_not_a_walk_that_failed() {
let passive_only = wat::parse_str(
r#"(module
(memory (export "memory") 1)
(data "passive bytes")
(func (export "_start") (nop))
)"#,
)
.expect("the fixture is valid WAT");
let shape = module_shape(&passive_only).expect("the section walks cleanly");
assert_eq!(
shape.data_end, 0,
"a passive segment measures nothing, so it must contribute nothing",
);
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &passive_only)
.expect("a passive-only module must still load");
let mixed = wat::parse_str(
r#"(module
(memory (export "memory") 1)
(data "passive bytes")
(data (offset (i32.const 16)) "active")
(func (export "_start") (nop))
)"#,
)
.expect("the fixture is valid WAT");
let shape = module_shape(&mixed).expect("the section walks cleanly");
assert_eq!(
shape.data_end, 22,
"the active segment's end must survive the passive one beside it",
);
let out_of_bounds = wat::parse_str(
r#"(module
(memory (export "memory") 1)
(data "passive bytes")
(data (offset (i32.const 65535)) "spills")
(func (export "_start") (nop))
)"#,
)
.expect("the fixture is valid WAT");
let err =
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &out_of_bounds)
.expect_err("an active segment past the end of memory must be refused");
assert!(
matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
"{err:?}",
);
}
#[test]
fn an_extended_const_segment_offset_is_evaluated_rather_than_waved_through() {
let out_of_bounds = wat::parse_str(
r#"(module
(memory (export "memory") 1)
(data (offset (i32.add (i32.const 65535) (i32.const 2))) "xx")
(func (export "_start") (nop))
)"#,
)
.expect("the fixture is valid WAT");
let mut config = wasmi::Config::default();
config.consume_fuel(true);
wasmi::Module::new(&wasmi::Engine::new(&config), &out_of_bounds[..])
.expect("wasmi compiles an extended-const offset; the finding depends on it");
let err =
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &out_of_bounds)
.expect_err("a segment past the end of memory must be refused at load");
assert!(
matches!(
err,
SandboxLoadError::SegmentOutOfBounds { end, capacity, .. }
if end == 65539 && capacity == WASM_PAGE_BYTES
),
"{err:?}",
);
let in_bounds = wat::parse_str(
r#"(module
(memory (export "memory") 1)
(data (offset (i32.add (i32.const 16) (i32.const 8))) "xx")
(func (export "_start") (nop))
)"#,
)
.expect("the fixture is valid WAT");
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &in_bounds)
.expect("an extended-const offset inside the memory must still load");
}
#[test]
fn a_bidi_override_cannot_reorder_the_record_it_appears_in() {
let forged = guest_text("denied \u{202E}dewolla\u{202D} by policy");
assert!(
!forged.contains('\u{202E}') && !forged.contains('\u{202D}'),
"a bidi override reached the log verbatim: {forged:?}",
);
assert!(
forged.contains("\\u{202e}"),
"the attempt must survive as an escape, not be silently dropped: {forged:?}",
);
assert!(
forged.contains("denied") && forged.contains("by policy"),
"the host's own words must survive: {forged:?}",
);
for ch in [
'\u{061C}', '\u{200B}', '\u{200E}', '\u{200F}', '\u{2060}', '\u{2066}', '\u{2069}',
'\u{FEFF}',
] {
let out = guest_text(&format!("before{ch}after"));
assert!(
!out.contains(ch),
"U+{:04X} reached the log verbatim: {out:?}",
ch as u32,
);
}
let readable = guest_text("le module n'a pas démarré — 起動しませんでした");
assert!(
readable.contains("démarré") && readable.contains("起動しませんでした"),
"real text was escaped along with the formatting characters: {readable:?}",
);
}
#[test]
fn a_unicode_line_break_cannot_forge_a_second_log_record() {
for ch in ['\u{2028}', '\u{2029}'] {
assert!(
!ch.is_control(),
"U+{:04X} is a control character after all; this test guards the wrong gap",
ch as u32,
);
let split = guest_text(&format!("denied{ch}allowed by policy"));
assert!(
!split.contains(ch),
"U+{:04X} reached the log verbatim: {split:?}",
ch as u32,
);
assert!(
split.contains(&format!("\\u{{{:x}}}", ch as u32)),
"the attempt must survive as an escape, not be dropped: {split:?}",
);
}
}
#[test]
fn generating_a_random_byte_costs_more_than_copying_one() {
fn drawing(bytes: u32) -> u64 {
let wat = format!(
r#"(module
(import "wasi_snapshot_preview1" "random_get" (func $random_get (param i32 i32) (result i32)))
(memory (export "memory") 2)
(func (export "_start")
(drop (call $random_get (i32.const 4096) (i32.const {bytes}))))
)"#
);
host(&wat).run(&request("GET", "/hello/greet")).fuel_used
}
let none = drawing(0);
let many = drawing(65536);
let charged = many.saturating_sub(none);
assert!(
charged > 32768,
"drawing 64 KiB of entropy cost {charged} fuel — the bulk-copy rate \
would charge about 1,024, so the mixer's work is being sold at a \
memcpy's price",
);
}
#[test]
fn the_pending_frame_never_allocates_past_the_budget_it_is_bounded_by() {
let limits = ResourceLimits {
max_response_bytes: 20_000,
..ResourceLimits::default()
};
let budget = limits.max_response_bytes * 2 + 4096;
let mut state = bare_state(limits, b"");
for _ in 0..budget {
assert!(state.write_stdout(b"x"), "the budget refused a legal frame");
}
assert_eq!(state.stdout_line.len(), budget, "the buffer did not fill");
assert!(
state.stdout_line.capacity() <= budget,
"a {budget}-byte ceiling holds {} bytes of allocation",
state.stdout_line.capacity(),
);
assert!(
!state.write_stdout(b"x"),
"a frame past the ceiling was accepted",
);
}
#[test]
fn one_read_cannot_copy_the_whole_frame_out_of_the_queue() {
let mut state = bare_state(ResourceLimits::default(), b"");
state.stdin = VecDeque::from(vec![b'x'; HOST_IO_CHUNK_BYTES * 4]);
let chunk = state.take_stdin(HOST_IO_CHUNK_BYTES * 4);
assert_eq!(
chunk.len(),
HOST_IO_CHUNK_BYTES,
"one read copied more than the scratch the footprint budgets",
);
let mut drained = chunk.len();
while !state.stdin.is_empty() {
let next = state.take_stdin(HOST_IO_CHUNK_BYTES * 4);
assert!(
!next.is_empty(),
"the queue stopped yielding with bytes left"
);
drained += next.len();
}
assert_eq!(
drained,
HOST_IO_CHUNK_BYTES * 4,
"the frame did not survive being read in chunks",
);
let mut small = bare_state(ResourceLimits::default(), b"");
small.stdin = VecDeque::from(b"{\"op\":\"request\"}".to_vec());
assert_eq!(small.take_stdin(4).len(), 4, "a short read must stay short");
}
#[test]
fn a_cut_stderr_excerpt_says_that_it_was_cut() {
let mut state = bare_state(ResourceLimits::default(), b"");
state.write_stderr("a".repeat(STDERR_EXCERPT * 2).as_bytes());
let excerpt = state.stderr_excerpt();
assert!(
excerpt.ends_with(" … (truncated)"),
"a cut excerpt must say so: {:?}",
excerpt.get(excerpt.len().saturating_sub(40)..),
);
let mut short = bare_state(ResourceLimits::default(), b"");
short.write_stderr(b"panicked at the disco");
let whole = short.stderr_excerpt();
assert_eq!(
whole, "panicked at the disco",
"an excerpt that fits is untouched"
);
}
#[test]
fn the_streamed_lossy_decode_says_what_from_utf8_lossy_says() {
for case in [
b"".as_slice(),
b"plain ascii",
b"\xff",
b"\xff\xff\xff",
b"\xe2\x82",
b"\xe2\x82x",
b"\x80\x80",
"é日本".as_bytes(),
b"caf\xe9 latte",
b"\xc0\xaf",
] {
let streamed: String = lossy_chars(case).collect();
assert_eq!(
streamed,
String::from_utf8_lossy(case),
"streamed decode diverged on {case:?}",
);
}
}
#[test]
fn a_stderr_of_invalid_bytes_is_still_bounded_at_the_excerpt() {
let mut state = bare_state(ResourceLimits::default(), b"");
state.write_stderr(&vec![0xff; STDERR_BUDGET_BYTES * 2]);
assert_eq!(
state.stderr.len(),
STDERR_BUDGET_BYTES,
"the buffer itself must still be capped",
);
let excerpt = state.stderr_excerpt();
assert!(
excerpt.chars().count() <= DETAIL_EXCERPT + " … (truncated)".len(),
"the excerpt outgrew its bound: {} characters",
excerpt.chars().count(),
);
assert!(
excerpt.contains(char::REPLACEMENT_CHARACTER),
"the replacement characters did not survive: {excerpt:?}",
);
}
#[test]
fn stderr_is_escaped_as_well_as_bounded() {
let mut state = bare_state(ResourceLimits::default(), b"");
state.write_stderr(b"panicked\n2026-01-01 INFO forged\x1b[2K");
let excerpt = state.stderr_excerpt();
assert!(
!excerpt.contains('\n') && !excerpt.contains('\u{1b}'),
"{excerpt:?}"
);
assert!(excerpt.contains("panicked"), "{excerpt:?}");
}
#[test]
fn a_manifest_mutated_after_validation_is_refused_rather_than_trusted() {
let wasm = wat::parse_str(guests::HELLO).expect("the fixture is valid WAT");
let mut manifest = manifest_with(ResourceLimits::default());
manifest.limits.max_concurrency = usize::MAX;
let err = SandboxHost::from_module(manifest, &wasm)
.expect_err("an invalid manifest must not produce a host");
assert!(matches!(err, SandboxLoadError::InvalidManifest(_)), "{err}");
let mut manifest = manifest_with(ResourceLimits::default());
manifest.routes[0].path = "/{".to_owned();
let err = SandboxHost::from_module(manifest, &wasm)
.expect_err("an unbuildable route must not produce a host");
assert!(matches!(err, SandboxLoadError::InvalidManifest(_)), "{err}");
}
#[test]
fn encoding_the_request_frame_is_priced_before_it_is_performed() {
let host = host(guests::HELLO);
let mut request = get("/hello/greet");
request.body = vec![b'x'; 200_000];
let starved = try_host_with(
guests::HELLO,
ResourceLimits {
fuel: host.instantiation_fuel() + 1,
..ResourceLimits::default()
},
)
.expect("the fixture loads");
let outcome = starved.run(&request);
assert!(
matches!(outcome.result, Err(SandboxFailure::FuelExhausted { .. })),
"{:?}",
outcome.result
);
let outcome = host.run(&request);
assert!(
outcome.fuel_used >= 200_000 / BYTES_PER_FUEL,
"the encoding was not charged: {} units",
outcome.fuel_used
);
}
#[test]
fn a_direct_caller_cannot_start_more_instances_than_the_manifest_allows() {
let host = try_host_with(
guests::HELLO,
ResourceLimits {
max_concurrency: 1,
..ResourceLimits::default()
},
)
.expect("the fixture loads");
let held = host
.permits
.try_acquire()
.expect("the first permit is free");
assert_eq!(
host.run(&get("/hello/greet")).result,
Err(SandboxFailure::AtCapacity { max: 1 }),
"a second concurrent run was admitted past max_concurrency = 1"
);
drop(held);
assert!(
host.run(&get("/hello/greet")).result.is_ok(),
"the permit was not returned when the run finished"
);
}
#[test]
fn a_fuel_budget_that_cannot_reach_start_is_refused_at_load() {
let err = try_host_with(
guests::HELLO,
ResourceLimits {
fuel: 1,
..ResourceLimits::default()
},
)
.expect_err("a budget below the fixed charges must not produce a host");
let SandboxLoadError::FuelBelowFixedCharges {
fuel,
instantiation,
} = err
else {
panic!("expected the fuel refusal, got {err:?}");
};
assert_eq!(fuel, 1);
assert!(
instantiation >= 1,
"the charge must be real to refuse against"
);
assert!(
try_host_with(
guests::HELLO,
ResourceLimits {
fuel: instantiation + 1,
..ResourceLimits::default()
},
)
.is_ok(),
"a budget just above the fixed charge is legal"
);
}
#[test]
fn the_import_list_a_review_surface_shows_is_bounded() {
let imports = (0..MAX_REPORTED_IMPORTS + 50)
.map(|n| format!(r#" (import "env" "f{n}" (func))"#))
.collect::<Vec<_>>()
.join("\n");
let wat = format!("(module\n{imports}\n (func (export \"_start\") (nop)))");
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let listed = SandboxHost::imports_of(&wasm).expect("a module's imports can be read");
assert!(
listed.len() <= MAX_REPORTED_IMPORTS + 1,
"the list is unbounded: {} entries",
listed.len()
);
let last = listed.last().expect("a non-empty list");
assert!(last.contains("truncated") && last.contains("50"), "{last}");
}
#[test]
fn a_module_declaring_more_imports_than_the_ceiling_is_refused_at_load() {
let imports = (0..=MAX_IMPORTS)
.map(|_| {
format!(
r#" (import "{WASI}" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#
)
})
.collect::<Vec<_>>()
.join("\n");
let wat = format!(
"(module\n{imports}\n (memory (export \"memory\") 1)\n (func (export \"_start\") (nop)))"
);
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a module past the import ceiling must not load");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "imports",
..
}
),
"{err:?}"
);
}
#[test]
fn the_import_ceiling_does_not_weaken_the_forbidden_import_gate() {
let mut lines = (0..=MAX_IMPORTS)
.map(|_| {
format!(
r#" (import "{WASI}" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#
)
})
.collect::<Vec<_>>();
lines.push(r#" (import "env" "escape" (func))"#.to_owned());
let wat = format!(
"(module\n{}\n (memory (export \"memory\") 1)\n (func (export \"_start\") (nop)))",
lines.join("\n")
);
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
assert!(
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm).is_err(),
"a module carrying a forbidden import must never load"
);
}
#[test]
fn resolving_imports_is_priced_into_the_instantiation_charge() {
let bare = wat::parse_str(
"(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
)
.expect("the fixture is valid WAT");
let imports = (0..32)
.map(|_| {
format!(
r#" (import "{WASI}" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#
)
})
.collect::<Vec<_>>()
.join("\n");
let with_imports = wat::parse_str(format!(
"(module\n{imports}\n (memory (export \"memory\") 1)\n (func (export \"_start\") (nop)))"
))
.expect("the fixture is valid WAT");
let cheap = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &bare)
.expect("loads")
.instantiation_fuel();
let dear =
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &with_imports)
.expect("loads")
.instantiation_fuel();
assert_eq!(
dear,
cheap + 32,
"each admitted import must cost a unit: {cheap} then {dear}"
);
}
#[test]
fn a_module_pays_instantiation_fuel_for_the_memory_it_starts_with() {
let limits = ResourceLimits {
memory_bytes: 64 * 1024 * 1024,
..ResourceLimits::default()
};
let one_page = wat::parse_str(
r#"(module (memory (export "memory") 1) (func (export "_start") (nop)))"#,
)
.expect("the fixture is valid WAT");
let many_pages = wat::parse_str(
r#"(module (memory (export "memory") 512) (func (export "_start") (nop)))"#,
)
.expect("the fixture is valid WAT");
let small = SandboxHost::from_module(manifest_with(limits), &one_page)
.expect("loads")
.instantiation_fuel();
let large = SandboxHost::from_module(manifest_with(limits), &many_pages)
.expect("loads")
.instantiation_fuel();
let extra = (511 * WASM_PAGE_BYTES) / BYTES_PER_FUEL;
assert_eq!(
large,
small + extra,
"initial memory is not charged: {small} then {large}"
);
assert!(
extra > 0,
"the fixture must differ by something the charge can see"
);
}
#[test]
fn a_metadata_list_of_empty_pairs_is_charged_for_its_structure() {
let host = host(guests::HELLO);
let mut request = get("/hello/greet");
request.path_params = vec![(String::new(), String::new()); 100_000];
let err = host
.run(&request)
.result
.expect_err("a list of empty pairs must not be free");
assert!(
matches!(err, SandboxFailure::RequestMetadataBudget { .. }),
"{err:?}"
);
}
#[test]
fn a_module_whose_tables_start_over_the_ceiling_is_refused_at_load() {
let over = MAX_TABLE_ELEMENTS + 1;
let wasm = wat::parse_str(format!(
"(module (table {over} funcref) (memory (export \"memory\") 1) (func (export \"_start\") (nop)))"
))
.expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a module over the table ceiling must not load");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "initial table elements",
..
}
),
"{err:?}"
);
}
#[test]
fn an_element_expression_whose_operand_is_the_end_opcode_still_bounds_the_segment() {
use std::fmt::Write as _;
let mut wat =
String::from("(module\n (memory (export \"memory\") 1)\n (table 1 funcref)\n");
for i in 0..12 {
let _ = writeln!(wat, " (func $f{i} (nop))");
}
wat.push_str(" (elem (i32.const 1) funcref (ref.func $f11))\n");
wat.push_str(" (func (export \"_start\") (nop))\n)");
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("the operand must not be mistaken for the terminator");
assert!(
matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
"{err:?}"
);
}
#[test]
fn an_element_segment_past_the_end_of_its_table_is_refused_at_load() {
let wat = r#"(module
(memory (export "memory") 1)
(table 1 funcref)
(func $f (nop))
(elem (i32.const 1) $f)
(func (export "_start") (nop)))"#;
let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a segment past the end of its table must not load");
assert!(
matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
"{err:?}"
);
}
#[test]
fn a_module_carrying_a_start_section_is_refused() {
let wat = r#"(module
(memory (export "memory") 1)
(func $init (nop))
(start $init)
(func (export "_start") (nop)))"#;
let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a start section must not load");
assert!(
matches!(err, SandboxLoadError::StartSectionForbidden),
"{err:?}"
);
}
#[test]
fn a_negative_segment_offset_is_refused_rather_than_ignored() {
let wat = r#"(module
(memory (export "memory") 1)
(data (i32.const -1) "x")
(func (export "_start") (nop)))"#;
let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a negative offset must not load");
assert!(
matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
"{err:?}"
);
}
#[test]
fn a_data_segment_past_the_end_of_memory_is_refused_at_load() {
let wat = r#"(module
(memory (export "memory") 1)
(data (i32.const 65536) "x")
(func (export "_start") (nop)))"#;
let wasm = wat::parse_str(wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a segment past the end of memory must not load");
assert!(
matches!(err, SandboxLoadError::SegmentOutOfBounds { .. }),
"{err:?}"
);
}
#[test]
fn the_segment_ceiling_is_enforced_by_the_pre_compilation_gate() {
use std::fmt::Write as _;
let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
for offset in 0..=MAX_INIT_SEGMENTS {
let _ = writeln!(wat, " (data (i32.const {offset}) \"x\")");
}
wat.push_str(" (func (export \"_start\") (nop))\n)");
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let err = refuse_unbounded_shape(&wasm)
.expect_err("the pre-compilation gate must refuse the segment count");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "data and element segments",
..
}
),
"{err:?}"
);
let shape = module_shape(&wasm).expect("the fixture walks");
assert!(
shape.declared_entries <= MAX_DECLARED_ENTRIES,
"the fixture would have been refused for its entry count anyway"
);
}
#[test]
fn a_module_past_the_segment_ceiling_is_refused_without_walking_its_segments() {
use std::fmt::Write as _;
let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
let _ = writeln!(wat, " (data (i32.const 65536) \"x\")");
for offset in 0..=MAX_INIT_SEGMENTS {
let _ = writeln!(wat, " (data (i32.const {offset}) \"x\")");
}
wat.push_str(" (func (export \"_start\") (nop))\n)");
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a module past the segment ceiling must not load");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "data and element segments",
..
}
),
"{err:?}"
);
}
#[test]
fn a_module_declaring_more_tables_than_the_ceiling_is_refused_on_the_count() {
use std::fmt::Write as _;
let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
for _ in 0..=MAX_TABLES {
let _ = writeln!(wat, " (table 1 funcref)");
}
wat.push_str(" (func (export \"_start\") (nop))\n)");
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a module past the table ceiling must not load");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive { what: "tables", .. }
),
"{err:?}"
);
}
#[test]
fn a_module_whose_code_section_is_too_large_is_refused_before_compilation() {
let mut wasm = wat::parse_str(
"(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
)
.expect("the fixture is valid WAT");
let over = u32::try_from(MAX_CODE_BYTES + 1).expect("fits");
let mut header = vec![10u8]; let mut size = over;
loop {
let mut byte = (size & 0x7f) as u8;
size >>= 7;
if size != 0 {
byte |= 0x80;
}
header.push(byte);
if size == 0 {
break;
}
}
header.extend(std::iter::repeat_n(0u8, over as usize));
wasm.extend_from_slice(&header);
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("an oversized code section must not reach the compiler");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "code section bytes",
..
}
),
"{err:?}"
);
}
#[test]
fn a_module_whose_global_section_is_too_large_is_refused_before_compilation() {
let mut wasm = wat::parse_str(
"(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
)
.expect("the fixture is valid WAT");
let over = u32::try_from(MAX_CODE_BYTES + 1).expect("fits");
let mut header = vec![6u8]; let mut size = over;
loop {
let mut byte = (size & 0x7f) as u8;
size >>= 7;
if size != 0 {
byte |= 0x80;
}
header.push(byte);
if size == 0 {
break;
}
}
header.push(1u8);
header.extend(std::iter::repeat_n(0u8, over as usize - 1));
wasm.extend_from_slice(&header);
let err = refuse_unbounded_shape(&wasm)
.expect_err("an oversized global section must not reach the compiler");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "code and global section bytes",
..
}
),
"{err:?}"
);
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("an oversized global section must not load");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "code and global section bytes",
..
}
),
"{err:?}"
);
let err =
SandboxHost::imports_of(&wasm).expect_err("imports_of must apply the same ceiling");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "code and global section bytes",
..
}
),
"expected the ceiling on imports_of, got: {err:?}"
);
}
#[test]
fn a_global_section_at_exactly_the_instruction_ceiling_still_passes_the_gate() {
let mut wasm = wat::parse_str(
"(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
)
.expect("the fixture is valid WAT");
let code_bytes = refuse_unbounded_shape(&wasm)
.expect("the fixture passes the gate")
.code_bytes;
let global_len = MAX_CODE_BYTES - code_bytes;
let at = u32::try_from(global_len).expect("fits");
let mut header = vec![6u8]; let mut size = at;
loop {
let mut byte = (size & 0x7f) as u8;
size >>= 7;
if size != 0 {
byte |= 0x80;
}
header.push(byte);
if size == 0 {
break;
}
}
header.push(1u8);
header.extend(std::iter::repeat_n(0u8, at as usize - 1));
wasm.extend_from_slice(&header);
let shape = refuse_unbounded_shape(&wasm)
.expect("a global section at exactly the ceiling must pass the gate");
assert_eq!(
shape.global_bytes, global_len,
"the gate must see the section's full size"
);
assert_eq!(
shape.code_bytes.saturating_add(shape.global_bytes),
MAX_CODE_BYTES,
"the combined instruction volume sits exactly on the ceiling"
);
}
#[test]
fn a_module_past_the_byte_ceiling_is_refused_on_the_direct_path_too() {
let base = wat::parse_str(
"(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
)
.expect("the fixture is valid WAT");
let padding = super::super::MAX_MODULE_BYTES + 1 - base.len();
let mut section = vec![0x00u8]; let mut payload = vec![0x04u8]; payload.extend_from_slice(b"pad\0");
payload.resize(payload.len() + padding, 0);
let mut len = payload.len();
loop {
let mut byte = u8::try_from(len & 0x7f).expect("masked to 7 bits");
len >>= 7;
if len != 0 {
byte |= 0x80;
}
section.push(byte);
if len == 0 {
break;
}
}
section.extend_from_slice(&payload);
let mut wasm = base;
wasm.extend_from_slice(§ion);
assert!(
wasm.len() > super::super::MAX_MODULE_BYTES,
"the fixture must exceed the ceiling: {} bytes",
wasm.len(),
);
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a module past the byte ceiling must not load");
assert!(
matches!(err, SandboxLoadError::ModuleTooLarge { .. }),
"expected the byte ceiling to refuse it, got: {err}",
);
let err =
SandboxHost::imports_of(&wasm).expect_err("imports_of must apply the same ceiling");
assert!(
matches!(err, SandboxLoadError::ModuleTooLarge { .. }),
"expected the byte ceiling on imports_of, got: {err}",
);
}
#[test]
fn a_module_defining_more_functions_than_the_ceiling_is_refused_at_load() {
use std::fmt::Write as _;
let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
for i in 0..=MAX_FUNCTIONS {
let _ = writeln!(wat, " (func $f{i} (nop))");
}
wat.push_str(" (func (export \"_start\") (nop))\n)");
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a module past the function ceiling must not load");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "functions",
..
}
),
"{err:?}"
);
}
#[test]
fn a_module_declaring_more_globals_than_the_ceiling_is_refused_at_load() {
use std::fmt::Write as _;
let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
for _ in 0..=MAX_GLOBALS {
let _ = writeln!(wat, " (global i32 (i32.const 0))");
}
wat.push_str(" (func (export \"_start\") (nop))\n)");
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a module past the globals ceiling must not load");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "globals",
..
}
),
"{err:?}"
);
}
#[test]
fn globals_are_priced_into_the_instantiation_charge() {
use std::fmt::Write as _;
let bare = wat::parse_str(
"(module (memory (export \"memory\") 1) (func (export \"_start\") (nop)))",
)
.expect("the fixture is valid WAT");
let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
for _ in 0..64 {
let _ = writeln!(wat, " (global i32 (i32.const 0))");
}
wat.push_str(" (func (export \"_start\") (nop))\n)");
let with_globals = wat::parse_str(&wat).expect("the fixture is valid WAT");
let cheap = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &bare)
.expect("loads")
.instantiation_fuel();
let dear =
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &with_globals)
.expect("loads")
.instantiation_fuel();
assert_eq!(
dear,
cheap + 64,
"each admitted global must cost a unit: {cheap} then {dear}"
);
}
#[test]
fn a_flood_of_table_declarations_is_refused_without_collecting_them() {
use std::fmt::Write as _;
let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
for _ in 0..50_000 {
let _ = writeln!(wat, " (table 0 funcref)");
}
wat.push_str(" (func (export \"_start\") (nop))\n)");
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
assert!(
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm).is_err(),
"a flood of table declarations must not load"
);
}
#[test]
fn a_module_with_more_tables_than_the_store_will_build_is_refused_at_load() {
use std::fmt::Write as _;
let mut wat = String::from("(module\n (memory (export \"memory\") 1)\n");
for _ in 0..=MAX_TABLES {
let _ = writeln!(wat, " (table 0 funcref)");
}
wat.push_str(" (func (export \"_start\") (nop))\n)");
let wasm = wat::parse_str(&wat).expect("the fixture is valid WAT");
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a module past the table-count ceiling must not load");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive { what: "tables", .. }
),
"{err:?}"
);
}
#[test]
fn tables_within_the_ceiling_still_load() {
let half = MAX_TABLE_ELEMENTS / 4;
let wasm = wat::parse_str(format!(
"(module (table {half} funcref) (table {half} funcref) (memory (export \"memory\") 1) (func (export \"_start\") (nop)))"
))
.expect("the fixture is valid WAT");
assert!(
SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm).is_ok(),
"tables under the ceiling must load"
);
}
#[test]
fn a_module_declaring_more_entries_than_the_ceiling_never_reaches_the_compiler() {
let mut wasm = wat::parse_str("(module (func (export \"_start\") (nop)))")
.expect("the fixture is valid WAT");
let forged = {
let mut bytes = vec![0x01u8, 0x05]; bytes.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF, 0x07]); bytes
};
wasm.splice(8..8, forged);
let err = SandboxHost::from_module(manifest_with(ResourceLimits::default()), &wasm)
.expect_err("a module past the declared-entry ceiling must not load");
assert!(
matches!(
err,
SandboxLoadError::InstantiationTooExpensive {
what: "declared section entries",
..
}
),
"{err:?}"
);
assert!(
SandboxHost::imports_of(&wasm).is_err(),
"the import listing compiled a module the loader would not"
);
}
#[test]
fn the_metadata_footprint_covers_what_json_escaping_expands_it_to() {
let granted = [SandboxCapability::HttpRequest];
let filler = "\u{0}".repeat(4096);
let mut request = get("/hello/greet");
request.query = filler.clone();
let with = crate::plugin_sandbox::wire::to_line(
&crate::plugin_sandbox::wire::HostFrame::request(&request, &granted),
)
.expect("serialises")
.len();
request.query = String::new();
let without = crate::plugin_sandbox::wire::to_line(
&crate::plugin_sandbox::wire::HostFrame::request(&request, &granted),
)
.expect("serialises")
.len();
let expansion = with.saturating_sub(without) / filler.len();
assert!(
expansion > 4,
"the fixture does not exceed the old factor, so it proves nothing: {expansion}"
);
assert!(
expansion <= 6,
"escaping expands further than the footprint charges: {expansion}"
);
let bare = ResourceLimits {
memory_bytes: 0,
max_request_body_bytes: 0,
max_response_bytes: 0,
..ResourceLimits::default()
};
let fixed = u128::from(MAX_TABLE_ELEMENTS) * 16
+ MAX_GLOBALS as u128 * 16
+ MAX_FUNCTIONS as u128 * 32
+ FIXED_HOST_BUFFER_BYTES as u128;
let charged_for_metadata = bare.request_footprint_bytes().saturating_sub(fixed);
assert_eq!(
charged_for_metadata,
MAX_REQUEST_METADATA_BYTES as u128 * (2 + expansion as u128),
"the metadata term does not match what the serialiser was measured to write"
);
}
#[test]
fn request_metadata_over_the_ceiling_is_refused_before_the_frame_is_built() {
let host = host(guests::HELLO);
let mut request = get("/hello/greet");
request.query = "x=".repeat(MAX_REQUEST_METADATA_BYTES);
let outcome = host.run(&request);
let err = outcome
.result
.expect_err("oversized metadata must be refused");
assert!(
matches!(err, SandboxFailure::RequestMetadataBudget { .. }),
"{err:?}"
);
assert_eq!(err.status(), http::StatusCode::PAYLOAD_TOO_LARGE);
assert_eq!(
outcome.fuel_used, 0,
"nothing was built, so nothing was spent"
);
}
#[test]
fn the_metadata_ceiling_leaves_an_ordinary_request_alone() {
let host = host(guests::HELLO);
let mut request = get("/hello/greet");
request.query = "x=1&".repeat(64);
request
.headers
.push(("x-trace".to_owned(), "a".repeat(1024)));
assert!(
host.run(&request).result.is_ok(),
"an ordinary request must not be caught by the metadata ceiling"
);
}
#[test]
fn a_body_over_the_ceiling_is_refused_by_run_itself() {
let host = try_host_with(
guests::HELLO,
ResourceLimits {
max_request_body_bytes: 1_024,
fuel: 100_000_000_000,
..ResourceLimits::default()
},
)
.expect("the fixture loads");
let mut request = get("/hello/greet");
request.body = vec![b'x'; 1_025];
let outcome = host.run(&request);
assert_eq!(
outcome.result,
Err(SandboxFailure::RequestBudget {
max: 1_024,
len: 1_025,
}),
"a body over the ceiling must be refused, not encoded"
);
assert_eq!(outcome.fuel_used, 0);
assert_eq!(outcome.peak_memory_bytes, 0);
assert_eq!(
outcome.result.unwrap_err().status(),
http::StatusCode::PAYLOAD_TOO_LARGE
);
request.body = vec![b'x'; 1_024];
let outcome = host.run(&request);
assert!(outcome.result.is_ok(), "{:?}", outcome.result);
}
#[test]
fn a_malformed_frame_is_refused() {
let outcome = host(guests::MALFORMED_FRAME).run(&get("/hello/greet"));
assert!(
matches!(outcome.result, Err(SandboxFailure::MalformedFrame(_))),
"{:?}",
outcome.result
);
}
#[test]
fn an_op_the_wire_does_not_define_is_refused() {
let outcome = host(guests::UNKNOWN_OP).run(&get("/hello/greet"));
assert!(
matches!(outcome.result, Err(SandboxFailure::MalformedFrame(_))),
"{:?}",
outcome.result
);
}
#[test]
fn the_first_answer_is_the_answer() {
let outcome = host(guests::DOUBLE_ANSWER).run(&get("/hello/greet"));
assert_eq!(
outcome.result.expect("answers").body,
b"hello from the sandbox"
);
}
#[test]
fn every_refusal_stub_matches_the_wasi_signature_it_stands_in_for() {
use std::fmt::Write as _;
let mut wat = String::from("(module\n");
for (name, _, _, signature) in DENIED_IMPORTS {
let params: Vec<&str> = signature
.chars()
.map(|ch| if ch == 'l' { "i64" } else { "i32" })
.collect();
let _ = writeln!(
wat,
" (import \"wasi_snapshot_preview1\" \"{name}\" (func (param {params}) (result i32)))",
params = params.join(" ")
);
}
wat.push_str(" (memory (export \"memory\") 1)\n (func (export \"_start\") (nop))\n)");
let outcome = host(&wat).run(&get("/hello/greet"));
assert!(
matches!(outcome.result, Err(SandboxFailure::NoAnswer)),
"every stub must link: {:?}",
outcome.result
);
}
#[test]
fn an_import_with_the_wrong_signature_is_refused_at_load() {
let wat = r#"(module
(import "wasi_snapshot_preview1" "fd_write"
(func (param i32 i32) (result i32)))
(memory (export "memory") 1)
(func (export "_start") (nop)))"#;
let err = try_host(wat).expect_err("must be refused");
let SandboxLoadError::ForbiddenImports(denials) = err else {
panic!("expected a forbidden-import refusal, got {err}");
};
assert!(denials[0].operation.contains("fd_write"), "{denials:?}");
assert!(denials[0].detail.contains("signature"), "{denials:?}");
}
#[test]
fn a_non_function_import_is_refused_at_load() {
let wat = r#"(module
(import "wasi_snapshot_preview1" "fd_write" (memory 1))
(memory (export "memory") 1)
(func (export "_start") (nop)))"#;
assert!(matches!(
try_host(wat),
Err(SandboxLoadError::ForbiddenImports(_))
));
}
#[test]
fn a_start_that_takes_arguments_is_not_a_start() {
let wat = r#"(module
(memory (export "memory") 1)
(func (export "_start") (param i32) (nop)))"#;
assert!(matches!(try_host(wat), Err(SandboxLoadError::MissingStart)));
let wat = r#"(module
(memory (export "memory") 1)
(func (export "_start") (result i32) (i32.const 0)))"#;
assert!(matches!(try_host(wat), Err(SandboxLoadError::MissingStart)));
}
#[test]
fn the_load_gate_admits_exactly_what_the_shim_defines() {
for (name, ..) in SERVED_IMPORTS {
assert!(is_shim_function(name), "{name} is served but not admitted");
}
for (name, ..) in DENIED_IMPORTS {
assert!(is_shim_function(name), "{name} is refused but not admitted");
}
for name in ["sock_connect", "path_open_v2", "fd_write2"] {
assert!(!is_shim_function(name), "{name} must not be admitted");
}
}
#[test]
fn the_import_list_is_readable_for_review() {
let imports = host(guests::HELLO).imports();
assert!(
imports.contains(&"wasi_snapshot_preview1::fd_read".to_owned()),
"{imports:?}"
);
}
fn outbound_state(
queued: usize,
) -> (
HostState,
std::sync::Arc<crate::plugin_sandbox::RecordingHttp>,
) {
let limits = ResourceLimits::default();
let manifest = crate::plugin_sandbox::manifest::SandboxManifest::parse(&format!(
r#"
name = "hello"
version = "0.1.0"
wire_version = 1
prefix = "/hello"
capabilities = ["http-request", "http-outbound", "kv"]
sha256 = "{digest}"
[[routes]]
method = "GET"
path = "/hello/greet"
[grants]
hosts = ["api.example.com"]
"#,
digest = "a".repeat(64)
))
.expect("valid manifest");
let http = crate::plugin_sandbox::RecordingHttp::new();
let services = crate::plugin_sandbox::CapabilityServices {
http: Some(std::sync::Arc::clone(&http)
as std::sync::Arc<dyn crate::plugin_sandbox::OutboundHttp>),
kv: Some(crate::plugin_sandbox::MemoryKvStore::new()
as std::sync::Arc<dyn crate::plugin_sandbox::KvStore>),
..crate::plugin_sandbox::CapabilityServices::none()
};
let mut state = HostState::new(
"hello".to_owned(),
limits,
b"",
Exchange::Request,
crate::plugin_sandbox::capability::CapabilityRuntime::new(&manifest, services),
);
state.queued_replies = queued;
(state, http)
}
fn post() -> crate::plugin_sandbox::CapabilityCall {
crate::plugin_sandbox::CapabilityCall::HttpFetch {
id: 1,
method: "POST".to_owned(),
url: "https://api.example.com/orders".to_owned(),
headers: Vec::new(),
body: String::new(),
}
}
#[test]
fn a_call_is_not_committed_when_its_reply_could_not_be_queued() {
let (mut state, http) = outbound_state(MAX_QUEUED_REPLY_BYTES - 1024);
state.service(&post(), u64::MAX);
assert!(http.seen().is_empty(), "the POST left the host");
assert!(state.answer.is_none(), "the request must go on");
let queued =
String::from_utf8_lossy(&state.stdin.iter().copied().collect::<Vec<_>>()).into_owned();
assert!(queued.contains("quota-exceeded"), "{queued}");
}
#[test]
fn a_call_is_not_committed_when_the_fuel_could_not_pay_for_its_reply() {
let (mut state, http) = outbound_state(0);
state.service(&post(), 1_000);
assert!(http.seen().is_empty(), "the POST left the host");
assert!(state.answer.is_none(), "the request must go on");
}
#[test]
fn a_call_with_room_and_fuel_is_dispatched() {
let (mut state, http) = outbound_state(0);
state.service(&post(), u64::MAX);
assert_eq!(http.seen().len(), 1);
}
fn priced(call: &crate::plugin_sandbox::CapabilityCall) -> u64 {
let allowance = FINISH_ALLOWANCE_FUEL.min(ResourceLimits::default().fuel / 4);
allowance + (call.reply_reserve() as u64).div_ceil(BYTES_PER_FUEL)
}
fn get_call() -> crate::plugin_sandbox::CapabilityCall {
crate::plugin_sandbox::CapabilityCall::HttpFetch {
id: 1,
method: "GET".to_owned(),
url: "https://api.example.com/".to_owned(),
headers: Vec::new(),
body: String::new(),
}
}
#[test]
fn the_fuel_edge_for_a_write_includes_the_guest_s_read() {
let need = priced(&post()) + post().reply_reserve() as u64 * GUEST_READ_FUEL_PER_BYTE;
let (mut state, http) = outbound_state(0);
state.service(&post(), priced(&post()));
assert!(http.seen().is_empty(), "the guest could not read the reply");
let (mut state, http) = outbound_state(0);
state.service(&post(), need - 1);
assert!(http.seen().is_empty(), "one unit short must not dispatch");
let (mut state, http) = outbound_state(0);
state.service(&post(), need);
assert_eq!(http.seen().len(), 1);
}
#[test]
fn the_fuel_edge_for_a_read_is_its_price() {
let (mut state, http) = outbound_state(0);
state.service(&get_call(), priced(&get_call()) - 1);
assert!(http.seen().is_empty(), "one unit short must not dispatch");
let (mut state, http) = outbound_state(0);
state.service(&get_call(), priced(&get_call()));
assert_eq!(http.seen().len(), 1);
}
fn queued_text(state: &HostState) -> String {
String::from_utf8_lossy(&state.stdin.iter().copied().collect::<Vec<_>>()).into_owned()
}
fn with_read(call: &crate::plugin_sandbox::CapabilityCall, unread: usize) -> u64 {
priced(call) + (call.reply_reserve() + unread) as u64 * GUEST_READ_FUEL_PER_BYTE
}
#[test]
fn a_fuel_refusal_and_a_queue_refusal_say_different_things() {
let (mut state, http) = outbound_state(0);
state.service(&post(), priced(&post()));
assert!(http.seen().is_empty());
assert!(state.answer.is_none(), "the request must go on");
let text = queued_text(&state);
assert!(text.contains("quota-exceeded"), "{text}");
assert!(text.contains("increase `fuel`"), "{text}");
assert!(!text.contains("pending replies"), "{text}");
let (mut state, _) = outbound_state(MAX_QUEUED_REPLY_BYTES - 1024);
state.service(&post(), u64::MAX);
let text = queued_text(&state);
assert!(text.contains("pending replies"), "{text}");
assert!(!text.contains("fuel"), "{text}");
}
#[test]
fn a_write_keeps_fuel_to_read_the_replies_not_yet_read() {
let unread = 10_000;
let (mut state, http) = outbound_state(unread);
state.service(&post(), with_read(&post(), unread) - 1);
assert!(http.seen().is_empty(), "one unit short must not dispatch");
let (mut state, http) = outbound_state(unread);
state.service(&post(), with_read(&post(), unread));
assert_eq!(http.seen().len(), 1);
}
#[test]
fn a_read_after_a_write_keeps_fuel_to_read_its_reply() {
let (mut state, http) = outbound_state(0);
state.service(&post(), u64::MAX);
assert_eq!(http.seen().len(), 1);
state.service(
&get_call(),
with_read(&get_call(), state.queued_replies) - 1,
);
assert_eq!(http.seen().len(), 1, "one unit short must not dispatch");
state.service(&get_call(), with_read(&get_call(), state.queued_replies));
assert_eq!(http.seen().len(), 2);
}
#[test]
fn a_write_refused_before_it_runs_does_not_make_reads_keep_fuel() {
let (mut state, http) = outbound_state(0);
state.service(&post(), priced(&post()));
assert!(http.seen().is_empty());
state.service(&get_call(), priced(&get_call()));
assert_eq!(http.seen().len(), 1);
}
#[test]
fn the_fuel_edge_for_a_kv_write_is_about_0_6_million() {
let set = crate::plugin_sandbox::CapabilityCall::KvSet {
id: 1,
key: "cart".to_owned(),
value: crate::plugin_sandbox::PluginValue::Text("one".to_owned()),
};
assert_eq!(with_read(&set, 0), 624_352);
let (mut state, _) = outbound_state(0);
state.service(&set, with_read(&set, 0) - 1);
assert!(queued_text(&state).contains("increase `fuel`"));
let (mut state, _) = outbound_state(0);
state.service(&set, with_read(&set, 0));
let text = queued_text(&state);
assert!(text.contains(r#""status":"ok""#), "{text}");
}
#[test]
fn a_get_is_denied_not_failed_when_the_fuel_cannot_price_its_reply() {
let read = || crate::plugin_sandbox::CapabilityCall::HttpFetch {
id: 1,
method: "GET".to_owned(),
url: "https://api.example.com/".to_owned(),
headers: Vec::new(),
body: String::new(),
};
let queued = |state: &HostState| {
String::from_utf8_lossy(&state.stdin.iter().copied().collect::<Vec<_>>()).into_owned()
};
let (mut state, _) = outbound_state(0);
state.service(&read(), 10);
assert!(state.answer.is_none(), "the request must go on");
assert!(
queued(&state).contains("quota-exceeded"),
"{}",
queued(&state)
);
let (mut state, _) = outbound_state(0);
state.service(&read(), 1_000_000);
assert!(
!queued(&state).contains("quota-exceeded"),
"{}",
queued(&state)
);
}
}