use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::error::ConfigError;
use crate::matcher::Pattern;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DeployConfig {
pub version: u32,
pub index: Vec<String>,
pub clean_urls: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub case_insensitive: bool,
pub trailing_slash: TrailingSlash,
pub error_documents: BTreeMap<u16, String>,
pub redirects: Vec<Redirect>,
pub rewrites: Vec<Rewrite>,
pub headers: Vec<HeaderRule>,
pub cache: CacheConfig,
pub mime_overrides: BTreeMap<String, String>,
pub proxy_allow: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub handlers: Vec<HandlerConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub consumers: Vec<ConsumerConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub crons: Vec<CronConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub streams: Vec<StreamConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sessions: Vec<SessionConfig>,
}
impl Default for DeployConfig {
fn default() -> Self {
Self {
version: crate::SCHEMA_VERSION,
index: vec!["index.html".to_string()],
clean_urls: false,
case_insensitive: false,
trailing_slash: TrailingSlash::default(),
error_documents: BTreeMap::new(),
redirects: Vec::new(),
rewrites: Vec::new(),
headers: Vec::new(),
cache: CacheConfig::default(),
mime_overrides: BTreeMap::new(),
proxy_allow: Vec::new(),
handlers: Vec::new(),
consumers: Vec::new(),
crons: Vec::new(),
streams: Vec::new(),
sessions: Vec::new(),
}
}
}
impl DeployConfig {
pub fn from_ron(text: &str) -> Result<Self, ConfigError> {
let options = ron::Options::default()
.with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME);
let config: Self = options
.from_str(text)
.map_err(|err| ConfigError::parse(err.to_string()))?;
config.compile_check()?;
Ok(config)
}
pub fn proxy_host_allowed(&self, host: &str) -> bool {
if self.proxy_allow.is_empty() {
return true;
}
let host = host.trim_end_matches('.').to_ascii_lowercase();
self.proxy_allow.iter().any(|entry| {
let entry = entry.trim().to_ascii_lowercase();
match entry.strip_prefix('.') {
Some(suffix) => host_matches_suffix(&host, suffix),
None => host == entry,
}
})
}
pub fn compile_check(&self) -> Result<(), ConfigError> {
for redirect in &self.redirects {
Pattern::compile(&redirect.from)?;
if let Some(when) = &redirect.when {
crate::predicate::Predicate::compile(when)?;
}
if crate::predicate::Template::is_template(&redirect.to) {
crate::predicate::Template::compile(&redirect.to)?;
}
}
for rewrite in &self.rewrites {
Pattern::compile(&rewrite.from)?;
if let Some(when) = &rewrite.when {
crate::predicate::Predicate::compile(when)?;
}
if crate::predicate::Template::is_template(&rewrite.to) {
crate::predicate::Template::compile(&rewrite.to)?;
}
}
for header in &self.headers {
Pattern::compile(&header.matches)?;
}
self.check_handlers()?;
Ok(())
}
fn check_handlers(&self) -> Result<(), ConfigError> {
let handler_patterns: Vec<Pattern> = self
.handlers
.iter()
.map(|h| Pattern::compile(&h.route))
.collect::<Result<_, _>>()?;
for handler in &self.handlers {
if handler.component.is_empty() {
return Err(ConfigError::parse(format!(
"handler {} has an empty component path",
handler.route
)));
}
for method in &handler.methods {
check_http_method(method)?;
}
for import in &handler.imports {
check_import(import)?;
}
for (key, value) in &handler.env {
if looks_like_secret(value) {
return Err(ConfigError::parse(format!(
"handler {} env var {key:?} looks like a secret; move it to \
[handlers].secrets as a reference to a host env var rather than \
inlining it in `env` (which is stored in the manifest)",
handler.route
)));
}
}
}
for consumer in &self.consumers {
if consumer.topic.is_empty() || consumer.component.is_empty() {
return Err(ConfigError::parse(
"consumer needs a non-empty topic and component".to_string(),
));
}
for import in &consumer.imports {
check_import(import)?;
}
}
for cron in &self.crons {
check_cron_schedule(&cron.schedule)?;
if !handler_patterns.iter().any(|p| p.is_match(&cron.route)) {
return Err(ConfigError::parse(format!(
"cron route {} is not served by any declared handler",
cron.route
)));
}
}
for stream in &self.streams {
Pattern::compile(&stream.route)?;
if stream.topics.is_empty() {
return Err(ConfigError::parse(format!(
"stream {} subscribes to no topics",
stream.route
)));
}
}
for session in &self.sessions {
Pattern::compile(&session.route)?;
if session.component.is_empty() {
return Err(ConfigError::parse(format!(
"session {} has an empty component path",
session.route
)));
}
for import in &session.imports {
check_import(import)?;
}
}
Ok(())
}
}
const KNOWN_IMPORTS: &[&str] = &[
"sql",
"invoke",
"graphql",
"email",
"capability",
"wasi:http",
"wasi:io",
"wasi:keyvalue",
"wasi:blobstore",
"wasi:messaging",
"wasi:clocks",
"wasi:random",
"wasi:logging",
"wasi:sockets",
"tcp-listener",
];
pub fn known_imports() -> &'static [&'static str] {
KNOWN_IMPORTS
}
fn looks_like_secret(value: &str) -> bool {
let v = value.trim();
if v.contains("-----BEGIN") && v.contains("PRIVATE KEY") {
return true;
}
const PREFIXES: &[&str] = &[
"AKIA",
"ASIA",
"ghp_",
"gho_",
"ghu_",
"ghs_",
"github_pat_",
"xoxb-",
"xoxp-",
"xoxa-",
"glpat-",
"AIza",
"AccountKey=",
];
if PREFIXES.iter().any(|p| v.contains(p)) {
return true;
}
let has_digit = v.bytes().any(|b| b.is_ascii_digit());
if v.len() >= 40 && has_digit && v.bytes().all(|b| b.is_ascii_hexdigit()) {
return true;
}
let charset_ok = v
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'-' | b'_'));
let mixed_case =
v.bytes().any(|b| b.is_ascii_uppercase()) && v.bytes().any(|b| b.is_ascii_lowercase());
v.len() >= 32 && charset_ok && has_digit && mixed_case && shannon_entropy_bits(v) >= 3.5
}
fn shannon_entropy_bits(s: &str) -> f64 {
if s.is_empty() {
return 0.0;
}
let mut counts = [0u32; 256];
for b in s.bytes() {
counts[b as usize] += 1;
}
let len = s.len() as f64;
counts
.iter()
.filter(|&&c| c > 0)
.map(|&c| {
let p = c as f64 / len;
-p * p.log2()
})
.sum()
}
fn is_named_sql_import(import: &str) -> bool {
let Some(name) = import.strip_prefix("sql:") else {
return false;
};
name == "*"
|| (!name.is_empty()
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'))
}
pub fn is_named_admin_import(import: &str) -> bool {
matches!(
import,
"admin:domains" | "admin:email" | "admin:site" | "admin:secrets"
)
}
pub fn is_named_tenant_secrets_import(import: &str) -> bool {
matches!(import, "tenant-secrets:read" | "tenant-secrets:admin")
}
pub fn is_named_blob_upload_import(import: &str) -> bool {
matches!(import, "blob-upload:write" | "blob-upload:multipart")
}
fn check_import(import: &str) -> Result<(), ConfigError> {
if KNOWN_IMPORTS.contains(&import)
|| import == "session"
|| import == "tenancy"
|| import == "messaging-stats"
|| is_named_sql_import(import)
|| is_named_admin_import(import)
|| is_named_tenant_secrets_import(import)
|| is_named_blob_upload_import(import)
{
Ok(())
} else {
Err(ConfigError::parse(format!(
"unknown handler import {import:?}; allowed: {}, `session`, `tenancy`, `messaging-stats`, a named SQL binding `sql:<name>` / `sql:*`, an admin surface `admin:{{domains,email,site,secrets}}`, a tenant-secret right `tenant-secrets:{{read,admin}}`, or a blob-upload right `blob-upload:{{write,multipart}}`",
KNOWN_IMPORTS.join(", ")
)))
}
}
pub(crate) const STANDARD_METHODS: &[&str] =
&["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"];
pub(crate) const METHOD_WILDCARD: &str = "*";
fn is_tchar(b: u8) -> bool {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}
fn is_http_method_token(method: &str) -> bool {
!method.is_empty() && method.bytes().all(is_tchar)
}
fn check_http_method(method: &str) -> Result<(), ConfigError> {
if extmethod_gate_mutation().as_deref() == Some("admit_nontoken") {
return Ok(());
}
if is_http_method_token(method) {
Ok(())
} else {
Err(ConfigError::parse(format!(
"invalid HTTP method {method:?}: expected a standard verb, the `*` catch-all, or a \
valid RFC 7230 token (an extension verb such as PROPFIND)"
)))
}
}
#[cfg(all(feature = "extmethod-gate-mutation", not(test), not(debug_assertions)))]
compile_error!(
"extmethod-gate-mutation is a test-only anti-hollow seam and must never be compiled into a \
release build — remove it from the feature set"
);
#[cfg(any(test, feature = "extmethod-gate-mutation"))]
pub(crate) fn extmethod_gate_mutation() -> Option<String> {
std::env::var("BOATRAMP_EXTMETHOD_MUTATION").ok()
}
#[cfg(not(any(test, feature = "extmethod-gate-mutation")))]
#[inline]
pub(crate) fn extmethod_gate_mutation() -> Option<String> {
None
}
fn check_cron_schedule(schedule: &str) -> Result<(), ConfigError> {
crate::cron::CronSchedule::parse(schedule)
.map(|_| ())
.map_err(|err| ConfigError::parse(format!("cron schedule {schedule:?}: {err}")))
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TrailingSlash {
#[default]
Preserve,
Always,
Never,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Redirect {
pub from: String,
pub to: String,
#[serde(default = "default_redirect_status")]
pub status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<String>,
}
fn default_redirect_status() -> u16 {
308
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rewrite {
pub from: String,
pub to: String,
#[serde(default = "default_rewrite_status")]
pub status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub when: Option<String>,
}
fn default_rewrite_status() -> u16 {
200
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HeaderRule {
pub matches: String,
#[serde(default)]
pub set: BTreeMap<String, String>,
#[serde(default)]
pub unset: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CacheConfig {
pub default: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HandlerConfig {
pub route: String,
#[serde(default)]
pub methods: Vec<String>,
pub component: String,
#[serde(default)]
pub imports: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub streaming: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limits: Option<HandlerLimits>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub no_secrets: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub invoke_targets: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stats_topics: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tenant_secret_names: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub upload_containers: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blobstore_containers: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub egress_allowlist: Vec<String>,
#[serde(
default,
deserialize_with = "crate::tenancy::de_opt_tenancy",
skip_serializing_if = "Option::is_none"
)]
pub tenancy: Option<crate::tenancy::Tenancy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_claims: Option<HandlerGraphqlTokenClaims>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HandlerLimits {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_mb: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fuel: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StartPosition {
#[default]
Latest,
Earliest,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ConsumerConfig {
pub topic: String,
pub component: String,
#[serde(default)]
pub imports: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub group: String,
#[serde(default, skip_serializing_if = "crate::config::is_default_start")]
pub start: StartPosition,
#[serde(
default,
deserialize_with = "crate::tenancy::de_opt_tenancy",
skip_serializing_if = "Option::is_none"
)]
pub tenancy: Option<crate::tenancy::Tenancy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_claims: Option<HandlerGraphqlTokenClaims>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secrets: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lease_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_attempts: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_batch: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_ack_pending: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub backoff_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retention_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stats_topics: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tenant_secret_names: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub upload_containers: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub blobstore_containers: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub egress_allowlist: Vec<String>,
}
pub fn is_default_start(s: &StartPosition) -> bool {
*s == StartPosition::default()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CronConfig {
pub schedule: String,
pub route: String,
#[serde(default)]
pub overlap: Overlap,
#[serde(default, skip_serializing_if = "CronRunAs::is_default")]
pub run_as: CronRunAs,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Overlap {
#[default]
Skip,
Allow,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CronRunAs {
#[default]
Unauthenticated,
Deployer,
}
impl CronRunAs {
#[must_use]
pub fn is_default(&self) -> bool {
matches!(self, Self::Unauthenticated)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct StreamConfig {
pub route: String,
pub topics: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub websocket: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publish_topic: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SessionConfig {
pub route: String,
pub component: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub imports: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limits: Option<HandlerLimits>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub env: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub invoke_targets: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stats_topics: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub egress_allowlist: Vec<String>,
#[serde(
default,
deserialize_with = "crate::tenancy::de_opt_tenancy",
skip_serializing_if = "Option::is_none"
)]
pub tenancy: Option<crate::tenancy::Tenancy>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_claims: Option<HandlerGraphqlTokenClaims>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SiteConfig {
pub version: u32,
pub domains: DomainConfig,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub access: crate::access::AccessConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handlers: Option<HandlersSiteConfig>,
#[serde(default, skip_serializing_if = "CompressionConfig::is_default")]
pub compression: CompressionConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<crate::gateway::GatewayConfig>,
}
impl Default for SiteConfig {
fn default() -> Self {
Self {
version: crate::SCHEMA_VERSION,
domains: DomainConfig::default(),
security: SecurityConfig::default(),
access: crate::access::AccessConfig::default(),
handlers: None,
compression: CompressionConfig::default(),
gateway: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CompressionConfig {
pub enabled: bool,
pub min_size: u64,
}
impl Default for CompressionConfig {
fn default() -> Self {
Self {
enabled: false,
min_size: 1024,
}
}
}
impl CompressionConfig {
fn is_default(&self) -> bool {
*self == Self::default()
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HandlersSiteConfig {
pub enabled: bool,
pub allow_imports: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_memory_mb: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_timeout_ms: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_concurrency: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_fuel: Option<u64>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub secrets: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub background_aliases: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_stream_connections: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_log_rate: Option<u32>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub disable_log_capture: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cache: Option<HandlerCacheConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub graphql: Option<HandlerGraphqlConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cookie_auth: Option<CookieAuthConfig>,
#[serde(
default,
deserialize_with = "crate::tenancy::de_opt_tenancy",
skip_serializing_if = "Option::is_none"
)]
pub tenancy: Option<crate::tenancy::Tenancy>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub allow_ceiling_exceptions: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct CookieAuthConfig {
pub cookie_name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_origins: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HandlerGraphqlConfig {
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_depth: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_complexity: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub introspection: Option<bool>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub persisted_queries: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub enforce_safelist: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub safelisted_ops: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub safelisted_ops_path: Option<std::path::PathBuf>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub federated: bool,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub graphiql: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub route: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<HandlerGraphqlDataConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edge_hidden_operations: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub edge_hidden_subgraphs: Vec<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HandlerGraphqlDataConfig {
pub enabled: bool,
#[serde(skip_serializing_if = "String::is_empty")]
pub source: String,
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub tables: std::collections::BTreeMap<String, HandlerGraphqlTableConfig>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub mutations: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub claims_from_token: Option<HandlerGraphqlTokenClaims>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HandlerGraphqlTokenClaims {
pub issuer: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_env: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub jwks_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub audience: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_persona_claim: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer_trust: Option<IssuerTrust>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub jwks: Option<JwksDiscovery>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_when: Option<SystemWhen>,
}
fn default_role_claim() -> String {
"role".to_string()
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SystemWhen {
pub issuer: String,
#[serde(default = "default_role_claim")]
pub claim: String,
pub equals: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct IssuerTrust {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allow: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suffix: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct JwksDiscovery {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discover: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub template: Option<String>,
}
#[must_use]
pub fn host_matches_suffix(host: &str, bare_suffix: &str) -> bool {
host == bare_suffix || host.ends_with(&format!(".{bare_suffix}"))
}
impl HandlerGraphqlTokenClaims {
pub fn validate(&self) -> Result<(), ConfigError> {
let multi = self.issuer_trust.is_some() || self.jwks.is_some();
let single = !self.issuer.is_empty() || self.jwks_url.is_some() || self.jwks_env.is_some();
if multi && single {
return Err(ConfigError::parse(
"token_claims: the single-issuer form (issuer/jwks_url/jwks_env) and the \
multi-issuer form (issuer_trust/jwks) are mutually exclusive — set one",
));
}
if let Some(sw) = &self.system_when {
if sw.issuer.is_empty() {
return Err(ConfigError::parse(
"token_claims.system_when.issuer: set the EXACT first-party issuer permitted to \
mint the system principal",
));
}
if sw.claim.is_empty() {
return Err(ConfigError::parse(
"token_claims.system_when.claim: must be a non-empty claim name",
));
}
if sw.equals.is_empty() {
return Err(ConfigError::parse(
"token_claims.system_when.equals: set the exact claim value that confers system",
));
}
let exact_ok = if multi {
self.issuer_trust
.as_ref()
.is_some_and(|t| t.allow.iter().any(|a| a == &sw.issuer))
} else {
!self.issuer.is_empty() && self.issuer == sw.issuer
};
if !exact_ok {
return Err(ConfigError::parse(format!(
"token_claims.system_when.issuer {:?} must be an EXACT trusted issuer — the \
single `issuer`, or a member of `issuer_trust.allow`. A `suffix`-matched or \
discovered issuer may NEVER mint the node-wide system principal (S0)",
sw.issuer
)));
}
}
if !multi {
return Ok(());
}
let trust = self.issuer_trust.as_ref().ok_or_else(|| {
ConfigError::parse("token_claims: `jwks` (multi-issuer) requires `issuer_trust`")
})?;
let jwks = self
.jwks
.as_ref()
.ok_or_else(|| ConfigError::parse("token_claims: `issuer_trust` requires `jwks`"))?;
if trust.allow.is_empty() && trust.suffix.is_none() {
return Err(ConfigError::parse(
"token_claims.issuer_trust: set `allow` and/or `suffix` (deny-by-default)",
));
}
for a in &trust.allow {
validate_issuer_entry(a)?;
}
if let Some(sfx) = &trust.suffix {
validate_issuer_suffix(sfx)?;
}
match (jwks.discover.as_deref(), jwks.template.as_deref()) {
(Some("oidc"), None) => {}
(Some(other), None) => {
return Err(ConfigError::parse(format!(
"token_claims.jwks.discover must be \"oidc\" (got {other:?})"
)));
}
(None, Some(t)) if t.starts_with("{iss}") => {}
(None, Some(t)) => {
return Err(ConfigError::parse(format!(
"token_claims.jwks.template must START with the `{{iss}}` placeholder (so the \
JWKS host is the issuer host), e.g. \"{{iss}}/id/keys\" (got {t:?})"
)));
}
(None, None) => {
return Err(ConfigError::parse(
"token_claims.jwks: set exactly one of `discover: \"oidc\"` or `template`",
));
}
(Some(_), Some(_)) => {
return Err(ConfigError::parse(
"token_claims.jwks: set exactly one of `discover` or `template`, not both",
));
}
}
Ok(())
}
}
fn validate_issuer_entry(entry: &str) -> Result<(), ConfigError> {
let rest = entry.strip_prefix("https://").ok_or_else(|| {
ConfigError::parse(format!(
"token_claims.issuer_trust.allow: {entry:?} must be an https:// issuer"
))
})?;
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
if authority.is_empty() {
return Err(ConfigError::parse(format!(
"token_claims.issuer_trust.allow: {entry:?} has no host"
)));
}
if authority.contains('@') {
return Err(ConfigError::parse(format!(
"token_claims.issuer_trust.allow: {entry:?} must not contain userinfo (`@`)"
)));
}
Ok(())
}
fn validate_issuer_suffix(suffix: &str) -> Result<(), ConfigError> {
let bare = suffix.strip_prefix('.').ok_or_else(|| {
ConfigError::parse(format!(
"token_claims.issuer_trust.suffix: {suffix:?} must start with a dot (anchored, e.g. \
\".my.salesforce.com\")"
))
})?;
if bare.contains("://") || bare.contains(['/', '@', ':', '?', '#']) || bare.ends_with('.') {
return Err(ConfigError::parse(format!(
"token_claims.issuer_trust.suffix: {suffix:?} must be a bare host suffix (no scheme, \
path, userinfo, or port)"
)));
}
if bare.split('.').filter(|l| !l.is_empty()).count() < 2 {
return Err(ConfigError::parse(format!(
"token_claims.issuer_trust.suffix: {suffix:?} is too broad — need at least two labels \
(never a bare TLD)"
)));
}
Ok(())
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HandlerGraphqlTableConfig {
pub columns: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub row_filter: Vec<HandlerGraphqlRowTerm>,
#[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub resolvers: std::collections::BTreeMap<String, String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HandlerGraphqlRowTerm {
pub column: String,
pub claim: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HandlerCacheConfig {
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_entry_bytes: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_ttl_secs: Option<u64>,
}
impl SiteConfig {
pub fn from_json(bytes: &[u8]) -> Result<Self, ConfigError> {
serde_json::from_slice(bytes).map_err(|err| ConfigError::parse(err.to_string()))
}
pub fn to_json(&self) -> Result<Vec<u8>, ConfigError> {
serde_json::to_vec(self).map_err(|err| ConfigError::parse(err.to_string()))
}
pub fn validate_allow_imports(&self) -> Result<(), ConfigError> {
if let Some(handlers) = &self.handlers {
for import in &handlers.allow_imports {
check_import(import)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DomainConfig {
pub primary: Option<String>,
pub aliases: Vec<String>,
pub wildcards: Vec<String>,
pub canonical_redirect: bool,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub contexts: BTreeMap<String, String>,
}
impl DomainConfig {
pub fn exact_hosts(&self) -> impl Iterator<Item = &str> {
self.primary
.as_deref()
.into_iter()
.chain(self.aliases.iter().map(String::as_str))
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SecurityConfig {
pub https_redirect: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub hsts: Option<Hsts>,
#[serde(skip_serializing_if = "Option::is_none")]
pub csp: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub frame_options: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Hsts {
pub max_age: u64,
pub include_subdomains: bool,
pub preload: bool,
}
impl Default for Hsts {
fn default() -> Self {
Self {
max_age: 31_536_000,
include_subdomains: true,
preload: false,
}
}
}
impl Hsts {
pub fn header_value(&self) -> String {
let mut v = format!("max-age={}", self.max_age);
if self.include_subdomains {
v.push_str("; includeSubDomains");
}
if self.preload {
v.push_str("; preload");
}
v
}
}
pub fn transport_redirect(
security: &SecurityConfig,
domains: &DomainConfig,
scheme: &str,
host: &str,
path_and_query: &str,
) -> Option<String> {
let target_scheme = if security.https_redirect && scheme == "http" {
"https"
} else {
scheme
};
let target_host = match &domains.primary {
Some(primary)
if domains.canonical_redirect
&& primary != host
&& domains.aliases.iter().any(|a| a == host) =>
{
primary.as_str()
}
_ => host,
};
if target_scheme == scheme && target_host == host {
return None;
}
Some(format!("{target_scheme}://{target_host}{path_and_query}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_config_uses_defaults() {
let config = DeployConfig::from_ron("()").unwrap();
assert_eq!(config.index, vec!["index.html".to_string()]);
assert_eq!(config.trailing_slash, TrailingSlash::Preserve);
assert!(config.redirects.is_empty());
}
#[test]
fn consumer_per_consumer_tuning_is_optional_and_round_trips() {
let bare: ConsumerConfig =
serde_json::from_str(r#"{"topic":"orders","component":"c.wasm"}"#).unwrap();
assert_eq!(bare.lease_ms, None);
assert_eq!(bare.max_attempts, None);
assert_eq!(bare.max_batch, None);
let tuned: ConsumerConfig = serde_json::from_str(
r#"{"topic":"orders","component":"c.wasm","lease_ms":5000,"max_attempts":10,"max_batch":64}"#,
)
.unwrap();
assert_eq!(tuned.lease_ms, Some(5000));
assert_eq!(tuned.max_attempts, Some(10));
assert_eq!(tuned.max_batch, Some(64));
let reparsed: ConsumerConfig =
serde_json::from_str(&serde_json::to_string(&tuned).unwrap()).unwrap();
assert_eq!(reparsed.lease_ms, Some(5000));
assert_eq!(reparsed.max_attempts, Some(10));
assert_eq!(reparsed.max_batch, Some(64));
}
#[test]
fn secret_allowlist_field_defaults_empty_and_round_trips() {
let bare: HandlerConfig =
serde_json::from_str(r#"{"route":"/*","component":"h.wasm"}"#).unwrap();
assert!(
bare.secrets.is_empty(),
"absent ⇒ empty (inject-all default)"
);
let scoped: HandlerConfig = serde_json::from_str(
r#"{"route":"/*","component":"h.wasm","secrets":["SECRET_A","SECRET_B"]}"#,
)
.unwrap();
assert_eq!(
scoped.secrets,
vec!["SECRET_A".to_string(), "SECRET_B".to_string()]
);
let reparsed: HandlerConfig =
serde_json::from_str(&serde_json::to_string(&scoped).unwrap()).unwrap();
assert_eq!(reparsed.secrets, scoped.secrets);
let json = serde_json::to_string(&bare).unwrap();
assert!(
!json.contains("secrets"),
"empty allowlist is not serialized: {json}"
);
let consumer: ConsumerConfig = serde_json::from_str(
r#"{"topic":"orders","component":"c.wasm","secrets":["SECRET_A"]}"#,
)
.unwrap();
assert_eq!(consumer.secrets, vec!["SECRET_A".to_string()]);
let bare_consumer: ConsumerConfig =
serde_json::from_str(r#"{"topic":"orders","component":"c.wasm"}"#).unwrap();
assert!(bare_consumer.secrets.is_empty());
}
#[test]
fn edge_hidden_fields_default_empty_and_round_trip() {
let bare: HandlerGraphqlConfig =
serde_json::from_str(r#"{"enabled":true,"federated":true}"#).unwrap();
assert!(bare.edge_hidden_operations.is_empty());
assert!(bare.edge_hidden_subgraphs.is_empty());
let json = serde_json::to_string(&bare).unwrap();
assert!(
!json.contains("edge_hidden"),
"empty edge-hidden manifest is not serialized: {json}"
);
let scoped: HandlerGraphqlConfig = serde_json::from_str(
r#"{"enabled":true,"federated":true,"edge_hidden_operations":["identity.socialLogin","identity.exchange"],"edge_hidden_subgraphs":["identity"]}"#,
)
.unwrap();
assert_eq!(
scoped.edge_hidden_operations,
vec![
"identity.socialLogin".to_string(),
"identity.exchange".to_string()
]
);
assert_eq!(scoped.edge_hidden_subgraphs, vec!["identity".to_string()]);
let reparsed: HandlerGraphqlConfig =
serde_json::from_str(&serde_json::to_string(&scoped).unwrap()).unwrap();
assert_eq!(
reparsed.edge_hidden_operations,
scoped.edge_hidden_operations
);
assert_eq!(reparsed.edge_hidden_subgraphs, scoped.edge_hidden_subgraphs);
}
#[test]
fn transport_redirect_https_canonical_and_noop() {
let mut domains = DomainConfig {
primary: Some("example.com".into()),
aliases: vec!["www.example.com".into()],
..Default::default()
};
let mut sec = SecurityConfig::default();
assert_eq!(
transport_redirect(&sec, &domains, "http", "example.com", "/a?b=1"),
None
);
sec.https_redirect = true;
assert_eq!(
transport_redirect(&sec, &domains, "http", "example.com", "/a?b=1").as_deref(),
Some("https://example.com/a?b=1")
);
assert_eq!(
transport_redirect(&sec, &domains, "https", "example.com", "/a"),
None
);
domains.canonical_redirect = true;
assert_eq!(
transport_redirect(&sec, &domains, "http", "www.example.com", "/p").as_deref(),
Some("https://example.com/p")
);
assert_eq!(
transport_redirect(&sec, &domains, "https", "example.com", "/p"),
None
);
assert_eq!(
transport_redirect(&sec, &domains, "https", "blog.example.com", "/p"),
None
);
sec.https_redirect = false;
assert_eq!(
transport_redirect(&sec, &domains, "https", "www.example.com", "/p").as_deref(),
Some("https://example.com/p"),
"canonical redirect applies even without https_redirect"
);
}
#[test]
fn hsts_header_value() {
assert_eq!(
Hsts::default().header_value(),
"max-age=31536000; includeSubDomains"
);
assert_eq!(
Hsts {
max_age: 60,
include_subdomains: false,
preload: true
}
.header_value(),
"max-age=60; preload"
);
}
#[test]
fn secret_heuristic_flags_credentials_not_plain_config() {
for ok in [
"info",
"production",
"https://api.example.com/v1",
"3000",
"en-US,en;q=0.9",
"a-normal-kebab-case-flag",
] {
assert!(!looks_like_secret(ok), "false positive on {ok:?}");
}
let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIabc\n-----END RSA PRIVATE KEY-----";
for bad in [
pem,
"AKIAIOSFODNN7EXAMPLE",
"ghp_16C7e42F292c6912E7710c838347Ae178B4a", "AIzaSyA-1234567890abcdefghijklmnopqrstuv", "wJalrXUtnFEMI1bK7MDENGbPxRfiCYEXAMPLEKEY12", "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef0123", ] {
assert!(looks_like_secret(bad), "missed secret {bad:?}");
}
}
#[test]
fn check_handlers_rejects_secret_in_env() {
use std::collections::BTreeMap;
let config = DeployConfig {
handlers: vec![HandlerConfig {
tenancy: None,
token_claims: None,
no_secrets: false,
route: "/h".into(),
methods: Vec::new(),
component: "h.wasm".into(),
imports: Vec::new(),
streaming: false,
limits: None,
env: BTreeMap::from([("AWS_KEY".to_string(), "AKIAIOSFODNN7EXAMPLE".to_string())]),
secrets: Vec::new(),
invoke_targets: Vec::new(),
stats_topics: Vec::new(),
tenant_secret_names: Vec::new(),
upload_containers: Vec::new(),
blobstore_containers: Vec::new(),
egress_allowlist: Vec::new(),
}],
..Default::default()
};
let err = config.compile_check().unwrap_err().to_string();
assert!(err.contains("looks like a secret"), "got: {err}");
}
#[test]
fn check_handlers_validates_sessions() {
let ok = DeployConfig {
sessions: vec![SessionConfig {
route: "/agent".into(),
component: "agent.wasm".into(),
imports: vec!["session".into(), "sql".into()],
..Default::default()
}],
..Default::default()
};
ok.compile_check().expect("valid session config");
let no_component = DeployConfig {
sessions: vec![SessionConfig {
route: "/agent".into(),
component: String::new(),
..Default::default()
}],
..Default::default()
};
assert!(
no_component
.compile_check()
.unwrap_err()
.to_string()
.contains("empty component")
);
let bad_import = DeployConfig {
sessions: vec![SessionConfig {
route: "/agent".into(),
component: "agent.wasm".into(),
imports: vec!["not-a-capability".into()],
..Default::default()
}],
..Default::default()
};
assert!(
bad_import
.compile_check()
.unwrap_err()
.to_string()
.contains("unknown handler import")
);
}
#[test]
fn parses_a_full_document() {
let text = r#"(
clean_urls: true,
trailing_slash: Never,
error_documents: { 404: "/404.html" },
redirects: [ (from: "/old/:slug", to: "/new/:slug", status: 301) ],
rewrites: [ (from: "/app/**", to: "/index.html") ],
headers: [ (matches: "**.js", set: { "Cache-Control": "public, max-age=31536000, immutable" }) ],
cache: ( default: "public, max-age=0, must-revalidate" ),
mime_overrides: { ".webmanifest": "application/manifest+json" },
)"#;
let config = DeployConfig::from_ron(text).unwrap();
assert!(config.clean_urls);
assert_eq!(config.trailing_slash, TrailingSlash::Never);
assert_eq!(config.redirects[0].status, 301);
assert_eq!(config.rewrites[0].status, 200); assert_eq!(
config.error_documents.get(&404).map(String::as_str),
Some("/404.html")
);
}
#[test]
fn rejects_bad_pattern_at_parse() {
let text = r#"( redirects: [ (from: "/a/**/b/**", to: "/x") ] )"#;
assert!(DeployConfig::from_ron(text).is_err());
}
#[test]
fn rejects_unknown_field() {
assert!(DeployConfig::from_ron("( nope: true )").is_err());
}
#[test]
fn accepts_named_sql_imports_and_rejects_malformed_ones() {
use super::check_import;
assert!(check_import("sql").is_ok());
assert!(check_import("sql:product").is_ok());
assert!(check_import("sql:privileged_2-a").is_ok());
assert!(check_import("sql:*").is_ok());
assert!(check_import("sql:").is_err());
assert!(check_import("sql:a/b").is_err());
assert!(check_import("sql:a b").is_err());
assert!(check_import("session").is_ok());
assert!(check_import("tenancy").is_ok());
assert!(check_import("messaging-stats").is_ok());
assert!(check_import("tenant-secrets:read").is_ok());
assert!(check_import("tenant-secrets:admin").is_ok());
assert!(super::is_named_tenant_secrets_import("tenant-secrets:read"));
assert!(super::is_named_tenant_secrets_import(
"tenant-secrets:admin"
));
assert!(check_import("tenant-secrets").is_err());
assert!(check_import("tenant-secrets:*").is_err());
assert!(check_import("tenant-secrets:write").is_err());
assert!(check_import("wasi:sockets").is_ok());
assert!(check_import("wasi:filesystem").is_err());
}
#[test]
fn handler_config_with_a_sockets_import_and_egress_allowlist_round_trips() {
let text = r#"(
handlers: [
( route: "/imap", methods: ["POST"],
component: "handlers/imap.wasm",
imports: ["wasi:sockets"],
egress_allowlist: ["imap.example.com:993", "10.0.0.0/8:5432"] ),
],
consumers: [
( topic: "mail/fetch", component: "handlers/fetch.wasm",
imports: ["wasi:sockets"],
egress_allowlist: ["pop.example.com:995"] ),
],
)"#;
let config = DeployConfig::from_ron(text).expect("a wasi:sockets manifest parses");
assert_eq!(config.handlers[0].imports, vec!["wasi:sockets".to_string()]);
assert_eq!(
config.handlers[0].egress_allowlist,
vec![
"imap.example.com:993".to_string(),
"10.0.0.0/8:5432".to_string()
]
);
assert_eq!(
config.consumers[0].egress_allowlist,
vec!["pop.example.com:995".to_string()]
);
let empty = DeployConfig::from_ron(
r#"( handlers: [ ( route: "/x", component: "x.wasm", imports: ["wasi:sockets"] ) ] )"#,
)
.expect("parses without an allowlist");
assert!(empty.handlers[0].egress_allowlist.is_empty());
}
#[test]
fn accepts_named_blob_upload_imports_and_rejects_malformed_ones() {
use super::{check_import, is_named_blob_upload_import};
assert!(is_named_blob_upload_import("blob-upload:write"));
assert!(is_named_blob_upload_import("blob-upload:multipart"));
assert!(check_import("blob-upload:write").is_ok());
assert!(check_import("blob-upload:multipart").is_ok());
assert!(!is_named_blob_upload_import("blob-upload"));
assert!(!is_named_blob_upload_import("blob-upload:read"));
assert!(!is_named_blob_upload_import("blob-upload:*"));
assert!(check_import("blob-upload").is_err());
assert!(check_import("blob-upload:read").is_err());
assert!(check_import("blob-upload:*").is_err());
}
#[test]
fn handler_config_with_a_blob_upload_import_passes_check_import() {
let text = r#"(
handlers: [
( route: "/api/upload", methods: ["POST"],
component: "handlers/upload.wasm",
imports: ["blob-upload:write", "blob-upload:multipart"],
upload_containers: ["assets-{tenant}"] ),
],
)"#;
let config = DeployConfig::from_ron(text).expect("a blob-upload manifest now parses");
assert_eq!(
config.handlers[0].imports,
vec![
"blob-upload:write".to_string(),
"blob-upload:multipart".to_string()
]
);
assert_eq!(
config.handlers[0].upload_containers,
vec!["assets-{tenant}".to_string()]
);
}
#[test]
fn proxy_allow_list_matching() {
assert!(DeployConfig::default().proxy_host_allowed("anything.example"));
let cfg = DeployConfig {
proxy_allow: vec!["api.example.com".into(), ".internal.test".into()],
..DeployConfig::default()
};
assert!(cfg.proxy_host_allowed("api.example.com")); assert!(cfg.proxy_host_allowed("API.EXAMPLE.COM")); assert!(cfg.proxy_host_allowed("a.internal.test")); assert!(cfg.proxy_host_allowed("internal.test")); assert!(!cfg.proxy_host_allowed("evil.com"));
assert!(!cfg.proxy_host_allowed("notapi.example.com"));
}
#[test]
fn parses_handler_config() {
let text = r#"(
handlers: [
( route: "/api/orders/*", methods: ["GET", "POST"],
component: "handlers/orders.wasm",
imports: ["sql", "wasi:keyvalue", "wasi:messaging", "messaging-stats"],
stats_topics: ["bus:sync/{tenant}/import"],
limits: ( memory_mb: 64, timeout_ms: 10000 ),
env: { "LOG_LEVEL": "info" } ),
],
consumers: [
( topic: "orders/created", component: "handlers/agg.wasm",
imports: ["sql", "messaging-stats"],
stats_topics: ["bus:sync/{tenant}/import"] ),
],
crons: [ ( schedule: "0 */6 * * *", route: "/api/orders/reindex", overlap: Skip ) ],
streams: [ ( route: "/events/orders", topics: ["orders/created"] ) ],
)"#;
let config = DeployConfig::from_ron(text).unwrap();
assert_eq!(config.handlers.len(), 1);
assert_eq!(config.handlers[0].imports.len(), 4);
assert_eq!(
config.handlers[0].limits.as_ref().unwrap().memory_mb,
Some(64)
);
assert_eq!(
config.handlers[0].stats_topics,
vec!["bus:sync/{tenant}/import".to_string()]
);
assert_eq!(config.consumers.len(), 1);
assert_eq!(
config.consumers[0].stats_topics,
vec!["bus:sync/{tenant}/import".to_string()]
);
assert_eq!(config.crons[0].overlap, Overlap::Skip);
assert_eq!(config.streams[0].topics, vec!["orders/created".to_string()]);
}
#[test]
fn site_config_round_trips_through_json_and_ron() {
let cfg = SiteConfig {
handlers: Some(HandlersSiteConfig {
enabled: true,
allow_imports: vec!["sql".into(), "graphql".into()],
cache: Some(HandlerCacheConfig {
enabled: true,
max_entry_bytes: Some(262_144),
max_ttl_secs: Some(600),
}),
graphql: Some(HandlerGraphqlConfig {
enabled: true,
federated: true,
max_depth: Some(12),
enforce_safelist: true,
..Default::default()
}),
cookie_auth: Some(CookieAuthConfig {
cookie_name: "__Host-session".into(),
allowed_origins: vec!["https://app.example.com".into()],
}),
..Default::default()
}),
..Default::default()
};
let json = serde_json::to_vec(&cfg).unwrap();
let from_json = SiteConfig::from_json(&json)
.unwrap_or_else(|e| panic!("SiteConfig JSON round-trip failed to parse: {e}"));
assert_eq!(
from_json, cfg,
"SiteConfig did not survive a JSON round-trip"
);
let ron = ron::ser::to_string(&cfg).unwrap();
let from_ron: SiteConfig = ron::from_str(&ron)
.unwrap_or_else(|e| panic!("SiteConfig RON round-trip failed to parse: {e}\n{ron}"));
assert_eq!(from_ron, cfg, "SiteConfig did not survive a RON round-trip");
}
#[test]
fn handler_validation_rejects_bad_config() {
assert!(
DeployConfig::from_ron(
r#"( handlers: [ ( route: "/a", component: "a.wasm", imports: ["wasi:gpu"] ) ] )"#
)
.is_err()
);
assert!(
DeployConfig::from_ron(
r#"( handlers: [ ( route: "/a", component: "a.wasm", methods: ["GET THING"] ) ] )"#
)
.is_err()
);
assert!(
DeployConfig::from_ron(
r#"( handlers: [ ( route: "/a", component: "a.wasm" ) ],
crons: [ ( schedule: "* * * * *", route: "/nope" ) ] )"#
)
.is_err()
);
assert!(
DeployConfig::from_ron(
r#"( handlers: [ ( route: "/tasks/*", component: "a.wasm" ) ],
crons: [ ( schedule: "0 0 * * *", route: "/tasks/x" ) ] )"#
)
.is_ok()
);
}
#[test]
fn http_methods_accept_tokens_reject_non_tokens() {
for m in STANDARD_METHODS {
assert!(
check_http_method(m).is_ok(),
"standard verb {m} must validate"
);
}
for m in [
"PROPFIND",
"PROPPATCH",
"REPORT",
"MKCALENDAR",
"MKCOL",
"MOVE",
"COPY",
"ACL", ] {
assert!(
check_http_method(m).is_ok(),
"extension verb {m} must validate"
);
}
assert!(check_http_method(METHOD_WILDCARD).is_ok());
for bad in [
"", "GET THING", "GE/T", "GET\r\nHost", "GET\tX", "G(ET)", "GET;X", "GET@X", ] {
assert!(
check_http_method(bad).is_err(),
"non-token method {bad:?} must be rejected"
);
}
}
#[test]
fn cron_schedule_validation() {
for ok in [
"* * * * *",
"0 */6 * * *",
"30 2 1 1 0",
"0,15,30,45 9-17 * * 1-5",
] {
assert!(check_cron_schedule(ok).is_ok(), "{ok} should be valid");
}
for bad in [
"* * * *", "60 * * * *", "* 24 * * *", "* * 0 * *", "* * * 13 *", "*/0 * * * *", "5-1 * * * *", ] {
assert!(check_cron_schedule(bad).is_err(), "{bad} should be invalid");
}
}
#[test]
fn handler_free_config_omits_handler_fields() {
let json = serde_json::to_string(&DeployConfig::default()).unwrap();
assert!(!json.contains("handlers"));
assert!(!json.contains("crons"));
}
#[test]
fn schema_version_defaults_to_one() {
assert_eq!(DeployConfig::from_ron("()").unwrap().version, 1);
assert_eq!(DeployConfig::from_ron("(version: 1)").unwrap().version, 1);
assert_eq!(SiteConfig::default().version, 1);
assert_eq!(SiteConfig::from_json(b"{}").unwrap().version, 1);
}
#[test]
fn system_when_requires_an_exact_trusted_issuer_s0() {
let sw = |iss: &str| SystemWhen {
issuer: iss.into(),
claim: "role".into(),
equals: "super_admin".into(),
};
let mut single = HandlerGraphqlTokenClaims {
issuer: "https://admin.acme.example".into(),
jwks_env: Some("ADMIN_JWKS".into()),
system_when: Some(sw("https://admin.acme.example")),
..Default::default()
};
single
.validate()
.expect("exact single issuer confers system");
single.system_when = Some(sw("https://other.example"));
assert!(
single.validate().is_err(),
"non-matching single issuer must be refused"
);
let multi = |iss: &str| HandlerGraphqlTokenClaims {
issuer_trust: Some(IssuerTrust {
allow: vec!["https://admin.acme.example".into()],
suffix: Some(".my.salesforce.com".into()),
}),
jwks: Some(JwksDiscovery {
template: Some("{iss}/id/keys".into()),
..Default::default()
}),
system_when: Some(sw(iss)),
..Default::default()
};
multi("https://admin.acme.example")
.validate()
.expect("an issuer in `allow` confers system");
assert!(
multi("https://evilorg.my.salesforce.com")
.validate()
.is_err(),
"a suffix-matched issuer must NEVER be allowed to mint the system principal (S0)"
);
let mut bad = multi("https://admin.acme.example");
bad.system_when = Some(SystemWhen {
issuer: String::new(),
claim: "role".into(),
equals: "x".into(),
});
assert!(bad.validate().is_err(), "empty system_when.issuer refused");
bad.system_when = Some(SystemWhen {
issuer: "https://admin.acme.example".into(),
claim: "role".into(),
equals: String::new(),
});
assert!(bad.validate().is_err(), "empty system_when.equals refused");
}
#[test]
fn system_when_round_trips_and_defaults_the_claim_to_role() {
let bare: HandlerGraphqlTokenClaims =
serde_json::from_str(r#"{"issuer":"https://a.example"}"#).unwrap();
assert!(bare.system_when.is_none());
let with: HandlerGraphqlTokenClaims = serde_json::from_str(
r#"{"issuer":"https://a.example","system_when":{"issuer":"https://a.example","equals":"super_admin"}}"#,
)
.unwrap();
let sw = with.system_when.as_ref().unwrap();
assert_eq!(sw.claim, "role", "claim defaults to `role`");
assert_eq!(sw.equals, "super_admin");
let reparsed: HandlerGraphqlTokenClaims =
serde_json::from_str(&serde_json::to_string(&with).unwrap()).unwrap();
assert_eq!(reparsed.system_when, with.system_when);
}
}