#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
use std::sync::Arc;
use std::time::Duration;
use crate::app::ScopedGroup;
use crate::config::AutumnConfig;
#[cfg(feature = "maud")]
use crate::error_pages::{self, SharedRenderer};
use crate::idempotency::{IdempotencyLayer, IdempotencyStore, MemoryIdempotencyStore};
use crate::middleware::RequestIdLayer;
use crate::middleware::dev;
use crate::middleware::exception_filter::{
ExceptionFilter, ExceptionFilterLayer, ProblemDetailsFilter,
};
use crate::route::Route;
use crate::state::AppState;
use axum::response::IntoResponse;
use http::{Request, StatusCode};
use thiserror::Error;
pub const DEFAULT_FAVICON_PATH: &str = "/favicon.ico";
#[derive(Debug, Error, PartialEq, Eq)]
pub enum RouterBuildError {
#[error("invalid session backend configuration: {0}")]
InvalidSessionBackend(#[from] crate::session::SessionBackendConfigError),
#[error("invalid idempotency backend configuration: {0}")]
#[allow(dead_code)] InvalidIdempotencyBackend(String),
#[error("invalid submit-token backend configuration: {0}")]
InvalidSubmitTokenBackend(String),
#[error("framework route overlap at {path}: {existing} conflicts with {incoming}")]
FrameworkRouteOverlap {
path: String,
existing: &'static str,
incoming: &'static str,
},
#[cfg(feature = "openapi")]
#[error("invalid OpenAPI {field} path: {value:?} (must start with '/' and be non-empty)")]
InvalidOpenApiPath {
field: &'static str,
value: String,
},
#[cfg(feature = "openapi")]
#[error(
"openapi_json_path and swagger_ui_path both resolve to {path:?}; they must differ or `swagger_ui_path` must be `None`"
)]
DuplicateOpenApiPath {
path: String,
},
#[cfg(feature = "openapi")]
#[error(
"OpenAPI {field} path {path:?} collides with an existing GET route; choose a different `OpenApiConfig::{field}`"
)]
OpenApiPathCollision {
field: &'static str,
path: String,
},
#[error("route '{route_name}' uses unregistered API version '{version}'")]
UnregisteredApiVersion { route_name: String, version: String },
#[cfg(feature = "mcp")]
#[error("invalid MCP mount path: {value:?} (must start with '/' and be non-empty)")]
InvalidMcpPath {
value: String,
},
#[cfg(feature = "mcp")]
#[error(
"MCP mount path {path:?} collides with an existing {method} route; choose a different `mount_mcp` path"
)]
McpPathCollision {
path: String,
method: String,
},
#[error(
"duplicate user route: {existing:?} and {incoming:?} both resolve to {method} {path:?}; \
choose a different path for one of them or remove the duplicate registration"
)]
DuplicateUserRoute {
method: String,
path: String,
existing: String,
incoming: String,
},
#[error(
"two routers are nested at {prefix:?} ({owners}); axum gives each nested router a fallback \
and cannot merge two that both have one, so the second panics at startup — give each \
router its own prefix"
)]
DuplicateNestPrefix {
prefix: String,
owners: String,
},
#[error(
"conflicting route shapes: {existing:?} ({existing_path:?}) and {incoming:?} ({incoming_path:?}) \
resolve to the same Axum path shape but use different path templates; axum's matchit router \
rejects this as a route conflict regardless of HTTP method — rename the captures so both use the \
same template, or make their static paths distinct"
)]
ConflictingRouteShape {
existing: String,
existing_path: String,
incoming: String,
incoming_path: String,
},
#[cfg(feature = "i18n")]
#[error(
"locale-prefix routing generates {generated:?} (locale {locale:?} + route {path:?}), \
which collides with another route already registered at that path — rename one of the \
routes, or exclude it via `[i18n] locale_prefix_exclude`"
)]
LocalePrefixPathCollision {
locale: String,
path: String,
generated: String,
},
}
#[allow(dead_code)]
pub fn build_router(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
) -> axum::Router {
try_build_router(route_list, config, state)
.unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
}
pub struct RouterContext {
pub exception_filters: Vec<Arc<dyn ExceptionFilter>>,
pub scoped_groups: Vec<ScopedGroup>,
pub merge_routers: Vec<axum::Router<AppState>>,
pub nest_routers: Vec<(String, axum::Router<AppState>)>,
pub declared_routes: Vec<crate::route_listing::RouteInfo>,
pub custom_layers: Vec<crate::app::CustomLayerRegistration>,
pub static_gate_layers: Vec<crate::app::CustomLayerRegistration>,
#[cfg(feature = "maud")]
pub error_page_renderer: Option<SharedRenderer>,
pub session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
#[cfg(feature = "openapi")]
pub openapi: Option<crate::openapi::OpenApiConfig>,
#[cfg(feature = "mcp")]
pub mcp: Option<crate::mcp::McpRuntime>,
#[cfg(feature = "webmcp")]
pub webmcp: Option<crate::webmcp::WebMcpRuntime>,
}
pub fn try_build_router(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
) -> Result<axum::Router, RouterBuildError> {
try_build_router_with_layers(route_list, config, state, Vec::new())
}
pub fn try_build_router_with_layers(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
custom_layers: Vec<crate::app::CustomLayerRegistration>,
) -> Result<axum::Router, RouterBuildError> {
let startup_barrier_state = state.clone();
let router = try_build_router_inner(
route_list,
config,
state,
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers,
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
},
)?;
Ok(apply_startup_barrier(
router,
config,
&startup_barrier_state,
))
}
#[allow(dead_code)]
pub fn build_router_merged(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
merge_routers: Vec<axum::Router<AppState>>,
nest_routers: Vec<(String, axum::Router<AppState>)>,
) -> axum::Router {
try_build_router_merged(route_list, config, state, merge_routers, nest_routers)
.unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
}
#[allow(dead_code)]
pub fn try_build_router_merged(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
merge_routers: Vec<axum::Router<AppState>>,
nest_routers: Vec<(String, axum::Router<AppState>)>,
) -> Result<axum::Router, RouterBuildError> {
let startup_barrier_state = state.clone();
let router = try_build_router_inner(
route_list,
config,
state,
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers,
nest_routers,
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
},
)?;
Ok(apply_startup_barrier(
router,
config,
&startup_barrier_state,
))
}
pub fn try_build_router_inner(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
ctx: RouterContext,
) -> Result<axum::Router, RouterBuildError> {
let (router, deferred_mcp) =
build_router_pre_state(route_list, config, &state, ctx, None, false, Vec::new())?;
debug_assert!(
deferred_mcp.is_none(),
"the fully-dynamic path always merges MCP internally"
);
Ok(router.with_state(state))
}
pub fn try_build_probe_only_router(
config: &AutumnConfig,
state: AppState,
) -> Result<axum::Router, RouterBuildError> {
let barrier_state = state.clone();
let no_user_routes = std::collections::HashSet::new();
let (mounted_probe_paths, router) =
mount_probe_endpoints(axum::Router::<AppState>::new(), config, &no_user_routes);
let router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
let router = router.with_state(state);
let router = apply_startup_barrier(router, config, &barrier_state);
if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
return Ok(router.layer(require_client_cert));
}
Ok(router)
}
#[cfg(feature = "mcp")]
type McpPrepared = (
String,
Vec<crate::mcp::McpToolInfo>,
Option<crate::mcp::McpEndpointLayer>,
crate::mcp::McpTaskConfig,
Option<String>,
crate::mcp::McpCatalog,
);
#[allow(clippy::too_many_lines)]
#[cfg_attr(not(feature = "mcp"), allow(clippy::needless_pass_by_value))]
fn build_router_pre_state(
route_list: Vec<Route>,
config: &AutumnConfig,
state: &AppState,
#[cfg_attr(not(feature = "mcp"), allow(unused_mut))] mut ctx: RouterContext,
opaque_app_layers_override: Option<bool>,
defer_security_headers: bool,
#[cfg_attr(not(feature = "mcp"), allow(unused_variables))] mcp_dispatch_extra_layers: Vec<
crate::app::CustomLayerRegistration,
>,
) -> Result<
(
axum::Router<AppState>,
// The mounted `/mcp` router, held back rather than merged in here
// when `defer_security_headers` is true (SSG/ISG path) — see the
// `mcp_prepared` comment below for why. `None` in the fully-dynamic
// path (merged internally, as before) and whenever MCP isn't
// configured.
Option<axum::Router<AppState>>,
),
RouterBuildError,
> {
let versions = state.extension::<crate::app::RegisteredApiVersions>();
let registered_versions: std::collections::HashSet<&str> = versions
.as_ref()
.map(|v| v.0.iter().map(|av| av.version.as_str()).collect())
.unwrap_or_default();
reject_unregistered_api_versions(&route_list, &ctx.scoped_groups, ®istered_versions)?;
reject_duplicate_user_routes(
&route_list,
&ctx.scoped_groups,
&ctx.merge_routers,
&ctx.nest_routers,
&ctx.declared_routes,
config,
)?;
#[cfg(feature = "openapi")]
reject_declared_routes_on_context_mounts(&ctx.declared_routes, ctx.openapi.as_ref(), {
#[cfg(feature = "mcp")]
{
ctx.mcp.as_ref().map(|rt| rt.mount_path.as_str())
}
#[cfg(not(feature = "mcp"))]
{
None
}
})?;
#[cfg(feature = "openapi")]
reject_openapi_path_collisions(
ctx.openapi.as_ref(),
&route_list,
&ctx.scoped_groups,
&ctx.merge_routers,
&ctx.nest_routers,
config,
)?;
#[cfg(feature = "openapi")]
let openapi_router = build_openapi_router(
&route_list,
&ctx.scoped_groups,
ctx.openapi.as_ref(),
&config.session.cookie_name,
versions.as_ref().map_or(&[], |v| v.0.as_slice()),
)?;
#[cfg(feature = "mcp")]
let mcp_prepared: Option<McpPrepared> = if let Some(rt) = ctx.mcp.take() {
let path = rt.mount_path.as_str();
validate_mcp_mount_path(path)?;
reject_mcp_path_collisions(
path,
&route_list,
&ctx.scoped_groups,
config,
ctx.openapi.as_ref(),
&ctx.merge_routers,
&ctx.nest_routers,
)?;
let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
let tools = crate::mcp::derive_tools(&docs, rt.expose_all, ctx.openapi.as_ref());
let tools = crate::mcp::drop_reserved_tools(tools, rt.catalog);
Some((
rt.mount_path,
tools,
rt.endpoint_layer,
rt.tasks,
rt.instructions,
rt.catalog,
))
} else {
None
};
#[cfg(feature = "webmcp")]
if let Some(rt) = ctx.webmcp.take() {
let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
crate::webmcp::install(state, &docs, rt, ctx.openapi.as_ref(), config);
} else {
crate::webmcp::install_empty(state, config);
}
#[cfg(feature = "openapi")]
let aeo_openapi = ctx.openapi.as_ref();
#[cfg(not(feature = "openapi"))]
let aeo_openapi: Option<&()> = None;
#[cfg(feature = "mcp")]
let aeo_mcp = mcp_prepared.as_ref();
#[cfg(not(feature = "mcp"))]
let aeo_mcp: Option<&()> = None;
if config.aeo.enabled
&& ctx.nest_routers.iter().any(|(prefix, _)| {
let prefix = prefix.trim_end_matches('/');
prefix.is_empty() || prefix == "/.well-known"
})
{
tracing::warn!(
"aeo: a router nested at /.well-known answers every path under it; the \
generated /.well-known documents (agent skills, ARD, server card) are hidden"
);
}
state.insert_extension(build_aeo_site(
config,
state,
&route_list,
&ctx.scoped_groups,
!ctx.merge_routers.is_empty(),
aeo_openapi,
aeo_mcp,
));
if config.aeo.enabled && !config.aeo.paid_routes.is_empty() {
state.insert_extension(crate::aeo::commerce::RoutedTemplates {
mounted: routed_templates(&route_list, &ctx.scoped_groups, config),
nested: route_list
.iter()
.filter(|r| mounted_paths(r.path, config) != [r.path])
.map(|r| r.path.to_owned())
.collect(),
});
}
let route_timeouts = build_route_timeout_table(&route_list, &ctx.scoped_groups, config);
let criticality_layer = crate::middleware::criticality::CriticalityLayer::from_table(
build_route_criticality_table(&route_list, &ctx.scoped_groups, config),
config.server.admission.trust_criticality_header,
);
let idempotency_layers = build_idempotency_layers(config, state)?;
let opaque_app_layers_present = opaque_app_layers_override.unwrap_or_else(|| {
custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
|| custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers)
});
let user_get_paths = collect_user_get_paths(&route_list, &ctx.scoped_groups);
let mut router = mount_user_routes(
route_list,
&ctx.scoped_groups,
&ctx.declared_routes,
idempotency_layers.as_ref(),
opaque_app_layers_present,
state,
config,
)?;
let dev_reload_enabled = dev::is_enabled_with_env(&crate::config::OsEnv);
router = mount_framework_routes(router, config, dev_reload_enabled);
let (mounted_probe_paths, router_with_probes) =
mount_probe_endpoints(router, config, &user_get_paths);
router = router_with_probes;
router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
#[cfg(feature = "openapi")]
if let Some(openapi_router) = openapi_router {
router = router.merge(openapi_router);
}
#[cfg(feature = "embed-assets")]
let embedded_static = crate::assets::embedded_static_dir().is_some();
#[cfg(not(feature = "embed-assets"))]
let embedded_static = false;
if embedded_static {
#[cfg(feature = "embed-assets")]
{
router = router.route(
"/static/{*path}",
axum::routing::get(crate::assets::serve_embedded),
);
}
} else {
let env = crate::config::OsEnv;
let static_dir = crate::app::project_dir("static", &env);
router = router.nest_service("/static", tower_http::services::ServeDir::new(&static_dir));
}
router = router.layer(crate::assets::AssetCacheControlLayer);
router = mount_scoped_groups(
router,
ctx.scoped_groups,
idempotency_layers.as_ref(),
state,
);
router = mount_raw_routers(
router,
ctx.merge_routers,
ctx.nest_routers,
idempotency_layers.as_ref(),
);
let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
let load_shed_layer = build_load_shed_layer(config, state);
#[cfg(feature = "mcp")]
let mcp_load_shed_layer = load_shed_layer.clone();
router = apply_middleware(
router,
config,
state,
ctx.exception_filters,
ctx.custom_layers,
#[cfg(feature = "maud")]
ctx.error_page_renderer,
ctx.session_store,
route_timeouts,
criticality_layer,
load_shed_layer,
defer_security_headers,
)?;
if dev_reload_enabled {
router = router.layer((
axum::middleware::from_fn(dev::inject_live_reload),
axum::middleware::from_fn(dev::disable_static_cache),
));
}
let is_dev_profile = crate::config::profile_is_dev(config.profile.as_deref());
if is_dev_profile {
router = router.route_layer(axum::middleware::from_fn(
crate::middleware::dev::capture_matched_path_middleware,
));
}
if is_dev_profile {
let buf = crate::inspector::InspectorBuffer::new(config.dev.inspector_capacity);
let inspector_path = config.dev.inspector_path.clone();
let threshold = config.dev.inspector_n_plus_one_threshold;
let mut inspector = crate::inspector::inspector_router(buf.clone(), &inspector_path);
if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
inspector = inspector.layer(require_client_cert);
}
router = router.merge(inspector);
tracing::debug!(
path = %inspector_path,
"Mounted dev request inspector"
);
let layer = crate::inspector::InspectorLayer::new(buf, threshold, inspector_path)
.with_session_cookie_name(config.session.cookie_name.clone());
router = router.layer(layer);
}
#[cfg(feature = "oauth2")]
let http_interceptor = HttpInterceptorLayer::new(state.clone());
#[cfg(not(feature = "oauth2"))]
let http_interceptor = tower::layer::util::Identity::new();
let router = router.layer((
crate::events::EventAppContextLayer::new(state.clone()),
http_interceptor,
));
#[cfg(feature = "mcp")]
let (router, deferred_mcp_router) =
if let Some((mount_path, tools, endpoint_layer, tasks, instructions, catalog)) =
mcp_prepared
{
let dispatch = apply_layers_in_registration_order(
router.clone(),
mcp_dispatch_extra_layers,
"Custom (MCP dispatch parity, static/ISR mode)",
)
.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
))
.with_state(state.clone());
let tenant_header = (config.tenancy.enabled && config.tenancy.source == "header")
.then(|| config.tenancy.header_name.clone());
let wiring = crate::mcp::McpWiring {
cors: config.cors.clone(),
trusted_hosts: TrustedHostPolicy::from_config_with_state(config, state),
tenant_header,
csrf_header: config.security.csrf.token_header.to_ascii_lowercase(),
envelope_rate_limited: config.security.rate_limit.enabled,
envelope_load_shed: mcp_load_shed_layer.is_some(),
state: state.clone(),
session_cookie: config.session.cookie_name.clone(),
tasks,
instructions,
catalog,
};
let mut mcp_router =
crate::mcp::build_mcp_router(&mount_path, tools, dispatch, wiring, endpoint_layer);
mcp_router = mcp_router.layer(build_maintenance_layer(config, state));
if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
mcp_router = mcp_router.layer(require_client_cert);
}
if let Some(load_shed) = mcp_load_shed_layer {
mcp_router = mcp_router
.layer(load_shed)
.layer(axum::Extension(crate::middleware::LoadShedEnvelope))
.layer(axum::Extension(crate::admission::Criticality::Critical));
}
mcp_router = apply_trusted_proxies_middleware(mcp_router, config);
mcp_router = mcp_router.layer(axum::extract::DefaultBodyLimit::max(
config.security.upload.max_request_size_bytes,
));
mcp_router = apply_rate_limit_middleware(mcp_router, config, state);
mcp_router = apply_request_timeout_middleware(
mcp_router,
config,
state,
std::sync::Arc::new(std::collections::HashMap::new()),
false,
);
mcp_router = mcp_router.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
));
mcp_router = crate::mcp::apply_mcp_cors_layer(mcp_router, &config.cors);
if defer_security_headers {
(router, Some(mcp_router))
} else {
(router.merge(mcp_router), None)
}
} else {
(router, None)
};
#[cfg(not(feature = "mcp"))]
let deferred_mcp_router: Option<axum::Router<AppState>> = None;
let router = if defer_security_headers {
router
} else if static_gate_layers.is_empty() {
router.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
))
} else {
tracing::debug!(
count = static_gate_layers.len(),
"Pre-static gate Tower layers applied"
);
router.layer((
crate::security::SecurityHeadersLayer::from_config(&config.security.headers),
ComposedRegisteredLayers::new(static_gate_layers),
))
};
Ok((router, deferred_mcp_router))
}
#[cfg(feature = "openapi")]
pub fn extract_path_params(path: &str) -> Vec<String> {
let mut out = Vec::new();
let mut remaining = path;
while let Some(start) = remaining.find('{') {
let after_brace = &remaining[start + 1..];
if let Some(rest) = after_brace.strip_prefix('{') {
remaining = rest;
continue;
}
let Some(end_rel) = after_brace.find('}') else {
break;
};
let inner = &after_brace[..end_rel];
let name = inner.split(':').next().unwrap_or(inner).trim();
if !name.is_empty() && !name.contains('{') && !name.contains('}') {
out.push(name.to_owned());
}
remaining = &after_brace[end_rel + 1..];
}
out
}
#[cfg(feature = "openapi")]
async fn serve_openapi_spec(
state: axum::extract::State<AppState>,
axum::extract::Extension(config): axum::extract::Extension<
std::sync::Arc<crate::openapi::OpenApiConfig>,
>,
axum::extract::Extension(docs): axum::extract::Extension<
std::sync::Arc<Vec<crate::openapi::ApiDoc>>,
>,
) -> impl axum::response::IntoResponse {
use axum::response::IntoResponse;
let refs: Vec<&crate::openapi::ApiDoc> = docs.iter().collect();
let now = state.clock().now();
let spec = crate::openapi::generate_spec_at(&config, &refs, now);
let spec_json = state.extension::<AutumnConfig>().map_or_else(
|| serde_json::to_string_pretty(&spec),
|app| crate::aeo::commerce::spec_json_with_mpp(&spec, &app),
);
let spec_json =
spec_json.unwrap_or_else(|e| format!("{{\"error\": \"failed to serialize spec: {e}\"}}"));
(
[(http::header::CONTENT_TYPE, "application/json")],
spec_json,
)
.into_response()
}
#[cfg(feature = "openapi")]
fn build_openapi_router(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
openapi_config: Option<&crate::openapi::OpenApiConfig>,
session_cookie_name: &str,
api_versions: &[crate::app::ApiVersion],
) -> Result<Option<axum::Router<AppState>>, RouterBuildError> {
let Some(config) = openapi_config else {
return Ok(None);
};
let mut config = config.clone();
session_cookie_name.clone_into(&mut config.session_cookie_name);
config.api_versions = api_versions.to_vec();
validate_openapi_mount_paths(&config)?;
let docs = collect_openapi_docs(route_list, scoped_groups);
let json_path = config.openapi_json_path.clone();
let swagger_path = config.swagger_ui_path.clone();
let title = config.title.clone();
let mut router = axum::Router::<AppState>::new()
.route(&json_path, axum::routing::get(serve_openapi_spec))
.layer(axum::extract::Extension(std::sync::Arc::new(
config.clone(),
)))
.layer(axum::extract::Extension(std::sync::Arc::new(docs)));
if let Some(path) = swagger_path {
router = mount_swagger_ui_routes(router, &path, &title, &json_path);
}
tracing::debug!(
openapi_json = %json_path,
swagger_ui = ?config.swagger_ui_path,
swagger_ui_version = crate::openapi::SWAGGER_UI_VERSION,
"Mounted OpenAPI endpoints"
);
Ok(Some(router))
}
#[allow(dead_code)]
pub fn join_nested_path(prefix: &str, child: &str) -> String {
if child == "/" || child.is_empty() {
if prefix.is_empty() {
"/".to_owned()
} else {
prefix.to_owned()
}
} else {
let prefix_trimmed = prefix.trim_end_matches('/');
if child.starts_with('/') {
format!("{prefix_trimmed}{child}")
} else {
format!("{prefix_trimmed}/{child}")
}
}
}
#[cfg(feature = "openapi")]
fn validate_route_path(field: &'static str, value: &str) -> Result<(), RouterBuildError> {
let reject = |reason_fragment: &str| {
Err(RouterBuildError::InvalidOpenApiPath {
field,
value: format!("{value:?} {reason_fragment}"),
})
};
if value.is_empty() {
return reject("(must be non-empty)");
}
if !value.starts_with('/') {
return reject("(must start with '/')");
}
if value.contains("//") {
return reject("(must not contain '//')");
}
let mut depth: i32 = 0;
for ch in value.chars() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth < 0 {
return reject("(unbalanced '}')");
}
}
'*' => return reject("(wildcard '*' is not allowed in an OpenAPI mount path)"),
_ => {}
}
}
if depth != 0 {
return reject("(unbalanced '{')");
}
if value.contains('{') {
return reject("(OpenAPI mount paths must be static; `{…}` captures are not allowed)");
}
Ok(())
}
#[cfg(feature = "http-client")]
fn aeo_x402_layer(
config: &AutumnConfig,
state: &AppState,
static_paths: crate::aeo::commerce::StaticPaths,
) -> tower::util::Either<crate::aeo::commerce::X402Layer, tower::layer::util::Identity> {
tower::util::option_layer(
crate::aeo::commerce::X402Layer::shared(config, state).map(|layer| match static_paths {
Some(paths) => layer.for_static_pages(paths),
None => layer,
}),
)
}
#[cfg(not(feature = "http-client"))]
fn aeo_x402_layer(
config: &AutumnConfig,
state: &AppState,
static_paths: crate::aeo::commerce::StaticPaths,
) -> tower::util::Either<crate::aeo::commerce::X402Unavailable, tower::layer::util::Identity> {
tower::util::option_layer(
crate::aeo::commerce::X402Unavailable::from_config(config, state).map(|layer| {
match static_paths {
Some(paths) => layer.for_static_pages(paths),
None => layer,
}
}),
)
}
struct HomeRoutes {
routed: Option<bool>,
scoped: Option<bool>,
}
impl HomeRoutes {
fn find(route_list: &[Route], scoped_groups: &[ScopedGroup]) -> Self {
let routed: Vec<&Route> = route_list
.iter()
.filter(|r| r.method == http::Method::GET && r.path == "/")
.collect();
let scoped: Vec<&Route> = scoped_groups
.iter()
.flat_map(|g| {
g.routes.iter().filter(|r| {
r.method == http::Method::GET && join_nested_path(&g.prefix, r.path) == "/"
})
})
.collect();
let home = |routes: &[&Route]| {
(!routes.is_empty()).then(|| {
routes.iter().any(|r| {
r.seo
.robots
.is_some_and(crate::seo::robots_directive_is_noindex)
})
})
};
Self {
routed: home(&routed),
scoped: home(&scoped),
}
}
}
#[cfg_attr(
not(all(feature = "openapi", feature = "mcp")),
allow(clippy::needless_pass_by_value, unused_variables)
)]
fn build_aeo_site<O: AeoOpenApiFacts, M: AeoMcpFacts>(
config: &AutumnConfig,
state: &AppState,
route_list: &[Route],
scoped_groups: &[ScopedGroup],
merged_routers: bool,
openapi: Option<&O>,
mcp: Option<&M>,
) -> crate::aeo::AeoSite {
use crate::aeo::documents::{PageFacts, SiteFacts};
let page = |path: String, seo: crate::seo::SeoRouteDefaults| {
let title = seo.title?;
let indexable = !seo
.robots
.is_some_and(crate::seo::robots_directive_is_noindex);
(indexable && !path.contains('{') && !path.contains('*')).then(|| PageFacts {
path,
title: title.to_owned(),
description: seo.description.map(str::to_owned),
})
};
let pages: Vec<PageFacts> = route_list
.iter()
.filter(|r| r.method == http::Method::GET)
.filter_map(|r| page(r.path.to_owned(), r.seo))
.collect();
#[cfg(feature = "i18n")]
let locales = if config.i18n.locale_prefix_enabled {
validated_locale_prefix_locales(&config.i18n)
} else {
Vec::new()
};
#[cfg(feature = "i18n")]
let localize = |pages: Vec<PageFacts>| {
crate::aeo::localize_pages(
pages,
&locales,
&config.i18n.locale_prefix_exclude,
&config.i18n.locale_prefix_exclude_exact,
)
};
#[cfg(not(feature = "i18n"))]
let localize = |pages: Vec<PageFacts>| pages;
let mut pages = localize(pages);
let home_paths: Vec<String> = localize(vec![PageFacts {
path: "/".to_owned(),
..PageFacts::default()
}])
.into_iter()
.map(|p| p.path)
.collect();
for group in scoped_groups {
pages.extend(
group
.routes
.iter()
.filter(|r| r.method == http::Method::GET)
.filter_map(|r| page(join_nested_path(&group.prefix, r.path), r.seo)),
);
}
pages.sort_by(|a, b| a.path.cmp(&b.path));
pages.dedup_by(|a, b| a.path == b.path);
let home = HomeRoutes::find(route_list, scoped_groups);
let has_home = merged_routers || home.routed.is_some() || home.scoped.is_some();
let mut noindex_homes = Vec::new();
if home.routed == Some(true) {
noindex_homes.clone_from(&home_paths);
}
if home.scoped == Some(true) {
noindex_homes.push("/".to_owned());
}
let mut home_paths = if home.routed.is_some() {
home_paths
} else {
Vec::new()
};
if home.routed.is_none() || home.scoped.is_some() {
home_paths.push("/".to_owned());
}
home_paths.dedup();
let mut facts = SiteFacts {
pages,
home_paths,
noindex_homes,
no_home: !has_home,
health_path: config.health.enabled.then(|| config.health.path.clone()),
skills: state
.extension::<crate::aeo::RegisteredAgentSkills>()
.map(|s| s.0.clone())
.unwrap_or_default(),
commerce: state
.extension::<crate::aeo::RegisteredCommerceDocs>()
.map(|c| (*c).clone())
.unwrap_or_default(),
..SiteFacts::default()
};
#[cfg(feature = "webmcp")]
if let Some(manifest) = state.extension::<crate::webmcp::WebMcpManifest>() {
facts.webmcp_projected = manifest.names().to_vec();
}
if let Some(api) = openapi {
api.fill(&mut facts);
}
if let Some(mcp) = mcp {
mcp.fill(&mut facts);
}
crate::aeo::AeoSite::new(config, facts)
}
trait AeoOpenApiFacts {
fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts);
}
impl AeoOpenApiFacts for () {
fn fill(&self, _: &mut crate::aeo::documents::SiteFacts) {}
}
#[cfg(feature = "openapi")]
impl AeoOpenApiFacts for crate::openapi::OpenApiConfig {
fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts) {
facts.name = Some(self.title.clone()).filter(|t| !t.trim().is_empty());
facts.description.clone_from(&self.description);
facts.openapi = Some(crate::aeo::documents::OpenApiFacts {
json_path: self.openapi_json_path.clone(),
docs_path: self.swagger_ui_path.clone(),
version: self.version.clone(),
});
}
}
trait AeoMcpFacts {
fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts);
}
impl AeoMcpFacts for () {
fn fill(&self, _: &mut crate::aeo::documents::SiteFacts) {}
}
#[cfg(feature = "mcp")]
impl AeoMcpFacts for McpPrepared {
fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts) {
let (path, tools, endpoint_layer, ..) = self;
let public_tools = endpoint_layer.is_none();
let tools = if public_tools {
tools
.iter()
.map(|t| crate::aeo::documents::ToolFacts {
name: t.name().to_owned(),
description: t.description().map(str::to_owned),
input_schema: t.input_schema().clone(),
annotations: t.annotations().clone(),
})
.collect()
} else {
Vec::new()
};
facts.mcp = Some(crate::aeo::documents::McpFacts {
path: path.clone(),
tools,
public_tools,
});
}
}
fn collect_user_get_paths(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
) -> std::collections::HashSet<String> {
let mut owned: std::collections::HashSet<String> = std::collections::HashSet::new();
for route in route_list {
if route.method == http::Method::GET || route.method.as_str() == "WS" {
owned.insert(route.path.to_owned());
}
}
for group in scoped_groups {
for route in &group.routes {
if route.method == http::Method::GET || route.method.as_str() == "WS" {
owned.insert(join_nested_path(&group.prefix, route.path));
}
}
}
owned
}
const fn framework_namespaces() -> &'static [&'static str] {
&["/static", "/_autumn"]
}
fn path_is_under_namespace(path: &str, namespace: &str) -> bool {
path == namespace
|| path
.strip_prefix(namespace)
.is_some_and(|rest| rest.starts_with('/'))
}
fn collect_framework_get_paths(config: &AutumnConfig) -> std::collections::HashSet<String> {
let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
if config.health.enabled {
claimed.insert(config.health.path.clone());
claimed.insert(config.health.live_path.clone());
claimed.insert(config.health.ready_path.clone());
claimed.insert(config.health.startup_path.clone());
}
let actuator_mutating = crate::actuator::actuator_mutating_routes(
&config.actuator.prefix,
config.actuator.sensitive,
);
let mutating_paths: std::collections::HashSet<&str> = actuator_mutating
.iter()
.map(|(_, path)| path.as_str())
.collect();
for path in crate::actuator::actuator_endpoint_paths(
&config.actuator.prefix,
config.actuator.sensitive,
config.actuator.prometheus,
) {
if mutating_paths.contains(path.as_str()) {
continue;
}
claimed.insert(path);
}
#[cfg(feature = "htmx")]
{
for asset in crate::htmx::framework_scripts() {
claimed.insert(asset.plain_url().to_owned());
claimed.insert(asset.url().to_owned());
}
}
#[cfg(feature = "flash")]
claimed.insert(crate::flash::FLASH_CSS_PATH.to_owned());
#[cfg(feature = "maud")]
claimed.insert(crate::ui::WIDGETS_CSS_PATH.to_owned());
if dev::is_enabled_with_env(&crate::config::OsEnv) {
claimed.insert(dev::LIVE_RELOAD_PATH.to_owned());
claimed.insert(dev::LIVE_RELOAD_SCRIPT_PATH.to_owned());
}
if matches!(config.profile.as_deref(), Some("dev" | "development")) {
claimed.extend(crate::inspector::inspector_endpoint_paths(
&config.dev.inspector_path,
));
}
#[cfg(feature = "mail")]
if config
.mail
.preview_routes_enabled(config.profile.as_deref())
{
claimed.insert(crate::mail::MAIL_PREVIEW_PATH.to_owned());
claimed.insert("/_autumn/mail/messages/{message_id}".to_owned());
claimed.insert("/_autumn/mail/previews/{mailer}/{method}".to_owned());
}
#[cfg(feature = "maud")]
if config.stories.enabled {
claimed.insert(crate::stories::STORIES_PATH.to_owned());
claimed.insert("/_stories/{slug}".to_owned());
claimed.insert("/_stories/demo/search".to_owned());
claimed.insert("/_stories/demo/tags/search".to_owned());
claimed.insert("/_stories/demo/posts/feed".to_owned());
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
claimed.insert(crate::mail::UNSUBSCRIBE_PATH.to_owned());
}
if config.jobs.tracking.route_enabled {
claimed.insert(crate::job_tracking::JOB_STATUS_ROUTE_PATH.to_owned());
}
claimed
}
#[cfg(feature = "openapi")]
fn collect_claimed_get_paths(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
config: &AutumnConfig,
) -> std::collections::HashSet<String> {
let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
for route in route_list {
if route.method == http::Method::GET || route.method.as_str() == "WS" {
claimed.insert(route.path.to_owned());
}
}
for group in scoped_groups {
for route in &group.routes {
if route.method == http::Method::GET || route.method.as_str() == "WS" {
claimed.insert(join_nested_path(&group.prefix, route.path));
}
}
}
claimed.extend(collect_framework_get_paths(config));
claimed
}
#[cfg(feature = "mcp")]
pub fn reject_mcp_path_collisions(
mount_path: &str,
route_list: &[Route],
scoped_groups: &[ScopedGroup],
config: &AutumnConfig,
openapi: Option<&crate::openapi::OpenApiConfig>,
merge_routers: &[axum::Router<AppState>],
nest_routers: &[(String, axum::Router<AppState>)],
) -> Result<(), RouterBuildError> {
let mut claimed_get = collect_claimed_get_paths(route_list, scoped_groups, config);
if let Some(openapi) = openapi {
claimed_get.insert(openapi.openapi_json_path.clone());
if let Some(ui_path) = &openapi.swagger_ui_path {
claimed_get.insert(ui_path.clone());
claimed_get.extend(crate::openapi::swagger_ui_asset_paths(ui_path));
}
}
if claimed_get.contains(mount_path) {
return Err(RouterBuildError::McpPathCollision {
path: mount_path.to_owned(),
method: "GET".to_owned(),
});
}
let post_owns_path = route_list
.iter()
.any(|route| route.method == http::Method::POST && route.path == mount_path)
|| scoped_groups.iter().any(|group| {
group.routes.iter().any(|route| {
route.method == http::Method::POST
&& join_nested_path(&group.prefix, route.path) == mount_path
})
});
if post_owns_path {
return Err(RouterBuildError::McpPathCollision {
path: mount_path.to_owned(),
method: "POST".to_owned(),
});
}
let nest_prefixes = nest_routers
.iter()
.map(|(prefix, _)| prefix.as_str())
.chain(std::iter::once("/static"));
for prefix in nest_prefixes {
let prefix_slash = format!("{prefix}/");
if mount_path == prefix || mount_path.starts_with(&prefix_slash) {
return Err(RouterBuildError::McpPathCollision {
path: mount_path.to_owned(),
method: "nested router".to_owned(),
});
}
}
if !merge_routers.is_empty() {
tracing::warn!(
mcp_mount_path = %mount_path,
merged_routers = merge_routers.len(),
"MCP mount collision check skipped for AppBuilder::merge routers: \
axum does not expose their route table, so an overlapping handler \
will still panic at startup. Choose an MCP mount path that doesn't \
overlap with any merged router's handlers."
);
}
Ok(())
}
#[cfg(feature = "openapi")]
pub fn reject_openapi_path_collisions(
openapi_config: Option<&crate::openapi::OpenApiConfig>,
route_list: &[Route],
scoped_groups: &[ScopedGroup],
merge_routers: &[axum::Router<AppState>],
nest_routers: &[(String, axum::Router<AppState>)],
config: &AutumnConfig,
) -> Result<(), RouterBuildError> {
let Some(openapi) = openapi_config else {
return Ok(());
};
let claimed = collect_claimed_get_paths(route_list, scoped_groups, config);
check_openapi_path_against(
"openapi_json_path",
&openapi.openapi_json_path,
&claimed,
nest_routers,
)?;
if let Some(path) = &openapi.swagger_ui_path {
check_openapi_path_against("swagger_ui_path", path, &claimed, nest_routers)?;
let mut claimed_with_openapi = claimed;
claimed_with_openapi.insert(openapi.openapi_json_path.clone());
for asset_path in crate::openapi::swagger_ui_asset_paths(path) {
check_openapi_path_against(
"swagger_ui_path",
&asset_path,
&claimed_with_openapi,
nest_routers,
)?;
}
}
if !merge_routers.is_empty() {
tracing::warn!(
openapi_json_path = %openapi.openapi_json_path,
swagger_ui_path = ?openapi.swagger_ui_path,
merged_routers = merge_routers.len(),
"OpenAPI mount collision check skipped for AppBuilder::merge routers: \
axum does not expose their route table, so overlapping GET handlers \
will still panic at startup. Choose OpenAPI paths that don't overlap \
with any merged router's handlers."
);
}
Ok(())
}
#[cfg(feature = "openapi")]
fn check_openapi_path_against(
field: &'static str,
path: &str,
claimed: &std::collections::HashSet<String>,
nest_routers: &[(String, axum::Router<AppState>)],
) -> Result<(), RouterBuildError> {
if claimed.contains(path) {
return Err(RouterBuildError::OpenApiPathCollision {
field,
path: path.to_owned(),
});
}
for (prefix, _) in nest_routers {
let prefix_slash = format!("{prefix}/");
if path == prefix || path.starts_with(&prefix_slash) {
return Err(RouterBuildError::OpenApiPathCollision {
field,
path: path.to_owned(),
});
}
}
Ok(())
}
fn effective_mount_method(method: &http::Method) -> http::Method {
if method.as_str() == "WS" {
http::Method::GET
} else {
method.clone()
}
}
fn paths_conflict_under_matchit(existing: &str, incoming: &str) -> bool {
let mut probe: matchit::Router<()> = matchit::Router::new();
if probe.insert(existing, ()).is_err() {
return false;
}
matches!(
probe.insert(incoming, ()),
Err(matchit::InsertError::Conflict { .. })
)
}
fn reject_duplicate_nest_prefixes(
nest_routers: &[(String, axum::Router<AppState>)],
declared_routes: &[crate::route_listing::RouteInfo],
) -> Result<(), RouterBuildError> {
let mut nested_at: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (prefix, _) in nest_routers {
if nested_at.insert(prefix.as_str()) {
continue;
}
let mut owners: Vec<&str> = declared_routes
.iter()
.filter(|declared| declared.path.starts_with(prefix.as_str()))
.map(|declared| declared.handler.as_str())
.collect();
owners.sort_unstable();
owners.dedup();
let owners = if owners.is_empty() {
"two undeclared nested routers".to_owned()
} else {
owners.join(", ")
};
return Err(RouterBuildError::DuplicateNestPrefix {
prefix: prefix.clone(),
owners,
});
}
Ok(())
}
fn framework_route_clashes(
framework_path: &str,
framework_method: &str,
declared_path: &str,
declared_method: &str,
) -> bool {
if framework_path == declared_path {
return declared_method.eq_ignore_ascii_case(framework_method);
}
paths_conflict_under_matchit(framework_path, declared_path)
}
#[cfg(feature = "openapi")]
pub fn validate_openapi_mount_paths(
config: &crate::openapi::OpenApiConfig,
) -> Result<(), RouterBuildError> {
validate_route_path("openapi_json_path", &config.openapi_json_path)?;
if let Some(path) = &config.swagger_ui_path {
validate_route_path("swagger_ui_path", path)?;
if path == &config.openapi_json_path {
return Err(RouterBuildError::DuplicateOpenApiPath { path: path.clone() });
}
}
Ok(())
}
#[cfg(feature = "mcp")]
pub fn validate_mcp_mount_path(path: &str) -> Result<(), RouterBuildError> {
if path.is_empty()
|| !path.starts_with('/')
|| path.contains("//")
|| path.contains('{')
|| path.contains('*')
|| path.split('/').any(|segment| segment.starts_with(':'))
{
return Err(RouterBuildError::InvalidMcpPath {
value: path.to_owned(),
});
}
Ok(())
}
pub fn reject_unregistered_api_versions(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
registered_versions: &std::collections::HashSet<&str>,
) -> Result<(), RouterBuildError> {
let check = |route: &Route| -> Result<(), RouterBuildError> {
if let Some(version) = route
.api_version
.filter(|ver| !registered_versions.contains(*ver))
{
return Err(RouterBuildError::UnregisteredApiVersion {
route_name: route.name.to_string(),
version: version.to_string(),
});
}
Ok(())
};
for route in route_list {
check(route)?;
}
for group in scoped_groups {
for route in &group.routes {
check(route)?;
}
}
Ok(())
}
pub fn reject_duplicate_user_routes(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
merge_routers: &[axum::Router<AppState>],
nest_routers: &[(String, axum::Router<AppState>)],
declared_routes: &[crate::route_listing::RouteInfo],
config: &AutumnConfig,
) -> Result<(), RouterBuildError> {
let mut claimed: std::collections::HashMap<(String, String), String> =
std::collections::HashMap::new();
let mut shape_router: matchit::Router<String> = matchit::Router::new();
let mut inserted_shapes: Vec<(String, String)> = Vec::new();
let mut record =
|method: &http::Method, path: String, name: &str| -> Result<(), RouterBuildError> {
let effective_method = effective_mount_method(method).to_string();
let already_inserted = inserted_shapes.iter().any(|(p, _)| p == &path);
if !already_inserted {
match shape_router.insert(&path, name.to_owned()) {
Ok(()) => inserted_shapes.push((path.clone(), name.to_owned())),
Err(matchit::InsertError::Conflict { .. }) => {
let (existing_path, existing_name) = inserted_shapes
.iter()
.find(|(prior, _)| paths_conflict_under_matchit(prior, &path))
.cloned()
.unwrap_or_else(|| inserted_shapes[0].clone());
return Err(RouterBuildError::ConflictingRouteShape {
existing: existing_name,
existing_path,
incoming: name.to_owned(),
incoming_path: path,
});
}
Err(_) => {}
}
}
let key = (effective_method.clone(), path.clone());
if let Some(existing) = claimed.get(&key) {
return Err(RouterBuildError::DuplicateUserRoute {
method: effective_method,
path,
existing: existing.clone(),
incoming: name.to_owned(),
});
}
claimed.insert(key, name.to_owned());
Ok(())
};
for route in route_list {
record(&route.method, route.path.to_owned(), route.name)?;
}
for group in scoped_groups {
for route in &group.routes {
record(
&route.method,
join_nested_path(&group.prefix, route.path),
route.name,
)?;
}
}
for declared in declared_routes {
let Ok(method) = http::Method::from_bytes(declared.method.as_bytes()) else {
continue;
};
record(&method, declared.path.clone(), &declared.handler)?;
}
reject_declared_framework_collisions(declared_routes, config)?;
if !merge_routers.is_empty() {
tracing::warn!(
merged_routers = merge_routers.len(),
"duplicate-route preflight (#1012) skipped for AppBuilder::merge routers: \
axum does not expose their route table, so an overlapping handler on a \
method+path Autumn already owns will still panic at startup. Keep merged \
routers on disjoint paths from your `.routes()`/`.scoped()` registrations."
);
}
reject_duplicate_nest_prefixes(nest_routers, declared_routes)?;
if !nest_routers.is_empty() {
tracing::warn!(
nested_routers = nest_routers.len(),
declared_routes = declared_routes.len(),
"duplicate-route preflight (#1012) covers AppBuilder::nest routers only \
through declare_plugin_routes: axum does not expose a nested router's \
route table, so any handler it serves that was NOT declared can still \
panic at startup if it overlaps a method+path Autumn already owns. \
Declared routes — a sandboxed plugin declares its whole manifest — ARE \
checked; for anything else keep nested routers on disjoint prefixes \
from your `.routes()`/`.scoped()` registrations."
);
}
Ok(())
}
#[cfg(feature = "openapi")]
pub fn reject_declared_routes_on_context_mounts(
declared_routes: &[crate::route_listing::RouteInfo],
openapi: Option<&crate::openapi::OpenApiConfig>,
mcp_mount_path: Option<&str>,
) -> Result<(), RouterBuildError> {
if declared_routes.is_empty() {
return Ok(());
}
let mut mounts: Vec<(&'static str, String)> = Vec::new();
if let Some(openapi) = openapi {
mounts.push(("GET", openapi.openapi_json_path.clone()));
if let Some(ui_path) = &openapi.swagger_ui_path {
mounts.push(("GET", ui_path.clone()));
mounts.extend(
crate::openapi::swagger_ui_asset_paths(ui_path)
.into_iter()
.map(|path| ("GET", path)),
);
}
}
if let Some(path) = mcp_mount_path {
for method in ["GET", "POST", "OPTIONS"] {
mounts.push((method, path.to_owned()));
}
}
for declared in declared_routes {
let method = if declared.method.eq_ignore_ascii_case("WS") {
"GET"
} else {
declared.method.as_str()
};
if let Some((_, path)) = mounts
.iter()
.find(|(owned, path)| framework_route_clashes(path, owned, &declared.path, method))
{
return Err(RouterBuildError::DuplicateUserRoute {
method: declared.method.clone(),
path: declared.path.clone(),
existing: format!("autumn framework route {path}"),
incoming: declared.handler.clone(),
});
}
}
Ok(())
}
fn reject_declared_framework_collisions(
declared_routes: &[crate::route_listing::RouteInfo],
config: &AutumnConfig,
) -> Result<(), RouterBuildError> {
let framework_get_paths = collect_framework_get_paths(config);
let actuator_mutating_routes = crate::actuator::actuator_mutating_routes(
&config.actuator.prefix,
config.actuator.sensitive,
);
for declared in declared_routes {
if let Some(namespace) = framework_namespaces()
.iter()
.find(|namespace| path_is_under_namespace(&declared.path, namespace))
&& !crate::assets::plugin::is_framework_asset_route(declared)
{
return Err(RouterBuildError::DuplicateUserRoute {
method: declared.method.clone(),
path: declared.path.clone(),
existing: format!("autumn framework namespace {namespace}"),
incoming: declared.handler.clone(),
});
}
let effective_method = if declared.method.eq_ignore_ascii_case("GET")
|| declared.method.eq_ignore_ascii_case("WS")
{
"GET"
} else {
declared.method.as_str()
};
for (framework_method, framework_path) in &actuator_mutating_routes {
if framework_route_clashes(
framework_path,
framework_method,
&declared.path,
effective_method,
) {
return Err(RouterBuildError::DuplicateUserRoute {
method: declared.method.clone(),
path: declared.path.clone(),
existing: format!("autumn framework route {framework_path}"),
incoming: declared.handler.clone(),
});
}
}
let collision = framework_get_paths.iter().find(|framework_path| {
framework_route_clashes(framework_path, "GET", &declared.path, effective_method)
});
if let Some(framework_path) = collision {
return Err(RouterBuildError::DuplicateUserRoute {
method: declared.method.clone(),
path: declared.path.clone(),
existing: format!("autumn framework route {framework_path}"),
incoming: declared.handler.clone(),
});
}
}
Ok(())
}
fn attach_seo_defaults(
handler: axum::routing::MethodRouter<AppState>,
seo: crate::seo::SeoRouteDefaults,
) -> axum::routing::MethodRouter<AppState> {
if seo.is_empty() {
return handler;
}
handler.layer(axum::Extension(seo))
}
fn group_and_mount_routes(
route_list: Vec<Route>,
idempotency_layers: Option<&BuiltIdempotencyLayers>,
opaque_app_layers_present: bool,
state: &AppState,
) -> axum::Router<AppState> {
let mut grouped: indexmap::IndexMap<&str, axum::routing::MethodRouter<AppState>> =
indexmap::IndexMap::new();
for route in &route_list {
tracing::debug!(
method = %route.method,
path = route.path,
name = route.name,
"Mounted route"
);
}
for route in route_list {
let selected_layer = idempotency_layers.and_then(|layers| {
idempotency_layer_for_route(&route, layers, opaque_app_layers_present)
});
let mut handler = route.handler;
if let Some(layer) = selected_layer {
handler = handler.layer(layer.clone());
}
handler = attach_seo_defaults(handler, route.seo);
if let Some(version) = route.api_version {
handler = handler.layer(axum::middleware::from_fn_with_state(
state.clone(),
api_versioning_middleware,
));
handler = handler.layer(axum::Extension(RouteVersionMetadata {
version: version.to_string(),
sunset_opt_out: route.sunset_opt_out,
secured: route.api_doc.secured,
required_roles: route.api_doc.required_roles,
has_policy: route.api_doc.has_policy,
}));
}
grouped
.entry(route.path)
.and_modify(|existing| {
*existing = std::mem::take(existing).merge(handler.clone());
})
.or_insert(handler);
}
let mut router = axum::Router::new();
for (path, method_router) in grouped {
router = router.route(path, method_router);
}
router
}
#[cfg(feature = "i18n")]
fn mount_user_routes(
route_list: Vec<Route>,
scoped_groups: &[ScopedGroup],
declared_routes: &[crate::route_listing::RouteInfo],
idempotency_layers: Option<&BuiltIdempotencyLayers>,
opaque_app_layers_present: bool,
state: &AppState,
config: &AutumnConfig,
) -> Result<axum::Router<AppState>, RouterBuildError> {
if !config.i18n.locale_prefix_enabled {
return Ok(group_and_mount_routes(
route_list,
idempotency_layers,
opaque_app_layers_present,
state,
));
}
let (included, excluded) = partition_routes_for_locale_prefix(
route_list,
&config.i18n.locale_prefix_exclude,
&config.i18n.locale_prefix_exclude_exact,
);
let included_path_methods = route_list_path_methods(&included);
let excluded_path_methods = route_list_path_methods(&excluded);
let scoped_path_methods = scoped_group_path_methods(scoped_groups);
let framework_path_methods = framework_probe_path_methods(config);
let declared_path_methods = declared_path_methods(declared_routes);
let valid_locales = validated_locale_prefix_locales(&config.i18n);
if let Some(err) = detect_locale_prefix_path_collision(
&included_path_methods,
&excluded_path_methods,
&scoped_path_methods,
&framework_path_methods,
&declared_path_methods,
&valid_locales,
) {
return Err(err);
}
let path_method_filters = path_method_filters(&included_path_methods);
let content_router = group_and_mount_routes(
included,
idempotency_layers,
opaque_app_layers_present,
state,
);
let excluded_router = group_and_mount_routes(
excluded,
idempotency_layers,
opaque_app_layers_present,
state,
);
Ok(apply_locale_prefix_routing(
excluded_router,
&content_router,
&path_method_filters,
&config.i18n,
&valid_locales,
))
}
#[cfg(not(feature = "i18n"))]
#[allow(clippy::unnecessary_wraps)]
fn mount_user_routes(
route_list: Vec<Route>,
_scoped_groups: &[ScopedGroup],
_declared_routes: &[crate::route_listing::RouteInfo],
idempotency_layers: Option<&BuiltIdempotencyLayers>,
opaque_app_layers_present: bool,
state: &AppState,
_config: &AutumnConfig,
) -> Result<axum::Router<AppState>, RouterBuildError> {
Ok(group_and_mount_routes(
route_list,
idempotency_layers,
opaque_app_layers_present,
state,
))
}
#[cfg(feature = "i18n")]
fn partition_routes_for_locale_prefix(
route_list: Vec<Route>,
exclude_prefixes: &[String],
exclude_exact: &[String],
) -> (Vec<Route>, Vec<Route>) {
let mut included = Vec::new();
let mut excluded = Vec::new();
for route in route_list {
if exclude_exact.iter().any(|p| p == route.path)
|| matches_locale_exclude_prefix(route.path, exclude_prefixes)
{
excluded.push(route);
} else {
included.push(route);
}
}
(included, excluded)
}
#[cfg(feature = "i18n")]
pub fn matches_locale_exclude_prefix(path: &str, prefixes: &[String]) -> bool {
prefixes.iter().any(|raw| {
let prefix = raw.strip_suffix("/*").unwrap_or(raw.as_str());
let prefix = if prefix == "/" {
prefix
} else {
prefix.strip_suffix('/').unwrap_or(prefix)
};
!prefix.is_empty() && (path == prefix || path.starts_with(&format!("{prefix}/")))
})
}
#[cfg(feature = "i18n")]
fn is_valid_locale_segment(locale: &str) -> bool {
!locale.is_empty()
&& !locale.starts_with(':')
&& !locale.contains(['/', '{', '}', '*', '?', '#'])
&& !locale.chars().any(char::is_whitespace)
}
#[cfg(feature = "i18n")]
pub fn validated_locale_prefix_locales(i18n: &crate::i18n::I18nConfig) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
i18n.supported_locales
.iter()
.filter(|locale| is_valid_locale_segment(locale) && seen.insert(locale.as_str()))
.cloned()
.collect()
}
#[cfg(feature = "i18n")]
fn method_filter_for(method: &http::Method) -> axum::routing::MethodFilter {
use axum::routing::MethodFilter;
match method.as_str() {
"POST" => MethodFilter::POST,
"PUT" => MethodFilter::PUT,
"DELETE" => MethodFilter::DELETE,
"PATCH" => MethodFilter::PATCH,
"HEAD" => MethodFilter::HEAD,
"OPTIONS" => MethodFilter::OPTIONS,
"TRACE" => MethodFilter::TRACE,
"CONNECT" => MethodFilter::CONNECT,
_ => MethodFilter::GET,
}
}
#[cfg(feature = "i18n")]
fn declared_path_methods(
declared: &[crate::route_listing::RouteInfo],
) -> std::collections::HashMap<String, Vec<http::Method>> {
let mut map: std::collections::HashMap<String, Vec<http::Method>> =
std::collections::HashMap::new();
for route in declared {
let Ok(parsed) = http::Method::from_bytes(route.method.as_bytes()) else {
if route.method.eq_ignore_ascii_case("WS") {
let methods = map.entry(route.path.clone()).or_default();
if !methods.contains(&http::Method::GET) {
methods.push(http::Method::GET);
}
if !methods.contains(&http::Method::HEAD) {
methods.push(http::Method::HEAD);
}
}
continue;
};
let effective = effective_mount_method(&parsed);
let methods = map.entry(route.path.clone()).or_default();
if !methods.contains(&effective) {
methods.push(effective.clone());
}
if effective == http::Method::GET && !methods.contains(&http::Method::HEAD) {
methods.push(http::Method::HEAD);
}
}
map
}
#[cfg(feature = "i18n")]
fn route_list_path_methods(
routes: &[Route],
) -> std::collections::HashMap<String, Vec<http::Method>> {
let mut map: std::collections::HashMap<String, Vec<http::Method>> =
std::collections::HashMap::new();
for route in routes {
let effective = effective_mount_method(&route.method);
let methods = map.entry(route.path.to_owned()).or_default();
if !methods.contains(&effective) {
methods.push(effective.clone());
}
if effective == http::Method::GET && !methods.contains(&http::Method::HEAD) {
methods.push(http::Method::HEAD);
}
if route.method.as_str() == "WS" && !methods.contains(&http::Method::CONNECT) {
methods.push(http::Method::CONNECT);
}
}
map
}
#[cfg(feature = "i18n")]
fn scoped_group_path_methods(
scoped_groups: &[ScopedGroup],
) -> std::collections::HashMap<String, Vec<http::Method>> {
let mut map: std::collections::HashMap<String, Vec<http::Method>> =
std::collections::HashMap::new();
for group in scoped_groups {
for route in &group.routes {
let resolved = join_nested_path(&group.prefix, route.path);
let effective = effective_mount_method(&route.method);
let methods = map.entry(resolved).or_default();
if !methods.contains(&effective) {
methods.push(effective.clone());
}
if effective == http::Method::GET && !methods.contains(&http::Method::HEAD) {
methods.push(http::Method::HEAD);
}
if route.method.as_str() == "WS" && !methods.contains(&http::Method::CONNECT) {
methods.push(http::Method::CONNECT);
}
}
}
map
}
#[cfg(feature = "i18n")]
fn framework_probe_path_methods(
config: &AutumnConfig,
) -> std::collections::HashMap<String, Vec<http::Method>> {
let mut map = std::collections::HashMap::new();
if !config.health.enabled {
return map;
}
for path in [
&config.health.path,
&config.health.live_path,
&config.health.ready_path,
&config.health.startup_path,
] {
if path.is_empty() {
continue;
}
map.entry(path.clone())
.or_insert_with(|| vec![http::Method::GET, http::Method::HEAD]);
}
map
}
#[cfg(feature = "i18n")]
fn path_method_filter(methods: &[http::Method]) -> axum::routing::MethodFilter {
methods
.iter()
.map(method_filter_for)
.reduce(axum::routing::MethodFilter::or)
.unwrap_or(axum::routing::MethodFilter::GET)
}
#[cfg(feature = "i18n")]
fn path_method_filters(
included_path_methods: &std::collections::HashMap<String, Vec<http::Method>>,
) -> Vec<(String, axum::routing::MethodFilter)> {
let mut result: Vec<_> = included_path_methods
.iter()
.map(|(path, methods)| (path.clone(), path_method_filter(methods)))
.collect();
result.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
result
}
#[cfg(feature = "i18n")]
fn detect_locale_prefix_path_collision(
included: &std::collections::HashMap<String, Vec<http::Method>>,
excluded: &std::collections::HashMap<String, Vec<http::Method>>,
scoped: &std::collections::HashMap<String, Vec<http::Method>>,
framework: &std::collections::HashMap<String, Vec<http::Method>>,
declared: &std::collections::HashMap<String, Vec<http::Method>>,
valid_locales: &[String],
) -> Option<RouterBuildError> {
let all_other_paths: Vec<&String> = excluded
.keys()
.chain(scoped.keys())
.chain(framework.keys())
.chain(declared.keys())
.chain(included.keys())
.collect();
for locale in valid_locales {
for (path, methods) in included {
let generated = if path == "/" {
format!("/{locale}")
} else {
format!("/{locale}{path}")
};
let exact_conflict = excluded
.get(&generated)
.into_iter()
.chain(scoped.get(&generated))
.chain(framework.get(&generated))
.chain(declared.get(&generated))
.chain(included.get(&generated))
.any(|other_methods| methods.iter().any(|m| other_methods.contains(m)));
let shape_conflict = all_other_paths
.iter()
.any(|p| **p != generated && paths_conflict_under_matchit(p, &generated));
if exact_conflict || shape_conflict {
return Some(RouterBuildError::LocalePrefixPathCollision {
locale: locale.clone(),
path: path.clone(),
generated,
});
}
}
}
None
}
#[cfg(feature = "i18n")]
fn apply_locale_prefix_routing(
excluded_router: axum::Router<AppState>,
content_router: &axum::Router<AppState>,
path_method_filters: &[(String, axum::routing::MethodFilter)],
i18n: &crate::i18n::I18nConfig,
valid_locales: &[String],
) -> axum::Router<AppState> {
let mut router = excluded_router;
if !path_method_filters.is_empty() && !valid_locales.is_empty() {
let mut redirect_router = axum::Router::<AppState>::new();
for (path, filter) in path_method_filters {
redirect_router = redirect_router.route(
path,
axum::routing::on(*filter, locale_prefix_redirect_handler),
);
}
router = router.merge(redirect_router);
}
let prefix_chain = i18n.resolved_fallback_chain();
for locale in valid_locales {
let nested = content_router
.clone()
.fallback(crate::middleware::error_page_filter::fallback_404_handler)
.layer(crate::i18n::LocalePrefixScopeLayer::new(
locale.clone(),
prefix_chain.clone(),
&i18n.default_locale,
))
.layer(axum::Extension(crate::i18n::UriPrefixedLocale(
locale.clone(),
)));
router = router.nest(&format!("/{locale}"), nested);
}
let effective_default_locale = if valid_locales.contains(&i18n.default_locale) {
i18n.default_locale.clone()
} else {
valid_locales
.first()
.cloned()
.unwrap_or_else(|| i18n.default_locale.clone())
};
router.layer(axum::Extension(crate::i18n::LocaleRoutingConfig {
supported_locales: valid_locales.to_vec(),
default_locale: effective_default_locale,
}))
}
#[cfg(feature = "i18n")]
async fn locale_prefix_redirect_handler(
locale: crate::i18n::Locale,
uri: axum::http::Uri,
) -> axum::response::Redirect {
let path = uri.path();
let mut target = if path == "/" {
format!("/{}", locale.tag())
} else {
format!("/{}{path}", locale.tag())
};
if let Some(query) = uri.query() {
target.push('?');
target.push_str(query);
}
axum::response::Redirect::permanent(&target)
}
const fn idempotency_layer_for_route<'a>(
route: &Route,
layers: &'a BuiltIdempotencyLayers,
opaque_app_layers_present: bool,
) -> Option<&'a IdempotencyLayer> {
if matches!(route.idempotency, crate::route::RouteIdempotency::Off) {
None
} else if opaque_app_layers_present {
Some(&layers.manual)
} else if route_uses_generated_replay_stop(route) {
Some(&layers.route)
} else {
Some(&layers.manual)
}
}
const fn route_uses_generated_replay_stop(route: &Route) -> bool {
matches!(
route.idempotency,
crate::route::RouteIdempotency::ReplayThroughInner
)
}
fn custom_layers_require_fail_closed_idempotency(
custom_layers: &[crate::app::CustomLayerRegistration],
) -> bool {
custom_layers
.iter()
.any(|registered| !is_idempotency_transparent_app_layer(registered))
}
fn is_idempotency_transparent_app_layer(registered: &crate::app::CustomLayerRegistration) -> bool {
registered
.type_name
.starts_with("autumn_web::session::SessionLayer<")
|| registered
.type_name
.starts_with("autumn::session::SessionLayer<")
|| registered.type_id
== std::any::TypeId::of::<crate::session::SessionLayer<crate::session::MemoryStore>>()
|| is_i18n_bundle_extension_layer(registered.type_id)
|| is_edge_fallthrough_sentinel_strip_layer(registered.type_id)
|| is_require_api_token_layer(registered.type_id)
}
fn is_require_api_token_layer(type_id: std::any::TypeId) -> bool {
type_id == std::any::TypeId::of::<crate::auth::RequireApiToken>()
}
#[cfg(feature = "edge")]
fn is_edge_fallthrough_sentinel_strip_layer(type_id: std::any::TypeId) -> bool {
type_id == std::any::TypeId::of::<crate::app::StripEdgeFallthroughSentinelLayer>()
}
#[cfg(not(feature = "edge"))]
const fn is_edge_fallthrough_sentinel_strip_layer(_type_id: std::any::TypeId) -> bool {
false
}
#[cfg(feature = "i18n")]
fn is_i18n_bundle_extension_layer(type_id: std::any::TypeId) -> bool {
type_id == std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>()
}
#[cfg(not(feature = "i18n"))]
const fn is_i18n_bundle_extension_layer(_type_id: std::any::TypeId) -> bool {
false
}
#[cfg_attr(not(feature = "mail"), allow(unused_variables))]
#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
fn mount_framework_routes(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
dev_reload_enabled: bool,
) -> axum::Router<AppState> {
#[cfg(not(feature = "mail"))]
let _ = config;
#[cfg(feature = "htmx")]
{
if crate::assets::htmx_is_vendored() {
tracing::debug!(
path = crate::htmx::HTMX_JS_PATH,
"htmx vendored via `autumn assets`; built-in handler skipped, ServeDir serves it"
);
}
router = router.merge(framework_scripts_router());
for asset in crate::htmx::framework_scripts() {
tracing::debug!(
method = "GET",
path = asset.plain_url(),
fingerprinted = asset.url(),
"Mounted route"
);
}
}
#[cfg(feature = "flash")]
{
router = router.route(
crate::flash::FLASH_CSS_PATH,
axum::routing::get(flash_css_handler),
);
tracing::debug!(
method = "GET",
path = crate::flash::FLASH_CSS_PATH,
name = "autumn flash stylesheet",
"Mounted route"
);
}
#[cfg(feature = "maud")]
{
router = router.route(
crate::ui::WIDGETS_CSS_PATH,
axum::routing::get(widgets_css_handler),
);
tracing::debug!(
method = "GET",
path = crate::ui::WIDGETS_CSS_PATH,
name = "autumn widget stylesheet",
"Mounted route"
);
}
if dev_reload_enabled {
router = router.route(
dev::LIVE_RELOAD_PATH,
axum::routing::get(dev::live_reload_state_handler),
);
router = router.route(
dev::LIVE_RELOAD_SCRIPT_PATH,
axum::routing::get(dev::live_reload_script_handler),
);
tracing::debug!(
state_path = dev::LIVE_RELOAD_PATH,
script_path = dev::LIVE_RELOAD_SCRIPT_PATH,
"Mounted dev live reload endpoints"
);
}
#[cfg(feature = "mail")]
if config
.mail
.preview_routes_enabled(config.profile.as_deref())
{
router = router.merge(crate::mail::mail_preview_router(
config.mail.file_dir.clone(),
));
tracing::debug!(
path = crate::mail::MAIL_PREVIEW_PATH,
"Mounted dev mail preview endpoints"
);
}
#[cfg(feature = "maud")]
if config.stories.enabled {
router = router.merge(crate::stories::story_router());
tracing::debug!(
path = crate::stories::STORIES_PATH,
"Mounted story gallery endpoints"
);
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
router = router.merge(crate::mail::unsubscribe_router());
tracing::debug!(
path = crate::mail::UNSUBSCRIBE_PATH,
"Mounted default unsubscribe endpoint"
);
}
if config.jobs.tracking.route_enabled {
router = router.merge(crate::job_tracking::status_router());
tracing::debug!(
path = crate::job_tracking::JOB_STATUS_ROUTE_PATH,
"Mounted tracked-job status endpoint"
);
}
router
}
fn mount_probe_endpoints<S>(
mut router: axum::Router<S>,
config: &AutumnConfig,
user_get_paths: &std::collections::HashSet<String>,
) -> (std::collections::HashSet<String>, axum::Router<S>)
where
S: Clone + Send + Sync + 'static,
AppState: axum::extract::FromRef<S>,
{
let mut mounted_probe_paths = std::collections::HashSet::new();
if !config.health.enabled {
tracing::info!("health.enabled = false; built-in probe endpoints are not auto-mounted");
return (mounted_probe_paths, router);
}
let mut mount_probe = |mut router: axum::Router<S>,
path: &str,
label: &'static str,
handler: axum::routing::MethodRouter<S>|
-> axum::Router<S> {
if user_get_paths.contains(path) {
tracing::info!(
probe = label,
path,
"a user route already owns this path; the built-in probe was \
not auto-mounted (the user handler wins)"
);
mounted_probe_paths.insert(path.to_owned());
return router;
}
if mounted_probe_paths.insert(path.to_owned()) {
router = router.route(path, handler);
}
router
};
router = mount_probe(
router,
&config.health.live_path,
"liveness",
axum::routing::get(crate::probe::live_handler::<AppState>),
);
router = mount_probe(
router,
&config.health.ready_path,
"readiness",
axum::routing::get(crate::probe::ready_handler::<AppState>),
);
router = mount_probe(
router,
&config.health.startup_path,
"startup",
axum::routing::get(crate::probe::startup_handler::<AppState>),
);
router = mount_probe(
router,
&config.health.path,
"health",
axum::routing::get(crate::health::handler::<AppState>),
);
tracing::debug!(
health = %config.health.path,
live = %config.health.live_path,
ready = %config.health.ready_path,
startup = %config.health.startup_path,
"Mounted probe endpoints"
);
(mounted_probe_paths, router)
}
fn mount_actuator_endpoints(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
mounted_probe_paths: &std::collections::HashSet<String>,
) -> Result<axum::Router<AppState>, RouterBuildError> {
let actuator_sensitive = config.actuator.sensitive;
let actuator_prometheus = config.actuator.prometheus;
let actuator_paths = crate::actuator::actuator_endpoint_paths(
&config.actuator.prefix,
actuator_sensitive,
actuator_prometheus,
);
if let Some(path) = actuator_paths
.iter()
.find(|path| mounted_probe_paths.contains(path.as_str()))
{
return Err(RouterBuildError::FrameworkRouteOverlap {
path: path.clone(),
existing: "probe endpoint",
incoming: "actuator endpoint",
});
}
router = router.merge(crate::actuator::actuator_router_with_prefix(
&config.actuator.prefix,
actuator_sensitive,
actuator_prometheus,
));
tracing::debug!(
sensitive = actuator_sensitive,
prometheus = actuator_prometheus,
prefix = %config.actuator.prefix,
"Mounted actuator endpoints"
);
if !actuator_prometheus {
tracing::info!(
config_key = "actuator.prometheus",
"Prometheus scrape endpoint is disabled; app metrics recorded through autumn_web::metrics are still collected, and still visible as JSON under the `app` key of the actuator's metrics endpoint — only the Prometheus scrape format is gated. Set actuator.prometheus = true to expose it"
);
}
Ok(router)
}
fn mount_scoped_groups(
mut router: axum::Router<AppState>,
scoped_groups: Vec<ScopedGroup>,
idempotency_layers: Option<&BuiltIdempotencyLayers>,
state: &AppState,
) -> axum::Router<AppState> {
for group in scoped_groups {
let mut sub_router = axum::Router::new();
for route in group.routes {
tracing::debug!(
method = %route.method,
path = route.path,
name = route.name,
scope = %group.prefix,
"Mounted scoped route"
);
let selected_layer = idempotency_layers
.filter(|_| !matches!(route.idempotency, crate::route::RouteIdempotency::Off))
.map(|layers| &layers.manual);
let seo = route.seo;
let mut handler = route.handler;
if let Some(layer) = selected_layer {
handler = handler.layer(layer.clone());
}
handler = attach_seo_defaults(handler, seo);
if let Some(version) = route.api_version {
handler = handler.layer(axum::middleware::from_fn_with_state(
state.clone(),
api_versioning_middleware,
));
handler = handler.layer(axum::Extension(RouteVersionMetadata {
version: version.to_string(),
sunset_opt_out: route.sunset_opt_out,
secured: route.api_doc.secured,
required_roles: route.api_doc.required_roles,
has_policy: route.api_doc.has_policy,
}));
}
sub_router = sub_router.route(route.path, handler);
}
sub_router = (group.apply_layer)(sub_router);
router = router.nest(&group.prefix, sub_router);
}
router
}
fn mount_raw_routers(
mut router: axum::Router<AppState>,
merge_routers: Vec<axum::Router<AppState>>,
nest_routers: Vec<(String, axum::Router<AppState>)>,
idempotency_layers: Option<&BuiltIdempotencyLayers>,
) -> axum::Router<AppState> {
for raw_router in merge_routers {
tracing::debug!("Merged raw Axum router");
let raw_router = if let Some(layers) = idempotency_layers {
raw_router.layer(layers.manual.clone())
} else {
raw_router
};
router = router.merge(raw_router);
}
for (prefix, raw_router) in nest_routers {
tracing::debug!(prefix = %prefix, "Nested raw Axum router");
let nested_router =
raw_router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
let nested_router = if let Some(layers) = idempotency_layers {
nested_router.layer(layers.manual.clone())
} else {
nested_router
};
router = router.nest(&prefix, nested_router);
}
router
}
const COMPRESSION_EXCLUDED_CONTENT_TYPES: &[&str] = &[
"audio/",
"video/",
"application/octet-stream",
"application/zip",
"application/gzip",
"application/x-gzip",
"application/zstd",
"application/x-bzip2",
"application/x-bzip",
"application/x-rar-compressed",
"application/vnd.rar",
"application/x-7z-compressed",
"font/woff",
"font/woff2",
];
#[derive(Clone)]
struct CompressionPredicate {
default: tower_http::compression::predicate::DefaultPredicate,
}
impl tower_http::compression::predicate::Predicate for CompressionPredicate {
fn should_compress<B>(&self, response: &http::Response<B>) -> bool
where
B: http_body::Body,
{
let headers = response.headers();
self.default.should_compress(response)
&& !headers.contains_key("content-digest")
&& !headers.contains_key("repr-digest")
&& response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.is_none_or(|content_type| !is_compression_excluded(content_type))
}
}
fn is_compression_excluded(content_type: &str) -> bool {
COMPRESSION_EXCLUDED_CONTENT_TYPES.iter().any(|excluded| {
content_type
.as_bytes()
.get(..excluded.len())
.is_some_and(|head| head.eq_ignore_ascii_case(excluded.as_bytes()))
})
}
fn compression_predicate() -> CompressionPredicate {
CompressionPredicate {
default: tower_http::compression::predicate::DefaultPredicate::new(),
}
}
fn apply_compression_middleware<S>(
mut router: axum::Router<S>,
config: &AutumnConfig,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
if config.compression.enabled {
router = router.layer(
tower_http::compression::CompressionLayer::new().compress_when(compression_predicate()),
);
tracing::info!("Response compression enabled (gzip/brotli)");
}
router
}
#[cfg(test)]
fn apply_cors_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
match build_ingress_cors_layer(config) {
Some(layer) => router.layer(layer),
None => router,
}
}
fn build_ingress_cors_layer(config: &AutumnConfig) -> Option<tower_http::cors::CorsLayer> {
if config.cors.allowed_origins.is_empty() {
return None;
}
let pays = config.aeo.enabled
&& config
.aeo
.paid_routes
.iter()
.any(crate::aeo::commerce::PaidRoute::is_x402)
&& !config.cors.allowed_headers.iter().any(|h| {
(h == "*" && !config.cors.allow_credentials)
|| h.eq_ignore_ascii_case("payment-signature")
});
let cors = if pays {
let mut cors = config.cors.clone();
cors.allowed_headers.push("payment-signature".to_owned());
build_cors_layer(&cors)
} else {
build_cors_layer(&config.cors)
};
tracing::info!(
origins = ?config.cors.allowed_origins,
credentials = config.cors.allow_credentials,
"CORS enabled"
);
Some(cors)
}
#[cfg(test)]
fn apply_csrf_middleware<S>(
router: axum::Router<S>,
config: &AutumnConfig,
signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
match build_csrf_layer(config, signing_keys) {
Some(layer) => router.layer(layer),
None => router,
}
}
#[cfg(feature = "tls")]
fn build_client_cert_requirement_layer(
config: &crate::config::AutumnConfig,
) -> Option<crate::tls::client_auth::RequireClientCertLayer> {
let client_auth = config.server.tls.as_ref()?.client_auth.as_ref()?;
if !client_auth.mode.requests_certificate() || client_auth.required_paths.is_empty() {
return None;
}
Some(crate::tls::client_auth::RequireClientCertLayer::for_paths(
client_auth.required_paths.clone(),
))
}
#[cfg(not(feature = "tls"))]
const fn build_client_cert_requirement_layer(
_config: &crate::config::AutumnConfig,
) -> Option<tower::layer::util::Identity> {
None
}
fn build_csrf_layer(
config: &AutumnConfig,
signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
) -> Option<crate::security::CsrfLayer> {
if config.security.csrf.enabled {
let effective_scan_bytes = config
.security
.csrf
.token_scan_bytes
.min(config.security.upload.max_request_size_bytes);
let mut csrf_layer = crate::security::CsrfLayer::from_config(&config.security.csrf)
.with_max_scan_bytes(effective_scan_bytes);
if let Some(keys) = signing_keys {
csrf_layer = csrf_layer.with_signing_keys(keys);
}
for endpoint in &config.security.webhooks.endpoints {
csrf_layer = csrf_layer.with_exempt_path(&endpoint.path);
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
csrf_layer = csrf_layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
}
tracing::info!("CSRF protection enabled");
Some(csrf_layer)
} else {
None
}
}
#[cfg(test)]
fn apply_submit_token_middleware<S>(
router: axum::Router<S>,
config: &AutumnConfig,
is_production: bool,
) -> Result<axum::Router<S>, RouterBuildError>
where
S: Clone + Send + Sync + 'static,
{
Ok(
match build_submit_token_layer(config, &AppState::for_test(), is_production)? {
Some(layer) => router.layer(layer),
None => router,
},
)
}
fn build_submit_token_layer(
config: &AutumnConfig,
state: &AppState,
is_production: bool,
) -> Result<Option<crate::security::SubmitTokenLayer>, RouterBuildError> {
let cfg = &config.security.submit_token;
if !cfg.enabled {
return Ok(None);
}
match cfg.production_memory_guard(config.idempotency.backend, is_production) {
crate::security::config::SubmitTokenMemoryGuard::Ok => {}
crate::security::config::SubmitTokenMemoryGuard::WarnInherited => {
tracing::warn!(
"[security.submit_token].backend resolved to the in-memory store in production \
(inherited from [idempotency].backend, which is unset or memory). \
Single-replica deployments are fine, but multi-replica deployments need a shared \
backend: configure [idempotency] with backend = \"redis\" (or set \
[security.submit_token].backend = \"redis\") so consumed tokens are shared across \
replicas — otherwise a duplicate submit can slip through on a different replica."
);
}
crate::security::config::SubmitTokenMemoryGuard::FailExplicit => {
return Err(RouterBuildError::InvalidSubmitTokenBackend(
"the in-memory submit-token backend is not safe for multi-replica production use. \
Set `[security.submit_token].backend = \"redis\"` in autumn.toml (it reuses the \
[idempotency.redis] connection settings), or remove the explicit `backend` \
override to inherit `[idempotency].backend`."
.to_owned(),
));
}
}
let ttl = Duration::from_secs(cfg.ttl_secs);
let backend = cfg.resolved_backend(config.idempotency.backend);
let store = build_idempotency_store(backend, config, state, ttl, "submit_token")?;
let mut layer = crate::security::SubmitTokenLayer::new(store, cfg)
.with_max_scan_bytes(config.security.upload.max_request_size_bytes);
for endpoint in &config.security.webhooks.endpoints {
layer = layer.with_exempt_path(&endpoint.path);
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
layer = layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
}
tracing::info!(
backend = ?backend,
inherited = cfg.backend.is_none(),
ttl_secs = cfg.ttl_secs,
"One-time submit-token protection enabled"
);
Ok(Some(layer))
}
fn build_bot_protection_layer(
config: &AutumnConfig,
) -> Option<crate::security::captcha::BotProtectionLayer> {
if config.bot_protection.enabled {
let mut exempt = config.security.captcha_exempt_paths.clone();
for endpoint in &config.security.webhooks.endpoints {
exempt.push(endpoint.path.clone());
}
#[cfg(feature = "mail")]
if config.mail.should_mount_unsubscribe_endpoint() {
exempt.push(crate::mail::UNSUBSCRIBE_PATH.to_owned());
}
let layer =
crate::security::captcha::BotProtectionLayer::from_config(&config.bot_protection)
.with_max_scan_bytes(config.security.upload.max_request_size_bytes)
.with_exempt_paths(exempt);
tracing::info!(
provider = ?config.bot_protection.provider,
dev_bypass = config.bot_protection.dev_bypass,
"Bot protection (CAPTCHA) enabled"
);
Some(layer)
} else {
None
}
}
async fn populate_rate_limit_principal(
axum::extract::State(state): axum::extract::State<AppState>,
mut req: axum::extract::Request,
next: axum::middleware::Next,
) -> axum::response::Response {
if let Some(session) = req.extensions().get::<crate::session::Session>() {
let auth_session_key = state.auth_session_key();
if let Some(user_id) =
crate::auth::impersonation::throttle_principal_id(session, auth_session_key).await
{
req.extensions_mut()
.insert(crate::security::RateLimitPrincipal(user_id));
}
}
next.run(req).await
}
#[cfg(feature = "mcp")]
fn apply_trusted_proxies_middleware<S>(
router: axum::Router<S>,
config: &AutumnConfig,
) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
router.layer(build_trusted_proxies_layer(config))
}
fn build_trusted_proxies_layer(config: &AutumnConfig) -> crate::security::TrustedProxiesLayer {
let tp = &config.security.trusted_proxies;
if tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some() {
tracing::info!(
ranges = ?tp.ranges,
trusted_hops = ?tp.trusted_hops,
"Centralized trusted-proxy resolution enabled"
);
}
crate::security::TrustedProxiesLayer::from_config(tp)
}
#[cfg(any(test, feature = "mcp"))]
fn apply_rate_limit_middleware(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
state: &AppState,
) -> axum::Router<AppState> {
let (limiter, principal_keying) = build_rate_limit_layers(config, state);
if let Some(limiter) = limiter {
router = router.layer(limiter);
}
if principal_keying {
router = router.layer(axum::middleware::from_fn_with_state(
state.clone(),
populate_rate_limit_principal,
));
}
router
}
fn build_rate_limit_layers(
config: &AutumnConfig,
state: &AppState,
) -> (Option<crate::security::RateLimitLayer>, bool) {
if config.security.rate_limit.enabled {
let tp = &config.security.trusted_proxies;
let rl = &config.security.rate_limit;
let has_top_level_proxy_config =
tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some();
let has_rate_limit_proxy_config =
rl.trust_forwarded_headers || !rl.trusted_proxies.is_empty();
let mut layer = crate::security::RateLimitLayer::from_config(rl)
.honoring_mcp_exempt()
.with_clock(state.clock.clone());
if has_top_level_proxy_config && !has_rate_limit_proxy_config {
let resolver = crate::security::ProxyResolver::from_config(tp);
layer = layer.with_proxy_resolver(resolver);
}
tracing::info!(
rps = config.security.rate_limit.requests_per_second,
burst = config.security.rate_limit.burst,
"Rate limiting enabled"
);
let principal_keying = config.security.rate_limit.key_strategy
== crate::security::KeyStrategy::AuthenticatedPrincipal;
(Some(layer), principal_keying)
} else {
(None, false)
}
}
#[cfg(test)]
fn apply_upload_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
where
S: Clone + Send + Sync + 'static,
{
let (body_limit, upload_config) = build_upload_layers(config);
router
.layer(body_limit)
.layer(axum::Extension(upload_config))
}
fn build_upload_layers(
config: &AutumnConfig,
) -> (
axum::extract::DefaultBodyLimit,
crate::security::config::UploadConfig,
) {
let upload_config = config.security.upload.clone();
let max_request_size = upload_config.max_request_size_bytes;
tracing::info!(
max_request_size_bytes = max_request_size,
max_file_size_bytes = upload_config.max_file_size_bytes,
allowed_mime_types = ?upload_config.allowed_mime_types,
"Request body size limits enabled (applies to all content types)"
);
(
axum::extract::DefaultBodyLimit::max(max_request_size),
upload_config,
)
}
fn probe_bypass_paths(config: &AutumnConfig) -> Vec<String> {
vec![
config.health.path.clone(),
config.health.live_path.clone(),
config.health.ready_path.clone(),
config.health.startup_path.clone(),
crate::actuator::actuator_route_path(&config.actuator.prefix, "/health"),
]
}
fn build_maintenance_layer(
config: &AutumnConfig,
state: &AppState,
) -> crate::middleware::maintenance::MaintenanceLayer {
let maintenance_state = state
.extension::<crate::maintenance::MaintenanceState>()
.map(|s| (*s).clone())
.unwrap_or_default();
crate::middleware::maintenance::MaintenanceLayer::new(maintenance_state)
.with_health_prefix(config.actuator.prefix.clone())
.with_probe_paths(probe_bypass_paths(config))
}
fn build_load_shed_layer(
config: &AutumnConfig,
state: &AppState,
) -> Option<crate::middleware::LoadShedLayer> {
let resolved = crate::capacity::resolve_configured_admission_limit_for_profile(
config.server.max_concurrent_requests,
config.server.capacity_contract.as_deref(),
config.profile_admission_default(),
config.profile.as_deref(),
);
let admission = &config.server.admission;
let shares = admission.partition_shares().unwrap_or_else(|error| {
tracing::warn!(%error, "invalid server.admission.partitions; using the defaults");
crate::admission::PartitionShares::default()
});
if admission.mode == crate::config::AdmissionMode::Adaptive {
match crate::admission::AdaptiveLimiter::from_config(admission, resolved.limit()) {
Ok(limiter) => {
tracing::info!(
algorithm = ?admission.algorithm,
initial_limit = limiter.limit(),
"admission control uses an adaptive limit"
);
return Some(finish_load_shed_layer(
crate::middleware::LoadShedLayer::adaptive(limiter, state.metrics.clone()),
shares,
config,
));
}
Err(error) => tracing::warn!(
%error,
"invalid server.admission limits; using the static ceiling"
),
}
}
let limit = resolved.limit()?;
match resolved {
crate::capacity::AdmissionLimit::Contract(_) => tracing::info!(
limit,
source = resolved.source(),
contract = config
.server
.capacity_contract
.as_deref()
.unwrap_or_default(),
"admission control sourced from the committed capacity contract"
),
crate::capacity::AdmissionLimit::ProfileDefault(_) => tracing::info!(
limit,
source = resolved.source(),
"admission control uses the profile default ceiling; set \
server.max_concurrent_requests to tune it, or 0 to turn it off"
),
_ => {}
}
Some(finish_load_shed_layer(
crate::middleware::LoadShedLayer::new(limit, state.metrics.clone()),
shares,
config,
))
}
fn finish_load_shed_layer(
layer: crate::middleware::LoadShedLayer,
shares: crate::admission::PartitionShares,
config: &AutumnConfig,
) -> crate::middleware::LoadShedLayer {
let cors =
(!config.cors.allowed_origins.is_empty()).then(|| std::sync::Arc::new(config.cors.clone()));
layer
.with_partitions(shares)
.with_health_prefix(config.actuator.prefix.clone())
.with_probe_paths(probe_bypass_paths(config))
.with_cors(cors)
}
fn fault_injection_exempt_paths(config: &AutumnConfig) -> Vec<String> {
let mut paths = probe_bypass_paths(config);
paths.extend(crate::actuator::actuator_endpoint_paths(
&config.actuator.prefix,
config.actuator.sensitive,
config.actuator.prometheus,
));
paths
}
fn install_outer_fault_injection(
router: axum::Router<AppState>,
config: &AutumnConfig,
state: &AppState,
) -> axum::Router<AppState> {
let Some(handle) = state.extension::<crate::fault_injection::FaultInjection>() else {
return router;
};
let deadline = config
.server
.timeouts
.request_timeout_ms
.filter(|ms| *ms > 0)
.map(std::time::Duration::from_millis);
router.layer(handle.layers(deadline))
}
fn build_shadow_layer(
config: &AutumnConfig,
state: &AppState,
) -> Option<crate::shadow::ShadowMirrorLayer> {
if !config.shadow.is_active() {
return None;
}
let target_base = config.shadow.target_base()?.to_owned();
#[cfg(not(feature = "http-client"))]
{
tracing::warn!(
"[shadow] enabled but this build has no `http-client` feature; \
traffic mirroring is inactive"
);
state.insert_extension(crate::shadow::ShadowHandle::inactive(target_base));
return None;
}
#[cfg(feature = "http-client")]
{
let timeout = std::time::Duration::from_millis(config.shadow.timeout_ms);
let transport = match crate::shadow::transport::HttpShadowTransport::new(
timeout,
config.shadow.max_body_bytes,
) {
Ok(transport) => std::sync::Arc::new(transport),
Err(error) => {
tracing::warn!(
%error,
"[shadow] could not build the mirroring HTTP client; traffic mirroring \
is inactive"
);
return None;
}
};
let registry = crate::shadow::ShadowRegistry::new(config.shadow.max_records);
state.insert_extension(crate::shadow::ShadowHandle {
registry: registry.clone(),
enabled: true,
target: Some(target_base.clone()),
});
let mut filter_parameters = config.log.filter_parameters.clone();
filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
filter_parameters.extend(crate::confidential::registered_confidential_column_names());
let filter = Arc::new(crate::log::filter::ParameterFilter::new(
&filter_parameters,
&config.log.unfilter_parameters,
));
let mut exempt_paths = probe_bypass_paths(config);
exempt_paths.extend(crate::actuator::actuator_endpoint_paths(
&config.actuator.prefix,
config.actuator.sensitive,
config.actuator.prometheus,
));
let selector = crate::shadow::MirrorSelector::new(
config.shadow.sample_rate,
&config.shadow.routes,
&config.actuator.prefix,
&exempt_paths,
);
tracing::info!(
target = %target_base,
sample_rate = config.shadow.sample_rate,
routes = ?config.shadow.routes,
"Shadow traffic mirroring enabled (GET/HEAD only)"
);
Some(crate::shadow::ShadowMirrorLayer::new(
crate::shadow::MirrorSettings {
target_base,
timeout,
max_in_flight: config.shadow.max_in_flight,
max_body_bytes: config.shadow.max_body_bytes,
max_sample_bytes: config.shadow.max_sample_bytes,
},
selector,
registry,
transport,
filter,
state.entropy_arc(),
state.clock_arc(),
))
}
}
type RouteTimeoutTable = RouteAttrTable<crate::route::RouteTimeout>;
pub type RouteAttrTable<T> =
std::sync::Arc<std::collections::HashMap<String, std::collections::HashMap<http::Method, T>>>;
#[derive(Debug)]
struct RequestDeadlineExceeded {
timeout_ms: u64,
}
impl std::fmt::Display for RequestDeadlineExceeded {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"the server did not produce a response within the configured {}ms deadline",
self.timeout_ms
)
}
}
impl std::error::Error for RequestDeadlineExceeded {}
#[derive(Clone, Copy, Debug)]
pub struct RequestDeadlineCancelled;
fn build_route_timeout_table(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
config: &AutumnConfig,
) -> RouteTimeoutTable {
build_route_attr_table(route_list, scoped_groups, config, |route| {
(!matches!(route.timeout, crate::route::RouteTimeout::Inherit)).then_some(route.timeout)
})
}
fn build_route_criticality_table(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
config: &AutumnConfig,
) -> RouteAttrTable<crate::admission::Criticality> {
build_route_attr_table(route_list, scoped_groups, config, |route| {
(route.criticality != crate::admission::Criticality::Default).then_some(route.criticality)
})
}
fn build_route_attr_table<T: Copy>(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
#[cfg_attr(not(feature = "i18n"), allow(unused_variables))] config: &AutumnConfig,
value: impl Fn(&Route) -> Option<T>,
) -> RouteAttrTable<T> {
let mut table: std::collections::HashMap<String, std::collections::HashMap<http::Method, T>> =
std::collections::HashMap::new();
let mut insert = |path: String, method: &http::Method, timeout: T| {
let by_method = table.entry(path).or_default();
by_method.insert(effective_mount_method(method), timeout);
if *method == http::Method::GET {
by_method.insert(http::Method::HEAD, timeout);
}
if method.as_str() == "WS" {
by_method.insert(http::Method::CONNECT, timeout);
}
};
for route in route_list {
if let Some(v) = value(route) {
insert(route.path.to_owned(), &route.method, v);
}
}
for group in scoped_groups {
for route in &group.routes {
if let Some(v) = value(route) {
insert(
join_nested_path(&group.prefix, route.path),
&route.method,
v,
);
}
}
}
#[cfg(feature = "i18n")]
expand_route_timeout_table_for_locale_prefix(&mut table, route_list, &config.i18n);
std::sync::Arc::new(table)
}
#[cfg(feature = "i18n")]
fn expand_route_timeout_table_for_locale_prefix<T: Clone>(
table: &mut std::collections::HashMap<String, std::collections::HashMap<http::Method, T>>,
route_list: &[Route],
i18n: &crate::i18n::I18nConfig,
) {
if !i18n.locale_prefix_enabled || i18n.supported_locales.is_empty() {
return;
}
for route in route_list {
if i18n
.locale_prefix_exclude_exact
.iter()
.any(|p| p == route.path)
|| matches_locale_exclude_prefix(route.path, &i18n.locale_prefix_exclude)
{
continue;
}
let Some(by_method) = table.get(route.path).cloned() else {
continue;
};
for locale in &i18n.supported_locales {
let prefixed_path = if route.path == "/" {
format!("/{locale}")
} else {
format!("/{locale}{}", route.path)
};
table
.entry(prefixed_path)
.or_default()
.extend(by_method.clone());
}
}
}
#[cfg(any(test, feature = "mcp"))]
fn apply_request_timeout_middleware(
router: axum::Router<AppState>,
config: &AutumnConfig,
state: &AppState,
route_timeouts: RouteTimeoutTable,
mirror_cors: bool,
) -> axum::Router<AppState> {
let Some(settings) = build_request_timeout_settings(config, state, route_timeouts, mirror_cors)
else {
return router;
};
router.layer(RequestTimeoutLayer::new(settings))
}
struct RequestTimeoutSettings {
global: Option<Duration>,
route_timeouts: RouteTimeoutTable,
metrics: crate::middleware::MetricsCollector,
cors: Option<std::sync::Arc<crate::config::CorsConfig>>,
accept_deadline_header: bool,
entropy: Arc<dyn crate::entropy::Entropy>,
}
fn build_request_timeout_settings(
config: &AutumnConfig,
state: &AppState,
route_timeouts: RouteTimeoutTable,
mirror_cors: bool,
) -> Option<RequestTimeoutSettings> {
let global = config
.server
.timeouts
.request_timeout_ms
.filter(|ms| *ms > 0)
.map(std::time::Duration::from_millis);
let has_override = route_timeouts
.values()
.flat_map(std::collections::HashMap::values)
.any(|t| matches!(t, crate::route::RouteTimeout::Override(_)));
let has_off_for_admission = config.server.admission.mode
== crate::config::AdmissionMode::Adaptive
&& route_timeouts
.values()
.flat_map(std::collections::HashMap::values)
.any(|t| matches!(t, crate::route::RouteTimeout::Disabled));
if global.is_none() && !has_override && !has_off_for_admission {
return None;
}
if let Some(duration) = global {
tracing::info!(
timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
"Inbound request timeout enabled"
);
}
let cors = (mirror_cors && !config.cors.allowed_origins.is_empty())
.then(|| std::sync::Arc::new(config.cors.clone()));
Some(RequestTimeoutSettings {
global,
route_timeouts,
metrics: state.metrics.clone(),
cors,
accept_deadline_header: config.server.timeouts.accept_deadline_header,
entropy: state.entropy_arc(),
})
}
#[derive(Clone)]
pub struct RequestTimeoutLayer {
settings: Arc<RequestTimeoutSettings>,
}
impl RequestTimeoutLayer {
fn new(settings: RequestTimeoutSettings) -> Self {
Self {
settings: Arc::new(settings),
}
}
}
impl<S> tower::Layer<S> for RequestTimeoutLayer {
type Service = RequestTimeoutService<S>;
fn layer(&self, inner: S) -> Self::Service {
RequestTimeoutService {
inner,
settings: Arc::clone(&self.settings),
}
}
}
#[derive(Clone)]
pub struct RequestTimeoutService<S> {
inner: S,
settings: Arc<RequestTimeoutSettings>,
}
impl<S> RequestTimeoutService<S> {
fn deadline_for<B>(&self, req: &Request<B>) -> Option<Duration> {
if req
.extensions()
.get::<crate::static_gen::RenderDeadlineExempt>()
.is_some()
{
return None;
}
let route_timeout = req
.extensions()
.get::<axum::extract::MatchedPath>()
.map(axum::extract::MatchedPath::as_str)
.and_then(|p| self.settings.route_timeouts.get(p))
.and_then(|by_method| by_method.get(req.method()))
.copied()
.unwrap_or(crate::route::RouteTimeout::Inherit);
let route_deadline = match route_timeout {
crate::route::RouteTimeout::Disabled => None,
crate::route::RouteTimeout::Override(d) => Some(d),
crate::route::RouteTimeout::Inherit => self.settings.global,
}?;
let caller_deadline = self
.settings
.accept_deadline_header
.then(|| req.headers().get(crate::deadline::DEADLINE_HEADER))
.flatten()
.and_then(crate::deadline::parse_header);
Some(caller_deadline.map_or(route_deadline, |caller| caller.min(route_deadline)))
}
fn route_timeout_is_off<B>(&self, req: &Request<B>) -> bool {
req.extensions()
.get::<axum::extract::MatchedPath>()
.and_then(|p| self.settings.route_timeouts.get(p.as_str()))
.and_then(|by_method| by_method.get(req.method()))
.is_some_and(|t| matches!(t, crate::route::RouteTimeout::Disabled))
}
}
impl<S> tower::Service<Request<axum::body::Body>> for RequestTimeoutService<S>
where
S: tower::Service<Request<axum::body::Body>, Response = axum::response::Response>,
{
type Response = S::Response;
type Error = S::Error;
type Future = RequestTimeoutFuture<S::Future>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, mut req: Request<axum::body::Body>) -> Self::Future {
let Some(duration) = self.deadline_for(&req) else {
if self.route_timeout_is_off(&req) {
req.extensions_mut()
.insert(crate::admission::InboundDeadline::Off);
}
return RequestTimeoutFuture::Unbounded {
inner: self.inner.call(req),
};
};
let duration =
crate::fault_injection::time_left().map_or(duration, |left| duration.min(left));
if let Some(at) = tokio::time::Instant::now().checked_add(duration) {
req.extensions_mut()
.insert(crate::admission::InboundDeadline::At(at));
}
let matched_path = req
.extensions()
.get::<axum::extract::MatchedPath>()
.cloned();
let request_id = req
.extensions()
.get::<crate::middleware::RequestId>()
.cloned();
let cors_origin = self
.settings
.cors
.as_ref()
.and_then(|_| req.headers().get(http::header::ORIGIN).cloned());
let start = crate::time::ambient_instant();
if duration.is_zero() {
return RequestTimeoutFuture::Elapsed {
response: Some(deadline_exceeded_response(
&self.settings,
duration,
matched_path
.as_ref()
.map(axum::extract::MatchedPath::as_str),
request_id.as_ref(),
cors_origin.as_ref(),
start,
)),
};
}
let deadline = crate::deadline::Deadline::after(duration);
let inner = deadline.sync_scope(|| self.inner.call(req));
RequestTimeoutFuture::Bounded {
inner: crate::deadline::Bounded::until(deadline, inner),
settings: Arc::clone(&self.settings),
duration,
matched_path,
request_id,
cors_origin,
start,
}
}
}
pin_project_lite::pin_project! {
#[project = RequestTimeoutFutureProj]
pub enum RequestTimeoutFuture<F> {
Unbounded {
#[pin]
inner: F,
},
Bounded {
#[pin]
inner: crate::deadline::Bounded<F>,
settings: Arc<RequestTimeoutSettings>,
duration: Duration,
matched_path: Option<axum::extract::MatchedPath>,
request_id: Option<crate::middleware::RequestId>,
cors_origin: Option<http::HeaderValue>,
start: std::time::Instant,
},
Elapsed {
response: Option<axum::response::Response>,
},
}
}
impl<F, E> std::future::Future for RequestTimeoutFuture<F>
where
F: std::future::Future<Output = Result<axum::response::Response, E>>,
{
type Output = Result<axum::response::Response, E>;
#[allow(
clippy::expect_used,
reason = "unreachable: future not polled after Ready"
)]
fn poll(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
loop {
let elapsed = match self.as_mut().project() {
RequestTimeoutFutureProj::Unbounded { inner } => return inner.poll(cx),
RequestTimeoutFutureProj::Bounded {
inner,
settings,
duration,
matched_path,
request_id,
cors_origin,
start,
} => match std::task::ready!(inner.poll(cx)) {
Ok(response) => return std::task::Poll::Ready(response),
Err(crate::deadline::DeadlineExceeded) => Self::Elapsed {
response: Some(deadline_exceeded_response(
settings,
*duration,
matched_path
.as_ref()
.map(axum::extract::MatchedPath::as_str),
request_id.as_ref(),
cors_origin.as_ref(),
*start,
)),
},
},
RequestTimeoutFutureProj::Elapsed { response } => {
return std::task::Poll::Ready(Ok(response
.take()
.expect("RequestTimeoutFuture polled after completion")));
}
};
self.as_mut().set(elapsed);
}
}
}
fn deadline_exceeded_response(
settings: &RequestTimeoutSettings,
duration: Duration,
matched_path: Option<&str>,
request_id: Option<&crate::middleware::RequestId>,
cors_origin: Option<&http::HeaderValue>,
start: std::time::Instant,
) -> axum::response::Response {
let elapsed_ms = u64::try_from(
crate::time::ambient_instant()
.saturating_duration_since(start)
.as_millis(),
)
.unwrap_or(u64::MAX);
let route = matched_path.unwrap_or("<unmatched>");
tracing::warn!(
target: "autumn::timeout",
route = route,
elapsed_ms = elapsed_ms,
timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
request_id = request_id.map(ToString::to_string),
"inbound request exceeded deadline"
);
settings.metrics.record_request_timeout();
let mut response = crate::error::AutumnError::service_unavailable(RequestDeadlineExceeded {
timeout_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
})
.into_response();
response.extensions_mut().insert(RequestDeadlineCancelled);
response.headers_mut().insert(
http::header::RETRY_AFTER,
http::HeaderValue::from(timeout_retry_after_secs(settings.entropy.as_ref())),
);
if let Some(cors) = settings.cors.as_deref() {
mirror_cors_headers(cors, cors_origin, &mut response);
}
response
}
fn build_idempotency_store(
backend: crate::config::IdempotencyBackend,
config: &AutumnConfig,
state: &AppState,
ttl: Duration,
label: &str,
) -> Result<std::sync::Arc<dyn IdempotencyStore>, RouterBuildError> {
#[cfg(not(feature = "db"))]
let _ = state;
match backend {
crate::config::IdempotencyBackend::Memory => {
Ok(std::sync::Arc::new(MemoryIdempotencyStore::new(ttl)))
}
#[cfg(feature = "redis")]
crate::config::IdempotencyBackend::Redis => {
crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency)
.map(|store| std::sync::Arc::new(store) as std::sync::Arc<dyn IdempotencyStore>)
.map_err(RouterBuildError::InvalidIdempotencyBackend)
}
#[cfg(not(feature = "redis"))]
crate::config::IdempotencyBackend::Redis => {
let _ = config;
Err(RouterBuildError::InvalidIdempotencyBackend(format!(
"{label} backend 'redis' requires the autumn-web 'redis' feature \
flag; rebuild with --features redis or switch to backend = \"memory\""
)))
}
#[cfg(feature = "db")]
crate::config::IdempotencyBackend::Database => state.pool().map_or_else(
|| {
Err(RouterBuildError::InvalidIdempotencyBackend(format!(
"{label} backend 'database' requires a configured database; \
set [database] url or switch to backend = \"memory\""
)))
},
|pool| {
Ok(std::sync::Arc::new(
crate::idempotency::DbIdempotencyStore::new(pool.clone(), ttl)
.with_legal_holds(state),
) as std::sync::Arc<dyn IdempotencyStore>)
},
),
#[cfg(not(feature = "db"))]
crate::config::IdempotencyBackend::Database => {
Err(RouterBuildError::InvalidIdempotencyBackend(format!(
"{label} backend 'database' requires the autumn-web 'db' feature"
)))
}
}
}
fn warn_on_short_in_flight_ttl(config: &AutumnConfig) {
let in_flight_secs = config.idempotency.in_flight_ttl_secs;
match config.server.timeouts.request_timeout_ms {
None => tracing::info!(
in_flight_ttl_secs = in_flight_secs,
"no request timeout is set; an idempotent request that runs longer than \
idempotency.in_flight_ttl_secs can run twice"
),
Some(timeout_ms) if in_flight_secs.saturating_mul(1000) < timeout_ms => tracing::warn!(
in_flight_ttl_secs = in_flight_secs,
request_timeout_ms = timeout_ms,
"idempotency.in_flight_ttl_secs is shorter than the request timeout; \
a retry can run while the first request still runs"
),
Some(_) => {}
}
}
const TIMEOUT_RETRY_AFTER_SECS: std::ops::RangeInclusive<u64> = 1..=3;
fn timeout_retry_after_secs(entropy: &dyn crate::entropy::Entropy) -> u64 {
let (low, high) = TIMEOUT_RETRY_AFTER_SECS.into_inner();
low + entropy.next_u64() % (high - low + 1)
}
struct BuiltIdempotencyLayers {
route: crate::idempotency::IdempotencyLayer,
manual: crate::idempotency::IdempotencyLayer,
}
fn build_idempotency_layers(
config: &AutumnConfig,
state: &AppState,
) -> Result<Option<BuiltIdempotencyLayers>, RouterBuildError> {
if !config.idempotency.enabled.unwrap_or(false) {
return Ok(None);
}
let ttl = Duration::from_secs(config.idempotency.ttl_secs);
let in_flight_ttl = Duration::from_secs(config.idempotency.in_flight_ttl_secs);
let store = build_idempotency_store(
config.idempotency.backend,
config,
state,
ttl,
"idempotency",
)?;
warn_on_short_in_flight_ttl(config);
tracing::debug!(
backend = ?config.idempotency.backend,
ttl_secs = config.idempotency.ttl_secs,
in_flight_ttl_secs = config.idempotency.in_flight_ttl_secs,
"Idempotency-key middleware enabled"
);
let base = IdempotencyLayer::new(store)
.with_ttl(ttl)
.with_in_flight_ttl(in_flight_ttl)
.with_metrics(state.metrics.clone())
.with_entropy(state.entropy_arc());
Ok(Some(BuiltIdempotencyLayers {
route: base.clone().replay_through_inner(),
manual: base.fail_closed_on_replay(),
}))
}
#[derive(Clone)]
struct ComposedRegisteredLayers(Vec<crate::app::ErasedAppLayer>);
impl ComposedRegisteredLayers {
fn new(registrations: Vec<crate::app::CustomLayerRegistration>) -> Self {
Self(registrations.into_iter().map(|reg| reg.layer).collect())
}
}
impl<S> tower::Layer<S> for ComposedRegisteredLayers
where
S: tower::Service<
axum::extract::Request,
Response = axum::response::Response,
Error = std::convert::Infallible,
> + Clone
+ Send
+ Sync
+ 'static,
S::Future: Send + 'static,
{
type Service = crate::app::ErasedAppService;
fn layer(&self, inner: S) -> Self::Service {
let mut svc = crate::app::ErasedAppService::new(inner);
for registered in self.0.iter().rev() {
svc = registered.layer(svc);
}
svc
}
}
#[derive(Clone, Copy)]
struct NormalizeBodyLayer;
impl<S> tower::Layer<S> for NormalizeBodyLayer {
type Service = NormalizeBody<S>;
fn layer(&self, inner: S) -> Self::Service {
NormalizeBody(inner)
}
}
#[derive(Clone, Copy)]
struct NormalizeBody<S>(S);
fn into_response_result<B, E>(
result: Result<http::Response<B>, E>,
) -> Result<axum::response::Response, E>
where
http::Response<B>: axum::response::IntoResponse,
{
result.map(axum::response::IntoResponse::into_response)
}
impl<S, B> tower::Service<axum::extract::Request> for NormalizeBody<S>
where
S: tower::Service<axum::extract::Request, Response = http::Response<B>>,
http::Response<B>: axum::response::IntoResponse,
{
type Response = axum::response::Response;
type Error = S::Error;
type Future = futures::future::Map<
S::Future,
fn(Result<http::Response<B>, S::Error>) -> Result<axum::response::Response, S::Error>,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.0.poll_ready(cx)
}
fn call(&mut self, req: axum::extract::Request) -> Self::Future {
futures::FutureExt::map(
self.0.call(req),
into_response_result::<B, S::Error> as fn(_) -> _,
)
}
}
#[allow(
clippy::cognitive_complexity,
clippy::too_many_lines,
clippy::too_many_arguments
)]
fn apply_middleware(
mut router: axum::Router<AppState>,
config: &AutumnConfig,
state: &AppState,
exception_filters: Vec<Arc<dyn ExceptionFilter>>,
custom_layers: Vec<crate::app::CustomLayerRegistration>,
#[cfg(feature = "maud")] error_page_renderer: Option<SharedRenderer>,
session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
route_timeouts: RouteTimeoutTable,
criticality_layer: crate::middleware::criticality::CriticalityLayer,
load_shed_layer: Option<crate::middleware::LoadShedLayer>,
defer_shadow: bool,
) -> Result<axum::Router<AppState>, RouterBuildError> {
let aeo_site = state.extension::<crate::aeo::AeoSite>();
let fallback_site = aeo_site.clone();
router = router.fallback(
move |axum::extract::State(app): axum::extract::State<AppState>,
req: axum::extract::Request| {
let site = fallback_site.clone();
async move {
#[cfg(feature = "plugin-sandbox")]
if app
.extension::<crate::plugin_sandbox::PluginMounts>()
.is_some_and(|mounts| mounts.owns(req.uri().path()))
{
return crate::plugin_sandbox::mounts_fallback(axum::extract::State(app), req)
.await;
}
#[cfg(feature = "webmcp")]
if let Some(res) = crate::webmcp::serve_manifest(
app.extension::<crate::webmcp::WebMcpManifest>(),
&req,
) {
return res;
}
#[cfg(not(feature = "plugin-sandbox"))]
let _ = app;
match crate::aeo::serve_document(site, &req) {
Some(res) => res,
None => {
crate::middleware::error_page_filter::fallback_404_handler(
req.method().clone(),
req.uri().clone(),
)
.await
}
}
}
},
);
let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
let signing_keys = std::sync::Arc::new(crate::security::config::resolve_signing_keys(
&config.security.signing_secret,
));
let signing_keys_opt: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>> =
if config.security.signing_secret.secret.is_some() || is_production {
Some(signing_keys)
} else {
None
};
let submit_token_layer = build_submit_token_layer(config, state, is_production)?;
let (body_limit, upload_config) = build_upload_layers(config);
let trusted_host_policy = TrustedHostPolicy::from_config_with_state(config, state);
let (rate_limit_layer, rate_limit_principal_keying) = build_rate_limit_layers(config, state);
let fault_injection = crate::fault_injection::build(
config,
state,
fault_injection_exempt_paths(config),
&route_timeouts,
);
let (fault_scope_layer, fault_route_layer, fault_handle) = match fault_injection {
Some((scope, route, handle)) => (Some(scope), Some(route), Some(handle)),
None => (None, None, None),
};
let inner_stack = (
axum::Extension(upload_config),
body_limit,
crate::webhook::WebhookReplayCleanupLayer,
criticality_layer,
tower::util::option_layer(load_shed_layer),
build_maintenance_layer(config, state),
tower::util::option_layer(rate_limit_principal_keying.then(|| {
axum::middleware::from_fn_with_state(state.clone(), populate_rate_limit_principal)
})),
tower::util::option_layer(rate_limit_layer),
crate::middleware::method_override::MethodOverrideRejectionLayer,
tower::util::option_layer(build_client_cert_requirement_layer(config)),
tower::util::option_layer(build_bot_protection_layer(config)),
(
tower::util::option_layer(
config
.security
.csrf
.enabled
.then(|| crate::aeo::commerce::X402CsrfLayer::from_config(config, state))
.flatten(),
),
tower::util::option_layer(build_csrf_layer(config, signing_keys_opt.clone())),
),
tower::util::option_layer(submit_token_layer),
TrustedHostLayer::new(trusted_host_policy),
tower::util::option_layer(build_ingress_cors_layer(config)),
(
tower::util::option_layer(fault_route_layer),
aeo_x402_layer(config, state, None),
),
);
let custom_layer_count = custom_layers.len();
if custom_layer_count > 0 {
tracing::debug!(count = custom_layer_count, "Custom Tower layers applied");
}
let timeout_layer = build_request_timeout_settings(config, state, route_timeouts, true)
.map(RequestTimeoutLayer::new);
#[cfg(feature = "reporting")]
let capture_layer = tower::util::option_layer(config.failure_capture.enabled.then(|| {
let mut capture_filter_parameters = config.log.filter_parameters.clone();
capture_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
capture_filter_parameters
.extend(crate::confidential::registered_confidential_column_names());
let capture_filter = Arc::new(crate::log::filter::ParameterFilter::new(
&capture_filter_parameters,
&config.log.unfilter_parameters,
));
#[cfg_attr(
any(not(feature = "db"), feature = "sqlite"),
allow(
unused_mut,
reason = "the role assignment below is compiled out on these builds"
)
)]
let mut capture_settings = crate::capsule::settings_from_config(config);
#[cfg(all(feature = "db", not(feature = "sqlite")))]
{
capture_settings.db_roles = crate::capsule::observed_db_roles(
state.pool().is_some(),
state.replica_pool().is_some(),
);
}
let probe_state = state.clone();
crate::capsule::CaptureLayer::new(capture_settings, capture_filter)
.with_state_cache_probe(Arc::new(move || probe_state.has_cache()))
}));
#[cfg(not(feature = "reporting"))]
let capture_layer = tower::layer::util::Identity::new();
let mut log_context_filter_parameters = config.log.filter_parameters.clone();
log_context_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
log_context_filter_parameters
.extend(crate::confidential::registered_confidential_column_names());
let log_context_filter = Arc::new(crate::log::filter::ParameterFilter::new(
&log_context_filter_parameters,
&config.log.unfilter_parameters,
));
#[cfg(feature = "reporting")]
let reporting_layer = crate::reporting::ReportingLayer::new(
state.error_reporters(),
config.reporting.enabled,
config.reporting.sample_rate,
);
#[cfg(not(feature = "reporting"))]
let reporting_layer = tower::layer::util::Identity::new();
let access_log_layer = config
.log
.access_log
.then(|| crate::middleware::AccessLogLayer::new(config.log.access_log_exclude.clone()));
let server_timing_layer = crate::config::server_timing_enabled(config)
.then(|| crate::middleware::ServerTimingLayer::new(true));
let cost_layer = config
.cost
.enabled
.then(|| state.extension::<crate::cost::CostAccountant>())
.flatten()
.map(|accountant| {
crate::middleware::CostLayer::new(
(*accountant).clone(),
crate::config::server_timing_enabled(config),
)
});
let tenancy_layer = config.tenancy.enabled.then(|| {
tracing::debug!("Multi-tenancy middleware enabled");
axum::middleware::from_fn_with_state(state.clone(), crate::tenancy::tenancy_middleware)
});
#[cfg(all(feature = "db", not(feature = "sqlite")))]
let tx_timeouts_layer = {
let timeouts = crate::db::TxTimeouts::from_config(&config.database);
Some(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
timeouts.scope_request(next.run(req))
},
))
};
#[cfg(not(all(feature = "db", not(feature = "sqlite"))))]
let tx_timeouts_layer: Option<tower::layer::util::Identity> = None;
let trusted_proxies_layer = build_trusted_proxies_layer(config);
let middle_stack = (
RequestIdLayer::with_entropy(state.entropy_arc())
.with_inbound_trust(trusted_proxies_layer.resolver()),
crate::middleware::LogContextLayer::new(log_context_filter),
tower::util::option_layer(server_timing_layer),
tower::util::option_layer(cost_layer),
tower::util::option_layer(access_log_layer),
capture_layer,
reporting_layer,
tower::util::option_layer(timeout_layer),
tower::util::option_layer(tenancy_layer),
tower::util::option_layer(tx_timeouts_layer),
trusted_proxies_layer,
);
#[cfg(feature = "db")]
let signing_keys_for_ryw = signing_keys_opt.clone();
let tenancy_session_key = (config.tenancy.enabled && config.tenancy.source == "session")
.then(|| Arc::<str>::from(config.tenancy.session_key.as_str()));
let session_layer = crate::session::build_session_layer(
&config.session,
config.profile.as_deref(),
session_store,
signing_keys_opt,
&state.entropy_arc(),
tenancy_session_key,
)?
.with_rotation_hooks(state.session_rotation_hooks());
tracing::debug!(backend = ?config.session.backend, "Session management enabled");
#[cfg(feature = "db")]
let ryw_layer = tower::util::option_layer(
(config.database.read_your_writes != crate::config::ReadYourWrites::Off).then(|| {
let ryw_mode = config.database.read_your_writes;
let window_secs = config.database.pin_after_write_secs;
let keys = signing_keys_for_ryw;
if ryw_mode == crate::config::ReadYourWrites::Session && keys.is_none() {
tracing::warn!(
"read_your_writes = \"session\" requires a configured \
security.signing_secret to sign the autumn.ryw cookie; \
cross-request pinning is disabled until a secret is set"
);
}
let metrics = state.metrics().clone();
crate::read_your_writes::ReadYourWritesLayer::new(ryw_mode, window_secs, keys, metrics)
}),
);
#[cfg(not(feature = "db"))]
let ryw_layer = tower::layer::util::Identity::new();
let is_dev = crate::config::profile_is_dev(config.profile.as_deref());
let mut all_filters: Vec<Arc<dyn ExceptionFilter>> =
vec![Arc::new(ProblemDetailsFilter { is_dev })];
#[cfg(feature = "maud")]
{
let mut filter_parameters = config.log.filter_parameters.clone();
filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
filter_parameters.extend(crate::confidential::registered_confidential_column_names());
let renderer = error_page_renderer.unwrap_or_else(error_pages::default_renderer);
let error_page_filter = crate::middleware::error_page_filter::ErrorPageFilter {
renderer,
is_dev,
parameter_filter: crate::log::filter::ParameterFilter::new(
&filter_parameters,
&config.log.unfilter_parameters,
),
};
all_filters.push(Arc::new(error_page_filter));
}
all_filters.extend(exception_filters);
let count = all_filters.len();
tracing::debug!(
count,
"Registered exception filters (including error page filter)"
);
let compression_layer = tower::util::option_layer(config.compression.enabled.then(|| {
tracing::info!("Response compression enabled (gzip/brotli)");
(
NormalizeBodyLayer,
tower_http::compression::CompressionLayer::new().compress_when(compression_predicate()),
)
}));
let outer_stack = (
tower::util::option_layer(
(!defer_shadow)
.then(|| build_shadow_layer(config, state))
.flatten(),
),
tower::util::option_layer(
(!defer_shadow)
.then(|| aeo_site.as_ref().and_then(|site| site.layer()))
.flatten(),
),
crate::middleware::MetricsLayer::new(state.metrics.clone()),
tower::util::option_layer(fault_scope_layer),
ExceptionFilterLayer::new(all_filters),
crate::middleware::error_page_filter::ErrorPageContextLayer { is_dev },
ryw_layer,
);
let router = router.layer((
compression_layer,
outer_stack,
session_layer,
NormalizeBodyLayer,
middle_stack,
ComposedRegisteredLayers::new(custom_layers),
inner_stack,
));
if let Some(handle) = fault_handle {
crate::fault_injection::announce(&handle);
state.insert_extension(handle);
}
Ok(router)
}
fn apply_layers_in_registration_order(
router: axum::Router<AppState>,
layers: Vec<crate::app::CustomLayerRegistration>,
what: &str,
) -> axum::Router<AppState> {
let count = layers.len();
if count == 0 {
return router;
}
tracing::debug!(count, "{what} Tower layers applied");
router.layer(ComposedRegisteredLayers::new(layers))
}
fn trusted_host_rejection<B>(
req: &Request<B>,
policy: &TrustedHostPolicy,
) -> Option<axum::response::Response> {
let path = req.uri().path();
if (req.method() == http::Method::GET || req.method() == http::Method::HEAD)
&& policy.probe_bypass_paths.contains(path)
{
return None;
}
let authority = req.uri().authority().map(http::uri::Authority::as_str);
let host_header = req
.headers()
.get(http::header::HOST)
.and_then(|v| v.to_str().ok());
let raw_host = authority.or(host_header);
let parsed_host = raw_host.and_then(extract_host_without_port);
let host = parsed_host.and_then(normalize_trusted_host);
let host_source_present = raw_host.is_some();
if host.is_none() && !host_source_present && policy.allow_missing_host {
return None;
}
if !host_source_present
&& req
.extensions()
.get::<crate::static_gen::RenderDeadlineExempt>()
.is_some()
{
return None;
}
if host.as_deref().is_some_and(|host| policy.allows_host(host)) {
return None;
}
tracing::warn!(host = ?host, "trusted host rejected request");
let body = crate::error::problem_details_json_string(
StatusCode::BAD_REQUEST,
"Invalid Host header",
None,
None,
None,
None,
true,
);
Some(
(
StatusCode::BAD_REQUEST,
[(http::header::CONTENT_TYPE, "application/problem+json")],
body,
)
.into_response(),
)
}
fn normalize_trusted_host(host: &str) -> Option<std::borrow::Cow<'_, str>> {
let host = host.trim_end_matches('.');
if host.is_empty() {
return None;
}
Some(if host.bytes().any(|b| b.is_ascii_uppercase()) {
std::borrow::Cow::Owned(host.to_ascii_lowercase())
} else {
std::borrow::Cow::Borrowed(host)
})
}
#[derive(Clone, Debug)]
pub struct TrustedHostLayer {
policy: TrustedHostPolicy,
}
impl TrustedHostLayer {
pub(crate) const fn new(policy: TrustedHostPolicy) -> Self {
Self { policy }
}
}
impl<S> tower::Layer<S> for TrustedHostLayer {
type Service = TrustedHostService<S>;
fn layer(&self, inner: S) -> Self::Service {
TrustedHostService {
inner,
policy: self.policy.clone(),
}
}
}
#[derive(Clone, Debug)]
pub struct TrustedHostService<S> {
inner: S,
policy: TrustedHostPolicy,
}
impl<S, ReqBody> tower::Service<Request<ReqBody>> for TrustedHostService<S>
where
S: tower::Service<Request<ReqBody>, Response = axum::response::Response>,
{
type Response = S::Response;
type Error = S::Error;
type Future = crate::middleware::short_circuit::ShortCircuitFuture<S::Future>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
use crate::middleware::short_circuit::ShortCircuitFuture;
trusted_host_rejection(&req, &self.policy).map_or_else(
|| ShortCircuitFuture::forward(self.inner.call(req)),
ShortCircuitFuture::short_circuit,
)
}
}
pub fn extract_host_without_port(header: &str) -> Option<&str> {
let host = header.trim();
if host.is_empty() {
return None;
}
if host.starts_with('[') {
let end = host.find(']')?;
let literal = host.get(1..end)?;
if literal.is_empty() || literal.parse::<std::net::IpAddr>().is_err() {
return None;
}
let remainder = host.get(end + 1..)?;
if remainder.is_empty() {
return Some(literal);
}
let maybe_port = remainder.strip_prefix(':')?;
if !maybe_port.is_empty() && maybe_port.chars().all(|c| c.is_ascii_digit()) {
return Some(literal);
}
return None;
}
let Some((candidate, maybe_port)) = host.rsplit_once(':') else {
return Some(host);
};
if candidate.contains(':') {
return Some(host);
}
if !maybe_port.is_empty()
&& maybe_port.chars().all(|c| c.is_ascii_digit())
&& !candidate.is_empty()
{
Some(candidate)
} else {
None
}
}
#[allow(dead_code)]
pub fn build_router_with_static(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
dist_dir: Option<&std::path::Path>,
) -> axum::Router {
try_build_router_with_static(route_list, config, state, dist_dir)
.unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
}
#[allow(dead_code)]
pub fn try_build_router_with_static(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
dist_dir: Option<&std::path::Path>,
) -> Result<axum::Router, RouterBuildError> {
try_build_router_with_static_inner(
route_list,
config,
state,
dist_dir,
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
},
)
}
#[cfg(feature = "i18n")]
pub fn partition_custom_layers_for_static_render(
custom_layers: Vec<crate::app::CustomLayerRegistration>,
) -> (
Vec<crate::app::CustomLayerRegistration>,
Vec<crate::app::CustomLayerRegistration>,
) {
let keep_type_ids = [
std::any::TypeId::of::<crate::i18n::AmbientLocaleLayer>(),
std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>(),
];
custom_layers
.into_iter()
.partition(|r| keep_type_ids.contains(&r.type_id))
}
#[cfg(not(feature = "i18n"))]
pub const fn partition_custom_layers_for_static_render(
custom_layers: Vec<crate::app::CustomLayerRegistration>,
) -> (
Vec<crate::app::CustomLayerRegistration>,
Vec<crate::app::CustomLayerRegistration>,
) {
(Vec::new(), custom_layers)
}
fn static_page_routes<'a>(
pages: impl Iterator<Item = &'a String>,
route_list: &[Route],
scoped_groups: &[ScopedGroup],
config: &AutumnConfig,
) -> std::collections::HashMap<String, Option<String>> {
let mut mounted: Vec<String> = Vec::new();
for route in route_list.iter().filter(|r| r.method == http::Method::GET) {
mounted.extend(mounted_paths(route.path, config));
}
mounted.extend(scoped_groups.iter().flat_map(|g| {
g.routes
.iter()
.filter(|r| r.method == http::Method::GET)
.map(|r| join_nested_path(&g.prefix, r.path))
}));
let mut matcher: matchit::Router<String> = matchit::Router::new();
for path in mounted {
let _ = matcher.insert(path.clone(), path);
}
pages
.map(|page| {
let route = matcher.at(page).ok().map(|hit| hit.value.clone());
(page.clone(), route)
})
.collect()
}
fn routed_templates(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
config: &AutumnConfig,
) -> Vec<String> {
let mut mounted: Vec<String> = route_list
.iter()
.flat_map(|r| mounted_paths(r.path, config))
.collect();
mounted.extend(
scoped_groups
.iter()
.flat_map(|g| g.routes.iter().map(|r| join_nested_path(&g.prefix, r.path))),
);
mounted.sort_unstable();
mounted.dedup();
mounted
}
#[cfg_attr(not(feature = "i18n"), allow(clippy::unnecessary_wraps))]
fn mounted_paths(path: &str, config: &AutumnConfig) -> Vec<String> {
#[cfg(feature = "i18n")]
{
let i18n = &config.i18n;
let excluded = i18n.locale_prefix_exclude_exact.iter().any(|p| p == path)
|| matches_locale_exclude_prefix(path, &i18n.locale_prefix_exclude);
if i18n.locale_prefix_enabled && !excluded {
return validated_locale_prefix_locales(i18n)
.iter()
.map(|locale| join_nested_path(&format!("/{locale}"), path))
.collect();
}
}
#[cfg(not(feature = "i18n"))]
let _ = config;
vec![path.to_owned()]
}
#[allow(clippy::too_many_lines)]
pub fn try_build_router_with_static_inner(
route_list: Vec<Route>,
config: &AutumnConfig,
state: AppState,
dist_dir: Option<&std::path::Path>,
mut ctx: RouterContext,
) -> Result<axum::Router, RouterBuildError> {
let startup_barrier_state = state.clone();
let Some(dist) = dist_dir else {
let app_router = try_build_router_inner(route_list, config, state, ctx)?;
return Ok(apply_startup_barrier(
app_router,
config,
&startup_barrier_state,
));
};
let Some(layer) = crate::static_gen::StaticFileLayer::new(dist) else {
tracing::debug!(
dist = %dist.display(),
"No valid manifest.json in dist dir; skipping static file layer"
);
let app_router = try_build_router_inner(route_list, config, state, ctx)?;
return Ok(apply_startup_barrier(
app_router,
config,
&startup_barrier_state,
));
};
for (route, entry) in &layer.manifest().routes {
tracing::debug!(
route = %route,
file = %entry.file,
revalidate = ?entry.revalidate,
"Static route"
);
}
let opaque_present = Some(
custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
|| custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers),
);
let custom_layers = std::mem::take(&mut ctx.custom_layers);
let (session_scoped, custom_layers) = partition_custom_layers_for_static_render(custom_layers);
ctx.custom_layers = session_scoped;
let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
#[cfg(feature = "mcp")]
let mcp_dispatch_extra_layers = custom_layers.clone();
#[cfg(not(feature = "mcp"))]
let mcp_dispatch_extra_layers = Vec::new();
let static_paths = Arc::new(static_page_routes(
layer.manifest().routes.keys(),
&route_list,
&ctx.scoped_groups,
config,
));
let (inner_router, deferred_mcp_router) = build_router_pre_state(
route_list,
config,
&state,
ctx,
opaque_present,
true,
mcp_dispatch_extra_layers,
)?;
let (priced_static, unrouted_static) =
crate::aeo::commerce::priced_static_pages(config, &state, &static_paths);
if !unrouted_static.is_empty() {
tracing::warn!(
pages = ?unrouted_static.keys().collect::<Vec<_>>(),
"aeo: x402 prices pre-rendered pages no route renders; they are served from \
the manifest, before rate limiting, bot protection, load shedding and CORS"
);
}
let priced_static = Arc::new(priced_static);
let has_isr = layer
.manifest()
.routes
.values()
.any(|e| e.revalidate.is_some());
let layer = if has_isr {
let regen_router = inner_router
.clone()
.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
))
.with_state(state.clone());
layer.with_router(regen_router)
} else {
layer
};
let layer = Arc::new(layer);
let static_layer = layer;
let mut router: axum::Router<AppState> = inner_router.layer(axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let static_layer = static_layer.clone();
let priced_static = Arc::clone(&priced_static);
async move {
let is_get = req.method() == http::Method::GET;
let is_head = req.method() == http::Method::HEAD;
let head_for_markdown =
is_head && crate::aeo::negotiate::prefers_markdown(req.headers());
if is_get || is_head {
let path = req.uri().path();
let normalized = if path.len() > 1 && path.ends_with('/') {
&path[..path.len() - 1]
} else {
path
};
if priced_static.contains(normalized) {
return next.run(req).await;
}
if let Some(hit) = static_layer.resolve_entry(normalized)
&& let Ok(contents) = tokio::fs::read(&hit.file_path).await
{
let len = contents.len();
let empty_head = is_head && !head_for_markdown;
let body = if empty_head {
axum::body::Body::empty()
} else {
axum::body::Body::from(contents)
};
let mut response = http::Response::builder()
.status(http::StatusCode::OK)
.header(http::header::CONTENT_TYPE, hit.content_type);
if empty_head {
response = response.header(http::header::CONTENT_LENGTH, len);
}
return response.body(body).expect("infallible response builder");
}
}
next.run(req).await
}
},
));
router = apply_layers_in_registration_order(
router,
custom_layers,
"Custom (outside static middleware)",
);
router = install_outer_fault_injection(router, config, &state);
if let Some(mcp_router) = deferred_mcp_router {
router = router.merge(mcp_router);
}
router = router.layer((
tower::util::option_layer(build_shadow_layer(config, &state)),
tower::util::option_layer(
state
.extension::<crate::aeo::AeoSite>()
.and_then(|site| site.layer()),
),
aeo_x402_layer(config, &state, Some(Arc::new(unrouted_static))),
));
router = apply_compression_middleware(router, config);
router = apply_layers_in_registration_order(
router,
static_gate_layers,
"Pre-static gate (outside static middleware)",
);
if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
router = router.layer(require_client_cert);
}
let router = router.layer(crate::security::SecurityHeadersLayer::from_config(
&config.security.headers,
));
Ok(apply_startup_barrier(
router.with_state(state),
config,
&startup_barrier_state,
))
}
#[derive(Clone)]
pub struct StartupBarrierState {
app_state: AppState,
probe_paths: Vec<String>,
actuator_paths: Vec<String>,
actuator_subtree_paths: Vec<String>,
}
impl StartupBarrierState {
fn from_config(config: &AutumnConfig, app_state: &AppState) -> Self {
let actuator_subtree_paths = if config.actuator.sensitive {
vec![crate::actuator::actuator_route_path(
&config.actuator.prefix,
"/loggers",
)]
} else {
Vec::new()
};
Self {
app_state: app_state.clone(),
probe_paths: probe_bypass_paths(config),
actuator_paths: crate::actuator::actuator_endpoint_paths(
&config.actuator.prefix,
config.actuator.sensitive,
config.actuator.prometheus,
),
actuator_subtree_paths,
}
}
fn allows_path(&self, path: &str) -> bool {
self.probe_paths.iter().any(|allowed| path == allowed)
|| self.actuator_paths.iter().any(|allowed| path == allowed)
|| self
.actuator_subtree_paths
.iter()
.any(|allowed| path_matches_route_prefix(path, allowed))
}
}
fn apply_startup_barrier(
router: axum::Router,
config: &AutumnConfig,
state: &AppState,
) -> axum::Router {
let barrier_state = StartupBarrierState::from_config(config, state);
#[cfg(feature = "telemetry-otlp")]
let trace_context = crate::middleware::TraceContextLayer;
#[cfg(not(feature = "telemetry-otlp"))]
let trace_context = tower::layer::util::Identity::new();
let server_timing_fallback = crate::config::server_timing_enabled(config)
.then(|| crate::middleware::ServerTimingLayer::fallback(true));
let access_log_fallback = config.log.access_log.then(|| {
crate::middleware::AccessLogLayer::fallback(config.log.access_log_exclude.clone())
});
let cost_fallback = config
.cost
.enabled
.then(|| state.extension::<crate::cost::CostAccountant>())
.flatten()
.map(|accountant| {
crate::middleware::CostLayer::fallback(
(*accountant).clone(),
crate::config::server_timing_enabled(config),
)
});
router.layer((
trace_context,
tower::util::option_layer(server_timing_fallback),
tower::util::option_layer(cost_fallback),
tower::util::option_layer(access_log_fallback),
StartupBarrierLayer::new(barrier_state),
))
}
#[derive(Clone)]
pub struct StartupBarrierLayer {
state: Arc<StartupBarrierState>,
}
impl StartupBarrierLayer {
pub(crate) fn new(state: StartupBarrierState) -> Self {
Self {
state: Arc::new(state),
}
}
}
impl<S> tower::Layer<S> for StartupBarrierLayer {
type Service = StartupBarrierService<S>;
fn layer(&self, inner: S) -> Self::Service {
StartupBarrierService {
inner,
state: Arc::clone(&self.state),
}
}
}
#[derive(Clone)]
pub struct StartupBarrierService<S> {
inner: S,
state: Arc<StartupBarrierState>,
}
impl<S, ReqBody> tower::Service<Request<ReqBody>> for StartupBarrierService<S>
where
S: tower::Service<Request<ReqBody>, Response = axum::response::Response>,
{
type Response = S::Response;
type Error = S::Error;
type Future = crate::middleware::short_circuit::ShortCircuitFuture<S::Future>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
use crate::middleware::short_circuit::ShortCircuitFuture;
if crate::app::is_static_build_mode()
|| self.state.app_state.probes().is_startup_complete()
|| self.state.allows_path(req.uri().path())
{
ShortCircuitFuture::forward(self.inner.call(req))
} else {
ShortCircuitFuture::short_circuit(
(
StatusCode::SERVICE_UNAVAILABLE,
"Service is still starting up",
)
.into_response(),
)
}
}
}
pub fn path_matches_route_prefix(path: &str, prefix: &str) -> bool {
path == prefix
|| path
.strip_prefix(prefix)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
pub fn build_cors_layer(cors: &crate::config::CorsConfig) -> tower_http::cors::CorsLayer {
use http::header::HeaderName;
use tower_http::cors::{AllowOrigin, CorsLayer};
let layer = if cors.allowed_origins.iter().any(|o| o == "*") {
CorsLayer::new().allow_origin(AllowOrigin::any())
} else {
let origins: Vec<http::HeaderValue> = cors
.allowed_origins
.iter()
.filter_map(|o| match o.parse() {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(origin = %o, error = %e, "CORS: ignoring malformed allowed_origin");
None
}
})
.collect();
CorsLayer::new().allow_origin(origins)
};
let methods: Vec<http::Method> = cors
.allowed_methods
.iter()
.filter_map(|m| match m.parse() {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(method = %m, error = %e, "CORS: ignoring malformed allowed_method");
None
}
})
.collect();
let headers: Vec<HeaderName> = cors
.allowed_headers
.iter()
.filter_map(|h| match h.parse() {
Ok(v) => Some(v),
Err(e) => {
tracing::warn!(header = %h, error = %e, "CORS: ignoring malformed allowed_header");
None
}
})
.collect();
layer
.allow_methods(methods)
.allow_headers(headers)
.allow_credentials(cors.allow_credentials)
.max_age(std::time::Duration::from_secs(cors.max_age_secs))
}
pub fn mirror_cors_headers(
cors: &crate::config::CorsConfig,
origin: Option<&http::HeaderValue>,
response: &mut axum::response::Response,
) {
use http::header;
let allow_any = cors.allowed_origins.iter().any(|o| o == "*");
let allow_origin = if allow_any {
Some(http::HeaderValue::from_static("*"))
} else {
origin.and_then(|value| {
let value_str = value.to_str().ok()?;
cors.allowed_origins
.iter()
.any(|allowed| allowed == value_str)
.then(|| value.clone())
})
};
let Some(allow_origin) = allow_origin else {
return;
};
let headers = response.headers_mut();
headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin);
if !allow_any {
headers.insert(header::VARY, http::HeaderValue::from_static("origin"));
}
if cors.allow_credentials {
headers.insert(
header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
http::HeaderValue::from_static("true"),
);
}
}
#[cfg(feature = "htmx")]
fn framework_scripts_router() -> axum::Router<AppState> {
crate::assets::plugin::routes_for(crate::htmx::framework_scripts(), "/static", "/static")
}
#[cfg(any(feature = "flash", feature = "maud"))]
struct PrecompressedCss {
gzip: bytes::Bytes,
brotli: bytes::Bytes,
}
#[cfg(any(feature = "flash", feature = "maud"))]
impl PrecompressedCss {
fn compute(body: &'static str) -> Self {
use std::io::Write as _;
let mut gzip_encoder =
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
gzip_encoder
.write_all(body.as_bytes())
.expect("in-memory gzip encoding cannot fail");
let gzip = gzip_encoder
.finish()
.expect("in-memory gzip encoding cannot fail");
let mut brotli_writer = brotli::CompressorWriter::new(Vec::new(), 4096, 11, 22);
brotli_writer
.write_all(body.as_bytes())
.expect("in-memory brotli encoding cannot fail");
let brotli = brotli_writer.into_inner();
Self {
gzip: gzip.into(),
brotli: brotli.into(),
}
}
}
#[cfg(any(feature = "flash", feature = "maud"))]
fn accepts_encoding(headers: &http::HeaderMap, coding: &str) -> bool {
let Some(value) = headers
.get(http::header::ACCEPT_ENCODING)
.and_then(|v| v.to_str().ok())
else {
return false;
};
value.split(',').any(|part| {
let mut segments = part.split(';');
let name = segments.next().unwrap_or("").trim();
name.eq_ignore_ascii_case(coding)
&& segments
.find_map(|q| q.trim().strip_prefix("q="))
.and_then(|q| q.parse::<f32>().ok())
.is_none_or(|q| q > 0.0)
})
}
#[cfg(any(feature = "flash", feature = "maud"))]
fn static_css_response(
headers: &http::HeaderMap,
body: &'static str,
precompressed: &'static PrecompressedCss,
) -> axum::response::Response {
use crate::etag::IntoETag as _;
use axum::response::IntoResponse;
let (encoded_body, content_encoding): (axum::body::Body, Option<&'static str>) =
if accepts_encoding(headers, "br") {
(precompressed.brotli.clone().into(), Some("br"))
} else if accepts_encoding(headers, "gzip") {
(precompressed.gzip.clone().into(), Some("gzip"))
} else {
(body.into(), None)
};
let mut response_headers = http::HeaderMap::new();
response_headers.insert(
http::header::CONTENT_TYPE,
http::HeaderValue::from_static("text/css; charset=utf-8"),
);
response_headers.insert(
http::header::CACHE_CONTROL,
http::HeaderValue::from_static("public, max-age=31536000, immutable"),
);
response_headers.insert(
http::header::VARY,
http::HeaderValue::from_static("Accept-Encoding"),
);
if let Some(encoding) = content_encoding {
response_headers.insert(
http::header::CONTENT_ENCODING,
http::HeaderValue::from_static(encoding),
);
}
let etag = crate::etag::ETag::weak(body.into_etag().tag().to_owned());
crate::etag::fresh_when(headers, etag)
.or((response_headers, encoded_body))
.into_response()
}
#[cfg(feature = "flash")]
pub async fn flash_css_handler(headers: http::HeaderMap) -> axum::response::Response {
static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
static_css_response(
&headers,
crate::flash::FLASH_CSS,
PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::flash::FLASH_CSS)),
)
}
#[cfg(feature = "maud")]
pub async fn widgets_css_handler(headers: http::HeaderMap) -> axum::response::Response {
static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
static_css_response(
&headers,
crate::ui::WIDGETS_CSS,
PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::ui::WIDGETS_CSS)),
)
}
#[cfg(feature = "openapi")]
pub fn collect_openapi_docs(
route_list: &[Route],
scoped_groups: &[ScopedGroup],
) -> Vec<crate::openapi::ApiDoc> {
let mut docs: Vec<crate::openapi::ApiDoc> = Vec::new();
for route in route_list {
let mut doc = route.api_doc.clone();
doc.api_version = route.api_version;
doc.sunset_opt_out = route.sunset_opt_out;
docs.push(doc);
}
for group in scoped_groups {
let prefix_params = extract_path_params(&group.prefix);
for route in &group.routes {
let mut doc = route.api_doc.clone();
doc.api_version = route.api_version;
doc.sunset_opt_out = route.sunset_opt_out;
let full = join_nested_path(&group.prefix, route.api_doc.path);
doc.path = Box::leak(full.into_boxed_str());
if !prefix_params.is_empty() {
let mut merged: Vec<&'static str> = prefix_params
.iter()
.map(|p| &*Box::leak(p.clone().into_boxed_str()))
.collect();
for existing in route.api_doc.path_params {
if !merged.iter().any(|n| n == existing) {
merged.push(existing);
}
}
doc.path_params = Box::leak(merged.into_boxed_slice());
}
docs.push(doc);
}
}
docs
}
#[cfg(feature = "openapi")]
fn mount_swagger_ui_routes(
mut router: axum::Router<AppState>,
path: &str,
title: &str,
json_path: &str,
) -> axum::Router<AppState> {
let [css_path, bundle_path, initializer_path] = crate::openapi::swagger_ui_asset_paths(path);
let html_body = Arc::new(crate::openapi::swagger_ui_html(
title,
&css_path,
&bundle_path,
&initializer_path,
));
let initializer_body = Arc::new(crate::openapi::swagger_ui_initializer_js(json_path));
router = router.route(
path,
axum::routing::get(move || {
let html = html_body.clone();
async move {
use axum::response::IntoResponse;
(
[(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
(*html).clone(),
)
.into_response()
}
}),
);
router = router.route(
&css_path,
axum::routing::get(|| async move {
use axum::response::IntoResponse;
(
[(http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
crate::openapi::SWAGGER_UI_CSS,
)
.into_response()
}),
);
router = router.route(
&bundle_path,
axum::routing::get(|| async move {
use axum::body::Bytes;
use axum::response::IntoResponse;
(
[(
http::header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)],
Bytes::from_static(crate::openapi::SWAGGER_UI_BUNDLE),
)
.into_response()
}),
);
router = router.route(
&initializer_path,
axum::routing::get(move || {
let js = initializer_body.clone();
async move {
use axum::response::IntoResponse;
(
[(
http::header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)],
(*js).clone(),
)
.into_response()
}
}),
);
router
}
#[cfg(feature = "oauth2")]
#[derive(Clone)]
pub struct HttpInterceptorLayer {
state: AppState,
}
#[cfg(feature = "oauth2")]
impl HttpInterceptorLayer {
pub(crate) const fn new(state: AppState) -> Self {
Self { state }
}
}
#[cfg(feature = "oauth2")]
impl<S> tower::Layer<S> for HttpInterceptorLayer {
type Service = HttpInterceptorService<S>;
fn layer(&self, inner: S) -> Self::Service {
HttpInterceptorService {
inner,
state: self.state.clone(),
}
}
}
#[cfg(feature = "oauth2")]
#[derive(Clone)]
pub struct HttpInterceptorService<S> {
inner: S,
state: AppState,
}
#[cfg(feature = "oauth2")]
pin_project_lite::pin_project! {
#[project = HttpInterceptorFutureProj]
pub enum HttpInterceptorScopeFuture<F> {
Plain {
#[pin]
inner: F,
},
Scoped {
#[pin]
inner: tokio::task::futures::TaskLocalFuture<
Vec<Arc<dyn crate::interceptor::HttpInterceptor>>,
F,
>,
},
}
}
#[cfg(feature = "oauth2")]
impl<F: std::future::Future> std::future::Future for HttpInterceptorScopeFuture<F> {
type Output = F::Output;
fn poll(
self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Self::Output> {
match self.project() {
HttpInterceptorFutureProj::Plain { inner } => inner.poll(cx),
HttpInterceptorFutureProj::Scoped { inner } => inner.poll(cx),
}
}
}
#[cfg(feature = "oauth2")]
impl<S, ReqBody> tower::Service<Request<ReqBody>> for HttpInterceptorService<S>
where
S: tower::Service<Request<ReqBody>>,
{
type Response = S::Response;
type Error = S::Error;
type Future = HttpInterceptorScopeFuture<S::Future>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor};
let Some(interceptor_arc) = self.state.extension::<Arc<dyn HttpInterceptor>>() else {
return HttpInterceptorScopeFuture::Plain {
inner: self.inner.call(req),
};
};
let interceptors = vec![(*interceptor_arc).clone()];
let inner =
ACTIVE_HTTP_INTERCEPTORS.sync_scope(interceptors.clone(), || self.inner.call(req));
HttpInterceptorScopeFuture::Scoped {
inner: ACTIVE_HTTP_INTERCEPTORS.scope(interceptors, inner),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
fn test_state() -> AppState {
AppState {
profile: Some("test".into()),
..AppState::test_default()
}
}
#[test]
fn adaptive_mode_installs_the_load_shed_layer_without_a_ceiling() {
let mut config = AutumnConfig::default();
assert!(build_load_shed_layer(&config, &test_state()).is_none());
config.server.admission.mode = crate::config::AdmissionMode::Adaptive;
let state = test_state();
assert!(build_load_shed_layer(&config, &state).is_some());
assert_eq!(
state.metrics.snapshot().http.admission.limit,
20,
"the gauge shows the initial adaptive limit"
);
}
#[test]
fn adaptive_mode_with_bad_bounds_falls_back_to_the_static_ceiling() {
let mut config = AutumnConfig::default();
config.server.admission.mode = crate::config::AdmissionMode::Adaptive;
config.server.admission.min_limit = 0;
assert!(build_load_shed_layer(&config, &test_state()).is_none());
config.server.max_concurrent_requests = Some(7);
let state = test_state();
assert!(build_load_shed_layer(&config, &state).is_some());
assert_eq!(state.metrics.snapshot().http.admission.limit, 7);
}
#[test]
fn prod_profile_installs_the_load_shed_layer() {
let mut config = AutumnConfig {
profile: Some("prod".into()),
..AutumnConfig::default()
};
assert!(build_load_shed_layer(&config, &test_state()).is_some());
config.server.max_concurrent_requests = Some(0);
assert!(build_load_shed_layer(&config, &test_state()).is_none());
}
#[test]
fn other_profiles_leave_load_shedding_off() {
for profile in ["dev", "staging"] {
let config = AutumnConfig {
profile: Some(profile.into()),
..AutumnConfig::default()
};
assert!(
build_load_shed_layer(&config, &test_state()).is_none(),
"{profile}"
);
}
}
fn contract_file_under(profile: &str) -> (tempfile::TempDir, String) {
use crate::capacity::{
CONTRACT_VERSION, Calibration, CapacityContract, Envelope, HostProfile, Provenance,
};
let contract = CapacityContract {
version: CONTRACT_VERSION,
provenance: Provenance {
autumn_version: "0.0.0".to_owned(),
calibrated_at: "2026-09-01T12:00:00Z".to_owned(),
git_commit: None,
git_dirty: false,
route_graph_digest: String::new(),
},
host: HostProfile::detect(),
envelope: Envelope {
sustained_rps: 1000.0,
p99_latency_ms: 10.0,
saturation_concurrency: 40,
admission_limit: 48,
},
calibration: Calibration {
profile: profile.to_owned(),
..Calibration::default()
},
routes: Vec::new(),
};
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(crate::capacity::CONTRACT_FILE_NAME);
std::fs::write(&path, contract.to_toml().expect("serialize")).expect("write contract");
(dir, path.to_str().expect("utf-8 path").to_owned())
}
#[test]
fn the_load_shed_layer_enforces_only_a_contract_for_the_running_profile() {
let (_dir, path) = contract_file_under("staging");
let mut config = AutumnConfig {
profile: Some("prod".into()),
..AutumnConfig::default()
};
config.server.capacity_contract = Some(path);
let state = test_state();
assert!(build_load_shed_layer(&config, &state).is_some());
let mismatched = state.metrics.snapshot().http.admission.limit;
assert_ne!(mismatched, 48, "a staging contract must not bind prod");
config.profile = Some("staging".into());
let state = test_state();
assert!(build_load_shed_layer(&config, &state).is_some());
assert_eq!(state.metrics.snapshot().http.admission.limit, 48);
}
#[test]
fn submit_token_explicit_memory_in_production_fails_router_build() {
let mut config = AutumnConfig::default();
config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
let err = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
.expect_err("explicit memory submit-token backend in prod must fail router build");
assert!(
matches!(err, RouterBuildError::InvalidSubmitTokenBackend(_)),
"expected InvalidSubmitTokenBackend, got {err:?}"
);
}
#[test]
fn submit_token_inherited_memory_in_production_builds() {
let mut config = AutumnConfig::default();
config.security.submit_token.backend = None;
config.idempotency.backend = crate::config::IdempotencyBackend::Memory;
let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
.expect("inherited memory submit-token backend in prod must still build (warn only)");
}
#[test]
fn submit_token_memory_outside_production_builds() {
let mut config = AutumnConfig::default();
config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, false)
.expect("memory submit-token backend outside production must build");
}
#[tokio::test]
async fn build_router_mounts_actuator_at_configured_prefix() {
let mut config = AutumnConfig::default();
config.actuator.prefix = "/ops".to_owned();
config.actuator.sensitive = true;
let app = build_router(Vec::new(), &config, test_state());
let prefixed = app
.clone()
.oneshot(
Request::builder()
.uri("/ops/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(prefixed.status(), StatusCode::OK);
let legacy = app
.oneshot(
Request::builder()
.uri("/actuator/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
}
#[cfg(feature = "tls")]
#[tokio::test]
async fn a_worker_actuator_under_a_required_path_still_demands_a_certificate() {
let mut config = AutumnConfig::default();
config.server.tls = Some(crate::config::TlsConfig {
cert_path: Some("cert.pem".into()),
key_path: Some("key.pem".into()),
reload_interval_secs: 60,
handshake_timeout_secs: 10,
acme: None,
client_auth: Some(crate::config::ClientAuthConfig {
mode: crate::config::ClientAuthMode::Optional,
ca_bundle_path: Some("client-ca.pem".into()),
crl_path: None,
required_paths: vec!["/actuator/".to_owned()],
reload_interval_secs: 60,
}),
});
let app = try_build_probe_only_router(&config, test_state())
.expect("probe-only router should build");
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/actuator/info")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"a worker actuator under a required_paths prefix must demand a certificate"
);
let identity = std::sync::Arc::new(crate::tls::client_auth::ClientIdentity::new_for_test(
"svc-ops",
Vec::new(),
));
let mut request = Request::builder()
.uri("/actuator/info")
.body(Body::empty())
.unwrap();
request.extensions_mut().insert(identity);
let response = app.clone().oneshot(request).await.unwrap();
assert_ne!(response.status(), StatusCode::FORBIDDEN);
let response = app
.oneshot(
Request::builder()
.uri(config.health.live_path.as_str())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_ne!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn probe_only_router_mounts_probes_and_actuator_but_no_user_routes() {
let config = AutumnConfig::default();
let app = try_build_probe_only_router(&config, test_state())
.expect("probe-only router should build");
for path in [
config.health.live_path.as_str(),
config.health.ready_path.as_str(),
config.health.startup_path.as_str(),
config.health.path.as_str(),
"/actuator/health",
"/actuator/info",
] {
let response = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_ne!(
response.status(),
StatusCode::NOT_FOUND,
"probe-only router should serve {path}"
);
}
let missing = app
.oneshot(
Request::builder()
.uri("/definitely-not-a-user-route")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn user_route_at_health_path_overrides_builtin_probe() {
async fn user_health() -> &'static str {
"user-health-handler"
}
let config = AutumnConfig::default();
assert_eq!(config.health.path, "/health");
let route = Route {
method: http::Method::GET,
path: "/health",
handler: axum::routing::get(user_health),
name: "user_health",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/health",
operation_id: "user_health",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let app = build_router(vec![route], &config, test_state());
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/health")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
&body[..],
b"user-health-handler",
"user route must win at the health path"
);
for path in ["/live", "/ready", "/startup"] {
let resp = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_ne!(
resp.status(),
StatusCode::NOT_FOUND,
"built-in probe {path} should still be mounted"
);
}
}
#[tokio::test]
async fn health_enabled_false_mounts_no_builtin_probes() {
let mut config = AutumnConfig::default();
config.health.enabled = false;
let app = build_router(vec![], &config, test_state());
for path in [
config.health.path.as_str(),
config.health.live_path.as_str(),
config.health.ready_path.as_str(),
config.health.startup_path.as_str(),
] {
let response = app
.clone()
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"probe path {path} must not be auto-mounted when health.enabled = false"
);
}
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_serves_the_shared_stylesheet() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get(http::header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap();
assert!(content_type.contains("text/css"), "{content_type}");
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains(".autumn-field"), "{body}");
assert!(body.contains(":root"), "{body}");
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_supports_conditional_get() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let first = app
.clone()
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(first.status(), StatusCode::OK);
let etag = first
.headers()
.get(http::header::ETAG)
.expect("widget stylesheet response should carry an ETag")
.clone();
let revalidated = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.header(http::header::IF_NONE_MATCH, etag)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(revalidated.status(), StatusCode::NOT_MODIFIED);
let revalidated_body = axum::body::to_bytes(revalidated.into_body(), usize::MAX)
.await
.unwrap();
assert!(revalidated_body.is_empty());
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_etag_is_weak_not_strong() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let etag = response
.headers()
.get(http::header::ETAG)
.expect("widget stylesheet response should carry an ETag")
.to_str()
.unwrap()
.to_owned();
assert!(
etag.starts_with("W/\""),
"ETag must be weak since encoded variants aren't byte-identical: {etag}"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_serves_precompressed_brotli_when_accepted() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.header(http::header::ACCEPT_ENCODING, "br")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.unwrap(),
"br"
);
assert_eq!(
response.headers().get(http::header::VARY).unwrap(),
"Accept-Encoding"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let mut decoded = Vec::new();
brotli::BrotliDecompress(&mut std::io::Cursor::new(body.as_ref()), &mut decoded)
.expect("response body must be valid brotli");
assert_eq!(String::from_utf8(decoded).unwrap(), crate::ui::WIDGETS_CSS);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_serves_precompressed_gzip_when_accepted() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.header(http::header::ACCEPT_ENCODING, "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.unwrap(),
"gzip"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let mut gz = flate2::read::GzDecoder::new(body.as_ref());
let mut output = String::new();
std::io::Read::read_to_string(&mut gz, &mut output)
.expect("response body must be valid gzip");
assert_eq!(output, crate::ui::WIDGETS_CSS);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_honors_q_zero_opt_out() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.header(http::header::ACCEPT_ENCODING, "br;q=0, gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.unwrap(),
"gzip"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn widgets_css_route_serves_identity_with_no_accept_encoding() {
let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
let response = app
.oneshot(
Request::builder()
.uri(crate::ui::WIDGETS_CSS_PATH)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
!response
.headers()
.contains_key(http::header::CONTENT_ENCODING)
);
}
#[test]
fn startup_barrier_503s_are_access_logged() {
use tracing_subscriber::layer::SubscriberExt as _;
#[derive(Clone, Default)]
struct Capture {
events: Arc<std::sync::Mutex<Vec<std::collections::BTreeMap<String, String>>>>,
}
struct Visitor<'a>(&'a mut std::collections::BTreeMap<String, String>);
impl tracing::field::Visit for Visitor<'_> {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.0.insert(field.name().to_owned(), format!("{value:?}"));
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.0.insert(field.name().to_owned(), value.to_string());
}
}
impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
if event.metadata().target() != crate::middleware::ACCESS_LOG_TARGET {
return;
}
let mut fields = std::collections::BTreeMap::new();
event.record(&mut Visitor(&mut fields));
self.events.lock().unwrap().push(fields);
}
}
let capture = Capture::default();
let events = Arc::clone(&capture.events);
let subscriber = tracing_subscriber::registry().with(capture);
tracing::subscriber::with_default(subscriber, || {
let state = AppState::for_test()
.with_profile("test")
.with_startup_complete(false);
let app = build_router(Vec::new(), &AutumnConfig::default(), state);
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let mut response = None;
for attempt in 1..=5 {
tracing::callsite::rebuild_interest_cache();
let resp = rt.block_on(async {
app.clone()
.oneshot(
Request::builder()
.uri("/not-a-probe")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
});
let captured = !events.lock().unwrap().is_empty();
response = Some(resp);
if captured {
break;
}
assert!(
attempt < 5,
"access-log event was not captured after {attempt} attempts \
(tracing interest-cache race with a concurrent test)"
);
}
assert_eq!(response.unwrap().status(), StatusCode::SERVICE_UNAVAILABLE);
});
let events = events.lock().unwrap().clone();
assert_eq!(
events.len(),
1,
"a barrier-rejected request should emit one access event: {events:?}"
);
assert_eq!(events[0].get("status").map(String::as_str), Some("503"));
assert!(
!events[0].contains_key("request_id"),
"barrier short-circuits before RequestIdLayer, so no request id"
);
}
#[tokio::test]
async fn startup_barrier_503s_carry_server_timing_header() {
let state = AppState::for_test()
.with_profile("test")
.with_startup_complete(false);
let mut config = AutumnConfig::default();
config.observability.server_timing = Some(true);
let app = build_router(Vec::new(), &config, state);
let response = app
.oneshot(
Request::builder()
.uri("/not-a-probe")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let header = response
.headers()
.get("server-timing")
.expect("startup 503 short-circuit should still carry Server-Timing via the fallback")
.to_str()
.expect("server-timing header should be valid ASCII");
assert!(
header.starts_with("total;dur="),
"fallback should emit a `total` metric, got {header:?}"
);
assert_eq!(
header.matches("total;dur=").count(),
1,
"short-circuit response must carry a single total metric: {header:?}"
);
}
#[test]
fn try_build_router_rejects_invalid_session_backend_config() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Redis;
let error = try_build_router(Vec::new(), &config, test_state())
.expect_err("missing redis config should fail checked router build");
assert!(matches!(
error,
RouterBuildError::InvalidSessionBackend(
crate::session::SessionBackendConfigError::MissingRedisUrl
)
));
}
#[test]
fn try_build_router_with_static_rejects_invalid_session_backend_config() {
let mut config = AutumnConfig::default();
config.session.backend = crate::session::SessionBackend::Redis;
let error = try_build_router_with_static(Vec::new(), &config, test_state(), None)
.expect_err("missing redis config should fail checked static router build");
assert!(matches!(
error,
RouterBuildError::InvalidSessionBackend(
crate::session::SessionBackendConfigError::MissingRedisUrl
)
));
}
#[test]
fn try_build_router_returns_error_for_probe_actuator_path_overlap() {
let mut config = AutumnConfig::default();
config.actuator.prefix = "/".to_owned();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
try_build_router(Vec::new(), &config, test_state())
}));
assert!(result.is_ok(), "try_build_router panicked on route overlap");
assert!(
result.unwrap().is_err(),
"route overlap should be reported as a checked router build error"
);
}
#[test]
fn probe_actuator_overlap_detected_when_user_route_owns_probe_path() {
async fn user_health() -> &'static str {
"user-health-handler"
}
let mut config = AutumnConfig::default();
config.actuator.prefix = "/".to_owned();
assert_eq!(config.health.path, "/health");
let route = Route {
method: http::Method::GET,
path: "/health",
handler: axum::routing::get(user_health),
name: "user_health",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/health",
operation_id: "user_health",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
try_build_router(vec![route], &config, test_state())
}));
assert!(
result.is_ok(),
"try_build_router panicked instead of returning a checked overlap error"
);
let build = result.unwrap();
assert!(
matches!(
&build,
Err(RouterBuildError::FrameworkRouteOverlap {
path,
incoming: "actuator endpoint",
..
}) if path == "/health"
),
"root-prefix actuator over a user-owned probe path must yield a checked \
FrameworkRouteOverlap for /health, got: {:?}",
build.as_ref().map(|_| "Ok(router)"),
);
}
#[tokio::test]
async fn apply_cors_middleware_skipped_when_no_origins() {
let config = AutumnConfig::default();
assert!(config.cors.allowed_origins.is_empty());
let base: axum::Router<AppState> =
axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
let router = apply_cors_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.uri("/test")
.header("Origin", "https://example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response
.headers()
.get("access-control-allow-origin")
.is_none(),
"CORS header must be absent when no origins are configured"
);
}
#[tokio::test]
async fn apply_cors_middleware_present_when_origins_configured() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["https://example.com".to_owned()];
let base: axum::Router<AppState> =
axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
let router = apply_cors_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.uri("/test")
.header("Origin", "https://example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert!(
response
.headers()
.get("access-control-allow-origin")
.is_some(),
"CORS header must be present when origins are configured"
);
}
#[tokio::test]
async fn apply_cors_middleware_handles_preflight_request() {
let mut config = AutumnConfig::default();
config.cors.allowed_origins = vec!["https://example.com".to_owned()];
let base: axum::Router<AppState> =
axum::Router::new().route("/api/widgets", axum::routing::post(|| async { "ok" }));
let router = apply_cors_middleware(base, &config).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.method("OPTIONS")
.uri("/api/widgets")
.header("Origin", "https://example.com")
.header("Access-Control-Request-Method", "POST")
.header("Access-Control-Request-Headers", "Content-Type")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let headers = response.headers();
assert_eq!(
headers
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("https://example.com"),
"preflight must echo the allowed origin"
);
assert!(
headers.get("access-control-allow-methods").is_some(),
"preflight must advertise allowed methods"
);
assert!(
headers.get("access-control-allow-headers").is_some(),
"preflight must advertise allowed headers"
);
assert!(
headers.get("access-control-max-age").is_some(),
"preflight must advertise max-age so browsers can cache it"
);
}
#[tokio::test]
async fn apply_csrf_middleware_skipped_when_disabled() {
let config = AutumnConfig::default();
assert!(!config.security.csrf.enabled);
let base: axum::Router<AppState> =
axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/form")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn apply_rate_limit_middleware_skipped_when_disabled() {
let config = AutumnConfig::default();
assert!(!config.security.rate_limit.enabled);
let base: axum::Router<AppState> =
axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
let state = test_state();
let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
for _ in 0..5 {
let response = router
.clone()
.oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
#[tokio::test]
async fn apply_rate_limit_middleware_returns_429_when_exhausted() {
let mut config = AutumnConfig::default();
config.security.rate_limit.enabled = true;
config.security.rate_limit.requests_per_second = 0.1;
config.security.rate_limit.burst = 1;
config.security.rate_limit.trust_forwarded_headers = true;
let base: axum::Router<AppState> =
axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
let state = test_state();
let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
let ok = router
.clone()
.oneshot(
Request::builder()
.uri("/ping")
.header("X-Forwarded-For", "203.0.113.9")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(ok.status(), StatusCode::OK);
let blocked = router
.oneshot(
Request::builder()
.uri("/ping")
.header("X-Forwarded-For", "203.0.113.9")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(blocked.headers().get("retry-after").is_some());
}
#[cfg(feature = "mcp")]
#[tokio::test]
async fn mcp_envelope_is_gated_during_maintenance() {
use crate::maintenance::{MaintenanceConfig, MaintenanceState};
let mut config = AutumnConfig::default();
config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
let wiring = crate::mcp::McpWiring {
cors: crate::config::CorsConfig::default(),
trusted_hosts: TrustedHostPolicy::from_config(&config),
tenant_header: None,
csrf_header: "x-csrf-token".to_owned(),
envelope_rate_limited: false,
envelope_load_shed: false,
state: test_state(),
session_cookie: "autumn.sid".to_owned(),
tasks: crate::mcp::McpTaskConfig::default(),
instructions: None,
catalog: crate::mcp::McpCatalog::Full,
};
let mcp_router =
crate::mcp::build_mcp_router("/mcp", Vec::new(), axum::Router::new(), wiring, None);
let initialize = || {
Request::builder()
.method("POST")
.uri("/mcp")
.header("host", "app.example")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize"}).to_string(),
))
.unwrap()
};
let state = test_state();
let maintenance = MaintenanceState::new();
maintenance.enable(MaintenanceConfig::default());
state.insert_extension(maintenance);
let gated = mcp_router
.clone()
.layer(build_maintenance_layer(&config, &state))
.with_state(state);
let resp = gated.oneshot(initialize()).await.unwrap();
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
let state = test_state();
let open = mcp_router
.layer(build_maintenance_layer(&config, &state))
.with_state(state);
let resp = open.oneshot(initialize()).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[cfg(feature = "mail")]
fn dev_mail_preview_config(dir: &std::path::Path) -> AutumnConfig {
let mut config = AutumnConfig {
profile: Some("dev".to_owned()),
mail: crate::mail::MailConfig {
transport: crate::mail::Transport::File,
file_dir: dir.to_path_buf(),
..Default::default()
},
..Default::default()
};
config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
config
}
#[cfg(any(feature = "mail", feature = "maud"))]
async fn response_text(response: axum::response::Response) -> String {
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body should collect");
String::from_utf8(body.to_vec()).expect("body should be utf8")
}
#[cfg(feature = "mail")]
#[tokio::test]
async fn build_router_mounts_dev_mail_preview_empty_state_for_file_transport() {
let dir = tempfile::tempdir().expect("tempdir");
let config = dev_mail_preview_config(dir.path());
let router = build_router(Vec::new(), &config, test_state());
let response = router
.oneshot(
Request::builder()
.uri("/_autumn/mail")
.header("host", "example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(
body.contains("No captured emails"),
"missing empty state: {body}"
);
assert!(
body.contains("mail.transport = "file""),
"empty state should explain capture setup: {body}"
);
}
#[cfg(feature = "mail")]
#[tokio::test]
async fn build_router_lists_captured_mail_newest_first() {
let dir = tempfile::tempdir().expect("tempdir");
let older = dir.path().join("older.eml");
let newer = dir.path().join("newer.eml");
std::fs::write(
&older,
"To: first@example.com\nSubject: First\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <first@example.com>\n\nfirst body\n",
)
.expect("write older eml");
std::fs::write(
&newer,
"To: second@example.com\nSubject: Second\nDate: Tue, 05 May 2026 10:01:00 +0000\nMessage-Id: <second@example.com>\n\nsecond body\n",
)
.expect("write newer eml");
filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(100, 0))
.expect("set older mtime");
filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(200, 0))
.expect("set newer mtime");
let config = dev_mail_preview_config(dir.path());
let router = build_router(Vec::new(), &config, test_state());
let response = router
.oneshot(
Request::builder()
.uri("/_autumn/mail")
.header("host", "example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
let second = body.find("Second").expect("newer subject should render");
let first = body.find("First").expect("older subject should render");
assert!(second < first, "newest message should render first: {body}");
assert!(
body.contains("second@example.com"),
"missing To column: {body}"
);
assert!(
body.contains("Timestamp"),
"missing timestamp column: {body}"
);
}
#[cfg(feature = "mail")]
#[tokio::test]
async fn build_router_mail_preview_detail_renders_html_in_sandboxed_iframe() {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(
dir.path().join("detail.eml"),
"From: Autumn <noreply@example.com>\nTo: ada@example.com\nReply-To: support@example.com\nSubject: Reset\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <reset@example.com>\nMIME-Version: 1.0\nContent-Type: multipart/alternative; boundary=\"autumn-mail\"\n\n--autumn-mail\nContent-Type: text/plain; charset=utf-8\n\nPlain reset\n--autumn-mail\nContent-Type: text/html; charset=utf-8\n\n<h1>Hello iframe</h1>\n--autumn-mail--\n",
)
.expect("write detail eml");
let config = dev_mail_preview_config(dir.path());
let router = build_router(Vec::new(), &config, test_state());
let response = router
.oneshot(
Request::builder()
.uri("/_autumn/mail/messages/detail.eml")
.header("host", "example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(body.contains("<iframe"), "missing iframe: {body}");
assert!(body.contains("sandbox"), "iframe must be sandboxed: {body}");
assert!(body.contains("Hello iframe"), "missing html body: {body}");
assert!(body.contains("Plain text"), "missing text toggle: {body}");
assert!(body.contains("Headers"), "missing headers toggle: {body}");
assert!(
body.contains("Raw .eml"),
"missing raw source toggle: {body}"
);
assert!(
body.contains("Message-Id"),
"missing message id header: {body}"
);
}
#[cfg(feature = "mail")]
#[tokio::test]
async fn build_router_does_not_mount_mail_preview_outside_dev() {
let dir = tempfile::tempdir().expect("tempdir");
let mut config = dev_mail_preview_config(dir.path());
config.profile = Some("prod".to_owned());
let router = build_router(Vec::new(), &config, test_state());
let response = router
.oneshot(
Request::builder()
.uri("/_autumn/mail")
.header("host", "example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[cfg(feature = "maud")]
fn story_gallery_config() -> AutumnConfig {
let mut config = AutumnConfig::default();
config.stories.enabled = true;
config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
config
}
#[cfg(feature = "maud")]
fn stories_state_with_builtin() -> AppState {
let state = test_state();
state.insert_extension(crate::stories::builtin());
state
}
#[cfg(feature = "maud")]
async fn get_with_host(router: axum::Router, uri: &str) -> axum::response::Response {
router
.oneshot(
Request::builder()
.uri(uri)
.header("host", "example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn build_router_mounts_story_gallery_when_enabled() {
let router = build_router(
Vec::new(),
&story_gallery_config(),
stories_state_with_builtin(),
);
let response = get_with_host(router, crate::stories::STORIES_PATH).await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(
body.contains("Data table"),
"index should list builtin story names: {body}"
);
assert!(
body.contains("autumn-widgets.css"),
"index should link the framework widget stylesheet: {body}"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn story_detail_route_serves_render_source_and_html() {
let router = build_router(
Vec::new(),
&story_gallery_config(),
stories_state_with_builtin(),
);
let response = get_with_host(router, "/_stories/data-table").await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(
body.contains("<table"),
"detail page must contain the live data_table render: {body}"
);
assert!(
body.contains("data_table("),
"detail page must show the source snippet that produced the render: {body}"
);
assert!(
body.contains("Rendered HTML"),
"detail page must offer the rendered-HTML tab: {body}"
);
assert!(
body.contains("Source"),
"detail page must offer the source tab: {body}"
);
}
#[cfg(all(feature = "maud", feature = "htmx"))]
#[tokio::test]
async fn story_detail_route_loads_htmx_for_hx_stories() {
let router = build_router(
Vec::new(),
&story_gallery_config(),
stories_state_with_builtin(),
);
let response = get_with_host(router, "/_stories/active-search").await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(body.contains("hx-get"), "story must use htmx: {body}");
let htmx_at = body
.find(crate::htmx::HTMX_JS_PATH)
.unwrap_or_else(|| panic!("page must load htmx.min.js: {body}"));
let widgets_at = body
.find(crate::htmx::AUTUMN_WIDGETS_JS_PATH)
.unwrap_or_else(|| panic!("page must load autumn-widgets.js: {body}"));
assert!(htmx_at < widgets_at, "htmx must load first: {body}");
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn story_detail_unknown_slug_is_404() {
let router = build_router(
Vec::new(),
&story_gallery_config(),
stories_state_with_builtin(),
);
let missing = get_with_host(router.clone(), "/_stories/nope").await;
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
let index = get_with_host(router, "/_stories").await;
assert_eq!(
index.status(),
StatusCode::OK,
"index route must exist even when a slug misses"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn build_router_omits_story_gallery_by_default() {
let mut config = AutumnConfig::default();
assert!(
!config.stories.enabled,
"stories gallery must be off by default"
);
config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
let router = build_router(Vec::new(), &config, stories_state_with_builtin());
let response = get_with_host(router, "/_stories").await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[cfg(feature = "i18n")]
mod locale_prefix_routing {
use super::*;
use crate::i18n::{Bundle, I18nConfig, Locale};
async fn locale_probe(locale: Locale) -> String {
locale.tag().to_owned()
}
async fn plain_ok() -> &'static str {
"ok"
}
fn simple_route(path: &'static str, name: &'static str, probe: bool) -> Route {
Route {
method: http::Method::GET,
path,
handler: if probe {
axum::routing::get(locale_probe)
} else {
axum::routing::get(plain_ok)
},
name,
api_doc: crate::openapi::ApiDoc {
method: "GET",
path,
operation_id: name,
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
}
}
fn config(supported: &[&str], exclude: &[&str]) -> AutumnConfig {
let mut config = AutumnConfig::default();
config.i18n.locale_prefix_enabled = true;
config.i18n.default_locale = supported.first().copied().unwrap_or("en").to_owned();
config.i18n.supported_locales = supported.iter().map(|s| (*s).to_owned()).collect();
config.i18n.locale_prefix_exclude = exclude.iter().map(|s| (*s).to_owned()).collect();
config
}
fn bundle(supported: &[&str]) -> Arc<Bundle> {
let cfg = I18nConfig {
default_locale: supported.first().copied().unwrap_or("en").to_owned(),
supported_locales: supported.iter().map(|s| (*s).to_owned()).collect(),
fallback_chain: vec![],
dir: "i18n".to_owned(),
locale_prefix_enabled: false,
locale_prefix_exclude: vec![],
locale_prefix_exclude_exact: vec![],
};
Arc::new(Bundle::from_messages(
std::collections::HashMap::new(),
&cfg,
))
}
async fn request(
router: &axum::Router,
uri: &str,
headers: &[(&str, &str)],
) -> axum::response::Response {
request_method(router, http::Method::GET, uri, headers).await
}
async fn request_method(
router: &axum::Router,
method: http::Method,
uri: &str,
headers: &[(&str, &str)],
) -> axum::response::Response {
let mut req = Request::builder().method(method).uri(uri);
for (k, v) in headers {
req = req.header(*k, *v);
}
router
.clone()
.oneshot(req.body(Body::empty()).unwrap())
.await
.unwrap()
}
async fn body_string(resp: axum::response::Response) -> String {
let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn uri_probe(uri: axum::http::Uri) -> String {
uri.path().to_owned()
}
async fn slug_probe(
locale: Locale,
axum::extract::Path(slug): axum::extract::Path<String>,
) -> String {
format!("{}:{slug}", locale.tag())
}
fn uri_probe_route(path: &'static str, name: &'static str) -> Route {
Route {
method: http::Method::GET,
path,
handler: axum::routing::get(uri_probe),
name,
api_doc: crate::openapi::ApiDoc {
method: "GET",
path,
operation_id: name,
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
}
}
fn slug_route(path: &'static str, name: &'static str) -> Route {
Route {
method: http::Method::GET,
path,
handler: axum::routing::get(slug_probe),
name,
api_doc: crate::openapi::ApiDoc {
method: "GET",
path,
operation_id: name,
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
}
}
fn make_post_route(path: &'static str, name: &'static str) -> Route {
Route {
method: http::Method::POST,
path,
handler: axum::routing::post(plain_ok),
name,
api_doc: crate::openapi::ApiDoc {
method: "POST",
path,
operation_id: name,
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
}
}
#[tokio::test]
async fn locale_prefix_redirect_works_without_an_i18n_bundle() {
let config = config(&["fr"], &[]);
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state());
let redirected = request(&app, "/posts", &[]).await;
assert_eq!(redirected.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
redirected
.headers()
.get(axum::http::header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("/fr/posts"),
"must redirect to the configured locale, not a hard-coded \"en\""
);
let target = request(&app, "/fr/posts", &[]).await;
assert_eq!(
target.status(),
StatusCode::OK,
"the redirect target must actually resolve"
);
assert_eq!(body_string(target).await, "fr");
}
#[tokio::test]
async fn locale_prefix_routing_off_by_default() {
let mut config = AutumnConfig::default();
assert!(!config.i18n.locale_prefix_enabled);
config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
let route = simple_route("/posts", "posts", false);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let bare = request(&app, "/posts", &[]).await;
assert_eq!(bare.status(), StatusCode::OK);
let prefixed = request(&app, "/en/posts", &[]).await;
assert_eq!(
prefixed.status(),
StatusCode::NOT_FOUND,
"no locale nest should exist when the flag is off"
);
}
#[tokio::test]
async fn root_path_redirects_to_bare_locale_prefix_without_trailing_slash() {
let config = config(&["en", "es"], &[]);
let route = simple_route("/", "root", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let bare = request(&app, "/", &[]).await;
assert_eq!(bare.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
bare.headers()
.get(axum::http::header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("/en"),
"root redirect target must have no trailing slash"
);
let nested = request(&app, "/en", &[]).await;
assert_eq!(nested.status(), StatusCode::OK);
assert_eq!(body_string(nested).await, "en");
let nested_with_slash = request(&app, "/en/", &[]).await;
assert_eq!(
nested_with_slash.status(),
StatusCode::NOT_FOUND,
"axum's nest-plus-root case does not also match the trailing-slash form"
);
}
#[tokio::test]
async fn root_path_redirect_preserves_query_without_trailing_slash() {
let config = config(&["en", "es"], &[]);
let route = simple_route("/", "root", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let response = request(&app, "/?ref=newsletter", &[]).await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
response
.headers()
.get(axum::http::header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("/en?ref=newsletter")
);
}
#[tokio::test]
async fn route_reachable_under_every_supported_locale() {
let config = config(&["en", "es"], &[]);
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let en = request(&app, "/en/posts", &[]).await;
assert_eq!(en.status(), StatusCode::OK);
assert_eq!(body_string(en).await, "en");
let es = request(&app, "/es/posts", &[]).await;
assert_eq!(es.status(), StatusCode::OK);
assert_eq!(body_string(es).await, "es");
}
#[tokio::test]
async fn duplicate_supported_locale_does_not_panic_and_still_routes() {
let config = config(&["en", "es", "en"], &[]);
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let en = request(&app, "/en/posts", &[]).await;
assert_eq!(en.status(), StatusCode::OK);
assert_eq!(body_string(en).await, "en");
let es = request(&app, "/es/posts", &[]).await;
assert_eq!(es.status(), StatusCode::OK);
assert_eq!(body_string(es).await, "es");
}
#[tokio::test]
async fn malformed_locale_segment_does_not_panic_and_valid_locale_still_routes() {
let config = config(&["en", "", "es"], &[]);
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let en = request(&app, "/en/posts", &[]).await;
assert_eq!(en.status(), StatusCode::OK);
assert_eq!(body_string(en).await, "en");
let es = request(&app, "/es/posts", &[]).await;
assert_eq!(es.status(), StatusCode::OK);
assert_eq!(body_string(es).await, "es");
}
#[tokio::test]
async fn generated_locale_path_collision_is_a_build_error_not_a_panic() {
let config = config(&["en", "es"], &[]);
let foo = simple_route("/foo", "foo", false);
let en_foo = simple_route("/en/foo", "en_foo", false);
let err = try_build_router(vec![foo, en_foo], &config, test_state())
.expect_err("a generated-path collision must be a build error, not a panic");
match err {
RouterBuildError::LocalePrefixPathCollision {
ref locale,
ref path,
ref generated,
} => {
assert_eq!(locale, "en");
assert_eq!(path, "/foo");
assert_eq!(generated, "/en/foo");
}
other => panic!("expected LocalePrefixPathCollision, got {other:?}"),
}
}
#[tokio::test]
async fn generated_locale_path_collision_via_capture_name_mismatch_is_a_build_error() {
let config = config(&["en"], &[]);
let users_id = slug_route("/users/{id}", "users_id");
let en_users_slug = slug_route("/en/users/{slug}", "en_users_slug");
let err = try_build_router(vec![users_id, en_users_slug], &config, test_state())
.expect_err("a capture-name-mismatched shape collision must be a build error");
assert!(
matches!(err, RouterBuildError::LocalePrefixPathCollision { .. }),
"expected LocalePrefixPathCollision, got {err:?}"
);
}
#[tokio::test]
async fn exact_generated_path_with_different_method_is_not_a_collision() {
let config = config(&["en"], &[]);
let get_foo = simple_route("/foo", "foo_get", false);
let post_en_foo = duplicate_test_route(http::Method::POST, "/en/foo", "post_en_foo");
let app = build_router(vec![get_foo, post_en_foo], &config, test_state());
let get_resp = request(&app, "/en/foo", &[]).await;
assert_eq!(
get_resp.status(),
StatusCode::OK,
"GET /en/foo must resolve to the nested /foo content"
);
}
#[tokio::test]
async fn generated_locale_path_colliding_with_a_scoped_route_is_a_build_error() {
let config = config(&["en"], &[]);
let foo = simple_route("/foo", "foo", false);
let scoped_route = duplicate_test_route(http::Method::GET, "/foo", "scoped_foo");
let group = crate::app::ScopedGroup {
prefix: "/en".to_owned(),
routes: vec![scoped_route],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let mut ctx = duplicate_test_ctx();
ctx.scoped_groups.push(group);
let err = super::try_build_router_inner(vec![foo], &config, test_state(), ctx)
.expect_err(
"a scoped route colliding with a generated locale path must be a build error",
);
assert!(
matches!(err, RouterBuildError::LocalePrefixPathCollision { .. }),
"expected LocalePrefixPathCollision, got {err:?}"
);
}
#[tokio::test]
async fn generated_locale_path_colliding_with_a_health_probe_is_a_build_error() {
let mut config = config(&["en"], &[]);
config.health.path = "/en/foo".to_owned();
let foo = simple_route("/foo", "foo", false);
let err = try_build_router(vec![foo], &config, test_state()).expect_err(
"a health probe colliding with a generated locale path must be a build error",
);
assert!(
matches!(err, RouterBuildError::LocalePrefixPathCollision { .. }),
"expected LocalePrefixPathCollision, got {err:?}"
);
}
#[tokio::test]
async fn colon_prefixed_locale_segment_does_not_panic() {
let config = config(&["en", ":en"], &[]);
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en"])));
let en = request(&app, "/en/posts", &[]).await;
assert_eq!(en.status(), StatusCode::OK);
assert_eq!(body_string(en).await, "en");
}
#[tokio::test]
async fn locale_segment_with_uri_delimiter_does_not_panic_and_valid_locale_still_routes() {
let config = config(&["en", "en?x", "en#x", "en y"], &[]);
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en"])));
let en = request(&app, "/en/posts", &[]).await;
assert_eq!(en.status(), StatusCode::OK);
assert_eq!(body_string(en).await, "en");
}
#[tokio::test]
async fn bare_path_redirect_claims_only_registered_methods_not_every_method() {
async fn user_health_post() -> &'static str {
"posted"
}
let config = config(&["en"], &[]);
let route = Route {
method: http::Method::POST,
path: "/health",
handler: axum::routing::post(user_health_post),
name: "user_health_post",
api_doc: crate::openapi::ApiDoc {
method: "POST",
path: "/health",
operation_id: "user_health_post",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let app = build_router(vec![route], &config, test_state());
let health = request(&app, "/health", &[]).await;
assert_eq!(
health.status(),
StatusCode::OK,
"the framework's own health probe must still respond at GET /health"
);
let redirected = request_method(&app, http::Method::POST, "/health", &[]).await;
assert_eq!(redirected.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
redirected
.headers()
.get(axum::http::header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("/en/health")
);
}
#[tokio::test]
async fn skipped_invalid_locale_is_never_selected_by_negotiation() {
let mut config = config(&["", "en"], &[]);
config.i18n.default_locale = String::new();
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state());
let response = request(&app, "/posts", &[]).await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
response
.headers()
.get(axum::http::header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("/en/posts"),
"must redirect to the validly-nested locale, not the skipped empty default"
);
}
#[tokio::test]
async fn unknown_locale_prefix_is_404_not_panic() {
let config = config(&["en", "es"], &[]);
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let response = request(&app, "/zz/posts", &[]).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn bare_path_redirects_preserving_query() {
let config = config(&["en", "es"], &[]);
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let response = request(&app, "/posts?sort=asc", &[]).await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
response
.headers()
.get(axum::http::header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("/en/posts?sort=asc")
);
let response = request(
&app,
"/posts?sort=asc",
&[(axum::http::header::ACCEPT_LANGUAGE.as_str(), "es")],
)
.await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
response
.headers()
.get(axum::http::header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("/es/posts?sort=asc")
);
}
#[tokio::test]
async fn url_prefix_locale_wins_over_cookie_and_accept_language() {
let config = config(&["en", "es"], &[]);
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let response = request(
&app,
"/es/posts",
&[
(axum::http::header::COOKIE.as_str(), "autumn_locale=en"),
(axum::http::header::ACCEPT_LANGUAGE.as_str(), "en"),
],
)
.await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_string(response).await, "es");
}
#[tokio::test]
async fn excluded_prefix_stays_unprefixed_and_unredirected() {
let config = config(&["en", "es"], &["/api"]);
let route = simple_route("/api/status", "api_status", false);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let direct = request(&app, "/api/status", &[]).await;
assert_eq!(
direct.status(),
StatusCode::OK,
"excluded route must serve directly, not redirect"
);
let nested = request(&app, "/en/api/status", &[]).await;
assert_eq!(
nested.status(),
StatusCode::NOT_FOUND,
"excluded route must not be nested under a locale prefix"
);
}
#[tokio::test]
async fn exclude_prefix_accepts_trailing_glob_suffix() {
let config = config(&["en", "es"], &["/api/*"]);
let route = simple_route("/api/status", "api_status", false);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let direct = request(&app, "/api/status", &[]).await;
assert_eq!(direct.status(), StatusCode::OK);
let nested = request(&app, "/en/api/status", &[]).await;
assert_eq!(nested.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn root_path_exclude_prefix_excludes_exactly_the_root() {
let config = config(&["en", "es"], &["/"]);
let root = simple_route("/", "root", false);
let posts = simple_route("/posts", "posts", true);
let app = build_router(vec![root, posts], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let bare_root = request(&app, "/", &[]).await;
assert_eq!(
bare_root.status(),
StatusCode::OK,
"excluded root route must serve directly, not redirect"
);
let nested_root = request(&app, "/en", &[]).await;
assert_eq!(
nested_root.status(),
StatusCode::NOT_FOUND,
"excluded root route must not be nested under a locale prefix"
);
let bare_posts = request(&app, "/posts", &[]).await;
assert_eq!(
bare_posts.status(),
StatusCode::PERMANENT_REDIRECT,
"\"/\" in the exclude list must not exclude unrelated routes"
);
}
#[tokio::test]
async fn exclude_prefix_does_not_match_unrelated_path_sharing_a_string_prefix() {
let config = config(&["en", "es"], &["/api"]);
let route = simple_route("/apikeys", "apikeys", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let bare = request(&app, "/apikeys", &[]).await;
assert_eq!(
bare.status(),
StatusCode::PERMANENT_REDIRECT,
"/apikeys must be treated as included (locale-prefixed), not excluded"
);
let nested = request(&app, "/en/apikeys", &[]).await;
assert_eq!(
nested.status(),
StatusCode::OK,
"/apikeys must be nested under the locale prefix like any other included route"
);
}
#[tokio::test]
async fn parameterized_route_reachable_under_locale_prefix() {
let config = config(&["en", "es"], &[]);
let route = slug_route("/posts/{slug}", "posts_show");
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let response = request(&app, "/es/posts/hello-world", &[]).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_string(response).await, "es:hello-world");
}
#[tokio::test]
async fn exact_exclude_does_not_swallow_a_dynamic_sibling_route() {
let mut config = config(&["en", "es"], &[]);
config.i18n.locale_prefix_exclude_exact = vec!["/posts".to_owned()];
let static_index = simple_route("/posts", "posts_index", false);
let dynamic_child = slug_route("/posts/{slug}", "posts_show");
let app = build_router(vec![static_index, dynamic_child], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let excluded = request(&app, "/posts", &[]).await;
assert_eq!(
excluded.status(),
StatusCode::OK,
"the exactly-excluded static route must stay unprefixed and unredirected"
);
let excluded_prefixed = request(&app, "/en/posts", &[]).await;
assert_eq!(
excluded_prefixed.status(),
StatusCode::NOT_FOUND,
"the exactly-excluded static route must not be nested under a locale prefix"
);
let sibling_prefixed = request(&app, "/en/posts/hello-world", &[]).await;
assert_eq!(
sibling_prefixed.status(),
StatusCode::OK,
"a dynamic sibling sharing the excluded path as a string prefix must \
still be locale-prefixed normally — an exact exclusion must not act \
like a prefix exclusion"
);
assert_eq!(body_string(sibling_prefixed).await, "en:hello-world");
let sibling_bare = request(&app, "/posts/hello-world", &[]).await;
assert_eq!(
sibling_bare.status(),
StatusCode::PERMANENT_REDIRECT,
"the dynamic sibling's bare path must still redirect, since only the \
exact literal `/posts` is excluded"
);
}
#[tokio::test]
async fn uri_extractor_inside_locale_nest_sees_locale_stripped_path() {
let config = config(&["en", "es"], &[]);
let route = uri_probe_route("/posts", "posts_uri_probe");
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let response = request(&app, "/es/posts", &[]).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
body_string(response).await,
"/posts",
"Uri extractor inside a locale nest must see the prefix-stripped path"
);
}
#[tokio::test]
async fn bare_path_redirect_applies_to_non_get_methods() {
let config = config(&["en", "es"], &[]);
let route = make_post_route("/posts", "posts_create");
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let response = request_method(&app, http::Method::POST, "/posts", &[]).await;
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
response
.headers()
.get(axum::http::header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("/en/posts")
);
}
#[tokio::test]
async fn same_path_different_methods_dedups_to_one_redirect_route() {
let config = config(&["en", "es"], &[]);
let get_route = simple_route("/posts", "posts_list", true);
let post_route = make_post_route("/posts", "posts_create");
let app = build_router(vec![get_route, post_route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es"])));
let get_redirect = request(&app, "/posts", &[]).await;
assert_eq!(get_redirect.status(), StatusCode::PERMANENT_REDIRECT);
let post_redirect = request_method(&app, http::Method::POST, "/posts", &[]).await;
assert_eq!(post_redirect.status(), StatusCode::PERMANENT_REDIRECT);
let en_get = request(&app, "/en/posts", &[]).await;
assert_eq!(en_get.status(), StatusCode::OK);
let en_post = request_method(&app, http::Method::POST, "/en/posts", &[]).await;
assert_eq!(en_post.status(), StatusCode::OK);
}
#[tokio::test]
async fn region_subtag_locale_nests_and_prefix_is_case_sensitive() {
let config = config(&["en", "es-MX"], &[]);
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state())
.layer(axum::Extension(bundle(&["en", "es-MX"])));
let response = request(&app, "/es-MX/posts", &[]).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(body_string(response).await, "es-MX");
let wrong_case = request(&app, "/ES-MX/posts", &[]).await;
assert_eq!(
wrong_case.status(),
StatusCode::NOT_FOUND,
"the locale nest prefix is a literal path segment, not case-negotiated"
);
}
#[tokio::test]
async fn empty_supported_locales_serves_clean_404_not_redirect_to_nowhere() {
let mut config = AutumnConfig::default();
config.i18n.locale_prefix_enabled = true;
config.i18n.supported_locales = vec![];
let route = simple_route("/posts", "posts", false);
let app = build_router(vec![route], &config, test_state());
let response = request(&app, "/posts", &[]).await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn misconfigured_default_locale_falls_back_to_a_mounted_locale() {
let mut config = config(&["en"], &[]);
config.i18n.default_locale = "fr".to_owned();
let route = simple_route("/posts", "posts", true);
let app = build_router(vec![route], &config, test_state());
let redirected = request(&app, "/posts", &[]).await;
assert_eq!(redirected.status(), StatusCode::PERMANENT_REDIRECT);
assert_eq!(
redirected
.headers()
.get(axum::http::header::LOCATION)
.and_then(|v| v.to_str().ok()),
Some("/en/posts"),
"must redirect to a locale that's actually mounted, not the \
misconfigured default_locale"
);
let target = request(&app, "/en/posts", &[]).await;
assert_eq!(target.status(), StatusCode::OK);
}
#[test]
fn a_localized_static_page_resolves_inside_its_nest() {
let config = config(&["en", "fr"], &[]);
let routes = vec![
simple_route("/files/public", "public", false),
simple_route("/{*path}", "any", false),
];
let pages = ["/en/files/public".to_owned(), "/fr/other".to_owned()];
let map = super::super::static_page_routes(pages.iter(), &routes, &[], &config);
assert_eq!(map["/en/files/public"].as_deref(), Some("/en/files/public"));
assert_eq!(map["/fr/other"].as_deref(), Some("/fr/{*path}"));
}
#[test]
fn an_excluded_route_is_matched_where_it_is_mounted() {
let config = config(&["en", "fr"], &["/api", "/en"]);
let routes = vec![
simple_route("/api/{id}", "api", false),
simple_route("/en/{*path}", "english", false),
simple_route("/posts", "posts", false),
];
let pages = [
"/en/api/42".to_owned(),
"/api/42".to_owned(),
"/fr/posts".to_owned(),
"/fr/api/42".to_owned(),
];
let map = super::super::static_page_routes(pages.iter(), &routes, &[], &config);
assert_eq!(map["/en/api/42"].as_deref(), Some("/en/{*path}"));
assert_eq!(map["/api/42"].as_deref(), Some("/api/{id}"));
assert_eq!(map["/fr/posts"].as_deref(), Some("/fr/posts"));
assert_eq!(map["/fr/api/42"], None);
}
#[test]
fn a_merged_router_home_stays_unprefixed() {
let config = config(&["en", "fr"], &[]);
let site = super::build_aeo_site::<(), ()>(
&config,
&test_state(),
&[simple_route("/posts", "posts", false)],
&[],
true,
None,
None,
);
assert_eq!(site.facts.home_paths, vec!["/".to_owned()]);
assert!(!site.facts.no_home);
let routed = super::build_aeo_site::<(), ()>(
&config,
&test_state(),
&[simple_route("/", "home", false)],
&[],
true,
None,
None,
);
assert_eq!(
routed.facts.home_paths,
vec!["/en".to_owned(), "/fr".to_owned()]
);
let scoped = crate::app::ScopedGroup {
prefix: "/".to_owned(),
routes: vec![simple_route("/", "scoped_home", false)],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let both = super::build_aeo_site::<(), ()>(
&config,
&test_state(),
&[simple_route("/", "home", false)],
&[scoped],
false,
None,
None,
);
assert_eq!(
both.facts.home_paths,
vec!["/en".to_owned(), "/fr".to_owned(), "/".to_owned()]
);
assert!(both.facts.noindex_homes.is_empty());
let mut scoped_home = simple_route("/", "scoped_home", false);
scoped_home.seo = crate::seo::SeoRouteDefaults {
robots: Some("noindex"),
..crate::seo::SeoRouteDefaults::EMPTY
};
let scoped = crate::app::ScopedGroup {
prefix: "/".to_owned(),
routes: vec![scoped_home],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let mixed = super::build_aeo_site::<(), ()>(
&config,
&test_state(),
&[simple_route("/", "home", false)],
&[scoped],
false,
None,
None,
);
assert_eq!(mixed.facts.noindex_homes, vec!["/".to_owned()]);
}
#[tokio::test]
async fn scoped_group_routes_are_not_locale_prefixed() {
let config = config(&["en", "es"], &[]);
let scoped_route = duplicate_test_route(http::Method::GET, "/status", "scoped_status");
let group = crate::app::ScopedGroup {
prefix: "/admin".to_owned(),
routes: vec![scoped_route],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let mut ctx = duplicate_test_ctx();
ctx.scoped_groups.push(group);
let router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect("router with a scoped group under locale-prefix routing should build")
.layer(axum::Extension(bundle(&["en", "es"])));
let direct = request(&router, "/admin/status", &[]).await;
assert_eq!(
direct.status(),
StatusCode::OK,
"scoped route must serve directly, unprefixed"
);
let nested = request(&router, "/en/admin/status", &[]).await;
assert_eq!(
nested.status(),
StatusCode::NOT_FOUND,
"scoped routes are mounted after locale-prefix nesting, so no nest exists for them"
);
}
}
#[cfg(feature = "maud")]
async fn stories_status_for_layered_profile(toml: &str, profile: &str) -> StatusCode {
let dir = tempfile::tempdir().expect("tempdir");
std::fs::write(dir.path().join("autumn.toml"), toml).expect("write autumn.toml");
let env = crate::config::MockEnv::new()
.with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap())
.with("AUTUMN_ENV", profile);
let mut config = AutumnConfig::load_with_env(&env).expect("layered config should load");
config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
let router = build_router(Vec::new(), &config, stories_state_with_builtin());
get_with_host(router, "/_stories").await.status()
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn story_routes_mount_iff_resolved_profile_flag() {
let dev_only = r"
[stories]
enabled = false
[profile.dev.stories]
enabled = true
";
assert_eq!(
stories_status_for_layered_profile(dev_only, "dev").await,
StatusCode::OK,
"dev profile override must mount the gallery"
);
assert_eq!(
stories_status_for_layered_profile(dev_only, "prod").await,
StatusCode::NOT_FOUND,
"prod must not mount the gallery when only dev enables it"
);
let public_showcase = r"
[stories]
enabled = false
[profile.prod.stories]
enabled = true
";
assert_eq!(
stories_status_for_layered_profile(public_showcase, "prod").await,
StatusCode::OK,
"prod profile override must mount the gallery for a public showcase"
);
assert_eq!(
stories_status_for_layered_profile(public_showcase, "dev").await,
StatusCode::NOT_FOUND,
"dev must not mount the gallery when only prod enables it"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn custom_story_served_alongside_builtins() {
let custom = crate::stories::story! {
"App",
"Greeting",
{
maud::html! { span class="app-greeting" { "hi from the app" } }
}
};
let state = test_state();
state.insert_extension(
crate::stories::StoryGallery::builtin()
.extend([custom])
.into_registry(),
);
let router = build_router(Vec::new(), &story_gallery_config(), state);
let detail = get_with_host(router.clone(), "/_stories/greeting").await;
assert_eq!(detail.status(), StatusCode::OK);
let body = response_text(detail).await;
assert!(
body.contains("hi from the app"),
"custom story must render at its slug: {body}"
);
let index = get_with_host(router, "/_stories").await;
let body = response_text(index).await;
assert!(
body.contains("Greeting"),
"index must list the custom story: {body}"
);
assert!(
body.contains("App"),
"index must show the custom story's group: {body}"
);
assert!(
body.contains("Data table"),
"builtins must still be listed alongside the custom story: {body}"
);
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn story_pages_inline_style_carries_csp_header_nonce() {
let mut config = story_gallery_config();
config.security.headers.csp_nonce.enabled = true;
let router = build_router(Vec::new(), &config, stories_state_with_builtin());
for uri in ["/_stories", "/_stories/data-table"] {
let response = get_with_host(router.clone(), uri).await;
assert_eq!(response.status(), StatusCode::OK);
let csp = response
.headers()
.get("content-security-policy")
.expect("CSP header must be present")
.to_str()
.unwrap()
.to_owned();
let nonce = csp
.split("'nonce-")
.nth(1)
.and_then(|rest| rest.split('\'').next())
.unwrap_or_else(|| panic!("CSP header must advertise a nonce: {csp}"))
.to_owned();
assert!(!nonce.is_empty(), "advertised nonce must be non-empty");
let body = response_text(response).await;
assert!(
body.contains(&format!(r#"<style nonce="{nonce}">"#)),
"{uri} inline style must carry the CSP header nonce {nonce}: {body}"
);
}
}
#[cfg(feature = "maud")]
#[tokio::test]
async fn enabled_without_registry_shows_empty_state() {
let router = build_router(Vec::new(), &story_gallery_config(), test_state());
let response = get_with_host(router, "/_stories").await;
assert_eq!(response.status(), StatusCode::OK);
let body = response_text(response).await;
assert!(
body.contains("with_story_gallery"),
"empty state should point at AppBuilder::with_story_gallery: {body}"
);
}
#[tokio::test]
async fn apply_csrf_middleware_blocks_without_token_when_enabled() {
let mut config = AutumnConfig::default();
config.security.csrf.enabled = true;
let base: axum::Router<AppState> =
axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/form")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_ne!(
response.status(),
StatusCode::OK,
"POST without CSRF token should be rejected when CSRF is enabled"
);
}
#[test]
fn join_nested_path_normalizes_like_axum() {
assert_eq!(super::join_nested_path("/api", "/"), "/api");
assert_eq!(super::join_nested_path("/api/", "/"), "/api/");
assert_eq!(super::join_nested_path("/api", "/users"), "/api/users");
assert_eq!(super::join_nested_path("/api/", "/users"), "/api/users");
assert_eq!(super::join_nested_path("", "/"), "/");
assert_eq!(super::join_nested_path("", "/users"), "/users");
}
#[tokio::test]
async fn join_nested_path_matches_axum_matched_path() {
use axum::routing::get;
async fn matched(mp: Option<axum::extract::MatchedPath>) -> String {
mp.map(|m| m.as_str().to_owned()).unwrap_or_default()
}
for (prefix, child, req) in [
("/api", "/", "/api"),
("/api/", "/", "/api/"),
("/api", "/users", "/api/users"),
("/api/", "/users", "/api/users"),
] {
let sub = axum::Router::new().route(child, get(matched));
let app: axum::Router = axum::Router::new().nest(prefix, sub);
let resp = tower::ServiceExt::oneshot(
app,
axum::http::Request::builder()
.uri(req)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK, "{prefix} + {child}");
let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap();
let axum_matched = String::from_utf8(body.to_vec()).unwrap();
assert_eq!(
super::join_nested_path(prefix, child),
axum_matched,
"join_nested_path must equal axum MatchedPath for nest({prefix:?}, {child:?})"
);
}
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_detects_scoped_root_collision() {
use crate::openapi::{ApiDoc, OpenApiConfig};
async fn child() -> &'static str {
"inner"
}
let group = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![Route {
method: http::Method::GET,
path: "/",
handler: axum::routing::get(child),
name: "root",
api_doc: ApiDoc {
method: "GET",
path: "/",
operation_id: "root",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
}],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let openapi = OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api");
let config = AutumnConfig::default();
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: vec![group],
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("scope '/api' + child '/' should collide with openapi path '/api'");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
}
#[cfg(all(feature = "openapi", feature = "maud"))]
#[test]
fn try_build_router_detects_widgets_css_path_collision() {
use crate::openapi::OpenApiConfig;
let openapi =
OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::ui::WIDGETS_CSS_PATH);
let config = AutumnConfig::default();
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
"openapi_json_path colliding with the widget stylesheet route should be rejected",
);
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
}
#[cfg(all(feature = "openapi", feature = "flash"))]
#[test]
fn try_build_router_detects_flash_css_path_collision() {
use crate::openapi::OpenApiConfig;
let openapi =
OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::flash::FLASH_CSS_PATH);
let config = AutumnConfig::default();
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
"openapi_json_path colliding with the flash stylesheet route should be rejected",
);
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[test]
fn extract_path_params_matches_macro_behavior() {
assert_eq!(
super::extract_path_params("/orgs/{org_id}/users/{id}"),
vec!["org_id".to_owned(), "id".to_owned()]
);
assert_eq!(
super::extract_path_params("/users/{id}/posts/{slug}"),
vec!["id".to_owned(), "slug".to_owned()]
);
assert!(super::extract_path_params("/static").is_empty());
assert_eq!(
super::extract_path_params("/users/{id:[0-9]+}"),
vec!["id".to_owned()]
);
assert_eq!(
super::extract_path_params("{id:[0-9]{1,3}}"),
vec!["id".to_owned()]
);
assert!(super::extract_path_params("{{hello}}").is_empty());
assert_eq!(
super::extract_path_params("{{literal}}/{id}"),
vec!["id".to_owned()]
);
assert!(super::extract_path_params("{{}").is_empty());
assert!(super::extract_path_params("{a{b}").is_empty());
assert!(super::extract_path_params("{").is_empty());
assert!(super::extract_path_params("}").is_empty());
assert!(super::extract_path_params("{}").is_empty());
}
#[cfg(feature = "openapi")]
#[test]
fn extract_path_params_handles_unbalanced_braces() {
for path in ["{{}", "{", "}", "{a{b}"] {
for name in super::extract_path_params(path) {
assert!(
!name.contains('{') && !name.contains('}'),
"param name should be brace-free for {path:?}: {name:?}"
);
assert!(
!name.is_empty(),
"param name should be non-empty for {path:?}"
);
}
}
assert!(super::extract_path_params("{{}").is_empty());
assert!(super::extract_path_params("{a{b}").is_empty());
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn openapi_merges_scoped_prefix_path_params() {
use crate::openapi::{ApiDoc, OpenApiConfig};
async fn handler() -> &'static str {
"ok"
}
let child = Route {
method: http::Method::GET,
path: "/users/{id}",
handler: axum::routing::get(handler),
name: "child",
api_doc: ApiDoc {
method: "GET",
path: "/users/{id}",
operation_id: "child",
path_params: &["id"],
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let group = crate::app::ScopedGroup {
prefix: "/orgs/{org_id}".to_owned(),
routes: vec![child],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let config = OpenApiConfig::new("Demo", "1.0.0");
let router = super::build_openapi_router(&[], &[group], Some(&config), "autumn.sid", &[])
.expect("openapi sub-router builds")
.expect("openapi sub-router present when config is Some");
let state = test_state();
let router = router.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/openapi.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
let params = &spec["paths"]["/orgs/{org_id}/users/{id}"]["get"]["parameters"];
let names: Vec<&str> = params
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"org_id"), "missing org_id: {names:?}");
assert!(names.contains(&"id"), "missing id: {names:?}");
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn openapi_documents_configured_session_cookie_name() {
use crate::openapi::{ApiDoc, OpenApiConfig};
async fn handler() -> &'static str {
"ok"
}
let route = Route {
method: http::Method::GET,
path: "/protected",
handler: axum::routing::get(handler),
name: "protected",
api_doc: ApiDoc {
method: "GET",
path: "/protected",
operation_id: "protected",
success_status: 200,
secured: true,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let protected_routes = vec![route];
let config = OpenApiConfig::new("Demo", "1.0.0");
let docs_router =
super::build_openapi_router(&protected_routes, &[], Some(&config), "demo.sid", &[])
.expect("openapi sub-router builds")
.expect("openapi sub-router present when config is Some");
let docs_router = docs_router.with_state(test_state());
let response = docs_router
.oneshot(
Request::builder()
.uri("/openapi.json")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
let schemes = &spec["components"]["securitySchemes"];
assert_eq!(schemes["SessionAuth"]["type"], "apiKey");
assert_eq!(schemes["SessionAuth"]["in"], "cookie");
assert_eq!(schemes["SessionAuth"]["name"], "demo.sid");
assert!(
schemes.get("BearerAuth").is_none(),
"secured routes must not be documented as bearer JWT routes"
);
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_json_path_without_leading_slash() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("openapi.json");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("non-slash path should be rejected");
assert!(matches!(
err,
RouterBuildError::InvalidOpenApiPath {
field: "openapi_json_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_captures() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id}");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("captures should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_unbalanced_brace() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("unbalanced brace should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_wildcard() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/*rest");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("wildcard should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_path_with_double_slash() {
let config =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("//docs");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("double-slash should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_swagger_ui_path_without_leading_slash() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.swagger_ui_path(Some("docs".to_owned()));
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("non-slash path should be rejected");
assert!(matches!(
err,
RouterBuildError::InvalidOpenApiPath {
field: "swagger_ui_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_empty_json_path() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("");
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("empty path should be rejected");
assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_accepts_valid_paths() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/api-docs")
.swagger_ui_path(Some("/ui".to_owned()));
let out = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect("valid paths must not error");
assert!(out.is_some());
}
#[cfg(feature = "openapi")]
#[test]
fn openapi_rejects_duplicate_json_and_swagger_paths() {
let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/docs")
.swagger_ui_path(Some("/docs".to_owned()));
let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
.expect_err("colliding paths should be rejected before axum panics");
assert!(matches!(
err,
RouterBuildError::DuplicateOpenApiPath { ref path } if path == "/docs"
));
}
#[cfg(feature = "openapi")]
async fn collision_test_handler() -> &'static str {
"user"
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_colliding_with_user_route() {
let mut config = AutumnConfig::default();
config.actuator.prefix = "/ops".to_owned();
let openapi =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/my-api-docs");
let user_route = Route {
method: http::Method::GET,
path: "/my-api-docs",
handler: axum::routing::get(collision_test_handler),
name: "collides",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/my-api-docs",
operation_id: "collides",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
.expect_err("user-owned path should prevent OpenAPI mount");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision { field: "openapi_json_path", ref path } if path == "/my-api-docs"
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_colliding_with_framework_route() {
let config = AutumnConfig::default(); let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/actuator/health");
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("framework-owned path should prevent OpenAPI mount");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_swagger_ui_asset_path_colliding_with_user_route() {
let config = AutumnConfig::default();
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0");
let user_route = Route {
method: http::Method::GET,
path: "/swagger-ui/swagger-ui.css",
handler: axum::routing::get(collision_test_handler),
name: "swagger-ui-asset-collides",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/swagger-ui/swagger-ui.css",
operation_id: "swagger_ui_asset_collides",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
.expect_err("swagger ui asset path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "swagger_ui_path",
ref path,
} if path == "/swagger-ui/swagger-ui.css"
));
}
#[cfg(all(feature = "openapi", feature = "htmx"))]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_colliding_with_htmx_csrf_route() {
let config = AutumnConfig::default();
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path(crate::htmx::HTMX_CSRF_JS_PATH);
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("htmx csrf helper path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == crate::htmx::HTMX_CSRF_JS_PATH
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_under_nest_prefix() {
let config = AutumnConfig::default();
let openapi =
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api/docs");
let nested = axum::Router::<AppState>::new()
.route("/inner", axum::routing::get(|| async { "inner" }));
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: vec![("/api".to_owned(), nested)],
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("OpenAPI path under a nest prefix should collide");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == "/api/docs"
));
}
#[cfg(all(feature = "openapi", feature = "mail"))]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_on_unsubscribe_endpoint() {
let mut config = AutumnConfig::default();
config.mail.mount_unsubscribe_endpoint = true;
config.mail.unsubscribe_base_url = Some("https://app.example.com".to_owned());
assert!(config.mail.should_mount_unsubscribe_endpoint());
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path(crate::mail::UNSUBSCRIBE_PATH);
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("unsubscribe endpoint path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == crate::mail::UNSUBSCRIBE_PATH
));
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_on_job_status_endpoint() {
let config = AutumnConfig::default();
assert!(config.jobs.tracking.route_enabled);
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path(crate::job_tracking::JOB_STATUS_ROUTE_PATH);
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("job status endpoint path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == crate::job_tracking::JOB_STATUS_ROUTE_PATH
));
}
#[cfg(all(feature = "openapi", feature = "maud"))]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_on_story_gallery() {
let mut config = AutumnConfig::default();
config.stories.enabled = true;
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path(crate::stories::STORIES_PATH);
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("story gallery path should be reserved while stories are enabled");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == crate::stories::STORIES_PATH
));
}
#[cfg(all(feature = "openapi", feature = "maud"))]
#[tokio::test]
async fn try_build_router_rejects_openapi_path_on_story_gallery_demo_route() {
let mut config = AutumnConfig::default();
config.stories.enabled = true;
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/_stories/demo/search");
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
"story gallery demo search path should be reserved while stories are enabled",
);
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
ref path,
} if path == "/_stories/demo/search"
));
}
#[cfg(feature = "openapi")]
#[test]
fn try_build_router_rejects_openapi_path_on_dev_live_reload() {
temp_env::with_vars(
[
("AUTUMN_DEV_RELOAD", Some("1")),
("AUTUMN_DEV_RELOAD_STATE", Some("/tmp/autumn-reload-test")),
],
|| {
let config = AutumnConfig::default();
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/__autumn/live-reload");
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
error_page_renderer: None,
session_store: None,
openapi: Some(openapi),
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
};
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("dev reload path should be reserved");
assert!(matches!(
err,
RouterBuildError::OpenApiPathCollision {
field: "openapi_json_path",
..
}
));
},
);
}
async fn duplicate_route_handler() -> &'static str {
"ok"
}
fn duplicate_test_route(method: http::Method, path: &'static str, name: &'static str) -> Route {
let handler = match method {
http::Method::POST => axum::routing::post(duplicate_route_handler),
http::Method::PUT => axum::routing::put(duplicate_route_handler),
http::Method::PATCH => axum::routing::patch(duplicate_route_handler),
http::Method::DELETE => axum::routing::delete(duplicate_route_handler),
_ => axum::routing::get(duplicate_route_handler),
};
let method_str = if method == http::Method::POST {
"POST"
} else if method == http::Method::PUT {
"PUT"
} else if method == http::Method::PATCH {
"PATCH"
} else if method == http::Method::DELETE {
"DELETE"
} else {
"GET"
};
Route {
method,
path,
handler,
name,
api_doc: crate::openapi::ApiDoc {
method: method_str,
path,
operation_id: name,
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
}
}
fn duplicate_test_ctx() -> RouterContext {
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
}
}
#[tokio::test]
async fn try_build_router_rejects_duplicate_user_route_paths() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/", "root_a");
let b = duplicate_test_route(http::Method::GET, "/", "root_b");
let err =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect_err("two GET / routes should be rejected before mount");
let display = err.to_string();
match err {
RouterBuildError::DuplicateUserRoute {
ref method,
ref path,
ref existing,
ref incoming,
} => {
assert_eq!(method, "GET");
assert_eq!(path, "/");
assert_eq!(existing, "root_a");
assert_eq!(incoming, "root_b");
}
other => panic!("expected DuplicateUserRoute, got {other:?}"),
}
assert!(
display.contains("root_a"),
"error message must name first handler; got: {display}"
);
assert!(
display.contains("root_b"),
"error message must name second handler; got: {display}"
);
assert!(
display.contains("GET"),
"error message must name the HTTP method; got: {display}"
);
assert!(
display.contains('/'),
"error message must contain the path; got: {display}"
);
}
fn declared_route(method: &str, path: &str, plugin: &str) -> crate::route_listing::RouteInfo {
crate::route_listing::RouteInfo {
method: method.to_owned(),
path: path.to_owned(),
handler: format!("sandbox:{plugin}"),
source: crate::route_listing::RouteSource::Plugin(plugin.to_owned()),
..crate::route_listing::RouteInfo::default()
}
}
#[tokio::test]
async fn try_build_router_rejects_a_declared_plugin_route_the_app_already_serves() {
let config = AutumnConfig::default();
let app_route = duplicate_test_route(http::Method::GET, "/hello/greet", "app_greet");
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route("GET", "/hello/greet", "evil-plugin")];
let err = super::try_build_router_inner(vec![app_route], &config, test_state(), ctx)
.expect_err("a plugin declaring a path the app serves must be refused");
match err {
RouterBuildError::DuplicateUserRoute {
ref method,
ref path,
ref existing,
ref incoming,
} => {
assert_eq!(method, "GET");
assert_eq!(path, "/hello/greet");
assert_eq!(existing, "app_greet");
assert_eq!(incoming, "sandbox:evil-plugin");
}
other => panic!("expected DuplicateUserRoute, got {other:?}"),
}
}
#[tokio::test]
async fn try_build_router_rejects_a_declared_plugin_route_on_a_framework_path() {
let config = AutumnConfig::default();
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route("GET", &config.health.path, "evil-plugin")];
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("a plugin declaring a framework probe path must be refused");
match err {
RouterBuildError::DuplicateUserRoute {
ref path,
ref existing,
ref incoming,
..
} => {
assert_eq!(path, &config.health.path);
assert!(
existing.starts_with("autumn framework route")
&& existing.contains(&config.health.path),
"the refusal must name the framework route it clashed with; got: {existing}"
);
assert_eq!(incoming, "sandbox:evil-plugin");
}
other => panic!("expected the framework-path refusal, got {other:?}"),
}
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_rejects_a_declared_route_on_the_openapi_paths() {
let config = AutumnConfig::default();
let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
.openapi_json_path("/spec.json")
.swagger_ui_path(Some("/docs".to_owned()));
let asset = crate::openapi::swagger_ui_asset_paths("/docs")
.into_iter()
.next()
.expect("the UI serves at least one asset");
for path in ["/spec.json", "/docs", asset.as_str()] {
let mut ctx = duplicate_test_ctx();
ctx.openapi = Some(openapi.clone());
ctx.declared_routes = vec![declared_route("GET", path, "evil-plugin")];
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("a declared route on an OpenAPI path must be refused");
match err {
RouterBuildError::DuplicateUserRoute {
path: ref found,
ref existing,
ref incoming,
..
} => {
assert_eq!(found, path);
assert!(existing.starts_with("autumn framework route"), "{existing}");
assert_eq!(incoming, "sandbox:evil-plugin");
}
other => panic!("expected a typed refusal for {path}, got {other:?}"),
}
}
}
#[cfg(feature = "openapi")]
#[tokio::test]
async fn try_build_router_accepts_a_declared_post_on_an_openapi_path() {
let config = AutumnConfig::default();
let mut ctx = duplicate_test_ctx();
ctx.openapi = Some(
crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/spec.json"),
);
ctx.declared_routes = vec![declared_route("POST", "/spec.json", "honest-plugin")];
let _built = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect("a declared POST does not clash with the OpenAPI GET");
}
#[cfg(feature = "mcp")]
#[tokio::test]
async fn try_build_router_rejects_a_declared_route_on_the_mcp_mount_path() {
let config = AutumnConfig::default();
for method in ["GET", "POST", "OPTIONS"] {
let mut ctx = duplicate_test_ctx();
ctx.mcp = Some(crate::mcp::McpRuntime::new("/agent-tools"));
ctx.declared_routes = vec![declared_route(method, "/agent-tools", "evil-plugin")];
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("a declared route on the MCP path must be refused");
assert!(
matches!(
err,
RouterBuildError::DuplicateUserRoute { ref existing, .. }
if existing.starts_with("autumn framework route")
),
"expected a typed refusal for {method}, got {err:?}"
);
}
}
#[cfg(feature = "mcp")]
#[tokio::test]
async fn try_build_router_accepts_a_declared_delete_on_the_mcp_mount_path() {
let config = AutumnConfig::default();
let mut ctx = duplicate_test_ctx();
ctx.mcp = Some(crate::mcp::McpRuntime::new("/agent-tools"));
ctx.declared_routes = vec![declared_route("DELETE", "/agent-tools", "honest-plugin")];
let _built = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect("a declared DELETE does not clash with the MCP verbs");
}
#[tokio::test]
async fn try_build_router_rejects_a_non_get_declared_route_that_shape_clashes_with_a_framework_path()
{
let mut config = AutumnConfig::default();
config.health.path = "/probe/{id}".to_owned();
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route("POST", "/probe/{slug}", "evil-plugin")];
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("a shape clash must be refused whatever the method");
match err {
RouterBuildError::DuplicateUserRoute {
ref method,
ref existing,
..
} => {
assert_eq!(method, "POST", "the refusal must name the declared method");
assert!(
existing.contains("/probe/{id}"),
"the refusal must name the framework template; got: {existing}"
);
}
other => panic!("expected the framework shape refusal, got {other:?}"),
}
}
#[tokio::test]
async fn try_build_router_allows_a_non_get_declared_route_at_a_framework_get_path() {
let config = AutumnConfig::default();
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route("POST", &config.health.path, "honest-plugin")];
assert!(
super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).is_ok(),
"a POST at a framework GET path merges into one MethodRouter and must be allowed"
);
}
#[tokio::test]
async fn try_build_router_rejects_a_declared_plugin_route_on_a_framework_mutating_path() {
let mut config = AutumnConfig::default();
config.actuator.sensitive = true;
let loggers =
crate::actuator::actuator_route_path(&config.actuator.prefix, "/loggers/{name}");
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route("PUT", &loggers, "evil-plugin")];
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("a declared PUT at the actuator's own PUT must be refused");
match err {
RouterBuildError::DuplicateUserRoute {
ref method,
ref existing,
..
} => {
assert_eq!(method, "PUT", "the refusal must name the real method");
assert!(
existing.contains(&loggers),
"the refusal must name the framework route; got: {existing}"
);
}
other => panic!("expected the framework mutating-route refusal, got {other:?}"),
}
}
#[cfg(feature = "http-client")]
#[tokio::test]
async fn try_build_router_allows_a_declared_get_at_a_post_only_framework_path() {
let mut config = AutumnConfig::default();
config.actuator.sensitive = true;
let replay =
crate::actuator::actuator_route_path(&config.actuator.prefix, "/webhooks/replay");
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route("GET", &replay, "honest-plugin")];
assert!(
super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).is_ok(),
"a GET at a POST-only framework path merges cleanly and must be allowed"
);
}
#[tokio::test]
async fn try_build_router_rejects_a_declared_plugin_route_that_shape_clashes_with_a_framework_path()
{
let mut config = AutumnConfig::default();
config.health.path = "/probe/{id}".to_owned();
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route("GET", "/probe/{slug}", "evil-plugin")];
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("a shape clash with a framework path must be refused");
match err {
RouterBuildError::DuplicateUserRoute { ref existing, .. } => assert!(
existing.contains("/probe/{id}"),
"the refusal must name the framework template it clashed with; got: {existing}"
),
other => panic!("expected the framework shape refusal, got {other:?}"),
}
}
#[tokio::test]
async fn try_build_router_rejects_a_declared_plugin_route_on_the_inspector_detail_path() {
let mut config = AutumnConfig {
profile: Some("dev".to_owned()),
..AutumnConfig::default()
};
config.dev.inspector_path = "/debug".to_owned();
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route(
"GET",
"/debug/requests/{slug}",
"evil-plugin",
)];
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("the inspector detail route must be claimed too");
match err {
RouterBuildError::DuplicateUserRoute { ref existing, .. } => assert!(
existing.contains("/debug/requests/{id}"),
"the refusal must name the inspector detail template; got: {existing}"
),
other => panic!("expected the inspector detail refusal, got {other:?}"),
}
}
#[tokio::test]
async fn try_build_router_allows_a_declared_plugin_post_on_a_framework_path() {
let config = AutumnConfig::default();
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![
declared_route("POST", &config.health.path, "good-plugin"),
declared_route("HEAD", &config.health.path, "good-plugin"),
];
let _router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect("non-GET declarations at a framework path must mount");
}
#[tokio::test]
async fn try_build_router_rejects_declared_plugin_routes_inside_framework_namespaces() {
for (method, path) in [
("GET", "/static/app.js"),
("GET", "/static"),
("GET", "/static/_plugins/not-installed/evil.js"),
("POST", "/_autumn/unsubscribe"),
("GET", "/_autumn/jobs/abc"),
] {
let config = AutumnConfig::default();
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route(method, path, "evil-plugin")];
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("a plugin route inside a framework namespace must be refused");
match err {
RouterBuildError::DuplicateUserRoute {
path: ref got,
ref existing,
..
} => {
assert_eq!(got, path);
assert!(
existing.contains("framework namespace"),
"the refusal must name the namespace; got: {existing}"
);
}
other => {
panic!("expected the namespace refusal for {method} {path}, got {other:?}")
}
}
}
}
#[tokio::test]
async fn try_build_router_allows_a_plugin_path_that_merely_starts_like_a_namespace() {
let config = AutumnConfig::default();
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route("GET", "/staticky/thing", "good-plugin")];
let _router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect("/staticky must not be read as /static");
}
#[tokio::test]
async fn try_build_router_allows_a_declared_probe_path_when_probes_are_disabled() {
let mut config = AutumnConfig::default();
config.health.enabled = false;
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route("GET", &config.health.path, "good-plugin")];
let _router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect("a disabled probe path must not be claimed");
}
#[tokio::test]
async fn try_build_router_rejects_a_declared_plugin_route_that_shape_clashes() {
let config = AutumnConfig::default();
let app_route = duplicate_test_route(http::Method::GET, "/hello/{id}", "app_show");
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![declared_route("POST", "/hello/{slug}", "evil-plugin")];
let err = super::try_build_router_inner(vec![app_route], &config, test_state(), ctx)
.expect_err("a shape clash between app and plugin routes must be refused");
assert!(
matches!(err, RouterBuildError::ConflictingRouteShape { .. }),
"expected ConflictingRouteShape, got {err:?}"
);
}
#[tokio::test]
async fn try_build_router_allows_declared_plugin_routes_that_do_not_collide() {
let config = AutumnConfig::default();
let routes = vec![
duplicate_test_route(http::Method::GET, "/hello/other", "app_other"),
duplicate_test_route(http::Method::GET, "/hello", "app_index"),
];
let mut ctx = duplicate_test_ctx();
ctx.declared_routes = vec![
declared_route("GET", "/hello/greet", "good-plugin"),
declared_route("HEAD", "/hello/greet", "good-plugin"),
];
let _router = super::try_build_router_inner(routes, &config, test_state(), ctx)
.expect("non-colliding plugin routes must mount");
}
#[tokio::test]
async fn try_build_router_allows_distinct_methods_on_same_path() {
let config = AutumnConfig::default();
let get = duplicate_test_route(http::Method::GET, "/admin", "admin_index");
let post = duplicate_test_route(http::Method::POST, "/admin", "admin_create");
let _router = super::try_build_router_inner(
vec![get, post],
&config,
test_state(),
duplicate_test_ctx(),
)
.expect("GET + POST on the same path should build cleanly");
}
#[tokio::test]
async fn try_build_router_rejects_duplicate_across_scoped_group() {
let config = AutumnConfig::default();
let top = duplicate_test_route(http::Method::GET, "/api/posts", "top_posts");
let scoped_child = duplicate_test_route(http::Method::GET, "/posts", "scoped_posts");
let group = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![scoped_child],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let mut ctx = duplicate_test_ctx();
ctx.scoped_groups.push(group);
let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
.expect_err("top-level + scoped resolving to same path should be rejected");
match err {
RouterBuildError::DuplicateUserRoute {
ref method,
ref path,
..
} => {
assert_eq!(method, "GET");
assert_eq!(path, "/api/posts");
}
other => panic!("expected DuplicateUserRoute, got {other:?}"),
}
}
#[tokio::test]
async fn try_build_router_rejects_duplicate_within_scoped_groups() {
let config = AutumnConfig::default();
let a = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![duplicate_test_route(
http::Method::GET,
"/posts",
"user_posts",
)],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let b = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![duplicate_test_route(
http::Method::GET,
"/posts",
"plugin_posts",
)],
source: crate::route_listing::RouteSource::Plugin("blog".to_owned()),
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let mut ctx = duplicate_test_ctx();
ctx.scoped_groups.push(a);
ctx.scoped_groups.push(b);
let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
.expect_err("two scoped groups colliding on /api/posts should be rejected");
assert!(matches!(
err,
RouterBuildError::DuplicateUserRoute { ref existing, ref incoming, .. }
if existing == "user_posts" && incoming == "plugin_posts"
));
}
#[tokio::test]
async fn try_build_router_skips_duplicate_check_for_opaque_merge_router() {
let config = AutumnConfig::default();
let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
let raw = axum::Router::<AppState>::new()
.route("/raw", axum::routing::get(duplicate_route_handler));
let mut ctx = duplicate_test_ctx();
ctx.merge_routers.push(raw);
let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
.expect("opaque merge routers must not fail the duplicate preflight");
}
#[tokio::test]
async fn try_build_router_skips_duplicate_check_for_opaque_nest_router() {
let config = AutumnConfig::default();
let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
let nested = axum::Router::<AppState>::new()
.route("/child", axum::routing::get(duplicate_route_handler));
let mut ctx = duplicate_test_ctx();
ctx.nest_routers.push(("/plugin".to_owned(), nested));
let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
.expect("opaque nest routers must not fail the duplicate preflight");
}
#[tokio::test]
async fn try_build_router_rejects_duplicate_capture_name_paths() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
let b = duplicate_test_route(http::Method::GET, "/users/{slug}", "by_slug");
let err =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect_err("capture-name-only difference must be rejected before mount");
match err {
RouterBuildError::ConflictingRouteShape {
ref existing,
ref existing_path,
ref incoming,
ref incoming_path,
} => {
assert_eq!(existing, "by_id");
assert_eq!(existing_path, "/users/{id}");
assert_eq!(incoming, "by_slug");
assert_eq!(incoming_path, "/users/{slug}");
}
other => panic!("expected ConflictingRouteShape, got {other:?}"),
}
let display = err.to_string();
assert!(
display.contains("/users/{id}") && display.contains("/users/{slug}"),
"error must show both original path templates; got: {display}"
);
}
#[tokio::test]
async fn try_build_router_rejects_duplicate_capture_name_across_scoped_group() {
let config = AutumnConfig::default();
let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
let scoped_child =
duplicate_test_route(http::Method::GET, "/users/{slug}", "scoped_by_slug");
let group = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![scoped_child],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let mut ctx = duplicate_test_ctx();
ctx.scoped_groups.push(group);
let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
.expect_err("scoped capture-name collision must be rejected before mount");
assert!(
matches!(
err,
RouterBuildError::ConflictingRouteShape {
ref existing, ref incoming, ref existing_path, ref incoming_path
}
if existing == "top_by_id" && incoming == "scoped_by_slug"
&& existing_path == "/api/users/{id}"
&& incoming_path == "/api/users/{slug}"
),
"expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
);
}
#[tokio::test]
async fn try_build_router_allows_distinct_route_shapes() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
let b = duplicate_test_route(http::Method::GET, "/users/{id}/posts", "posts");
let _router =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect("distinct route shapes must not be flagged as duplicates");
}
#[tokio::test]
async fn try_build_router_rejects_ws_get_collision() {
let config = AutumnConfig::default();
let get = duplicate_test_route(http::Method::GET, "/live", "live_poll");
let ws = duplicate_test_route(
http::Method::from_bytes(b"WS").unwrap(),
"/live",
"live_socket",
);
let err = super::try_build_router_inner(
vec![get, ws],
&config,
test_state(),
duplicate_test_ctx(),
)
.expect_err("GET + WS on the same path must be rejected before mount");
match err {
RouterBuildError::DuplicateUserRoute {
ref method,
ref path,
ref existing,
ref incoming,
} => {
assert_eq!(method, "GET", "WS must be normalized to its effective GET");
assert_eq!(path, "/live");
assert_eq!(existing, "live_poll");
assert_eq!(incoming, "live_socket");
}
other => panic!("expected DuplicateUserRoute, got {other:?}"),
}
}
#[tokio::test]
async fn try_build_router_rejects_cross_method_shape_conflict() {
let config = AutumnConfig::default();
let get = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
let post = duplicate_test_route(http::Method::POST, "/users/{slug}", "by_slug");
let err = super::try_build_router_inner(
vec![get, post],
&config,
test_state(),
duplicate_test_ctx(),
)
.expect_err("cross-method capture-name-only conflict must be rejected before mount");
match err {
RouterBuildError::ConflictingRouteShape {
ref existing,
ref existing_path,
ref incoming,
ref incoming_path,
} => {
assert_eq!(existing, "by_id");
assert_eq!(existing_path, "/users/{id}");
assert_eq!(incoming, "by_slug");
assert_eq!(incoming_path, "/users/{slug}");
}
other => panic!("expected ConflictingRouteShape, got {other:?}"),
}
let display = err.to_string();
assert!(
display.contains("by_id") && display.contains("by_slug"),
"error must name both handlers; got: {display}"
);
assert!(
display.contains("/users/{id}") && display.contains("/users/{slug}"),
"error must name both original templates; got: {display}"
);
}
#[tokio::test]
async fn try_build_router_rejects_cross_method_shape_conflict_across_scoped_group() {
let config = AutumnConfig::default();
let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
let scoped_child =
duplicate_test_route(http::Method::POST, "/users/{slug}", "scoped_by_slug");
let group = crate::app::ScopedGroup {
prefix: "/api".to_owned(),
routes: vec![scoped_child],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let mut ctx = duplicate_test_ctx();
ctx.scoped_groups.push(group);
let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
.expect_err("scoped cross-method shape conflict must be rejected before mount");
assert!(
matches!(
err,
RouterBuildError::ConflictingRouteShape {
ref existing, ref incoming, ref existing_path, ref incoming_path
}
if existing == "top_by_id" && incoming == "scoped_by_slug"
&& existing_path == "/api/users/{id}"
&& incoming_path == "/api/users/{slug}"
),
"expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
);
}
#[tokio::test]
async fn try_build_router_allows_same_capture_template_distinct_methods() {
let config = AutumnConfig::default();
let get = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
let post = duplicate_test_route(http::Method::POST, "/users/{id}", "update");
let _router = super::try_build_router_inner(
vec![get, post],
&config,
test_state(),
duplicate_test_ctx(),
)
.expect("same capture template on GET + POST must build cleanly");
}
#[tokio::test]
async fn try_build_router_allows_escaped_brace_literals() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/{{foo}}", "lit_foo");
let b = duplicate_test_route(http::Method::GET, "/{{bar}}", "lit_bar");
let _router =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect("distinct escaped-literal paths must not be flagged as duplicates");
}
#[tokio::test]
async fn try_build_router_allows_escaped_literal_prefix_with_capture() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/{{x}}/{id}", "x_show");
let b = duplicate_test_route(http::Method::GET, "/{{y}}/{id}", "y_show");
let _router =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect("distinct escaped-literal prefixes with a shared capture must build");
}
#[tokio::test]
async fn try_build_router_rejects_mixed_literal_capture_shape_conflict() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
let b = duplicate_test_route(http::Method::GET, "/file.{kind}", "by_kind");
let err =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect_err("mixed literal+capture shape conflict must be rejected before mount");
assert!(
matches!(
err,
RouterBuildError::ConflictingRouteShape {
ref existing_path, ref incoming_path, ..
}
if existing_path == "/file.{ext}" && incoming_path == "/file.{kind}"
),
"expected ConflictingRouteShape naming both templates, got {err:?}"
);
}
#[tokio::test]
async fn try_build_router_allows_mixed_capture_vs_static_segment() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
let b = duplicate_test_route(http::Method::GET, "/file.json", "static_json");
let _router =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect("a capture segment and a static segment must not be flagged as duplicates");
}
#[tokio::test]
async fn try_build_router_rejects_catch_all_vs_normal_capture() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/u/{id}", "one");
let b = duplicate_test_route(http::Method::GET, "/u/{*rest}", "rest");
let err =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect_err("catch-all vs normal capture must be rejected before mount");
assert!(
matches!(
err,
RouterBuildError::ConflictingRouteShape {
ref existing_path, ref incoming_path, ..
}
if existing_path == "/u/{id}" && incoming_path == "/u/{*rest}"
),
"expected ConflictingRouteShape naming both templates, got {err:?}"
);
}
#[tokio::test]
async fn try_build_router_rejects_catch_all_vs_dynamic_descendant() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/cmd/{tool}/{sub}", "cmd_sub");
let b = duplicate_test_route(http::Method::POST, "/cmd/{*path}", "cmd_all");
let err =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect_err("catch-all vs dynamic descendant must be rejected before mount");
match err {
RouterBuildError::ConflictingRouteShape {
ref existing,
ref existing_path,
ref incoming,
ref incoming_path,
} => {
assert_eq!(existing, "cmd_sub");
assert_eq!(existing_path, "/cmd/{tool}/{sub}");
assert_eq!(incoming, "cmd_all");
assert_eq!(incoming_path, "/cmd/{*path}");
}
other => panic!("expected ConflictingRouteShape, got {other:?}"),
}
let display = err.to_string();
assert!(
display.contains("/cmd/{tool}/{sub}") && display.contains("/cmd/{*path}"),
"error must name both original templates; got: {display}"
);
}
#[tokio::test]
async fn try_build_router_allows_static_vs_dynamic_segment() {
let config = AutumnConfig::default();
let a = duplicate_test_route(http::Method::GET, "/users/me", "me");
let b = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
let _router =
super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
.expect("a static segment and a dynamic capture must not be flagged as a conflict");
}
#[test]
fn matchit_agrees_with_axum_route_conflicts() {
let matrix: &[(&str, &str, bool)] = &[
("/users/{id}", "/users/{slug}", true),
("/users/{id}", "/users/{id}/posts", false),
("/cmd/{tool}/{sub}", "/cmd/{*path}", true),
("/users/me", "/users/{id}", false),
("/{{foo}}", "/{{bar}}", false),
("/file.{ext}", "/file.{kind}", true),
("/file.{ext}", "/file.json", false),
("/u/{id}", "/u/{*rest}", true),
];
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let mut rows = Vec::new();
let mut mismatches = Vec::new();
for &(a, b, expect_conflict) in matrix {
let axum_panics = std::panic::catch_unwind(|| {
let _ = axum::Router::<()>::new()
.route(a, axum::routing::get(|| async { "a" }))
.route(b, axum::routing::get(|| async { "b" }));
})
.is_err();
let mut r: matchit::Router<()> = matchit::Router::new();
r.insert(a, ()).expect("first template must insert cleanly");
let matchit_conflicts =
matches!(r.insert(b, ()), Err(matchit::InsertError::Conflict { .. }));
rows.push(format!(
"{a:<20} vs {b:<20} axum={} matchit={} expected={}",
if axum_panics { "PANIC" } else { "ok" },
if matchit_conflicts { "Err" } else { "Ok" },
if expect_conflict { "conflict" } else { "ok" },
));
if axum_panics != matchit_conflicts || axum_panics != expect_conflict {
mismatches.push(rows.last().unwrap().clone());
}
}
std::panic::set_hook(prev_hook);
assert!(
mismatches.is_empty(),
"matchit must agree with axum 0.8.9 AND the expected outcome on every \
case (oracle divergence => false positives/negatives at mount).\n\
full matrix:\n{}\nmismatches:\n{}",
rows.join("\n"),
mismatches.join("\n"),
);
}
fn create_ssg_dist(entries: &[(&str, &str, &[u8])]) -> tempfile::TempDir {
let with_types: Vec<_> = entries
.iter()
.map(|(route, file, bytes)| (*route, *file, *bytes, None))
.collect();
create_ssg_dist_with_types(&with_types)
}
fn create_ssg_dist_with_types(
entries: &[(&str, &str, &[u8], Option<&str>)],
) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
let mut routes = std::collections::HashMap::new();
for (route, file, bytes, content_type) in entries {
let path = dist.join(file);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("mkdir");
}
std::fs::write(&path, bytes).expect("write file");
routes.insert(
(*route).to_owned(),
crate::static_gen::ManifestEntry::new(*file)
.with_content_type(content_type.map(str::to_owned)),
);
}
let manifest = crate::static_gen::StaticManifest {
generated_at: "2026-07-12T00:00:00Z".to_owned(),
autumn_version: "0.6.0".to_owned(),
routes,
};
std::fs::write(
dist.join("manifest.json"),
serde_json::to_string(&manifest).unwrap(),
)
.unwrap();
dir
}
fn compression_enabled_config() -> AutumnConfig {
let mut config = AutumnConfig::default();
config.compression.enabled = true;
config
}
#[cfg(feature = "tls")]
#[tokio::test]
async fn a_cached_ssg_page_under_a_required_path_still_demands_a_certificate() {
let tmp = create_ssg_dist(&[("/internal/report", "report.html", b"<h1>secret</h1>")]);
let dist = tmp.path().join("dist");
let mut config = AutumnConfig::default();
config.server.tls = Some(crate::config::TlsConfig {
cert_path: Some("cert.pem".into()),
key_path: Some("key.pem".into()),
reload_interval_secs: 60,
handshake_timeout_secs: 10,
acme: None,
client_auth: Some(crate::config::ClientAuthConfig {
mode: crate::config::ClientAuthMode::Optional,
ca_bundle_path: Some("client-ca.pem".into()),
crl_path: None,
required_paths: vec!["/internal/".to_owned()],
reload_interval_secs: 60,
}),
});
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds");
let response = router
.clone()
.oneshot(
Request::builder()
.uri("/internal/report")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::FORBIDDEN,
"a cached page under a required_paths prefix must still demand a certificate"
);
let identity = std::sync::Arc::new(crate::tls::client_auth::ClientIdentity::new_for_test(
"svc-reports",
Vec::new(),
));
let mut request = Request::builder()
.uri("/internal/report")
.body(Body::empty())
.unwrap();
request.extensions_mut().insert(identity);
let response = router.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"<h1>secret</h1>");
}
#[test]
fn a_scoped_noindex_home_is_not_listed() {
let mut home = duplicate_test_route(http::Method::GET, "/", "home");
home.seo = crate::seo::SeoRouteDefaults {
robots: Some("noindex"),
..crate::seo::SeoRouteDefaults::EMPTY
};
let group = crate::app::ScopedGroup {
prefix: "/".to_owned(),
routes: vec![home],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let site = super::build_aeo_site::<(), ()>(
&AutumnConfig::default(),
&AppState::for_test(),
&[],
&[group],
false,
None,
None,
);
assert!(!site.facts.no_home);
assert_eq!(site.facts.noindex_homes, vec!["/".to_owned()]);
}
#[cfg(feature = "http-client")]
#[tokio::test]
async fn a_static_build_leaves_a_dynamic_miss_to_the_matched_route() {
let tmp = create_ssg_dist(&[("/about", "about.html", b"<h1>About</h1>")]);
let dist = tmp.path().join("dist");
let mut config = AutumnConfig::default();
config.aeo.x402 = toml::from_str(
"facilitator_url = \"https://facilitator.example\"\npay_to = \"0xabc\"\n\
network = \"eip155:84532\"\nasset = \"0xusdc\"",
)
.unwrap();
config.aeo.paid_routes = vec![crate::aeo::commerce::PaidRoute::new(
"GET",
"/files/{*path}",
"1",
)];
let router = try_build_router_with_static(
vec![duplicate_test_route(
http::Method::GET,
"/files/public",
"public",
)],
&config,
test_state(),
Some(&dist),
)
.expect("router builds");
let get = |uri: &'static str| {
router
.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
};
assert_eq!(get("/files/public").await.unwrap().status(), StatusCode::OK);
assert_eq!(
get("/files/report.pdf").await.unwrap().status(),
StatusCode::PAYMENT_REQUIRED
);
}
#[cfg(feature = "http-client")]
#[tokio::test]
async fn a_static_page_is_priced_by_the_route_that_rendered_it() {
let tmp = create_ssg_dist(&[
("/files/public", "files/public.html", b"<h1>Public</h1>"),
("/files/report.pdf", "files/report.html", b"<h1>Report</h1>"),
]);
let dist = tmp.path().join("dist");
let mut config = AutumnConfig::default();
config.aeo.x402 = toml::from_str(
"facilitator_url = \"https://facilitator.example\"\npay_to = \"0xabc\"\n\
network = \"eip155:84532\"\nasset = \"0xusdc\"",
)
.unwrap();
config.aeo.paid_routes = vec![crate::aeo::commerce::PaidRoute::new(
"GET",
"/files/{*path}",
"1",
)];
let router = try_build_router_with_static(
vec![
duplicate_test_route(http::Method::GET, "/files/public", "public"),
duplicate_test_route(http::Method::GET, "/files/{*path}", "files"),
],
&config,
test_state(),
Some(&dist),
)
.expect("router builds");
let get = |uri: &'static str| {
router
.clone()
.oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
};
let public = get("/files/public").await.unwrap();
assert_eq!(public.status(), StatusCode::OK);
let body = axum::body::to_bytes(public.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"<h1>Public</h1>");
assert_eq!(
get("/files/report.pdf").await.unwrap().status(),
StatusCode::PAYMENT_REQUIRED
);
}
#[cfg(feature = "http-client")]
#[tokio::test]
async fn a_priced_static_page_passes_the_ingress_stack() {
let tmp = create_ssg_dist(&[
("/report", "report.html", b"<h1>Report</h1>"),
("/about", "about.html", b"<h1>About</h1>"),
]);
let dist = tmp.path().join("dist");
let mut config = AutumnConfig::default();
config.aeo.x402 = toml::from_str(
"facilitator_url = \"https://facilitator.example\"\npay_to = \"0xabc\"\n\
network = \"eip155:84532\"\nasset = \"0xusdc\"",
)
.unwrap();
config.aeo.paid_routes = vec![crate::aeo::commerce::PaidRoute::new("GET", "/report", "1")];
config.cors.allowed_origins = vec!["https://app.example".to_owned()];
let router = try_build_router_with_static(
vec![
duplicate_test_route(http::Method::GET, "/report", "report"),
duplicate_test_route(http::Method::GET, "/about", "about"),
],
&config,
test_state(),
Some(&dist),
)
.expect("router builds");
let get = |uri: &'static str| {
router.clone().oneshot(
Request::builder()
.uri(uri)
.header(http::header::ORIGIN, "https://app.example")
.body(Body::empty())
.unwrap(),
)
};
let report = get("/report").await.unwrap();
assert_eq!(report.status(), StatusCode::PAYMENT_REQUIRED);
assert_eq!(
report.headers()[http::header::ACCESS_CONTROL_ALLOW_ORIGIN],
"https://app.example",
"the CORS layer ran"
);
let about = get("/about").await.unwrap();
assert_eq!(about.status(), StatusCode::OK);
let body = axum::body::to_bytes(about.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(&body[..], b"<h1>About</h1>", "served from the manifest");
}
#[test]
fn static_pages_resolve_to_the_route_axum_would_match() {
let routes = vec![
duplicate_test_route(http::Method::GET, "/files/public", "public"),
duplicate_test_route(http::Method::GET, "/files/{*path}", "files"),
];
let pages = [
"/files/public".to_owned(),
"/files/a.pdf".to_owned(),
"/hand-written".to_owned(),
];
let map = super::static_page_routes(pages.iter(), &routes, &[], &AutumnConfig::default());
assert_eq!(map["/files/public"].as_deref(), Some("/files/public"));
assert_eq!(map["/files/a.pdf"].as_deref(), Some("/files/{*path}"));
assert_eq!(map["/hand-written"], None);
}
#[tokio::test]
async fn ssg_head_mirrors_the_markdown_get() {
let html = b"<html><body><main><h1>About</h1></main></body></html>";
let tmp = create_ssg_dist(&[("/about", "about.html", html)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&AutumnConfig::default(),
test_state(),
Some(&dist),
)
.expect("router builds");
let send = |method: http::Method| {
router.clone().oneshot(
Request::builder()
.method(method)
.uri("/about")
.header(http::header::ACCEPT, "text/markdown")
.body(Body::empty())
.unwrap(),
)
};
let head = send(http::Method::HEAD).await.unwrap();
let get = send(http::Method::GET).await.unwrap();
assert_eq!(head.status(), StatusCode::OK);
assert_eq!(
head.headers().get(http::header::CONTENT_TYPE),
get.headers().get(http::header::CONTENT_TYPE),
);
assert!(
head.headers()[http::header::CONTENT_TYPE]
.to_str()
.unwrap()
.starts_with("text/markdown"),
"{:?}",
head.headers()
);
let get_len = get.headers()[http::header::CONTENT_LENGTH].clone();
assert_eq!(head.headers()[http::header::CONTENT_LENGTH], get_len);
let body = axum::body::to_bytes(head.into_body(), usize::MAX)
.await
.unwrap();
assert!(body.is_empty(), "{body:?}");
}
#[tokio::test]
async fn ssg_html_hit_is_gzip_compressed() {
let html = format!(
"<html><body>{}</body></html>",
"Lorem ipsum dolor sit amet. ".repeat(64)
);
let tmp = create_ssg_dist(&[("/", "index.html", html.as_bytes())]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"manifest-backed SSG HTML page must be gzip-compressed"
);
let vary = response
.headers()
.get_all(http::header::VARY)
.iter()
.filter_map(|v| v.to_str().ok())
.collect::<Vec<_>>()
.join(", ");
assert!(
vary.to_lowercase().contains("accept-encoding"),
"Vary must advertise Accept-Encoding, got {vary:?}"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8"),
"HTML page keeps its text/html content type"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_ne!(
body.as_ref(),
html.as_bytes(),
"compressed body must differ from the raw HTML"
);
}
#[tokio::test]
async fn ssg_binary_asset_is_not_compressed_and_keeps_mime() {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
let tmp = create_ssg_dist(&[("/logo", "logo.png", &bytes)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/logo")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("image/png"),
"binary manifest asset must keep its real MIME type, not text/html"
);
assert_eq!(
response.headers().get(http::header::CONTENT_ENCODING),
None,
"binary asset must not be blindly compressed"
);
}
#[tokio::test]
async fn ssg_woff2_font_is_not_compressed_and_keeps_mime() {
let mut bytes = b"wOF2".to_vec();
bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
let tmp = create_ssg_dist(&[("/inter", "fonts/inter.woff2", &bytes)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/inter")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("font/woff2"),
"woff2 manifest asset must keep its font/woff2 MIME type"
);
assert_eq!(
response.headers().get(http::header::CONTENT_ENCODING),
None,
"pre-compressed woff2 font must not be re-compressed"
);
}
#[tokio::test]
async fn ssg_woff_font_is_not_compressed_and_keeps_mime() {
let mut bytes = b"wOFF".to_vec();
bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
for (label, recorded) in [("derived", None), ("recorded", Some("font/woff"))] {
let tmp =
create_ssg_dist_with_types(&[("/inter", "fonts/inter.woff", &bytes, recorded)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/inter")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{label}");
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("font/woff"),
"{label}: woff asset must keep its font/woff MIME type"
);
assert_eq!(
response.headers().get(http::header::CONTENT_ENCODING),
None,
"{label}: pre-compressed woff font must not be re-compressed"
);
}
}
#[tokio::test]
async fn ssg_raw_sfnt_fonts_stay_compressible() {
let mut bytes = vec![0u8, 1, 0, 0];
bytes.extend(std::iter::repeat_n(b'A', 4096));
for (route, file, expected) in [
("/inter-ttf", "fonts/inter.ttf", "font/ttf"),
("/inter-otf", "fonts/inter.otf", "font/otf"),
] {
let tmp = create_ssg_dist(&[(route, file, &bytes)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri(route)
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{route}");
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some(expected),
"{route} must keep its raw-font MIME type"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"{route}: raw SFNT fonts are uncompressed data and must still be \
gzipped — only WOFF/WOFF2 embed their own compression"
);
}
}
fn predicate_compresses(content_type: &str) -> bool {
use tower_http::compression::predicate::Predicate;
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, content_type)
.body(Body::from(vec![b'A'; 4096]))
.unwrap();
compression_predicate().should_compress(&response)
}
#[test]
fn compression_carve_outs_ignore_case() {
for ct in [
"Audio/MPEG",
"VIDEO/mp4",
"Application/Zip",
"Application/Octet-Stream",
"Font/WOFF2",
"font/WOFF",
"Font/Woff2; foo=bar",
"audio/mpeg",
"application/zip",
"font/woff2",
] {
assert!(!predicate_compresses(ct), "{ct} must stay uncompressed");
}
}
#[test]
fn compression_still_applies_to_other_types_in_any_case() {
for ct in [
"font/ttf",
"Font/TTF",
"font/otf",
"text/html",
"Text/HTML",
"Application/JSON; charset=utf-8",
"application/zi",
"Font/WOF",
"au",
] {
assert!(predicate_compresses(ct), "{ct} must be compressed");
}
}
#[test]
fn compression_exclusion_is_safe_on_short_and_non_ascii_input() {
for ct in ["", "a", "au", "é", "audiö/x", "fönt/woff2"] {
assert!(!is_compression_excluded(ct), "{ct:?} must not match");
}
}
#[tokio::test]
async fn ssg_nested_multidot_asset_resolves_js_mime() {
let js = format!("console.log({:?});", "x".repeat(256));
let tmp = create_ssg_dist(&[("/app.js", "assets/js/app.min.js", js.as_bytes())]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/app.js")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/javascript; charset=utf-8"),
"nested multi-dot JS asset must resolve to the JavaScript MIME type"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"compressible JS asset must be gzip-compressed"
);
}
#[tokio::test]
async fn ssg_generated_html_page_keeps_text_html_and_is_compressed() {
let html = format!("<html><body>{}</body></html>", "About us. ".repeat(128));
let tmp = create_ssg_dist(&[("/about", "about/index.html", html.as_bytes())]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/about")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8"),
"extensionless generated page must stay text/html, not octet-stream"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"generated HTML page must be gzip-compressed"
);
}
#[tokio::test]
async fn ssg_generated_txt_route_is_text_plain_and_compressed() {
let body_text = format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(128));
let tmp =
create_ssg_dist(&[("/robots.txt", "robots.txt/index.html", body_text.as_bytes())]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/robots.txt")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/plain; charset=utf-8"),
"generated .txt route must be text/plain, derived from the route extension"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"compressible text/plain route must be gzip-compressed"
);
}
#[tokio::test]
async fn ssg_generated_xml_route_is_xml_mime() {
let xml = format!(
"<?xml version=\"1.0\"?><urlset>{}</urlset>",
"<url><loc>https://example.com/</loc></url>".repeat(64)
);
let tmp = create_ssg_dist(&[("/sitemap.xml", "sitemap.xml/index.html", xml.as_bytes())]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/sitemap.xml")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/xml"),
"generated .xml route must be application/xml, derived from the route extension"
);
}
#[tokio::test]
async fn ssg_dotted_slug_generated_page_stays_html_and_compressed() {
let html = format!(
"<html><body>{}</body></html>",
"Release notes. ".repeat(128)
);
let tmp = create_ssg_dist(&[(
"/posts/release.v1",
"release.v1/index.html",
html.as_bytes(),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/posts/release.v1")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8"),
"dotted-slug generated page must stay text/html, not octet-stream"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"dotted-slug generated HTML page must be gzip-compressed"
);
}
#[tokio::test]
async fn ssg_email_slug_generated_page_stays_html() {
let html = format!("<html><body>{}</body></html>", "Profile. ".repeat(64));
let tmp = create_ssg_dist(&[(
"/users/alice@example.com",
"alice@example.com/index.html",
html.as_bytes(),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/users/alice@example.com")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8"),
"email-like dotted-slug generated page must stay text/html"
);
}
#[tokio::test]
async fn ssg_recorded_content_type_overrides_route_extension() {
let rss = format!(
"<?xml version=\"1.0\"?><rss>{}</rss>",
"<item><title>Post</title></item>".repeat(64)
);
let tmp = create_ssg_dist_with_types(&[(
"/feed.xml",
"feed.xml/index.html",
rss.as_bytes(),
Some("application/rss+xml"),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/feed.xml")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/rss+xml"),
"the manifest's recorded Content-Type must win over route-extension derivation"
);
}
#[tokio::test]
async fn ssg_recorded_content_type_serves_type_outside_the_asset_table() {
let ics = format!(
"BEGIN:VCALENDAR\r\n{}END:VCALENDAR\r\n",
"BEGIN:VEVENT\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n".repeat(64)
);
let tmp = create_ssg_dist_with_types(&[(
"/calendar",
"calendar/index.html",
ics.as_bytes(),
Some("text/calendar; charset=utf-8"),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/calendar")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/calendar; charset=utf-8"),
"a recorded type outside the asset table must still be served"
);
}
#[tokio::test]
async fn ssg_recorded_binary_content_type_is_not_compressed() {
let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
let tmp = create_ssg_dist_with_types(&[(
"/badge",
"badge/index.html",
&bytes,
Some("image/png"),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/badge")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("image/png"),
"recorded binary type must be honoured on an extensionless route"
);
assert_eq!(
response.headers().get(http::header::CONTENT_ENCODING),
None,
"a recorded binary type must suppress compression just like a derived one"
);
}
#[tokio::test]
async fn ssg_header_illegal_recorded_content_type_falls_back_without_panicking() {
let html = format!("<html><body>{}</body></html>", "About us. ".repeat(128));
let tmp = create_ssg_dist_with_types(&[(
"/about",
"about/index.html",
html.as_bytes(),
Some("text/html\r\nX-Injected: yes"),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/about")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8"),
"a header-illegal recorded value must fall back to the derived type"
);
assert!(
response.headers().get("x-injected").is_none(),
"a CRLF in the manifest must never become a response header"
);
}
#[tokio::test]
async fn ssg_recorded_woff2_type_suppresses_compression() {
let mut bytes = b"wOF2".to_vec();
bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
let tmp = create_ssg_dist_with_types(&[(
"/inter",
"inter/index.html",
&bytes,
Some("font/woff2"),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/inter")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("font/woff2"),
);
assert_eq!(
response.headers().get(http::header::CONTENT_ENCODING),
None,
"a recorded pre-compressed font type must suppress compression that the derived text/html would have allowed"
);
}
#[tokio::test]
async fn ssg_recorded_text_type_enables_compression_derivation_would_refuse() {
let text = "log line ".repeat(256);
let tmp = create_ssg_dist_with_types(&[(
"/data",
"data.bin",
text.as_bytes(),
Some("text/plain; charset=utf-8"),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/data")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/plain; charset=utf-8"),
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"the recorded text type must enable compression the octet-stream derivation would have refused"
);
}
#[tokio::test]
async fn ssg_manifest_route_with_missing_file_falls_through_to_dynamic_router() {
async fn dynamic() -> impl axum::response::IntoResponse {
(
[(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
"<h1>dynamic</h1>".to_owned(),
)
}
let route = Route {
method: http::Method::GET,
path: "/feed",
handler: axum::routing::get(dynamic),
name: "feed",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/feed",
operation_id: "feed",
success_status: 200,
..Default::default()
},
api_version: None,
sunset_opt_out: false,
repository: None,
idempotency: crate::route::RouteIdempotency::default(),
timeout: crate::route::RouteTimeout::default(),
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
};
let tmp = create_ssg_dist_with_types(&[(
"/feed",
"feed/index.html",
b"<rss/>",
Some("application/rss+xml"),
)]);
let dist = tmp.path().join("dist");
std::fs::remove_file(dist.join("feed/index.html")).expect("remove generated file");
let router = try_build_router_with_static(
vec![route],
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/html; charset=utf-8"),
"a manifest miss on disk must serve the dynamic handler's own type, not the recorded one"
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body.as_ref(), b"<h1>dynamic</h1>");
}
#[tokio::test]
async fn ssg_trailing_slash_request_serves_recorded_content_type() {
let body_text = format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(64));
let tmp = create_ssg_dist_with_types(&[(
"/robots.txt",
"robots.txt/index.html",
body_text.as_bytes(),
Some("text/plain; charset=utf-8"),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/robots.txt/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("text/plain; charset=utf-8"),
"the normalized path drives both the manifest lookup and the header"
);
}
#[tokio::test]
async fn ssg_head_request_carries_recorded_content_type() {
let tmp = create_ssg_dist_with_types(&[(
"/feed",
"feed/index.html",
b"<rss/>",
Some("application/rss+xml"),
)]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.method(http::Method::HEAD)
.uri("/feed")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/rss+xml")
);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert!(body.is_empty(), "HEAD response must have an empty body");
}
#[tokio::test]
#[allow(clippy::too_many_lines)] async fn ssg_generated_manifest_round_trips_content_types_end_to_end() {
async fn robots() -> impl axum::response::IntoResponse {
(
[(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(128)),
)
}
async fn sitemap() -> impl axum::response::IntoResponse {
(
[(http::header::CONTENT_TYPE, "application/xml")],
format!(
"<?xml version=\"1.0\"?><urlset>{}</urlset>",
"<url><loc>https://example.com/</loc></url>".repeat(16)
),
)
}
async fn release_notes() -> impl axum::response::IntoResponse {
axum::response::Html(format!(
"<html><body>{}</body></html>",
"Release notes. ".repeat(128)
))
}
async fn feed() -> impl axum::response::IntoResponse {
(
[(http::header::CONTENT_TYPE, "application/rss+xml")],
format!("<rss><channel>{}</channel></rss>", "<item/>".repeat(64)),
)
}
async fn notes() -> impl axum::response::IntoResponse {
(
[(http::header::CONTENT_TYPE, "application/json")],
format!(
r#"{{"notes":[{}]}}"#,
r#""note","#.repeat(32).trim_end_matches(',')
),
)
}
async fn profile() -> impl axum::response::IntoResponse {
axum::response::Html(format!(
"<html><body>{}</body></html>",
"Alice. ".repeat(128)
))
}
async fn about() -> impl axum::response::IntoResponse {
"About us. ".repeat(128)
}
fn meta(path: &'static str, name: &'static str) -> crate::static_gen::StaticRouteMeta {
crate::static_gen::StaticRouteMeta {
path,
name,
revalidate: None,
params_fn: None,
seo: crate::seo::SeoRouteDefaults::EMPTY,
content_type: None,
}
}
let build_router = axum::Router::new()
.route("/robots.txt", axum::routing::get(robots))
.route("/sitemap.xml", axum::routing::get(sitemap))
.route("/posts/release.v1", axum::routing::get(release_notes))
.route("/feed", axum::routing::get(feed))
.route("/notes.txt", axum::routing::get(notes))
.route("/users/alice@example.com", axum::routing::get(profile))
.route("/about", axum::routing::get(about));
let tmp = tempfile::tempdir().expect("tempdir");
let dist = tmp.path().join("dist");
crate::static_gen::render_static_routes(
build_router,
&[
meta("/robots.txt", "robots"),
meta("/sitemap.xml", "sitemap"),
meta("/posts/release.v1", "release_notes"),
meta("/feed", "feed"),
meta("/notes.txt", "notes"),
meta("/users/alice@example.com", "profile"),
meta("/about", "about"),
],
&dist,
)
.await
.expect("static build succeeds");
for file in [
"robots.txt/index.html",
"sitemap.xml/index.html",
"posts/release.v1/index.html",
"feed/index.html",
"notes.txt/index.html",
"users/alice@example.com/index.html",
"about/index.html",
] {
assert!(dist.join(file).is_file(), "{file} must have been generated");
}
let expected = [
("/robots.txt", "text/plain; charset=utf-8", true),
("/sitemap.xml", "application/xml", true),
("/posts/release.v1", "text/html; charset=utf-8", true),
("/feed", "application/rss+xml", true),
("/notes.txt", "application/json", true),
("/users/alice@example.com", "text/html; charset=utf-8", true),
("/about", "text/plain; charset=utf-8", true),
];
let manifest =
crate::static_gen::StaticManifest::load(&dist.join("manifest.json")).expect("manifest");
for (route, content_type, _) in expected {
assert_eq!(
manifest.routes[route].content_type.as_deref(),
Some(content_type),
"{route} must carry its declared type in the manifest"
);
}
for (route, content_type, compressible) in expected {
let router = try_build_router_with_static(
Vec::new(),
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri(route)
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{route}");
assert_eq!(
response
.headers()
.get(http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some(content_type),
"{route} must be served as the type its handler declared at build time"
);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok())
== Some("gzip"),
compressible,
"{route}: the recorded type must drive compression negotiation"
);
}
}
#[tokio::test]
async fn ssg_dynamic_fallback_route_is_gzip_compressed() {
async fn dynamic() -> impl axum::response::IntoResponse {
(
[(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
format!(
"<html><body>{}</body></html>",
"dynamic content ".repeat(64)
),
)
}
let route = Route {
method: http::Method::GET,
path: "/dynamic",
handler: axum::routing::get(dynamic),
name: "dynamic",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/dynamic",
operation_id: "dynamic",
success_status: 200,
..Default::default()
},
api_version: None,
sunset_opt_out: false,
repository: None,
idempotency: crate::route::RouteIdempotency::default(),
timeout: crate::route::RouteTimeout::default(),
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
};
let tmp = create_ssg_dist(&[("/", "index.html", b"<h1>home</h1>")]);
let dist = tmp.path().join("dist");
let router = try_build_router_with_static(
vec![route],
&compression_enabled_config(),
test_state(),
Some(&dist),
)
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/dynamic")
.header("accept-encoding", "gzip")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(http::header::CONTENT_ENCODING)
.and_then(|v| v.to_str().ok()),
Some("gzip"),
"dynamic fallback route must be compressed just like SSG pages"
);
}
fn create_static_dist(revalidate: Option<u64>) -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
let dist = dir.path().join("dist");
std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
std::fs::write(dist.join("index.html"), b"<h1>Home</h1>").expect("write index");
std::fs::write(dist.join("about/index.html"), b"<h1>About</h1>").expect("write about");
let mut routes = std::collections::HashMap::new();
routes.insert(
"/".to_owned(),
crate::static_gen::ManifestEntry::new("index.html".to_owned()),
);
routes.insert(
"/about".to_owned(),
crate::static_gen::ManifestEntry::new("about/index.html".to_owned())
.with_revalidate(revalidate),
);
let manifest = crate::static_gen::StaticManifest {
generated_at: "2026-05-18T00:00:00Z".to_owned(),
autumn_version: "0.5.0".to_owned(),
routes,
};
let json = serde_json::to_string(&manifest).expect("serialize manifest");
std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
dir
}
#[tokio::test]
async fn static_serving_serves_get_request_inside_user_layers() {
let tmp = create_static_dist(None);
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/about")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(body.as_ref(), b"<h1>About</h1>");
}
#[tokio::test]
async fn static_serving_serves_head_request() {
let tmp = create_static_dist(None);
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.method("HEAD")
.uri("/about")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert!(body.is_empty(), "HEAD response body should be empty");
}
#[tokio::test]
async fn a_markdown_head_of_a_static_page_mirrors_the_get() {
let tmp = create_static_dist(None);
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds");
let send = |method: &str| {
router.clone().oneshot(
Request::builder()
.method(method)
.uri("/about")
.header("accept", "text/markdown")
.body(Body::empty())
.unwrap(),
)
};
let get = send("GET").await.unwrap();
let head = send("HEAD").await.unwrap();
let header = |res: &axum::response::Response, name: &str| {
res.headers()
.get(name)
.map(|v| v.to_str().unwrap().to_owned())
};
assert!(
header(&get, "content-type")
.unwrap()
.starts_with("text/markdown")
);
assert_eq!(header(&head, "content-type"), header(&get, "content-type"));
assert_eq!(
header(&head, "x-markdown-tokens"),
header(&get, "x-markdown-tokens")
);
assert!(header(&head, "x-markdown-tokens").is_some());
let head_length = header(&head, "content-length");
let get_body = axum::body::to_bytes(get.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(head_length, Some(get_body.len().to_string()));
}
#[tokio::test]
async fn static_serving_normalizes_trailing_slash() {
let tmp = create_static_dist(None);
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/about/")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn static_serving_falls_through_for_unknown_route() {
let tmp = create_static_dist(None);
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds");
let response = router
.oneshot(
Request::builder()
.uri("/not-in-manifest")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn static_serving_skipped_when_no_manifest() {
let tmp = tempfile::tempdir().expect("tempdir");
let dist = tmp.path().join("dist");
std::fs::create_dir_all(&dist).expect("mkdir dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router builds even without manifest");
let response = router
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn static_serving_with_isr_manifest_builds_successfully() {
let tmp = create_static_dist(Some(3600));
let dist = tmp.path().join("dist");
let config = AutumnConfig::default();
let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
.expect("router with ISR manifest should build");
let response = router
.oneshot(
Request::builder()
.uri("/about")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
}
#[cfg(test)]
mod trusted_host_tests {
use super::*;
use axum::body::Body;
use http::Request;
use tower::util::ServiceExt;
#[tokio::test]
async fn trusted_host_allows_matching_and_blocks_nonmatching() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into(), ".example.com".into()];
let state = crate::state::AppState::for_test();
let router = build_router(vec![], &cfg, state);
let ok = router
.clone()
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "api.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(ok.status(), StatusCode::NOT_FOUND);
let blocked = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "evil.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(blocked.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_wildcard_allows_any_host() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["*".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "anything.example")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_bypasses_probe_paths() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/actuator/health")
.header("host", "evil.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn trusted_host_bypasses_actuator_health_path() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/actuator/health")
.header("host", "evil.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::OK);
}
#[test]
fn probe_bypass_paths_is_the_single_source_for_trusted_host_and_startup_barrier() {
let mut cfg = AutumnConfig::default();
cfg.health.path = "/custom-health-check".into();
let expected = probe_bypass_paths(&cfg);
assert!(expected.contains(&"/custom-health-check".to_string()));
let trusted_host = TrustedHostPolicy::from_config(&cfg);
for path in &expected {
assert!(
trusted_host.probe_bypass_paths.contains(path),
"TrustedHostPolicy must derive its bypass set from probe_bypass_paths(): missing {path}"
);
}
let state = crate::state::AppState::for_test();
let barrier = StartupBarrierState::from_config(&cfg, &state);
for path in &expected {
assert!(
barrier.allows_path(path),
"StartupBarrierState must derive its bypass set from probe_bypass_paths(): missing {path}"
);
}
}
#[cfg(feature = "http-client")]
#[test]
fn startup_barrier_allows_webhook_replay_post_path() {
let mut cfg = AutumnConfig::default();
cfg.actuator.sensitive = true;
let state = crate::state::AppState::for_test();
let barrier = StartupBarrierState::from_config(&cfg, &state);
let replay_path =
crate::actuator::actuator_route_path(&cfg.actuator.prefix, "/webhooks/replay");
assert!(
barrier.allows_path(&replay_path),
"startup barrier must allow {replay_path} to bypass admission"
);
}
#[tokio::test]
async fn ready_probe_stays_green_while_maintenance_mode_is_active() {
let cfg = AutumnConfig::default();
let state = crate::state::AppState::for_test();
state.probes().mark_startup_complete();
let maintenance = crate::maintenance::MaintenanceState::new();
maintenance.enable(crate::maintenance::MaintenanceConfig::default());
state.insert_extension(maintenance);
let router = build_router(vec![], &cfg, state);
let ready = router
.clone()
.oneshot(
Request::builder()
.uri(&cfg.health.ready_path)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
ready.status(),
StatusCode::OK,
"/ready must BYPASS maintenance mode: it is the load balancer's health \
signal, and gating it would eject every host from rotation the moment \
maintenance is enabled (#1621)"
);
let app_route = router
.oneshot(
Request::builder()
.uri("/some-app-route")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
app_route.status(),
StatusCode::SERVICE_UNAVAILABLE,
"maintenance mode must still gate non-probe traffic"
);
}
#[tokio::test]
async fn trusted_host_release_rejects_loopback_unless_listed() {
let mut cfg = AutumnConfig {
profile: Some("prod".into()),
..AutumnConfig::default()
};
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "localhost")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_uses_uri_authority_when_host_header_missing() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("http://EXAMPLE.COM/nope")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_accepts_bracketed_ipv6_loopback_in_dev() {
let cfg = AutumnConfig::default();
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "[::1]:3000")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_matching_is_case_insensitive() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "EXAMPLE.COM")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_rejects_malformed_port() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "example.com:abc")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_rejects_empty_port_suffix() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "example.com:")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_rejects_bracketed_reg_name() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "[example.com]")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_configured_trailing_dot_matches_normalized_host() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com.".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "example.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_accepts_trailing_dot_fqdn() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "example.com.")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[test]
fn normalize_trusted_host_borrows_an_already_normal_host() {
use std::borrow::Cow;
assert!(matches!(
normalize_trusted_host("example.com"),
Some(Cow::Borrowed("example.com"))
));
assert!(matches!(
normalize_trusted_host("example.com."),
Some(Cow::Borrowed("example.com"))
));
assert!(matches!(
normalize_trusted_host("::1"),
Some(Cow::Borrowed("::1"))
));
assert!(matches!(
normalize_trusted_host("API.Example.COM"),
Some(Cow::Owned(_))
));
assert_eq!(
normalize_trusted_host("API.Example.COM..").as_deref(),
Some("api.example.com")
);
assert!(normalize_trusted_host("...").is_none());
assert!(normalize_trusted_host("").is_none());
}
#[tokio::test]
async fn trusted_host_mixed_case_trailing_dot_matches_subdomain_rule() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec![".example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "API.Example.COM.")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn trusted_host_wildcard_still_rejects_dots_only_host() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["*".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.uri("/nope")
.header("host", "...")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_bypasses_custom_probe_path_only() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
cfg.health.path = "/healthz".into();
cfg.health.startup_path = "/startupz".into();
cfg.health.ready_path = "/readyz".into();
cfg.health.live_path = "/livez".into();
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let bypassed = router
.clone()
.oneshot(
Request::builder()
.uri("/healthz")
.header("host", "evil.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(bypassed.status(), StatusCode::OK);
let not_bypassed = router
.oneshot(
Request::builder()
.uri("/health")
.header("host", "evil.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(not_bypassed.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn trusted_host_does_not_bypass_non_get_probe_path_requests() {
let mut cfg = AutumnConfig::default();
cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/health")
.header("host", "evil.com")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should complete");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn apply_upload_middleware_rejects_oversized_json_body() {
let mut config = AutumnConfig::default();
config.security.upload.max_request_size_bytes = 100;
let base: axum::Router<AppState> = axum::Router::new().route(
"/data",
axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
);
let router =
apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
let big_body = "x".repeat(200);
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/data")
.header("content-type", "application/json")
.body(Body::from(big_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::PAYLOAD_TOO_LARGE,
"oversized body must be rejected with 413 regardless of content type"
);
}
#[tokio::test]
async fn apply_upload_middleware_accepts_body_within_limit() {
let mut config = AutumnConfig::default();
config.security.upload.max_request_size_bytes = 1024;
let base: axum::Router<AppState> = axum::Router::new().route(
"/data",
axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
);
let router =
apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
let response = router
.oneshot(
Request::builder()
.method("POST")
.uri("/data")
.header("content-type", "application/json")
.body(Body::from("hello"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
fn no_route_timeouts() -> RouteTimeoutTable {
std::sync::Arc::new(std::collections::HashMap::new())
}
fn get_route_timeouts(path: &str, timeout: crate::route::RouteTimeout) -> RouteTimeoutTable {
let mut by_method = std::collections::HashMap::new();
by_method.insert(http::Method::GET, timeout);
let mut table = std::collections::HashMap::new();
table.insert(path.to_owned(), by_method);
std::sync::Arc::new(table)
}
#[tokio::test(start_paused = true)]
async fn request_timeout_returns_503_when_exceeded() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
.layer(RequestIdLayer::default())
.with_state(state);
let response = router
.oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a slow handler must trigger 503"
);
assert_eq!(
response
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok()),
Some("application/problem+json"),
"timeout response must use Problem Details content type"
);
}
#[derive(Clone)]
struct LogBuf(Arc<std::sync::Mutex<Vec<u8>>>);
impl std::io::Write for LogBuf {
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
if let Ok(mut held) = self.0.lock() {
held.extend_from_slice(bytes);
}
Ok(bytes.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogBuf {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
impl LogBuf {
fn install() -> (Self, tracing::subscriber::DefaultGuard) {
let buf = Self(Arc::default());
let subscriber = tracing_subscriber::fmt()
.with_writer(buf.clone())
.with_ansi(false)
.finish();
(buf, tracing::subscriber::set_default(subscriber))
}
fn text(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}
#[tokio::test(start_paused = true)]
async fn request_timeout_warn_names_the_route_template() {
let (logs, _guard) = LogBuf::install();
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow/{id}",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
.layer(RequestIdLayer::default())
.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/slow/7")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let request_id = response.headers()["x-request-id"].to_str().unwrap();
let out = logs.text();
assert!(out.contains("inbound request exceeded deadline"), "{out}");
assert!(out.contains(r#"route="/slow/{id}""#), "{out}");
assert!(
out.contains(&format!(r#"request_id="{request_id}""#)),
"{out}"
);
assert!(!out.contains("/slow/7"), "{out}");
}
#[tokio::test(start_paused = true)]
async fn zero_caller_deadline_warn_names_the_route_template() {
let (logs, _guard) = LogBuf::install();
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
config.server.timeouts.accept_deadline_header = true;
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> =
axum::Router::new().route("/slow/{id}", axum::routing::get(|| async { "ok" }));
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/slow/7")
.header(crate::deadline::DEADLINE_HEADER, "0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let out = logs.text();
assert!(out.contains(r#"route="/slow/{id}""#), "{out}");
assert!(!out.contains("/slow/7"), "{out}");
}
#[tokio::test(start_paused = true)]
async fn request_timeout_takes_the_time_left_in_a_fault_scope() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(1000);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
"ok"
}),
);
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
.with_state(state);
let request = Request::builder().uri("/slow").body(Body::empty()).unwrap();
let response = crate::fault_injection::with_request_budget(
std::time::Duration::from_millis(200),
router.oneshot(request),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_increments_metric() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
.layer(RequestIdLayer::default())
.with_state(state.clone());
router
.oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
.await
.unwrap();
let snap = state.metrics.snapshot();
assert_eq!(
snap.http.request_timeouts_total, 1,
"autumn_request_timeouts_total must be incremented on timeout"
);
}
#[tokio::test(start_paused = true)]
async fn render_deadline_exempt_marker_skips_timeout() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
.layer(RequestIdLayer::default())
.with_state(state);
let live = router
.clone()
.oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
live.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a live request to a slow handler must still time out"
);
let exempt = router
.oneshot(
Request::builder()
.uri("/slow")
.extension(crate::static_gen::RenderDeadlineExempt)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
exempt.status(),
StatusCode::OK,
"the build/ISR render marker must exempt the request from the deadline"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_503_mirrors_cors_headers() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
config.cors.allow_credentials = true;
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), true)
.layer(RequestIdLayer::default())
.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/slow")
.header("origin", "https://app.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response
.headers()
.get("access-control-allow-origin")
.and_then(|v| v.to_str().ok()),
Some("https://app.example.com"),
"an allowed origin must be reflected on the timeout 503 so browsers can read it"
);
assert_eq!(
response
.headers()
.get("access-control-allow-credentials")
.and_then(|v| v.to_str().ok()),
Some("true"),
"credentials flag must be mirrored when configured"
);
assert!(
response
.headers()
.get_all("vary")
.iter()
.any(|v| v.to_str().is_ok_and(|s| s.eq_ignore_ascii_case("origin"))),
"a reflected origin must carry Vary: origin"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_503_omits_cors_for_disallowed_origin() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), true)
.layer(RequestIdLayer::default())
.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/slow")
.header("origin", "https://evil.example.com")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(
response
.headers()
.get("access-control-allow-origin")
.is_none(),
"a disallowed origin must not be reflected, mirroring CorsLayer"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_response_includes_request_id_header() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
.layer(RequestIdLayer::default())
.with_state(state);
let response = router
.oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(
response.headers().contains_key("x-request-id"),
"503 response must carry the X-Request-Id header"
);
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
assert_eq!(body["status"], 503);
}
#[tokio::test]
async fn request_timeout_disabled_when_none() {
let config = AutumnConfig::default();
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> =
axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
.with_state(state);
let response = router
.oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn request_timeout_zero_treated_as_disabled() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(0);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> =
axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
.with_state(state);
let response = router
.oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_503_without_request_id_layer() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/slow",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"ok"
}),
);
let router =
apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
.with_state(state);
let response = router
.oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_per_route_override_extends_deadline() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/export",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
"report"
}),
);
let table = get_route_timeouts(
"/export",
crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10)),
);
let router = apply_request_timeout_middleware(router, &config, &state, table, false)
.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/export")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::OK,
"the override must let the slow route complete past the global deadline"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_per_route_disabled_exempts_route() {
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = Some(100);
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/stream",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
"done"
}),
);
let table = get_route_timeouts("/stream", crate::route::RouteTimeout::Disabled);
let router = apply_request_timeout_middleware(router, &config, &state, table, false)
.with_state(state.clone());
let response = router
.oneshot(
Request::builder()
.uri("/stream")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
state.metrics.snapshot().http.request_timeouts_total,
0,
"an exempt route must not record a timeout"
);
}
#[tokio::test(start_paused = true)]
async fn request_timeout_override_active_when_global_disabled() {
let config = AutumnConfig::default();
let state = crate::state::AppState::for_test();
let router: axum::Router<AppState> = axum::Router::new().route(
"/export",
axum::routing::get(|| async {
tokio::time::sleep(std::time::Duration::from_secs(60)).await;
"report"
}),
);
let table = get_route_timeouts(
"/export",
crate::route::RouteTimeout::Override(std::time::Duration::from_millis(100)),
);
let router = apply_request_timeout_middleware(router, &config, &state, table, false)
.with_state(state);
let response = router
.oneshot(
Request::builder()
.uri("/export")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::SERVICE_UNAVAILABLE,
"a per-route override must be enforced even with the global timeout off"
);
}
#[test]
fn build_route_timeout_table_is_empty_without_routes() {
let table = build_route_timeout_table(&[], &[], &AutumnConfig::default());
assert!(table.is_empty(), "no routes ⇒ empty override table");
}
fn timeout_route(
method: http::Method,
path: &'static str,
timeout: crate::route::RouteTimeout,
) -> Route {
async fn noop() -> &'static str {
"ok"
}
Route {
method,
path,
handler: axum::routing::get(noop),
name: "noop",
api_doc: crate::openapi::ApiDoc::default(),
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout,
criticality: crate::admission::Criticality::Default,
api_version: None,
sunset_opt_out: false,
seo: crate::seo::SeoRouteDefaults::EMPTY,
}
}
#[test]
fn build_route_timeout_table_normalizes_method_aliases() {
let override_10s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10));
let routes = vec![
timeout_route(http::Method::GET, "/export", override_10s),
timeout_route(
http::Method::from_bytes(b"WS").unwrap(),
"/live",
crate::route::RouteTimeout::Disabled,
),
timeout_route(http::Method::POST, "/submit", override_10s),
];
let table = build_route_timeout_table(&routes, &[], &AutumnConfig::default());
let export = table.get("/export").expect("/export keyed");
assert_eq!(export.get(&http::Method::GET), Some(&override_10s));
assert_eq!(
export.get(&http::Method::HEAD),
Some(&override_10s),
"a GET override must also cover the HEAD alias axum serves"
);
let live = table.get("/live").expect("/live keyed");
assert_eq!(
live.get(&http::Method::GET),
Some(&crate::route::RouteTimeout::Disabled),
"a WS override must be keyed under the GET the upgrade arrives as"
);
assert!(
live.get(&http::Method::from_bytes(b"WS").unwrap())
.is_none(),
"the synthetic WS method is never seen at lookup time"
);
let submit = table.get("/submit").expect("/submit keyed");
assert_eq!(submit.get(&http::Method::POST), Some(&override_10s));
assert!(submit.get(&http::Method::HEAD).is_none());
}
#[test]
fn build_route_timeout_table_keys_scoped_root_by_axum_matched_path() {
let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
let make_group = |prefix: &str| crate::app::ScopedGroup {
prefix: prefix.to_owned(),
routes: vec![timeout_route(http::Method::GET, "/", override_5s)],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let table =
build_route_timeout_table(&[], &[make_group("/api/")], &AutumnConfig::default());
assert_eq!(
table.get("/api/").and_then(|m| m.get(&http::Method::GET)),
Some(&override_5s),
"trailing-slash scoped root must key the override at /api/"
);
assert!(
table.get("/api").is_none(),
"the stripped /api key would never match the runtime lookup"
);
let table = build_route_timeout_table(&[], &[make_group("/api")], &AutumnConfig::default());
assert_eq!(
table.get("/api").and_then(|m| m.get(&http::Method::GET)),
Some(&override_5s),
);
}
#[test]
fn build_route_criticality_table_keys_scoped_routes_and_head() {
let mut shed = timeout_route(
http::Method::GET,
"/export",
crate::route::RouteTimeout::Inherit,
);
shed.criticality = crate::admission::Criticality::Sheddable;
let plain = timeout_route(
http::Method::POST,
"/export",
crate::route::RouteTimeout::Inherit,
);
let group = crate::app::ScopedGroup {
prefix: "/admin".to_owned(),
routes: vec![{
let mut r = timeout_route(
http::Method::POST,
"/pay",
crate::route::RouteTimeout::Inherit,
);
r.criticality = crate::admission::Criticality::Critical;
r
}],
source: crate::route_listing::RouteSource::User,
layer_guarded: false,
apply_layer: Box::new(|r| r),
};
let table =
build_route_criticality_table(&[shed, plain], &[group], &AutumnConfig::default());
let get = |p: &str, m: http::Method| table.get(p).and_then(|t| t.get(&m)).copied();
assert_eq!(
get("/export", http::Method::GET),
Some(crate::admission::Criticality::Sheddable)
);
assert_eq!(
get("/export", http::Method::HEAD),
Some(crate::admission::Criticality::Sheddable),
"a GET route is also served for HEAD"
);
assert_eq!(
get("/export", http::Method::POST),
None,
"default gets no entry"
);
assert_eq!(
get("/admin/pay", http::Method::POST),
Some(crate::admission::Criticality::Critical)
);
assert!(
build_route_criticality_table(&[], &[], &AutumnConfig::default()).is_empty(),
"no criticality routes: empty table, so a passive layer"
);
}
#[test]
fn timeout_off_only_routes_install_the_service_for_adaptive_admission() {
let mut by_method = std::collections::HashMap::new();
by_method.insert(http::Method::GET, crate::route::RouteTimeout::Disabled);
let mut table = std::collections::HashMap::new();
table.insert("/poll".to_owned(), by_method);
let table: RouteTimeoutTable = Arc::new(table);
let state = AppState::for_test();
let build = |config: &AutumnConfig| {
build_request_timeout_settings(config, &state, table.clone(), false).is_some()
};
let mut config = AutumnConfig::default();
config.server.timeouts.request_timeout_ms = None;
assert!(!build(&config), "static admission: nothing to do");
config.server.admission.mode = crate::config::AdmissionMode::Adaptive;
assert!(build(&config), "adaptive admission needs the Off marker");
}
#[cfg(feature = "i18n")]
#[test]
fn build_route_timeout_table_expands_entries_under_each_locale_prefix() {
let disabled = crate::route::RouteTimeout::Disabled;
let routes = vec![timeout_route(http::Method::GET, "/events", disabled)];
let mut config = AutumnConfig::default();
config.i18n.locale_prefix_enabled = true;
config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
let table = build_route_timeout_table(&routes, &[], &config);
for path in ["/events", "/en/events", "/es/events"] {
assert_eq!(
table.get(path).and_then(|m| m.get(&http::Method::GET)),
Some(&disabled),
"expected a timeout override at {path}"
);
}
}
#[cfg(feature = "i18n")]
#[test]
fn build_route_timeout_table_does_not_expand_excluded_routes() {
let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
let routes = vec![timeout_route(http::Method::GET, "/api/keys", override_5s)];
let mut config = AutumnConfig::default();
config.i18n.locale_prefix_enabled = true;
config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
config.i18n.locale_prefix_exclude = vec!["/api".to_owned()];
let table = build_route_timeout_table(&routes, &[], &config);
assert_eq!(
table
.get("/api/keys")
.and_then(|m| m.get(&http::Method::GET)),
Some(&override_5s)
);
assert!(
table.get("/en/api/keys").is_none(),
"an excluded route must not gain a locale-prefixed timeout entry"
);
}
#[cfg(feature = "i18n")]
#[test]
fn build_route_timeout_table_does_not_expand_when_locale_prefix_disabled() {
let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
let routes = vec![timeout_route(http::Method::GET, "/events", override_5s)];
let mut config = AutumnConfig::default();
assert!(!config.i18n.locale_prefix_enabled);
config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
let table = build_route_timeout_table(&routes, &[], &config);
assert_eq!(table.len(), 1, "only the bare-path entry should exist");
assert!(table.get("/en/events").is_none());
}
fn redirect_gate_registration() -> crate::app::CustomLayerRegistration {
let gate = axum::middleware::from_fn(
|req: axum::extract::Request, next: axum::middleware::Next| async move {
if req.headers().contains_key("x-authed") {
next.run(req).await
} else {
http::Response::builder()
.status(StatusCode::FOUND)
.header(http::header::LOCATION, "/login")
.body(Body::empty())
.unwrap()
}
},
);
crate::app::CustomLayerRegistration {
type_id: std::any::TypeId::of::<()>(),
type_name: "redirect_gate",
layer: tower::util::BoxCloneSyncServiceLayer::new(gate),
}
}
#[test]
fn static_render_partition_drains_user_layers() {
let (kept, drained) =
partition_custom_layers_for_static_render(vec![redirect_gate_registration()]);
assert!(
kept.is_empty(),
"user layers must not survive the static-render drain"
);
assert_eq!(drained.len(), 1);
assert_eq!(drained[0].type_name, "redirect_gate");
}
#[cfg(feature = "i18n")]
#[test]
fn static_render_partition_keeps_the_ambient_locale_layer() {
let ambient = crate::app::CustomLayerRegistration {
type_id: std::any::TypeId::of::<crate::i18n::AmbientLocaleLayer>(),
type_name: "ambient_locale",
layer: tower::util::BoxCloneSyncServiceLayer::new(axum::middleware::from_fn(
|req: axum::extract::Request, next: axum::middleware::Next| async move {
next.run(req).await
},
)),
};
let bundle_ext = crate::app::CustomLayerRegistration {
type_id: std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>(),
type_name: "i18n_bundle_extension",
layer: tower::util::BoxCloneSyncServiceLayer::new(axum::middleware::from_fn(
|req: axum::extract::Request, next: axum::middleware::Next| async move {
next.run(req).await
},
)),
};
let (kept, drained) = partition_custom_layers_for_static_render(vec![
redirect_gate_registration(),
bundle_ext,
ambient,
]);
assert_eq!(
kept.len(),
2,
"the ambient-locale layer and the i18n bundle extension must stay"
);
assert_eq!(kept[0].type_name, "i18n_bundle_extension");
assert_eq!(kept[1].type_name, "ambient_locale");
assert_eq!(drained.len(), 1, "the user layer must drain");
assert_eq!(drained[0].type_name, "redirect_gate");
}
fn build_cached_dist(marker: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let dist = tmp.path().join("dist");
std::fs::create_dir_all(&dist).expect("create dist");
std::fs::write(dist.join("index.html"), marker).expect("write index.html");
let mut routes = std::collections::HashMap::new();
routes.insert(
"/".to_owned(),
crate::static_gen::ManifestEntry::new("index.html".to_owned()),
);
let manifest = crate::static_gen::StaticManifest {
generated_at: "2026-06-14T00:00:00Z".to_owned(),
autumn_version: "0.3.0".to_owned(),
routes,
};
std::fs::write(
dist.join("manifest.json"),
serde_json::to_string(&manifest).expect("serialize manifest"),
)
.expect("write manifest");
(tmp, dist)
}
fn ctx_with_static_gate(gate: crate::app::CustomLayerRegistration) -> RouterContext {
RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: Vec::new(),
static_gate_layers: vec![gate],
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: None,
#[cfg(feature = "webmcp")]
webmcp: None,
}
}
#[tokio::test]
async fn route_fault_reaches_a_cached_static_page() {
let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
let mut config = AutumnConfig {
profile: Some("staging".to_owned()),
..AutumnConfig::default()
};
config.fault_injection.enabled = true;
config.fault_injection.faults = vec![crate::fault_injection::FaultRule::new(
crate::fault_injection::FaultTarget::Route,
crate::fault_injection::FaultKind::Error,
1.0,
)];
let state = crate::state::AppState::for_test();
let app = super::try_build_router_with_static_inner(
Vec::new(),
&config,
state.clone(),
Some(dist.as_path()),
ctx_with_static_gate(redirect_gate_registration()),
)
.expect("router builds");
let response = app
.oneshot(
Request::builder()
.uri("/")
.header("x-authed", "1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let faults = state
.extension::<crate::fault_injection::FaultInjection>()
.expect("the handle is installed");
assert_eq!(faults.snapshot().injected, 1, "one fault, not two");
}
#[tokio::test]
async fn static_gate_runs_before_cached_static_page() {
let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
let config = AutumnConfig::default();
let ctx = ctx_with_static_gate(redirect_gate_registration());
let app = super::try_build_router_with_static_inner(
Vec::new(),
&config,
crate::state::AppState::for_test(),
Some(dist.as_path()),
ctx,
)
.expect("router builds");
let unauthed = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
unauthed.status(),
StatusCode::FOUND,
"static_gate must redirect before the cached page is served"
);
assert_eq!(
unauthed.headers().get(http::header::LOCATION).unwrap(),
"/login"
);
let authed = app
.oneshot(
Request::builder()
.uri("/")
.header("x-authed", "1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(authed.status(), StatusCode::OK);
let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
.await
.unwrap();
assert!(
String::from_utf8_lossy(&body).contains("cached"),
"authenticated request should receive the cached page"
);
}
#[tokio::test]
async fn static_gate_runs_in_dynamic_mode() {
async fn dynamic_handler() -> &'static str {
"dynamic"
}
let route = Route {
method: http::Method::GET,
path: "/",
handler: axum::routing::get(dynamic_handler),
name: "root",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/",
operation_id: "root",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let config = AutumnConfig::default();
let ctx = ctx_with_static_gate(redirect_gate_registration());
let app = super::try_build_router_with_static_inner(
vec![route],
&config,
crate::state::AppState::for_test(),
None,
ctx,
)
.expect("router builds");
let unauthed = app
.clone()
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(unauthed.status(), StatusCode::FOUND);
let authed = app
.oneshot(
Request::builder()
.uri("/")
.header("x-authed", "1")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(authed.status(), StatusCode::OK);
let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(String::from_utf8_lossy(&body), "dynamic");
}
#[cfg(feature = "mcp")]
fn deny_unless_api_key_for_secret_path_registration() -> crate::app::CustomLayerRegistration {
let gate = axum::middleware::from_fn(
|req: axum::extract::Request, next: axum::middleware::Next| async move {
let protected = req.uri().path() == "/secret";
let has_credential = req.headers().get("x-api-key").and_then(|v| v.to_str().ok())
== Some("secret123");
if !protected || has_credential {
next.run(req).await
} else {
http::Response::builder()
.status(StatusCode::UNAUTHORIZED)
.body(Body::empty())
.unwrap()
}
},
);
crate::app::CustomLayerRegistration {
type_id: std::any::TypeId::of::<()>(),
type_name: "deny_unless_api_key_for_secret_path",
layer: tower::util::BoxCloneSyncServiceLayer::new(gate),
}
}
#[cfg(feature = "mcp")]
fn build_empty_dist() -> (tempfile::TempDir, std::path::PathBuf) {
let tmp = tempfile::tempdir().expect("tempdir");
let dist = tmp.path().join("dist");
std::fs::create_dir_all(&dist).expect("create dist");
let manifest = crate::static_gen::StaticManifest {
generated_at: "2026-09-07T00:00:00Z".to_owned(),
autumn_version: "0.3.0".to_owned(),
routes: std::collections::HashMap::new(),
};
std::fs::write(
dist.join("manifest.json"),
serde_json::to_string(&manifest).expect("serialize manifest"),
)
.expect("write manifest");
(tmp, dist)
}
#[cfg(feature = "mcp")]
#[tokio::test]
#[allow(clippy::too_many_lines)]
async fn custom_layer_protects_mcp_dispatch_in_static_mode() {
async fn secret_handler() -> StatusCode {
StatusCode::NO_CONTENT
}
let route = Route {
method: http::Method::GET,
path: "/secret",
handler: axum::routing::get(secret_handler),
name: "secret_tool",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/secret",
operation_id: "secret_tool",
success_status: 204,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let mut config = AutumnConfig::default();
config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: vec![deny_unless_api_key_for_secret_path_registration()],
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: Some(crate::mcp::McpRuntime {
expose_all: true,
..crate::mcp::McpRuntime::new("/mcp")
}),
#[cfg(feature = "webmcp")]
webmcp: None,
};
let (_tmp, dist) = build_empty_dist();
let app = super::try_build_router_with_static_inner(
vec![route],
&config,
crate::state::AppState::for_test(),
Some(dist.as_path()),
ctx,
)
.expect("router builds");
let direct = app
.clone()
.oneshot(
Request::builder()
.uri("/secret")
.header("host", "app.example")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
direct.status(),
StatusCode::UNAUTHORIZED,
"a direct request without the credential must be rejected by the custom layer"
);
let mcp_resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/mcp")
.header("host", "app.example")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "secret_tool", "arguments": {}}
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(mcp_resp.status(), StatusCode::OK);
let body = axum::body::to_bytes(mcp_resp.into_body(), usize::MAX)
.await
.unwrap();
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(
json["result"]["isError"], true,
"the custom AppBuilder::layer() gate that protects the direct route \
must also protect the MCP tools/call replay in static/ISR mode — an \
unauthenticated tool call must not reach the handler: {json}"
);
let text = json["result"]["content"][0]["text"].as_str().unwrap_or("");
assert!(
text.contains("401") || text.to_ascii_lowercase().contains("unauthorized"),
"the tool error should surface the layer's 401 rejection: {json}"
);
}
#[cfg(feature = "mcp")]
#[tokio::test]
async fn custom_layer_runs_exactly_once_per_tools_call_in_static_mode() {
async fn secret_handler() -> StatusCode {
StatusCode::NO_CONTENT
}
use std::sync::atomic::{AtomicUsize, Ordering};
let counter = std::sync::Arc::new(AtomicUsize::new(0));
let counter2 = counter.clone();
let gate = axum::middleware::from_fn(
move |req: axum::extract::Request, next: axum::middleware::Next| {
let counter = counter2.clone();
async move {
counter.fetch_add(1, Ordering::SeqCst);
next.run(req).await
}
},
);
let registration = crate::app::CustomLayerRegistration {
type_id: std::any::TypeId::of::<()>(),
type_name: "counter",
layer: tower::util::BoxCloneSyncServiceLayer::new(gate),
};
let route = Route {
method: http::Method::GET,
path: "/secret",
handler: axum::routing::get(secret_handler),
name: "secret_tool",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/secret",
operation_id: "secret_tool",
success_status: 204,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let mut config = AutumnConfig::default();
config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
let ctx = RouterContext {
exception_filters: Vec::new(),
scoped_groups: Vec::new(),
merge_routers: Vec::new(),
nest_routers: Vec::new(),
declared_routes: Vec::new(),
custom_layers: vec![registration],
static_gate_layers: Vec::new(),
#[cfg(feature = "maud")]
error_page_renderer: None,
session_store: None,
#[cfg(feature = "openapi")]
openapi: None,
#[cfg(feature = "mcp")]
mcp: Some(crate::mcp::McpRuntime {
expose_all: true,
..crate::mcp::McpRuntime::new("/mcp")
}),
#[cfg(feature = "webmcp")]
webmcp: None,
};
let (_tmp, dist) = build_empty_dist();
let app = super::try_build_router_with_static_inner(
vec![route],
&config,
crate::state::AppState::for_test(),
Some(dist.as_path()),
ctx,
)
.expect("router builds");
let _resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/mcp")
.header("host", "app.example")
.header("content-type", "application/json")
.body(Body::from(
serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "secret_tool", "arguments": {}}
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
counter.load(Ordering::SeqCst),
1,
"a custom layer protecting a route must run exactly once per \
tools/call, matching a direct HTTP request — not once for the \
live /mcp envelope and once again for the dispatch replay"
);
}
#[tokio::test]
async fn static_gate_redirect_carries_security_headers_ssg() {
let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
let config = AutumnConfig::default();
let ctx = ctx_with_static_gate(redirect_gate_registration());
let app = super::try_build_router_with_static_inner(
Vec::new(),
&config,
crate::state::AppState::for_test(),
Some(dist.as_path()),
ctx,
)
.expect("router builds");
let unauthed = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(unauthed.status(), StatusCode::FOUND);
assert_eq!(
unauthed
.headers()
.get("x-content-type-options")
.expect("gate redirect must carry security headers"),
"nosniff"
);
}
#[tokio::test]
async fn static_gate_redirect_carries_security_headers_dynamic() {
async fn dynamic_handler() -> &'static str {
"dynamic"
}
let route = Route {
method: http::Method::GET,
path: "/",
handler: axum::routing::get(dynamic_handler),
name: "root",
api_doc: crate::openapi::ApiDoc {
method: "GET",
path: "/",
operation_id: "root",
success_status: 200,
..Default::default()
},
repository: None,
idempotency: crate::route::RouteIdempotency::Direct,
timeout: crate::route::RouteTimeout::Inherit,
criticality: crate::admission::Criticality::Default,
seo: crate::seo::SeoRouteDefaults::EMPTY,
api_version: None,
sunset_opt_out: false,
};
let config = AutumnConfig::default();
let ctx = ctx_with_static_gate(redirect_gate_registration());
let app = super::try_build_router_with_static_inner(
vec![route],
&config,
crate::state::AppState::for_test(),
None,
ctx,
)
.expect("router builds");
let unauthed = app
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(unauthed.status(), StatusCode::FOUND);
assert_eq!(
unauthed
.headers()
.get("x-content-type-options")
.expect("dynamic gate redirect must carry security headers"),
"nosniff"
);
}
#[test]
fn static_gate_layer_requires_fail_closed_idempotency() {
let gate = vec![redirect_gate_registration()];
assert!(super::custom_layers_require_fail_closed_idempotency(&gate));
assert!(!super::custom_layers_require_fail_closed_idempotency(&[]));
}
#[test]
fn converted_ingress_middleware_futures_are_never_boxed() {
type Inner = tower::util::ServiceFn<
fn(
axum::extract::Request,
)
-> std::future::Ready<Result<axum::response::Response, std::convert::Infallible>>,
>;
type Fut<Svc> = <Svc as tower::Service<axum::extract::Request>>::Future;
fn assert_unboxed<Svc: tower::Service<axum::extract::Request>>(what: &str) {
let name = std::any::type_name::<Fut<Svc>>();
assert!(
!name.contains("Box"),
"{what} must return a named, unboxed future — an \
`axum::middleware::from_fn`-shaped `Box::pin` here is one heap \
allocation on every request (issue #2214). Got: {name}"
);
}
assert_unboxed::<super::TrustedHostService<Inner>>("TrustedHostService");
assert_unboxed::<super::StartupBarrierService<Inner>>("StartupBarrierService");
assert_unboxed::<super::RequestTimeoutService<Inner>>("RequestTimeoutService");
assert_unboxed::<crate::assets::AssetCacheControlService<Inner>>(
"AssetCacheControlService",
);
assert_unboxed::<crate::events::EventAppContextService<Inner>>("EventAppContextService");
assert_unboxed::<crate::read_your_writes::ReadYourWritesService<Inner>>(
"ReadYourWritesService",
);
assert_unboxed::<crate::middleware::method_override::MethodOverrideRejectionService<Inner>>(
"MethodOverrideRejectionService",
);
assert_unboxed::<crate::aeo::negotiate::NegotiateService<Inner>>("NegotiateService");
#[cfg(feature = "oauth2")]
assert_unboxed::<super::HttpInterceptorService<Inner>>("HttpInterceptorService");
}
#[test]
fn webhook_replay_cleanup_boxes_only_its_rare_release_branch() {
use std::mem::size_of;
type Ready = std::future::Ready<Result<axum::response::Response, std::convert::Infallible>>;
type Scoped = tokio::task::futures::TaskLocalFuture<crate::webhook::ReplayStoreCell, Ready>;
let serving_inline = size_of::<Scoped>()
+ size_of::<crate::webhook::ReplayStoreCell>()
+ size_of::<crate::webhook::ReplayKeyGuard>();
let whole = size_of::<crate::webhook::WebhookReplayCleanupFuture<Ready>>();
assert!(
whole >= serving_inline,
"WebhookReplayCleanupFuture must hold the scoped inner future, the \
replay cell and the drop guard inline in its `Serving` variant \
({serving_inline} bytes together), but the whole future is only \
{whole} bytes — the inner future has been boxed, which costs an \
allocation on every request rather than only on a failed webhook \
delivery (issue #2214)"
);
}
#[tokio::test]
async fn startup_barrier_refuses_requests_until_startup_completes() {
use tower::{Layer, ServiceExt};
fn ok_service() -> impl tower::Service<
axum::extract::Request,
Response = axum::response::Response,
Error = std::convert::Infallible,
> + Clone {
tower::service_fn(|_req: axum::extract::Request| async move {
Ok::<_, std::convert::Infallible>("handler ran".into_response())
})
}
#[allow(clippy::future_not_send)]
async fn status_for(startup_complete: bool, path: &str) -> (StatusCode, String) {
let state = AppState::for_test().with_startup_complete(startup_complete);
let config = AutumnConfig::default();
let layer = super::StartupBarrierLayer::new(super::StartupBarrierState::from_config(
&config, &state,
));
let response = layer
.layer(ok_service())
.oneshot(
axum::extract::Request::builder()
.uri(path)
.body(Body::empty())
.expect("request builds"),
)
.await
.expect("infallible");
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
.await
.expect("body collects");
(status, String::from_utf8_lossy(&body).into_owned())
}
let (status, body) = status_for(false, "/anything").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(body, "Service is still starting up");
let (status, body) = status_for(true, "/anything").await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, "handler ran");
let (status, _) = status_for(false, "/actuator/health").await;
assert_ne!(
status,
StatusCode::SERVICE_UNAVAILABLE,
"probe paths must bypass the startup barrier"
);
}
}
#[derive(Clone, Debug)]
pub struct TrustedHostPolicy {
rules: Arc<Vec<String>>,
allow_any: bool,
allow_missing_host: bool,
probe_bypass_paths: Arc<std::collections::HashSet<String>>,
custom_domains: Option<crate::state::LateExtensions>,
}
impl TrustedHostPolicy {
pub fn from_config(config: &AutumnConfig) -> Self {
let mut rules: Vec<String> = config
.security
.trusted_hosts
.hosts
.iter()
.map(|h| h.trim().to_ascii_lowercase())
.map(|h| h.trim_end_matches('.').to_owned())
.filter(|h| !h.is_empty())
.collect();
let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
if !is_production {
rules.extend(
["localhost", "127.0.0.1", "::1"]
.into_iter()
.map(std::borrow::ToOwned::to_owned),
);
}
let allow_any = rules.iter().any(|h| h == "*");
let probe_bypass_paths = probe_bypass_paths(config).into_iter().collect();
Self {
rules: Arc::new(rules),
allow_any,
allow_missing_host: !is_production,
probe_bypass_paths: Arc::new(probe_bypass_paths),
custom_domains: None,
}
}
pub(crate) fn from_config_with_state(config: &AutumnConfig, state: &AppState) -> Self {
Self {
custom_domains: Some(state.late_extensions()),
..Self::from_config(config)
}
}
#[cfg(feature = "mcp")]
pub const fn allows_missing_host(&self) -> bool {
self.allow_missing_host
}
pub fn allows_host(&self, host: &str) -> bool {
if self.allow_any {
return true;
}
let matches_rule = self.rules.iter().any(|rule| {
rule.strip_prefix('.').map_or_else(
|| host == rule,
|suffix| {
host == suffix
|| host
.strip_suffix(suffix)
.is_some_and(|prefix| prefix.ends_with('.'))
},
)
});
matches_rule || self.is_connected_domain(host)
}
fn is_connected_domain(&self, host: &str) -> bool {
self.custom_domains.as_ref().is_some_and(|extensions| {
extensions
.get::<Arc<crate::custom_domain::CustomDomainRegistry>>()
.is_some_and(|registry| registry.is_servable(host))
})
}
}
#[derive(Clone, Debug)]
pub struct RouteVersionMetadata {
pub version: String,
pub sunset_opt_out: bool,
pub secured: bool,
pub required_roles: &'static [&'static str],
pub has_policy: bool,
}
async fn api_versioning_middleware(
state: axum::extract::State<AppState>,
route_version: Option<axum::extract::Extension<RouteVersionMetadata>>,
request: axum::http::Request<axum::body::Body>,
next: axum::middleware::Next,
) -> axum::response::Response {
let Some(axum::extract::Extension(meta)) = route_version else {
return next.run(request).await;
};
let clock = state.clock();
let now = clock.now();
let versions = state.extension::<crate::app::RegisteredApiVersions>();
let matching_version = versions
.as_ref()
.and_then(|v| v.0.iter().find(|av| av.version == meta.version));
let Some(version) = matching_version else {
return next.run(request).await;
};
let is_deprecated = version.deprecated_at.is_some_and(|d| now >= d);
let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
if is_sunset && !meta.sunset_opt_out {
if meta.has_policy {
return next.run(request).await;
}
if meta.secured {
let session = request.extensions().get::<crate::session::Session>();
let mut auth_failed = false;
let mut auth_error = None;
if let Some(session) = session {
if let Err(err) = crate::auth::__check_secured_with_key(
session,
state.auth_session_key(),
meta.required_roles,
)
.await
{
auth_failed = true;
auth_error = Some(err);
}
} else {
auth_failed = true;
auth_error = Some(crate::error::AutumnError::unauthorized_msg(
"authentication required",
));
}
if auth_failed {
return auth_error.unwrap().into_response();
}
}
let err = crate::error::AutumnError::gone_msg(format!(
"API version '{}' has been sunsetted.",
meta.version
));
let mut response = err.into_response();
if let Some(sunset) = version.sunset_at {
let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
response.headers_mut().insert("Sunset", val);
}
}
let deprecation_date = match (version.deprecated_at, version.sunset_at) {
(Some(d), Some(s)) => Some(d.min(s)),
(d, s) => d.or(s),
};
if let Some(date) = deprecation_date {
let timestamp = date.timestamp();
if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
response.headers_mut().insert("Deprecation", val);
}
}
return response;
}
let mut response = next.run(request).await;
if is_deprecated || is_sunset {
let deprecation_date = match (version.deprecated_at, version.sunset_at) {
(Some(d), Some(s)) => Some(d.min(s)),
(d, s) => d.or(s),
};
if let Some(date) = deprecation_date {
let timestamp = date.timestamp();
if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
response.headers_mut().insert("Deprecation", val);
}
}
}
if let Some(sunset) = version.sunset_at.filter(|_| is_deprecated || is_sunset) {
let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
response.headers_mut().insert("Sunset", val);
}
}
response
}
#[must_use]
pub fn check_sunset(
state: &crate::state::AppState,
meta: &RouteVersionMetadata,
) -> Option<axum::response::Response> {
let clock = state.clock();
let now = clock.now();
let versions = state.extension::<crate::app::RegisteredApiVersions>();
let matching_version = versions
.as_ref()
.and_then(|v| v.0.iter().find(|av| av.version == meta.version));
let version = matching_version?;
let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
if is_sunset && !meta.sunset_opt_out {
let err = crate::error::AutumnError::gone_msg(format!(
"API version '{}' has been sunsetted.",
meta.version
));
let mut response = axum::response::IntoResponse::into_response(err);
if let Some(sunset) = version.sunset_at {
let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
response.headers_mut().insert("Sunset", val);
}
}
let deprecation_date = match (version.deprecated_at, version.sunset_at) {
(Some(d), Some(s)) => Some(d.min(s)),
(d, s) => d.or(s),
};
if let Some(date) = deprecation_date {
let timestamp = date.timestamp();
if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
response.headers_mut().insert("Deprecation", val);
}
}
return Some(response);
}
None
}
#[cfg(all(test, feature = "htmx"))]
mod framework_scripts_tests {
use super::*;
use http::StatusCode;
use http_body_util::BodyExt;
use tower::ServiceExt as _;
async fn get(path: &str, if_none_match: Option<&str>) -> axum::response::Response {
let mut request = http::Request::builder().uri(path);
if let Some(tag) = if_none_match {
request = request.header(http::header::IF_NONE_MATCH, tag);
}
framework_scripts_router()
.with_state(AppState::for_test())
.oneshot(request.body(axum::body::Body::empty()).unwrap())
.await
.unwrap()
}
fn header(response: &axum::response::Response, name: http::HeaderName) -> &str {
response
.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.unwrap_or("")
}
#[tokio::test]
async fn plain_paths_revalidate_with_a_weak_etag() {
for (path, bytes) in [
(crate::htmx::HTMX_JS_PATH, crate::htmx::HTMX_JS),
(crate::htmx::HTMX_SSE_JS_PATH, crate::htmx::HTMX_SSE_JS),
(crate::htmx::IDIOMORPH_JS_PATH, crate::htmx::IDIOMORPH_JS),
(
crate::htmx::AUTUMN_WIDGETS_JS_PATH,
crate::htmx::AUTUMN_WIDGETS_JS,
),
(
crate::htmx::HTMX_CSRF_JS_PATH,
crate::htmx::HTMX_CSRF_JS.as_bytes(),
),
] {
let response = get(path, None).await;
assert_eq!(response.status(), StatusCode::OK, "{path}");
assert_eq!(
header(&response, http::header::CONTENT_TYPE),
"text/javascript; charset=utf-8",
"{path}"
);
let cc = header(&response, http::header::CACHE_CONTROL);
assert!(cc.contains("must-revalidate"), "{path}: {cc}");
assert!(!cc.contains("immutable"), "{path}: {cc}");
let etag = header(&response, http::header::ETAG);
assert!(etag.starts_with("W/\""), "{path}: {etag}");
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], bytes, "{path}");
}
}
#[tokio::test]
async fn fingerprinted_paths_are_immutable_and_resolved_by_asset_url() {
let mut seen = 0;
for asset in crate::htmx::framework_scripts() {
seen += 1;
assert_eq!(
crate::assets::asset_url(asset.logical_path()),
asset.url(),
"asset_url must return the hashed URL"
);
assert_ne!(asset.url(), asset.plain_url());
let response = get(asset.url(), None).await;
assert_eq!(response.status(), StatusCode::OK, "{}", asset.url());
assert_eq!(
header(&response, http::header::CACHE_CONTROL),
"public, max-age=31536000, immutable"
);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], asset.bytes());
}
let expected = 5 + usize::from(cfg!(feature = "webmcp"));
assert_eq!(
seen, expected,
"htmx, sse, idiomorph, csrf helper, widgets (+ webmcp)"
);
}
#[tokio::test]
async fn revalidation_with_the_current_etag_is_not_modified() {
let first = get(crate::htmx::HTMX_JS_PATH, None).await;
let etag = header(&first, http::header::ETAG).to_owned();
let second = get(crate::htmx::HTMX_JS_PATH, Some(&etag)).await;
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
let body = second.into_body().collect().await.unwrap().to_bytes();
assert!(body.is_empty());
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn join_nested_path_root_child_is_identity(prefix in "/?[a-z0-9/]{0,20}", root in prop::sample::select(vec!["/", ""])) {
let once = join_nested_path(&prefix, root);
let expected = if prefix.is_empty() { "/".to_owned() } else { prefix };
prop_assert_eq!(&once, &expected);
let twice = join_nested_path(&once, root);
prop_assert_eq!(once, twice);
}
#[test]
fn join_nested_path_no_double_slash_at_seam(prefix in "/[a-z0-9]{1,8}/?", child in "/[a-z0-9]{1,8}") {
let joined = join_nested_path(&prefix, &child);
prop_assert!(!joined.contains("//"), "unexpected `//` in {joined:?}");
}
#[test]
fn extract_host_without_port_never_panics(header in ".*") {
if let Some(host) = extract_host_without_port(&header) {
prop_assert!(header.contains(host));
}
}
#[test]
fn path_matches_route_prefix_reflexive(path in ".*") {
prop_assert!(path_matches_route_prefix(&path, &path));
}
#[test]
fn path_matches_route_prefix_boundary(path in "/?[a-z0-9/]{0,24}", prefix in "/?[a-z0-9/]{0,24}") {
if path_matches_route_prefix(&path, &prefix) {
let boundary_ok = path == prefix
|| path.strip_prefix(&prefix).is_some_and(|rest| rest.starts_with('/'));
prop_assert!(boundary_ok, "match without boundary: path={path:?} prefix={prefix:?}");
}
}
}
#[cfg(feature = "openapi")]
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn extract_path_params_never_panics(path in ".*") {
for name in extract_path_params(&path) {
prop_assert!(!name.is_empty());
let has_brace = name.contains('{') || name.contains('}');
prop_assert!(!has_brace, "param name should be brace-free: {name:?}");
}
}
#[test]
fn extract_path_params_brace_inputs_are_brace_free(path in "[{}a-z:]{0,6}") {
for name in extract_path_params(&path) {
prop_assert!(!name.is_empty());
let has_brace = name.contains('{') || name.contains('}');
prop_assert!(!has_brace, "param name should be brace-free for {path:?}: {name:?}");
}
}
}
}