use std::fmt;
use serde::{Deserialize, Deserializer, Serialize};
use super::grants::{CapabilityGrants, CapabilityQuotas, ConsentDelta, added};
use crate::route_listing::{RouteClassification, RouteInfo, RouteSource};
use crate::security::path::{dot_segment_len, percent_escapes};
pub const WIRE_VERSION: u32 = 1;
const MAX_NAME_LEN: usize = 64;
pub const MAX_FUEL: u64 = 100_000_000_000;
pub const MAX_MEMORY_BYTES: usize = 1024 * 1024 * 1024;
pub const MAX_REQUEST_BODY_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_RESPONSE_BYTES: usize = 64 * 1024 * 1024;
pub const MAX_FOOTPRINT_BYTES: u128 = 1024 * 1024 * 1024;
pub const MAX_REQUEST_BODY_TIMEOUT_MS: u64 = 60_000;
pub const MAX_CONCURRENCY: usize = 1024;
const ALLOWED_METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum SandboxCapability {
HttpRequest,
Kv,
HttpOutbound,
Db,
Jobs,
Render,
}
impl SandboxCapability {
pub const ALL: &'static [Self] = &[
Self::HttpRequest,
Self::Kv,
Self::HttpOutbound,
Self::Db,
Self::Jobs,
Self::Render,
];
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::HttpRequest => "http-request",
Self::Kv => "kv",
Self::HttpOutbound => "http-outbound",
Self::Db => "db",
Self::Jobs => "jobs",
Self::Render => "render",
}
}
#[must_use]
pub const fn describe(self) -> &'static str {
match self {
Self::HttpRequest => {
"handle HTTP requests routed to this plugin's own prefix (no other authority)"
}
Self::Kv => {
"read and write a key/value namespace private to this plugin and the active \
tenant; no other plugin's or tenant's keys are reachable or visible"
}
Self::HttpOutbound => {
"call the hostnames listed under `[grants].hosts`, and no others, through the \
framework's HTTP client"
}
Self::Db => {
"read and write the plugin-owned tables listed under `[grants].tables`, scoped \
to the active tenant; no host-application table and no raw SQL"
}
Self::Jobs => {
"enqueue the job types listed under `[grants].job_types`, which run under this \
plugin's own grants and quotas"
}
Self::Render => {
"return a fragment for the host-declared render slots listed under \
`[grants].slots`; the host renders it, so no script, style or event handler \
can cross"
}
}
}
pub fn parse(raw: &str) -> Result<Self, ManifestError> {
Self::ALL
.iter()
.copied()
.find(|capability| capability.as_str() == raw)
.ok_or_else(|| ManifestError::UnknownCapability(rejected(raw)))
}
}
impl fmt::Display for SandboxCapability {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for SandboxCapability {
fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
let raw = String::deserialize(de)?;
Self::parse(&raw).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeclaredRoute {
pub method: String,
pub path: String,
}
pub const MAX_ROUTES: usize = 256;
pub const MAX_PATH_LEN: usize = 1024;
const _: () = assert!(MAX_PATH_LEN == 1024);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct ResourceLimits {
pub fuel: u64,
pub memory_bytes: usize,
pub max_request_body_bytes: usize,
pub max_response_bytes: usize,
pub max_concurrency: usize,
pub request_body_timeout_ms: u64,
}
impl Default for ResourceLimits {
fn default() -> Self {
Self {
fuel: 200_000_000,
memory_bytes: 32 * 1024 * 1024,
max_request_body_bytes: 1024 * 1024,
max_response_bytes: 4 * 1024 * 1024,
max_concurrency: 8,
request_body_timeout_ms: 5_000,
}
}
}
impl ResourceLimits {
#[must_use]
pub const fn request_footprint_bytes(&self) -> u128 {
(self.memory_bytes as u128)
.saturating_add((self.max_request_body_bytes as u128).saturating_mul(5))
.saturating_add((self.max_response_bytes as u128).saturating_mul(5))
.saturating_add(crate::plugin_sandbox::host::MAX_TABLE_ELEMENTS as u128 * 16)
.saturating_add(crate::plugin_sandbox::host::MAX_REQUEST_METADATA_BYTES as u128 * 8)
.saturating_add(crate::plugin_sandbox::host::MAX_GLOBALS as u128 * 16)
.saturating_add(crate::plugin_sandbox::host::MAX_FUNCTIONS as u128 * 32)
.saturating_add(crate::plugin_sandbox::host::FIXED_HOST_BUFFER_BYTES as u128)
}
#[must_use]
pub const fn fields(&self) -> [(&'static str, u128); 6] {
[
("fuel", self.fuel as u128),
("memory_bytes", self.memory_bytes as u128),
(
"max_request_body_bytes",
self.max_request_body_bytes as u128,
),
("max_response_bytes", self.max_response_bytes as u128),
("max_concurrency", self.max_concurrency as u128),
(
"request_body_timeout_ms",
self.request_body_timeout_ms as u128,
),
]
}
fn validate(&self) -> Result<(), ManifestError> {
let checks: [(&str, u128, u128); 6] = [
("fuel", u128::from(self.fuel), u128::from(MAX_FUEL)),
(
"memory_bytes",
self.memory_bytes as u128,
MAX_MEMORY_BYTES as u128,
),
(
"max_request_body_bytes",
self.max_request_body_bytes as u128,
MAX_REQUEST_BODY_BYTES as u128,
),
(
"max_response_bytes",
self.max_response_bytes as u128,
MAX_RESPONSE_BYTES as u128,
),
(
"max_concurrency",
self.max_concurrency as u128,
MAX_CONCURRENCY as u128,
),
(
"request_body_timeout_ms",
u128::from(self.request_body_timeout_ms),
u128::from(MAX_REQUEST_BODY_TIMEOUT_MS),
),
];
for (field, value, max) in checks {
if value == 0 || value > max {
return Err(ManifestError::LimitOutOfRange { field, value, max });
}
}
let footprint = self
.request_footprint_bytes()
.saturating_mul(self.max_concurrency as u128);
if footprint > MAX_FOOTPRINT_BYTES {
return Err(ManifestError::LimitOutOfRange {
field: "the per-request host footprint × max_concurrency",
value: footprint,
max: MAX_FOOTPRINT_BYTES,
});
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ManifestError {
Toml(String),
Serialize(String),
UnknownCapability(String),
UnsupportedWireVersion {
found: u32,
supported: u32,
},
InvalidName(String),
InvalidVersion(String),
InvalidPrefix {
prefix: String,
reason: &'static str,
},
MissingCapability(SandboxCapability),
NoRoutes,
TooManyRoutes {
found: usize,
max: usize,
},
InvalidMethod(String),
InvalidRoutePath {
path: String,
reason: &'static str,
},
RouteOutsidePrefix {
method: String,
path: String,
prefix: String,
},
ConflictingRoutes {
first: String,
second: String,
},
DuplicateRoute {
method: String,
path: String,
},
DuplicateCapability(SandboxCapability),
InvalidDigest(String),
LimitOutOfRange {
field: &'static str,
value: u128,
max: u128,
},
GrantWithoutCapability {
capability: SandboxCapability,
field: &'static str,
},
CapabilityWithoutGrant {
capability: SandboxCapability,
field: &'static str,
},
InvalidGrantEntry {
field: &'static str,
entry: String,
reason: &'static str,
},
DuplicateGrantEntry {
field: &'static str,
entry: String,
},
TooManyGrantEntries {
field: &'static str,
found: usize,
max: usize,
},
QuotaOutOfRange {
field: &'static str,
value: u32,
max: u32,
},
}
impl fmt::Display for ManifestError {
#[allow(
clippy::too_many_lines,
reason = "one arm per refusal, each writing the sentence an operator reads; the length \
is the vocabulary's, and splitting it would separate a variant from its words"
)]
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Toml(detail) => write!(f, "malformed sandbox plugin manifest: {detail}"),
Self::Serialize(detail) => {
write!(f, "could not render the sandbox plugin manifest: {detail}")
}
Self::UnknownCapability(name) => write!(
f,
"unknown sandbox capability `{name}`; this build understands: {known}. \
A capability this host cannot enforce is refused rather than ignored",
known = SandboxCapability::ALL
.iter()
.map(|cap| cap.as_str())
.collect::<Vec<_>>()
.join(", ")
),
Self::UnsupportedWireVersion { found, supported } => write!(
f,
"sandbox plugin manifest declares wire_version {found}, but this build speaks \
version {supported}"
),
Self::InvalidName(name) => write!(
f,
"invalid sandbox plugin name {name:?}; expected 1..={MAX_NAME_LEN} characters of \
`a-z A-Z 0-9 - _ .`"
),
Self::InvalidVersion(version) => {
write!(f, "invalid sandbox plugin version {version:?}")
}
Self::InvalidPrefix { prefix, reason } => {
write!(f, "invalid sandbox plugin prefix {prefix:?}: {reason}")
}
Self::MissingCapability(cap) => write!(
f,
"the manifest grants no `{cap}` capability, so the plugin could never serve a \
request; add it to `capabilities` or do not install this plugin"
),
Self::NoRoutes => write!(
f,
"the manifest declares no `[[routes]]`; a sandboxed plugin serves exactly the \
routes it declares, so an empty list can never serve anything"
),
Self::TooManyRoutes { found, max } => write!(
f,
"the manifest declares {found} routes, over the {max}-route ceiling; validating \
a route list scans the routes already seen, so the work grows with the square \
of the list, and the list is held again for the lifetime of the plugin"
),
Self::InvalidMethod(method) => write!(
f,
"invalid sandbox plugin route method {method:?}; expected one of {allowed}",
allowed = ALLOWED_METHODS.join(", ")
),
Self::InvalidRoutePath { path, reason } => {
write!(f, "invalid sandbox plugin route path {path:?}: {reason}")
}
Self::RouteOutsidePrefix {
method,
path,
prefix,
} => write!(
f,
"declared route `{method} {path}` is outside the declared prefix `{prefix}`; a \
sandboxed plugin may only mount under its own prefix"
),
Self::ConflictingRoutes { first, second } => write!(
f,
"declared routes `{first}` and `{second}` are the same route to the router, so \
mounting both is impossible; give them distinct paths"
),
Self::DuplicateRoute { method, path } => {
write!(f, "declared route `{method} {path}` appears twice")
}
Self::DuplicateCapability(cap) => write!(
f,
"the manifest grants `{cap}` more than once; a repeated grant conveys no \
additional authority, so list each capability exactly once",
cap = cap.as_str()
),
Self::InvalidDigest(digest) => write!(
f,
"invalid module digest {digest:?}; expected 64 lowercase hex characters"
),
Self::LimitOutOfRange { field, value, max } => write!(
f,
"sandbox limit `{field}` = {value} is out of range; expected 1..={max}"
),
Self::GrantWithoutCapability { capability, field } => write!(
f,
"`[grants].{field}` names something only the `{capability}` capability could \
reach, but `capabilities` does not ask for it; the consent screen and the \
runtime would disagree about what this plugin may do"
),
Self::CapabilityWithoutGrant { capability, field } => write!(
f,
"the manifest grants `{capability}` but `[grants].{field}` is empty, so the \
capability names nothing it could ever act on; an operator who approved it \
would have approved authority the runtime can never honour"
),
Self::InvalidGrantEntry {
field,
entry,
reason,
} => write!(f, "invalid `[grants].{field}` entry {entry:?}: {reason}"),
Self::DuplicateGrantEntry { field, entry } => write!(
f,
"`[grants].{field}` names {entry:?} more than once; a repeat conveys no \
authority the first entry did not"
),
Self::TooManyGrantEntries { field, found, max } => write!(
f,
"`[grants].{field}` declares {found} entries, over the {max}-entry ceiling; the \
list is scanned against itself during validation and consulted on every call"
),
Self::QuotaOutOfRange { field, value, max } => write!(
f,
"sandbox quota `{field}` = {value} is out of range; expected 1..={max}"
),
}
}
}
impl std::error::Error for ManifestError {}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SandboxManifest {
pub name: String,
pub version: String,
pub wire_version: u32,
pub prefix: String,
pub capabilities: Vec<SandboxCapability>,
pub sha256: String,
#[serde(default)]
pub routes: Vec<DeclaredRoute>,
#[serde(default)]
pub limits: ResourceLimits,
#[serde(default)]
pub grants: CapabilityGrants,
#[serde(default)]
pub quotas: CapabilityQuotas,
}
impl SandboxManifest {
pub fn parse(toml_src: &str) -> Result<Self, ManifestError> {
let mut manifest: Self =
toml::from_str(toml_src).map_err(|err| ManifestError::Toml(err.to_string()))?;
for route in &mut manifest.routes {
route.method = route.method.to_ascii_uppercase();
}
manifest.validate()?;
Ok(manifest)
}
pub fn to_toml(&self) -> Result<String, ManifestError> {
toml::to_string_pretty(self).map_err(|err| ManifestError::Serialize(err.to_string()))
}
#[must_use]
pub fn is_granted(&self, capability: SandboxCapability) -> bool {
self.capabilities.contains(&capability)
}
#[must_use]
pub fn route_infos(&self) -> Vec<RouteInfo> {
let mut infos = Vec::with_capacity(self.routes.len());
for route in &self.routes {
let info = |method: &str| RouteInfo {
method: method.to_owned(),
path: route.path.clone(),
handler: format!("sandbox:{}", self.name),
source: RouteSource::Plugin(self.name.clone()),
middleware: vec!["sandboxed".to_owned()],
classification: RouteClassification::Public,
..RouteInfo::default()
};
infos.push(info(&route.method));
if route.method == "GET" && !self.declares("HEAD", &route.path) {
infos.push(info("HEAD"));
}
}
infos
}
fn declares(&self, method: &str, path: &str) -> bool {
self.routes
.iter()
.any(|route| route.method == method && route.path == path)
}
#[must_use]
pub fn consent_summary(&self) -> String {
use std::fmt::Write as _;
let mut out = String::new();
let _ = writeln!(
out,
"Sandboxed plugin: {name} {version}",
name = self.name,
version = self.version
);
let _ = writeln!(out, " module sha256: {}", self.sha256);
let _ = writeln!(out, " mounts prefix: {}", self.prefix);
out.push_str(" routes it serves (and only these):\n");
for route in &self.routes {
let _ = writeln!(out, " {} {}", route.method, route.path);
if route.method == "GET" && !self.declares("HEAD", &route.path) {
let _ = writeln!(
out,
" HEAD {} (HTTP serves HEAD wherever it serves GET)",
route.path
);
}
}
out.push_str(" capabilities granted:\n");
for capability in &self.capabilities {
let _ = writeln!(
out,
" {name} — {describe}",
name = capability.as_str(),
describe = capability.describe()
);
}
for (label, capability) in [
(
"outbound hosts it may call",
SandboxCapability::HttpOutbound,
),
("tenant-scoped tables it owns", SandboxCapability::Db),
("job types it may enqueue", SandboxCapability::Jobs),
("render slots it may fill", SandboxCapability::Render),
] {
let Some(entries) = self
.grants
.list_for(capability)
.filter(|list| !list.is_empty())
else {
continue;
};
let _ = writeln!(out, " {label} (and only these):");
for entry in entries {
let _ = writeln!(out, " {entry}");
}
}
out.push_str(" denied, with no way to ask for it in this version:\n ");
let mut ungranted: Vec<&'static str> = vec![
"filesystem access",
"environment variables",
"session, auth and credential access",
"mail and file storage",
"raw SQL and host-application tables",
];
if !self.is_granted(SandboxCapability::HttpOutbound) {
ungranted.insert(1, "outbound network access");
}
if !self.is_granted(SandboxCapability::Db) {
ungranted.insert(1, "database access");
}
if !self.is_granted(SandboxCapability::Kv) {
ungranted.insert(1, "key/value storage");
}
if !self.is_granted(SandboxCapability::Jobs) {
ungranted.insert(1, "background jobs");
}
out.push_str(&ungranted.join(", "));
out.push_str(",\n and any host authority not listed above\n");
out.push_str(" per-request capability quotas:\n");
for (field, value) in self.quotas.fields() {
let _ = writeln!(out, " {field} = {value}");
}
out.push_str(" resource ceilings per request:\n");
let _ = writeln!(
out,
" cpu {fuel} fuel units, memory {memory} bytes, request body {body} bytes\n \
(read within {body_ms} ms), response {response} bytes, at most {concurrency} \
concurrent requests",
fuel = self.limits.fuel,
memory = self.limits.memory_bytes,
body = self.limits.max_request_body_bytes,
response = self.limits.max_response_bytes,
body_ms = self.limits.request_body_timeout_ms,
concurrency = self.limits.max_concurrency,
);
out
}
#[must_use]
pub fn consent_delta_from(&self, previous: &Self) -> ConsentDelta {
ConsentDelta {
added_capabilities: added(&previous.capabilities, &self.capabilities),
added_hosts: added(&previous.grants.hosts, &self.grants.hosts),
added_tables: added(&previous.grants.tables, &self.grants.tables),
added_job_types: added(&previous.grants.job_types, &self.grants.job_types),
added_slots: added(&previous.grants.slots, &self.grants.slots),
raised_quotas: previous
.quotas
.fields()
.into_iter()
.zip(self.quotas.fields())
.filter(|((field, _), _)| {
super::grants::CapabilityQuotas::governed_by(field)
.is_none_or(|capability| self.is_granted(capability))
})
.filter_map(|((field, approved), (_, requested))| {
(requested > approved).then_some((field, approved, requested))
})
.collect(),
added_routes: added(&previous.consent_routes(), &self.consent_routes()),
raised_limits: previous
.limits
.fields()
.into_iter()
.zip(self.limits.fields())
.filter_map(|((field, approved), (_, requested))| {
(requested > approved).then_some((field, approved, requested))
})
.collect(),
}
}
#[must_use]
fn consent_routes(&self) -> Vec<String> {
self.route_infos()
.into_iter()
.map(|route| format!("{} {}", route.method, route.path))
.collect()
}
pub(crate) fn validate(&self) -> Result<(), ManifestError> {
if self.wire_version != WIRE_VERSION {
return Err(ManifestError::UnsupportedWireVersion {
found: self.wire_version,
supported: WIRE_VERSION,
});
}
validate_name(&self.name)?;
let version_ok = !self.version.is_empty()
&& self.version.len() <= MAX_NAME_LEN
&& self.version.chars().all(|ch| ch.is_ascii_graphic());
if !version_ok {
return Err(ManifestError::InvalidVersion(rejected(&self.version)));
}
validate_prefix(&self.prefix)?;
if !self.is_granted(SandboxCapability::HttpRequest) {
return Err(ManifestError::MissingCapability(
SandboxCapability::HttpRequest,
));
}
let mut granted: Vec<SandboxCapability> =
Vec::with_capacity(SandboxCapability::ALL.len().saturating_add(1));
for capability in &self.capabilities {
if granted.contains(capability) {
return Err(ManifestError::DuplicateCapability(*capability));
}
granted.push(*capability);
}
self.grants.validate(&granted)?;
self.quotas.validate()?;
for table in &self.grants.tables {
if !crate::plugin_sandbox::capability::db::is_derivable(&self.name, table) {
return Err(ManifestError::InvalidGrantEntry {
field: "tables",
entry: rejected(table),
reason: "no physical table name can be derived for this plugin name and \
table together; both are escaped into one identifier, and the \
result must fit in 63 bytes",
});
}
}
validate_digest(&self.sha256)?;
if self.routes.is_empty() {
return Err(ManifestError::NoRoutes);
}
if self.routes.len() > MAX_ROUTES {
return Err(ManifestError::TooManyRoutes {
found: self.routes.len(),
max: MAX_ROUTES,
});
}
let mut seen: Vec<(&str, &str)> = Vec::with_capacity(self.routes.len());
let mut shapes: matchit::Router<()> = matchit::Router::new();
let mut inserted: Vec<&str> = Vec::with_capacity(self.routes.len());
for route in &self.routes {
if !ALLOWED_METHODS.contains(&route.method.as_str()) {
return Err(ManifestError::InvalidMethod(rejected(&route.method)));
}
validate_route_path(&route.path)?;
if !path_is_under_prefix(&route.path, &self.prefix) {
return Err(ManifestError::RouteOutsidePrefix {
method: rejected(&route.method),
path: rejected(&route.path),
prefix: rejected(&self.prefix),
});
}
let key = (route.method.as_str(), route.path.as_str());
if seen.contains(&key) {
return Err(ManifestError::DuplicateRoute {
method: rejected(&route.method),
path: rejected(&route.path),
});
}
seen.push(key);
if !inserted.contains(&route.path.as_str()) {
if let Err(matchit::InsertError::Conflict { with }) =
shapes.insert(route.path.as_str(), ())
{
return Err(ManifestError::ConflictingRoutes {
first: rejected(&with),
second: rejected(&route.path),
});
}
inserted.push(route.path.as_str());
}
}
self.limits.validate()
}
}
fn path_is_under_prefix(path: &str, prefix: &str) -> bool {
path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.starts_with('/'))
}
fn validate_name(name: &str) -> Result<(), ManifestError> {
let legal = !name.is_empty()
&& name.len() <= MAX_NAME_LEN
&& name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
&& name != "."
&& name != "..";
if legal {
Ok(())
} else {
Err(ManifestError::InvalidName(rejected(name)))
}
}
fn validate_digest(digest: &str) -> Result<(), ManifestError> {
let legal = digest.len() == 64
&& digest
.chars()
.all(|ch| ch.is_ascii_digit() || ('a'..='f').contains(&ch));
if legal {
Ok(())
} else {
Err(ManifestError::InvalidDigest(rejected(digest)))
}
}
pub(super) const fn is_display_reordering(ch: char) -> bool {
matches!(ch,
'\u{0600}'..='\u{0605}' | '\u{06DD}' | '\u{070F}' | '\u{0890}'..='\u{0891}' | '\u{08E2}' | '\u{110BD}' | '\u{110CD}' | '\u{13430}'..='\u{1343F}' | '\u{FFF9}'..='\u{FFFB}'
| '\u{00AD}' | '\u{034F}' | '\u{061C}' | '\u{115F}'..='\u{1160}' | '\u{17B4}'..='\u{17B5}' | '\u{180B}'..='\u{180F}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{3164}' | '\u{FE00}'..='\u{FE0F}' | '\u{FEFF}' | '\u{FFA0}' | '\u{FFF0}'..='\u{FFF8}' | '\u{1BCA0}'..='\u{1BCA3}' | '\u{1D173}'..='\u{1D17A}' | '\u{E0000}'..='\u{E0FFF}' )
}
pub(crate) fn rejected(value: &str) -> String {
super::host::guest_text(value)
}
fn validate_prefix(prefix: &str) -> Result<(), ManifestError> {
let refuse = |reason: &'static str| {
Err(ManifestError::InvalidPrefix {
prefix: rejected(prefix),
reason,
})
};
if prefix.len() > MAX_PATH_LEN {
return refuse("a prefix must be at most 1024 bytes");
}
if !prefix.starts_with('/') {
return refuse("a prefix must start with `/`");
}
if prefix == "/" {
return refuse(
"a plugin may not mount at the application root; give it a prefix of its own",
);
}
if prefix.ends_with('/') {
return refuse("a prefix must not end with `/`");
}
for segment in prefix.split('/').skip(1) {
if segment.is_empty() {
return refuse("a prefix must not contain an empty path segment");
}
if segment == "." || segment == ".." {
return refuse("a prefix must not contain `.` or `..` segments");
}
if !segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '~'))
{
return refuse(
"a prefix must be a literal path: no wildcards, captures, query or fragment",
);
}
}
Ok(())
}
fn is_capture_segment(segment: &str) -> bool {
segment
.strip_prefix('{')
.and_then(|rest| rest.strip_suffix('}'))
.is_some_and(|name| !name.contains(['{', '}']))
}
fn validate_route_path(path: &str) -> Result<(), ManifestError> {
let refuse = |reason: &'static str| {
Err(ManifestError::InvalidRoutePath {
path: rejected(path),
reason,
})
};
if path.len() > MAX_PATH_LEN {
return refuse("a route path must be at most 1024 bytes");
}
if !path.starts_with('/') {
return refuse("a route path must start with `/`");
}
if path.len() > 1 && path.ends_with('/') {
return refuse("a route path must not end with `/`");
}
if path.contains('?') || path.contains('#') {
return refuse("a route path must not carry a query string or fragment");
}
for segment in path.split('/').skip(1) {
if segment.is_empty() {
return refuse("a route path must not contain an empty path segment");
}
if dot_segment_len(segment).is_some() {
return refuse("a route path must not contain `.` or `..` segments in any spelling");
}
if percent_escapes(segment).any(|(byte, _)| byte.is_ascii()) {
return refuse("a route path must write an ASCII character as itself, not escaped");
}
if percent_escapes(segment).any(|(_, upper)| !upper) {
return refuse("a route path must write a percent-escape in upper-case hex");
}
if segment.chars().any(char::is_whitespace) {
return refuse("a route path must not contain whitespace");
}
if segment.chars().any(char::is_control) {
return refuse("a route path must not contain control characters");
}
if segment.chars().any(is_display_reordering) {
return refuse(
"a route path must not contain Unicode formatting characters; they change what \
the consent screen displays without changing what is mounted",
);
}
if !is_capture_segment(segment) && segment.bytes().any(|b| !b.is_ascii()) {
return refuse(
"a literal route segment must be ASCII; percent-encode each non-ASCII \
character as its upper-case UTF-8 bytes (e.g. `café` as `caf%C3%A9`)",
);
}
if segment.starts_with(':') {
return refuse("axum 0.8 spells a capture `{name}`, not `:name`");
}
if segment.starts_with('*') {
return refuse("axum 0.8 spells a catch-all `{*name}`, not `*name`");
}
}
let mut probe: matchit::Router<()> = matchit::Router::new();
if let Err(err) = probe.insert(path, ()) {
return Err(ManifestError::InvalidRoutePath {
path: rejected(path),
reason: match err {
matchit::InsertError::InvalidParam => {
"a capture must be spelled `{name}` with a non-empty name"
}
matchit::InsertError::InvalidCatchAll => {
"a catch-all `{*name}` is only allowed as the last segment"
}
matchit::InsertError::InvalidParamSegment => {
"a path segment may hold one whole capture and nothing else"
}
_ => "the router cannot mount this path",
},
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::plugin_sandbox::grants::{MAX_QUOTA, is_grantable_ident, is_grantable_name};
fn valid_toml() -> String {
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"
[limits]
fuel = 200000000
memory_bytes = 33554432
max_request_body_bytes = 1048576
max_response_bytes = 4194304
max_concurrency = 8
"#,
digest = "a".repeat(64)
)
}
#[test]
fn a_manifest_over_the_route_ceiling_is_refused_before_it_is_scanned() {
let mut manifest = SandboxManifest::parse(&valid_toml()).expect("valid manifest");
manifest.routes = (0..=MAX_ROUTES)
.map(|index| DeclaredRoute {
method: "GET".to_owned(),
path: format!("/hello/r{index}"),
})
.collect();
let err = manifest
.validate()
.expect_err("a manifest over the route ceiling must be refused");
assert!(
matches!(
err,
ManifestError::TooManyRoutes {
max: MAX_ROUTES,
..
}
),
"{err}",
);
let mut at_ceiling = SandboxManifest::parse(&valid_toml()).expect("valid manifest");
at_ceiling.routes = (0..MAX_ROUTES)
.map(|index| DeclaredRoute {
method: "GET".to_owned(),
path: format!("/hello/r{index}"),
})
.collect();
at_ceiling
.validate()
.expect("a manifest at the ceiling must still validate");
}
#[test]
fn a_route_path_past_the_byte_ceiling_is_refused_before_it_is_scanned() {
let long = format!("/hello/{}", "a".repeat(MAX_PATH_LEN));
assert!(long.len() > MAX_PATH_LEN);
let mut manifest = SandboxManifest::parse(&valid_toml()).expect("valid manifest");
manifest.routes = vec![DeclaredRoute {
method: "GET".to_owned(),
path: long,
}];
let err = manifest
.validate()
.expect_err("a route path over the byte ceiling must be refused");
assert!(
matches!(err, ManifestError::InvalidRoutePath { .. }),
"{err}",
);
assert!(
err.to_string().len() < MAX_PATH_LEN,
"the refusal is as large as the thing it refused: {} bytes",
err.to_string().len(),
);
let mut at_ceiling = SandboxManifest::parse(&valid_toml()).expect("valid manifest");
let exact = format!("/hello/{}", "a".repeat(MAX_PATH_LEN - "/hello/".len()));
assert_eq!(exact.len(), MAX_PATH_LEN);
at_ceiling.routes = vec![DeclaredRoute {
method: "GET".to_owned(),
path: exact,
}];
at_ceiling
.validate()
.expect("a route path at the ceiling must still validate");
}
#[test]
fn a_prefix_past_the_byte_ceiling_is_refused_before_it_is_scanned() {
let mut manifest = SandboxManifest::parse(&valid_toml()).expect("valid manifest");
manifest.prefix = format!("/{}", "a".repeat(MAX_PATH_LEN));
let err = manifest
.validate()
.expect_err("a prefix over the byte ceiling must be refused");
assert!(matches!(err, ManifestError::InvalidPrefix { .. }), "{err}");
}
#[test]
fn parses_a_valid_manifest() {
let manifest = SandboxManifest::parse(&valid_toml()).expect("valid manifest");
assert_eq!(manifest.name, "autumn-plugin-hello");
assert_eq!(manifest.prefix, "/hello");
assert_eq!(manifest.capabilities, vec![SandboxCapability::HttpRequest]);
assert_eq!(manifest.routes.len(), 1);
assert_eq!(manifest.limits.fuel, 200_000_000);
assert!(manifest.is_granted(SandboxCapability::HttpRequest));
}
#[test]
fn limits_default_when_the_section_is_absent() {
let src = valid_toml();
let trimmed = src.split("[limits]").next().expect("prefix").to_owned();
let manifest = SandboxManifest::parse(&trimmed).expect("valid manifest");
assert_eq!(manifest.limits, ResourceLimits::default());
}
#[test]
fn an_unknown_capability_is_a_hard_error_naming_it() {
let src = valid_toml().replace(r#"["http-request"]"#, r#"["http-request", "database"]"#);
let err = SandboxManifest::parse(&src).expect_err("unknown capability must fail");
let text = err.to_string();
assert!(text.contains("database"), "{text}");
assert!(text.contains("http-request"), "{text}");
}
#[test]
fn an_unknown_manifest_key_is_a_hard_error() {
let src = format!("{}\nallow_everything = true\n", valid_toml());
let err = SandboxManifest::parse(&src).expect_err("unknown key must fail");
assert!(err.to_string().contains("allow_everything"), "{err}");
}
#[test]
fn a_manifest_without_the_http_request_capability_is_refused() {
let src = valid_toml().replace(r#"["http-request"]"#, "[]");
let err = SandboxManifest::parse(&src).expect_err("no capability must fail");
assert!(matches!(err, ManifestError::MissingCapability(_)), "{err}");
}
#[test]
fn a_future_wire_version_is_refused() {
let src = valid_toml().replace("wire_version = 1", "wire_version = 2");
let err = SandboxManifest::parse(&src).expect_err("wire version must fail");
assert!(
matches!(err, ManifestError::UnsupportedWireVersion { found: 2, .. }),
"{err}"
);
}
#[test]
fn a_route_outside_the_declared_prefix_is_refused() {
let src = valid_toml().replace(r#"path = "/hello/greet""#, r#"path = "/admin/users""#);
let err = SandboxManifest::parse(&src).expect_err("off-prefix route must fail");
assert!(
matches!(err, ManifestError::RouteOutsidePrefix { .. }),
"{err}"
);
}
#[test]
fn a_route_that_only_shares_a_prefix_string_is_refused() {
let src = valid_toml().replace(r#"path = "/hello/greet""#, r#"path = "/helloworld""#);
let err = SandboxManifest::parse(&src).expect_err("string-prefix route must fail");
assert!(
matches!(err, ManifestError::RouteOutsidePrefix { .. }),
"{err}"
);
}
#[test]
fn the_prefix_itself_is_a_legal_route_path() {
let src = valid_toml().replace(r#"path = "/hello/greet""#, r#"path = "/hello""#);
assert!(SandboxManifest::parse(&src).is_ok());
}
#[test]
fn a_root_prefix_is_refused() {
let src = valid_toml()
.replace(r#"prefix = "/hello""#, r#"prefix = "/""#)
.replace(r#"path = "/hello/greet""#, r#"path = "/greet""#);
let err = SandboxManifest::parse(&src).expect_err("root prefix must fail");
assert!(matches!(err, ManifestError::InvalidPrefix { .. }), "{err}");
}
#[test]
fn a_route_path_that_reorders_its_own_display_is_refused() {
let toml =
valid_toml().replace(r#"path = "/hello/greet""#, "path = \"/hello/\u{202E}terg\"");
let err = SandboxManifest::parse(&toml).expect_err("must be refused");
assert!(format!("{err}").contains("formatting characters"), "{err}");
}
#[test]
fn a_prefix_with_a_wildcard_or_traversal_is_refused() {
for bad in [
"/he*llo",
"/{tenant}",
"/hello/",
"/hello//x",
"/../hello",
"hello",
] {
let src = valid_toml()
.replace(r#"prefix = "/hello""#, &format!(r#"prefix = "{bad}""#))
.replace(r#"path = "/hello/greet""#, &format!(r#"path = "{bad}""#));
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::InvalidPrefix { .. })
),
"prefix {bad} must be refused"
);
}
}
#[test]
fn a_route_path_the_router_would_refuse_is_refused_here() {
for bad in [
"/hello/:id", "/hello/*rest", "/hello/{id", "/hello/{}", "/hello/{*rest}/x", "/hello/a{b}c", ] {
let src =
valid_toml().replace(r#"path = "/hello/greet""#, &format!(r#"path = "{bad}""#));
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::InvalidRoutePath { .. })
),
"route path {bad} must be refused"
);
}
}
#[test]
fn a_route_path_with_a_second_spelling_is_refused() {
for bad in [
"/hello/./transfer",
"/hello/../transfer",
"/hello/%2e/transfer", "/hello/%2E/transfer", "/hello/%2e%2e/transfer", "/hello/.%2E/transfer", "/hello/%2e./transfer", "/hello/a%2fb", "/hello/a%2Fb", "/hello/a%5cb", "/hello/%74ransfer", "/hello/%7E", "/hello/a%20b", "/hello/a%3Fb", "/hello/%252e", "/hello/caf%c3%a9", ] {
let src = valid_toml()
.replace(r#"method = "GET""#, r#"method = "POST""#)
.replace(r#"path = "/hello/greet""#, &format!(r#"path = "{bad}""#));
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::InvalidRoutePath { .. })
),
"route path {bad} must be refused"
);
}
let src = valid_toml().replace(r#"path = "/hello/greet""#, r#"path = "/hello/caf%C3%A9""#);
assert!(
SandboxManifest::parse(&src).is_ok(),
"an escaped `é` is its one spelling"
);
}
#[test]
fn a_capture_route_is_accepted() {
let src = valid_toml().replace(r#"path = "/hello/greet""#, r#"path = "/hello/{name}""#);
assert!(SandboxManifest::parse(&src).is_ok());
let src = valid_toml().replace(r#"path = "/hello/greet""#, r#"path = "/hello/{*rest}""#);
assert!(SandboxManifest::parse(&src).is_ok());
}
#[test]
fn a_route_path_with_a_literal_non_ascii_segment_is_refused() {
for bad in [
"/hello/café",
"/café/x",
"/hello/䏿–‡",
"/😀",
"/hello/{{café}}",
] {
let src =
valid_toml().replace(r#"path = "/hello/greet""#, &format!(r#"path = "{bad}""#));
let err = SandboxManifest::parse(&src)
.expect_err("a literal non-ASCII segment must be refused");
assert!(
matches!(err, ManifestError::InvalidRoutePath { .. }),
"route path {bad} must be refused as an invalid route path: {err}"
);
}
let src = valid_toml().replace(r#"path = "/hello/greet""#, r#"path = "/hello/䏿–‡""#);
let err = SandboxManifest::parse(&src).expect_err("must be refused");
let message = format!("{err}");
assert!(
message.contains("percent-encode") && message.contains("e.g. `café` as `caf%C3%A9`"),
"the refusal should explain the encoded spelling: {err}"
);
}
#[test]
fn an_encoded_non_ascii_segment_is_the_one_spelling_the_consent_screen_reports() {
let src = valid_toml().replace(r#"path = "/hello/greet""#, r#"path = "/hello/caf%C3%A9""#);
let manifest = SandboxManifest::parse(&src).expect("the encoded spelling is valid");
assert!(
manifest.consent_summary().contains("GET /hello/caf%C3%A9"),
"the consent screen must report the mounted spelling"
);
}
#[test]
fn plain_ascii_routes_are_unaffected_by_the_non_ascii_rule() {
for good in [
"/hello/greet",
"/hello/a.b-c_d~e!f$g&h'i(j)k*l+m,n;o=p",
"/hello/{name}",
"/hello/{*rest}",
"/hello/{id}/posts/{post_id}",
] {
let src =
valid_toml().replace(r#"path = "/hello/greet""#, &format!(r#"path = "{good}""#));
assert!(
SandboxManifest::parse(&src).is_ok(),
"route path {good} must still validate"
);
}
}
#[test]
fn two_routes_that_collide_in_the_router_are_refused() {
for (first, second) in [("/hello/{a}", "/hello/{b}"), ("/hello/{*a}", "/hello/{b}")] {
let src = format!(
"{base}\n[[routes]]\nmethod = \"POST\"\npath = \"{second}\"\n",
base = valid_toml()
.replace(r#"path = "/hello/greet""#, &format!(r#"path = "{first}""#)),
);
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::ConflictingRoutes { .. })
),
"{first} and {second} must be refused as a conflict"
);
}
}
#[test]
fn the_same_path_under_two_methods_is_not_a_conflict() {
let src = format!(
"{base}\n[[routes]]\nmethod = \"POST\"\npath = \"/hello/{{name}}\"\n",
base = valid_toml().replace(r#"path = "/hello/greet""#, r#"path = "/hello/{name}""#),
);
assert!(SandboxManifest::parse(&src).is_ok());
}
#[test]
fn a_version_that_could_forge_a_consent_screen_is_refused() {
for bad in [
"",
"0.1.0 \u{1b}[2K",
"0.1.0\u{7f}",
"has space",
&"9".repeat(200),
] {
let src =
valid_toml().replace(r#"version = "0.1.0""#, &format!(r#"version = "{bad}""#));
assert!(
SandboxManifest::parse(&src).is_err(),
"version {bad:?} must be refused"
);
}
assert!(SandboxManifest::parse(&valid_toml()).is_ok());
}
#[test]
fn a_route_path_carrying_a_control_character_is_refused() {
let src = valid_toml().replace(
r#"path = "/hello/greet""#,
r#"path = "/hello/\u001B[2Kgreet""#,
);
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::InvalidRoutePath { .. })
),
"an escape sequence in a route path must be refused"
);
}
#[test]
fn an_invisible_letter_is_refused_though_no_format_class_contains_it() {
for (ch, what) in [
('\u{115F}', "hangul choseong filler"),
('\u{1160}', "hangul jungseong filler"),
('\u{3164}', "hangul filler"),
('\u{FFA0}', "halfwidth hangul filler"),
] {
assert!(
ch.is_alphabetic(),
"{what} is not a letter, so it does not test the gap that let it through",
);
assert!(
is_display_reordering(ch),
"{what} (U+{:04X}) renders as nothing and was admitted",
ch as u32,
);
}
}
#[test]
fn an_invisible_format_character_in_a_route_is_refused_like_a_visible_one() {
for (ch, what) in [
('\u{00AD}', "soft hyphen"),
('\u{200B}', "zero-width space"),
('\u{200E}', "left-to-right mark"),
('\u{2062}', "invisible times"),
('\u{206F}', "nominal digit shapes"),
('\u{FEFF}', "zero-width no-break space"),
('\u{FFF9}', "interlinear annotation anchor"),
('\u{034F}', "combining grapheme joiner"),
('\u{FE0F}', "variation selector-16"),
('\u{E0041}', "tag latin capital A"),
('\u{115F}', "hangul choseong filler"),
('\u{1160}', "hangul jungseong filler"),
('\u{3164}', "hangul filler"),
('\u{FFA0}', "halfwidth hangul filler"),
('\u{17B4}', "khmer vowel inherent aq"),
('\u{180F}', "mongolian free variation selector four"),
('\u{2065}', "reserved, default-ignorable"),
] {
assert!(
!ch.is_control() && !ch.is_whitespace(),
"{what} is caught by an earlier check, so it does not test this one",
);
assert!(
is_display_reordering(ch),
"{what} (U+{:04X}) is display-altering and was admitted",
ch as u32,
);
let src = valid_toml().replace(
r#"path = "/hello/greet""#,
&format!(r#"path = "/hello/ad{ch}min""#),
);
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::InvalidRoutePath { .. })
),
"a route path carrying {what} must be refused",
);
}
for ch in ['é', 'ß', '日', '🦀'] {
assert!(
!is_display_reordering(ch),
"{ch:?} is ordinary text and must not be refused",
);
}
}
#[test]
fn a_manifest_with_no_routes_is_refused() {
let src = valid_toml();
let trimmed = src.split("[[routes]]").next().expect("prefix").to_owned();
let err = SandboxManifest::parse(&trimmed).expect_err("no routes must fail");
assert!(matches!(err, ManifestError::NoRoutes), "{err}");
}
#[test]
fn an_unknown_http_method_is_refused() {
let src = valid_toml().replace(r#"method = "GET""#, r#"method = "CONNECT""#);
let err = SandboxManifest::parse(&src).expect_err("bad method must fail");
assert!(matches!(err, ManifestError::InvalidMethod(_)), "{err}");
}
#[test]
fn methods_are_normalised_to_upper_case() {
let src = valid_toml().replace(r#"method = "GET""#, r#"method = "get""#);
let manifest = SandboxManifest::parse(&src).expect("lowercase method is accepted");
assert_eq!(manifest.routes[0].method, "GET");
}
#[test]
fn duplicate_declared_routes_are_refused() {
let src = format!(
"{}\n[[routes]]\nmethod = \"GET\"\npath = \"/hello/greet\"\n",
valid_toml()
);
let err = SandboxManifest::parse(&src).expect_err("duplicate route must fail");
assert!(matches!(err, ManifestError::DuplicateRoute { .. }), "{err}");
}
#[test]
fn a_capability_granted_twice_is_refused() {
let src = valid_toml().replace(
r#"capabilities = ["http-request"]"#,
r#"capabilities = ["http-request", "http-request"]"#,
);
let err = SandboxManifest::parse(&src).expect_err("a repeated capability must be refused");
assert!(
matches!(err, ManifestError::DuplicateCapability(_)),
"{err}",
);
SandboxManifest::parse(&valid_toml()).expect("one grant is the normal case");
}
#[test]
fn a_malformed_digest_is_refused() {
for bad in ["", "abc", &"A".repeat(64), &"z".repeat(64)] {
let src = valid_toml().replace(&"a".repeat(64), bad);
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::InvalidDigest(_))
),
"digest {bad} must be refused"
);
}
}
#[test]
fn a_zero_or_oversized_limit_is_refused() {
for (field, value) in [
("fuel", "0"),
("memory_bytes", "0"),
("max_concurrency", "0"),
("max_response_bytes", "0"),
("fuel", "999999999999999"),
("memory_bytes", "9999999999"),
] {
let src = valid_toml()
.lines()
.map(|line| {
if line.starts_with(&format!("{field} = ")) {
format!("{field} = {value}")
} else {
line.to_owned()
}
})
.collect::<Vec<_>>()
.join("\n");
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::LimitOutOfRange { .. })
),
"{field} = {value} must be refused"
);
}
}
#[test]
fn the_footprint_counts_the_host_buffers_a_request_holds_too() {
let src = valid_toml()
.replace("memory_bytes = 33554432", "memory_bytes = 65536")
.replace(
"max_request_body_bytes = 1048576",
"max_request_body_bytes = 67108864",
)
.replace(
"max_response_bytes = 4194304",
"max_response_bytes = 67108864",
)
.replace("max_concurrency = 8", "max_concurrency = 1024");
let err = SandboxManifest::parse(&src).expect_err("must be refused");
assert!(
matches!(err, ManifestError::LimitOutOfRange { field, .. } if field.contains("footprint")),
"{err}"
);
}
#[test]
fn the_body_term_covers_the_base64_temporary_as_well_as_the_line() {
use crate::plugin_sandbox::wire::{HostFrame, SandboxRequest, to_line};
let granted = [SandboxCapability::HttpRequest];
let mut request = SandboxRequest {
method: "GET".to_owned(),
route: "/hello/greet".to_owned(),
path: "/hello/greet".to_owned(),
query: String::new(),
path_params: vec![],
headers: vec![("accept".to_owned(), "text/plain".to_owned())],
body: vec![],
};
request.body = vec![b'x'; 30_000];
let with = to_line(&HostFrame::request(&request, &granted))
.expect("serialises")
.len();
request.body = Vec::new();
let without = to_line(&HostFrame::request(&request, &granted))
.expect("serialises")
.len();
let expansion = (with.saturating_sub(without) * 100) / 30_000;
assert!(
(130..=140).contains(&expansion),
"the encoding is no longer ~4/3; the term below is derived from it: {expansion}%",
);
let peak_percent = 200 + 2 * expansion;
let limits = ResourceLimits {
memory_bytes: 0,
max_request_body_bytes: 1_000_000,
max_response_bytes: 0,
..ResourceLimits::default()
};
let fixed = u128::from(crate::plugin_sandbox::host::MAX_TABLE_ELEMENTS) * 16
+ crate::plugin_sandbox::host::MAX_REQUEST_METADATA_BYTES as u128 * 8
+ crate::plugin_sandbox::host::MAX_GLOBALS as u128 * 16
+ crate::plugin_sandbox::host::MAX_FUNCTIONS as u128 * 32
+ crate::plugin_sandbox::host::FIXED_HOST_BUFFER_BYTES as u128;
let charged_for_body = limits.request_footprint_bytes() - fixed;
assert!(
charged_for_body * 100 >= 1_000_000 * peak_percent as u128,
"the body term ({charged_for_body}) is under the measured peak of \
{peak_percent}% × the ceiling",
);
}
#[test]
fn the_footprint_counts_the_host_buffers_that_scale_with_nothing() {
use crate::plugin_sandbox::host::FIXED_HOST_BUFFER_BYTES;
let bare = ResourceLimits {
memory_bytes: 0,
max_request_body_bytes: 0,
max_response_bytes: 0,
..ResourceLimits::default()
};
let scaling = u128::from(crate::plugin_sandbox::host::MAX_TABLE_ELEMENTS) * 16
+ crate::plugin_sandbox::host::MAX_REQUEST_METADATA_BYTES as u128 * 8
+ crate::plugin_sandbox::host::MAX_GLOBALS as u128 * 16
+ crate::plugin_sandbox::host::MAX_FUNCTIONS as u128 * 32;
assert_eq!(
bare.request_footprint_bytes() - scaling,
FIXED_HOST_BUFFER_BYTES as u128,
);
const {
assert!(
FIXED_HOST_BUFFER_BYTES >= 64 * 1024 + 64 * 1024,
"the fixed term no longer covers the stderr budget and one I/O chunk",
);
assert!(
FIXED_HOST_BUFFER_BYTES > 4096 * 16,
"the fixed term is back to standing in for the buffers rather than counting them",
);
}
}
#[test]
fn the_footprint_counts_every_buffer_a_request_holds_at_once() {
let limits = ResourceLimits {
memory_bytes: 1_000_000,
max_request_body_bytes: 100_000,
max_response_bytes: 10_000,
..ResourceLimits::default()
};
let tables = u128::from(crate::plugin_sandbox::host::MAX_TABLE_ELEMENTS) * 16;
let metadata = crate::plugin_sandbox::host::MAX_REQUEST_METADATA_BYTES as u128 * 8;
let globals = crate::plugin_sandbox::host::MAX_GLOBALS as u128 * 16;
let functions = crate::plugin_sandbox::host::MAX_FUNCTIONS as u128 * 32;
let fixed = crate::plugin_sandbox::host::FIXED_HOST_BUFFER_BYTES as u128;
assert_eq!(
limits.request_footprint_bytes(),
1_000_000 + 5 * 100_000 + 5 * 10_000 + tables + metadata + globals + functions + fixed
);
}
#[test]
fn the_footprint_counts_the_peak_while_a_response_is_being_decoded() {
let limits = ResourceLimits {
memory_bytes: 0,
max_request_body_bytes: 0,
max_response_bytes: 1_000_000,
..ResourceLimits::default()
};
let tables = u128::from(crate::plugin_sandbox::host::MAX_TABLE_ELEMENTS) * 16;
let metadata = crate::plugin_sandbox::host::MAX_REQUEST_METADATA_BYTES as u128 * 8;
let globals = crate::plugin_sandbox::host::MAX_GLOBALS as u128 * 16;
let functions = crate::plugin_sandbox::host::MAX_FUNCTIONS as u128 * 32;
let fixed = crate::plugin_sandbox::host::FIXED_HOST_BUFFER_BYTES as u128;
assert_eq!(
limits.request_footprint_bytes(),
5 * 1_000_000 + tables + metadata + globals + functions + fixed,
"the response term must cover the line, the base64 copy and the decode at once"
);
}
#[test]
fn the_footprint_counts_the_metadata_a_request_may_carry() {
let bare = ResourceLimits {
memory_bytes: 0,
max_request_body_bytes: 0,
max_response_bytes: 0,
..ResourceLimits::default()
};
let metadata = crate::plugin_sandbox::host::MAX_REQUEST_METADATA_BYTES as u128 * 8;
assert!(
bare.request_footprint_bytes() >= metadata,
"the metadata a request may carry is not in the footprint"
);
}
#[test]
fn the_default_limits_are_within_the_footprint_ceiling() {
let manifest = SandboxManifest::parse(&valid_toml()).expect("valid");
assert_eq!(manifest.limits, ResourceLimits::default());
}
#[test]
fn a_value_rejected_for_its_size_is_not_copied_at_that_size() {
let huge = "x".repeat(512 * 1024);
let mut manifest = SandboxManifest::parse(&valid_toml()).expect("valid");
manifest.name.clone_from(&huge);
let err = manifest
.validate()
.expect_err("an oversized name is refused");
let rendered = err.to_string();
assert!(
rendered.len() < huge.len() / 100,
"the error carries the rejected value at its own size: {} bytes for a {} byte name",
rendered.len(),
huge.len(),
);
let mut manifest = SandboxManifest::parse(&valid_toml()).expect("valid");
manifest.sha256.clone_from(&huge);
let rendered = manifest
.validate()
.expect_err("an oversized digest is refused")
.to_string();
assert!(
rendered.len() < huge.len() / 100,
"the digest error carries the rejected value: {} bytes",
rendered.len(),
);
let mut manifest = SandboxManifest::parse(&valid_toml()).expect("valid");
manifest.prefix = format!("/{huge}");
let rendered = manifest
.validate()
.expect_err("an oversized prefix is refused")
.to_string();
assert!(
rendered.len() < huge.len() / 100,
"the prefix error carries the rejected value: {} bytes",
rendered.len(),
);
}
#[test]
fn a_name_that_could_forge_a_log_line_is_refused() {
for bad in ["", "a b", "../etc", "plugin:name", &"x".repeat(200)] {
let src = valid_toml().replace("autumn-plugin-hello", bad);
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::InvalidName(_))
),
"name {bad:?} must be refused"
);
}
}
#[test]
fn a_name_carrying_a_newline_is_refused() {
let src = valid_toml().replace("autumn-plugin-hello", "plugin\nname");
assert!(SandboxManifest::parse(&src).is_err());
}
#[test]
fn round_trips_through_toml() {
let manifest = SandboxManifest::parse(&valid_toml()).expect("valid");
let rendered = manifest.to_toml().expect("serializes");
let reparsed = SandboxManifest::parse(&rendered).expect("re-parses");
assert_eq!(manifest, reparsed);
}
fn vocabulary_toml() -> String {
format!(
r#"
name = "autumn-plugin-shop"
version = "0.1.0"
wire_version = 1
prefix = "/shop"
capabilities = ["http-request", "kv", "http-outbound", "db", "jobs", "render"]
sha256 = "{digest}"
[[routes]]
method = "GET"
path = "/shop/panel"
[grants]
hosts = ["api.example.com"]
tables = ["orders"]
job_types = ["reindex"]
slots = ["order-summary"]
"#,
digest = "b".repeat(64)
)
}
#[test]
fn the_vocabulary_covers_kv_outbound_db_jobs_and_render() {
let manifest = SandboxManifest::parse(&vocabulary_toml()).expect("valid");
for capability in [
SandboxCapability::HttpRequest,
SandboxCapability::Kv,
SandboxCapability::HttpOutbound,
SandboxCapability::Db,
SandboxCapability::Jobs,
SandboxCapability::Render,
] {
assert!(manifest.is_granted(capability), "{capability}");
assert!(SandboxCapability::ALL.contains(&capability), "{capability}");
}
}
#[test]
fn every_capability_round_trips_through_its_manifest_spelling() {
for capability in SandboxCapability::ALL {
assert_eq!(
SandboxCapability::parse(capability.as_str()),
Ok(*capability)
);
assert!(!capability.describe().is_empty());
}
}
#[test]
fn a_grant_list_without_its_capability_is_refused() {
let src = vocabulary_toml().replace(r#", "http-outbound""#, "");
assert_eq!(
SandboxManifest::parse(&src),
Err(ManifestError::GrantWithoutCapability {
capability: SandboxCapability::HttpOutbound,
field: "hosts",
})
);
}
#[test]
fn a_capability_with_an_empty_grant_list_is_refused() {
let src = vocabulary_toml().replace(r#"hosts = ["api.example.com"]"#, "hosts = []");
assert_eq!(
SandboxManifest::parse(&src),
Err(ManifestError::CapabilityWithoutGrant {
capability: SandboxCapability::HttpOutbound,
field: "hosts",
})
);
}
#[test]
fn an_outbound_host_carrying_a_scheme_port_or_path_is_refused() {
for bad in [
"https://api.example.com",
"api.example.com:443",
"api.example.com/v1",
"user@api.example.com",
"API.example.com",
"",
"-api.example.com",
"api..example.com",
"*.example.com",
] {
let src = vocabulary_toml().replace("api.example.com", bad);
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::InvalidGrantEntry { .. })
),
"{bad} was accepted"
);
}
}
#[test]
fn a_table_job_type_or_slot_that_is_not_a_plain_identifier_is_refused() {
for (field, bad) in [
("orders", "orders; drop table users"),
("orders", "orders--"),
("orders", "Orders"),
("orders", "public.users"),
("reindex", "re index"),
("order-summary", "order summary"),
("order-summary", "Order-Summary"),
] {
let src = vocabulary_toml().replace(field, bad);
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::InvalidGrantEntry { .. })
),
"{bad} was accepted for {field}"
);
}
for bad in ["\"orders\"", "orders`", "orders'", "ord ers", ""] {
assert!(!is_grantable_ident(bad), "{bad} is not an identifier");
assert!(!is_grantable_name(bad), "{bad} is not a name");
}
}
#[test]
fn a_repeated_grant_entry_is_refused() {
let src =
vocabulary_toml().replace(r#"tables = ["orders"]"#, r#"tables = ["orders", "orders"]"#);
assert!(matches!(
SandboxManifest::parse(&src),
Err(ManifestError::DuplicateGrantEntry { .. })
));
}
#[test]
fn quotas_default_conservatively_and_are_operator_configurable() {
let manifest = SandboxManifest::parse(&vocabulary_toml()).expect("valid");
let defaults = CapabilityQuotas::default();
assert_eq!(manifest.quotas, defaults);
assert!(defaults.kv_reads > 0 && defaults.kv_reads <= MAX_QUOTA);
let src = format!("{}\n[quotas]\nkv_reads = 3\n", vocabulary_toml());
let raised = SandboxManifest::parse(&src).expect("valid");
assert_eq!(raised.quotas.kv_reads, 3);
assert_eq!(raised.quotas.kv_writes, defaults.kv_writes);
}
#[test]
fn a_zero_or_oversized_quota_is_refused() {
for value in ["0", "4294967295"] {
let src = format!("{}\n[quotas]\nkv_reads = {value}\n", vocabulary_toml());
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::QuotaOutOfRange { .. })
),
"{value} was accepted"
);
}
}
#[test]
fn an_upgrade_that_asks_for_more_needs_fresh_consent() {
let previous = SandboxManifest::parse(
&vocabulary_toml()
.replace(
r#"capabilities = ["http-request", "kv", "http-outbound", "db", "jobs", "render"]"#,
r#"capabilities = ["http-request"]"#,
)
.replace(r#"hosts = ["api.example.com"]"#, "")
.replace(r#"tables = ["orders"]"#, "")
.replace(r#"job_types = ["reindex"]"#, "")
.replace(r#"slots = ["order-summary"]"#, ""),
)
.expect("valid");
let next = SandboxManifest::parse(&vocabulary_toml()).expect("valid");
let delta = next.consent_delta_from(&previous);
assert!(delta.requires_consent());
assert!(delta.added_capabilities.contains(&SandboxCapability::Kv));
assert!(
delta
.added_hosts
.iter()
.any(|host| host == "api.example.com")
);
assert!(delta.added_tables.iter().any(|table| table == "orders"));
assert!(delta.added_job_types.iter().any(|job| job == "reindex"));
assert!(delta.added_slots.iter().any(|slot| slot == "order-summary"));
assert!(
delta.added_routes.is_empty() && delta.raised_limits.is_empty(),
"only the grant moved: {delta:?}"
);
let summary = delta.summary();
assert!(summary.contains("api.example.com"), "{summary}");
assert!(!next.consent_delta_from(&next).requires_consent());
assert!(!previous.consent_delta_from(&next).requires_consent());
}
#[test]
fn raising_a_quota_needs_fresh_consent_but_lowering_one_does_not() {
let base = format!("{}\n[quotas]\nkv_writes = 4\n", vocabulary_toml());
let raised = format!("{}\n[quotas]\nkv_writes = 40\n", vocabulary_toml());
let base = SandboxManifest::parse(&base).expect("valid");
let raised = SandboxManifest::parse(&raised).expect("valid");
let up = raised.consent_delta_from(&base);
assert!(up.requires_consent());
assert!(
up.raised_quotas
.iter()
.any(|(field, ..)| *field == "kv_writes")
);
assert!(!base.consent_delta_from(&raised).requires_consent());
}
#[test]
fn an_upgrade_that_mounts_a_new_route_needs_fresh_consent() {
let previous = SandboxManifest::parse(&vocabulary_toml()).expect("valid");
let next = SandboxManifest::parse(&format!(
"{}\n[[routes]]\nmethod = \"POST\"\npath = \"/shop/checkout\"\n",
vocabulary_toml()
))
.expect("valid");
let delta = next.consent_delta_from(&previous);
assert!(delta.requires_consent());
assert!(
delta
.added_routes
.iter()
.any(|route| route == "POST /shop/checkout"),
"{:?}",
delta.added_routes
);
assert!(delta.summary().contains("/shop/checkout"));
assert!(!previous.consent_delta_from(&next).requires_consent());
}
#[test]
fn the_head_a_new_get_implies_is_part_of_the_upgrade_too() {
let previous = SandboxManifest::parse(&vocabulary_toml()).expect("valid");
let next = SandboxManifest::parse(&format!(
"{}\n[[routes]]\nmethod = \"GET\"\npath = \"/shop/orders\"\n",
vocabulary_toml()
))
.expect("valid");
let delta = next.consent_delta_from(&previous);
assert!(
delta.added_routes.contains(&"GET /shop/orders".to_owned())
&& delta.added_routes.contains(&"HEAD /shop/orders".to_owned()),
"{:?}",
delta.added_routes
);
}
#[test]
fn a_quota_for_a_dropped_capability_is_not_new_authority() {
let approved =
SandboxManifest::parse(&format!("{}\n[quotas]\nkv_reads = 8\n", vocabulary_toml()))
.expect("valid");
let narrowed_src = vocabulary_toml().replace(
r#"capabilities = ["http-request", "kv", "http-outbound", "db", "jobs", "render"]"#,
r#"capabilities = ["http-request", "http-outbound", "db", "jobs", "render"]"#,
);
let narrowed =
SandboxManifest::parse(&format!("{narrowed_src}\n[quotas]\nkv_reads = 64\n"))
.expect("valid");
let delta = narrowed.consent_delta_from(&approved);
assert!(
!delta.requires_consent(),
"dropping a capability is not growth: {delta:?}"
);
assert!(
!delta
.raised_quotas
.iter()
.any(|(field, ..)| *field == "kv_reads"),
"{:?}",
delta.raised_quotas
);
let greedy =
SandboxManifest::parse(&format!("{}\n[quotas]\nkv_reads = 64\n", vocabulary_toml()))
.expect("valid");
assert!(
greedy
.consent_delta_from(&approved)
.raised_quotas
.iter()
.any(|(field, ..)| *field == "kv_reads"),
"a raise on a capability that is still granted must still prompt"
);
}
#[test]
fn every_quota_says_which_capability_it_bounds() {
let ungoverned = ["calls", "calls_per_second"];
for (field, _) in CapabilityQuotas::default().fields() {
let governed = super::super::grants::CapabilityQuotas::governed_by(field).is_some();
assert_eq!(
governed,
!ungoverned.contains(&field),
"{field} is on the wrong side of the governed/ungoverned split"
);
}
}
#[test]
fn an_upgrade_that_raises_a_resource_ceiling_needs_fresh_consent() {
let modest = SandboxManifest::parse(&format!(
"{}\n[limits]\nfuel = 5000000\nmemory_bytes = 16777216\nmax_concurrency = 4\n",
vocabulary_toml()
))
.expect("valid");
let greedy = SandboxManifest::parse(&format!(
"{}\n[limits]\nfuel = 100000000000\nmemory_bytes = 16777216\nmax_concurrency = 4\n",
vocabulary_toml()
))
.expect("valid");
let delta = greedy.consent_delta_from(&modest);
assert!(delta.requires_consent());
assert!(
delta
.raised_limits
.iter()
.any(|(field, ..)| *field == "fuel"),
"{:?}",
delta.raised_limits
);
assert!(delta.summary().contains("fuel"));
assert!(!modest.consent_delta_from(&greedy).requires_consent());
}
#[test]
fn a_db_grant_whose_physical_name_cannot_be_derived_is_refused_at_load() {
let src = vocabulary_toml()
.replace("autumn-plugin-shop", &"p".repeat(40))
.replace("\"orders\"", &format!("\"{}\"", "t".repeat(30)));
assert!(
matches!(
SandboxManifest::parse(&src),
Err(ManifestError::InvalidGrantEntry {
field: "tables",
..
})
),
"{:?}",
SandboxManifest::parse(&src)
);
}
#[test]
fn the_consent_summary_enumerates_every_grant_detail_and_quota() {
let manifest = SandboxManifest::parse(&vocabulary_toml()).expect("valid");
let summary = manifest.consent_summary();
for expected in [
"api.example.com",
"orders",
"reindex",
"order-summary",
"kv_reads",
] {
assert!(
summary.contains(expected),
"{expected} missing from {summary}"
);
}
assert!(
!summary.contains("filesystem access, outbound network access"),
"{summary}"
);
}
#[test]
fn a_plugin_that_asks_for_nothing_extra_still_reads_as_denying_everything() {
let summary = SandboxManifest::parse(&valid_toml())
.expect("valid")
.consent_summary();
for denied in ["filesystem", "network", "environment", "database"] {
assert!(summary.contains(denied), "{denied} missing from {summary}");
}
}
#[test]
fn the_consent_summary_names_the_grant_the_prefix_and_the_digest() {
let manifest = SandboxManifest::parse(&valid_toml()).expect("valid");
let summary = manifest.consent_summary();
assert!(summary.contains("autumn-plugin-hello"), "{summary}");
assert!(summary.contains("/hello"), "{summary}");
assert!(summary.contains("http-request"), "{summary}");
assert!(summary.contains(&"a".repeat(64)), "{summary}");
assert!(summary.contains("GET /hello/greet"), "{summary}");
assert!(summary.contains("filesystem"), "{summary}");
assert!(summary.contains("network"), "{summary}");
assert!(summary.contains("environment"), "{summary}");
assert!(summary.contains("database"), "{summary}");
}
#[test]
fn a_declared_get_reports_the_head_it_also_serves() {
let manifest = SandboxManifest::parse(&valid_toml()).expect("valid");
let infos = manifest.route_infos();
assert_eq!(infos.len(), 2, "{infos:?}");
assert!(infos.iter().any(|route| route.method == "HEAD"));
assert!(manifest.consent_summary().contains("HEAD /hello/greet"));
}
#[test]
fn route_infos_carry_plugin_attribution_under_the_prefix() {
let manifest = SandboxManifest::parse(&valid_toml()).expect("valid");
let infos = manifest.route_infos();
assert_eq!(infos.len(), 2);
assert_eq!(infos[0].method, "GET");
assert_eq!(infos[0].path, "/hello/greet");
assert_eq!(
infos[0].source,
crate::route_listing::RouteSource::Plugin("autumn-plugin-hello".to_owned())
);
}
}