1#![cfg_attr(not(test), deny(clippy::disallowed_methods))]
15
16use std::sync::Arc;
17use std::time::Duration;
18
19use crate::app::ScopedGroup;
20use crate::config::AutumnConfig;
21#[cfg(feature = "maud")]
22use crate::error_pages::{self, SharedRenderer};
23use crate::idempotency::{IdempotencyLayer, IdempotencyStore, MemoryIdempotencyStore};
24use crate::middleware::RequestIdLayer;
25use crate::middleware::dev;
26use crate::middleware::exception_filter::{
27 ExceptionFilter, ExceptionFilterLayer, ProblemDetailsFilter,
28};
29use crate::route::Route;
30use crate::state::AppState;
31use axum::response::IntoResponse;
32use http::{Request, StatusCode};
33use thiserror::Error;
34
35pub const DEFAULT_FAVICON_PATH: &str = "/favicon.ico";
36
37#[derive(Debug, Error, PartialEq, Eq)]
42pub enum RouterBuildError {
43 #[error("invalid session backend configuration: {0}")]
45 InvalidSessionBackend(#[from] crate::session::SessionBackendConfigError),
46 #[error("invalid idempotency backend configuration: {0}")]
48 #[allow(dead_code)] InvalidIdempotencyBackend(String),
50 #[error("invalid submit-token backend configuration: {0}")]
55 InvalidSubmitTokenBackend(String),
56 #[error("framework route overlap at {path}: {existing} conflicts with {incoming}")]
58 FrameworkRouteOverlap {
59 path: String,
61 existing: &'static str,
63 incoming: &'static str,
65 },
66 #[cfg(feature = "openapi")]
70 #[error("invalid OpenAPI {field} path: {value:?} (must start with '/' and be non-empty)")]
71 InvalidOpenApiPath {
72 field: &'static str,
74 value: String,
76 },
77 #[cfg(feature = "openapi")]
81 #[error(
82 "openapi_json_path and swagger_ui_path both resolve to {path:?}; they must differ or `swagger_ui_path` must be `None`"
83 )]
84 DuplicateOpenApiPath {
85 path: String,
87 },
88 #[cfg(feature = "openapi")]
91 #[error(
92 "OpenAPI {field} path {path:?} collides with an existing GET route; choose a different `OpenApiConfig::{field}`"
93 )]
94 OpenApiPathCollision {
95 field: &'static str,
97 path: String,
99 },
100 #[error("route '{route_name}' uses unregistered API version '{version}'")]
102 UnregisteredApiVersion { route_name: String, version: String },
103 #[cfg(feature = "mcp")]
107 #[error("invalid MCP mount path: {value:?} (must start with '/' and be non-empty)")]
108 InvalidMcpPath {
109 value: String,
111 },
112 #[cfg(feature = "mcp")]
117 #[error(
118 "MCP mount path {path:?} collides with an existing {method} route; choose a different `mount_mcp` path"
119 )]
120 McpPathCollision {
121 path: String,
123 method: String,
125 },
126 #[error(
143 "duplicate user route: {existing:?} and {incoming:?} both resolve to {method} {path:?}; \
144 choose a different path for one of them or remove the duplicate registration"
145 )]
146 DuplicateUserRoute {
147 method: String,
149 path: String,
151 existing: String,
153 incoming: String,
156 },
157 #[error(
171 "two routers are nested at {prefix:?} ({owners}); axum gives each nested router a fallback \
172 and cannot merge two that both have one, so the second panics at startup — give each \
173 router its own prefix"
174 )]
175 DuplicateNestPrefix {
176 prefix: String,
178 owners: String,
180 },
181 #[error(
197 "conflicting route shapes: {existing:?} ({existing_path:?}) and {incoming:?} ({incoming_path:?}) \
198 resolve to the same Axum path shape but use different path templates; axum's matchit router \
199 rejects this as a route conflict regardless of HTTP method — rename the captures so both use the \
200 same template, or make their static paths distinct"
201 )]
202 ConflictingRouteShape {
203 existing: String,
205 existing_path: String,
207 incoming: String,
209 incoming_path: String,
211 },
212 #[cfg(feature = "i18n")]
221 #[error(
222 "locale-prefix routing generates {generated:?} (locale {locale:?} + route {path:?}), \
223 which collides with another route already registered at that path — rename one of the \
224 routes, or exclude it via `[i18n] locale_prefix_exclude`"
225 )]
226 LocalePrefixPathCollision {
227 locale: String,
229 path: String,
231 generated: String,
233 },
234}
235
236#[allow(dead_code)]
246pub fn build_router(
247 route_list: Vec<Route>,
248 config: &AutumnConfig,
249 state: AppState,
250) -> axum::Router {
251 try_build_router(route_list, config, state)
252 .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
253}
254
255pub struct RouterContext {
263 pub exception_filters: Vec<Arc<dyn ExceptionFilter>>,
264 pub scoped_groups: Vec<ScopedGroup>,
265 pub merge_routers: Vec<axum::Router<AppState>>,
266 pub nest_routers: Vec<(String, axum::Router<AppState>)>,
267 pub declared_routes: Vec<crate::route_listing::RouteInfo>,
279 pub custom_layers: Vec<crate::app::CustomLayerRegistration>,
292 pub static_gate_layers: Vec<crate::app::CustomLayerRegistration>,
301 #[cfg(feature = "maud")]
302 pub error_page_renderer: Option<SharedRenderer>,
303 pub session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
308 #[cfg(feature = "openapi")]
314 pub openapi: Option<crate::openapi::OpenApiConfig>,
315 #[cfg(feature = "mcp")]
322 pub mcp: Option<crate::mcp::McpRuntime>,
323 #[cfg(feature = "webmcp")]
326 pub webmcp: Option<crate::webmcp::WebMcpRuntime>,
327}
328
329pub fn try_build_router(
337 route_list: Vec<Route>,
338 config: &AutumnConfig,
339 state: AppState,
340) -> Result<axum::Router, RouterBuildError> {
341 try_build_router_with_layers(route_list, config, state, Vec::new())
342}
343
344pub fn try_build_router_with_layers(
362 route_list: Vec<Route>,
363 config: &AutumnConfig,
364 state: AppState,
365 custom_layers: Vec<crate::app::CustomLayerRegistration>,
366) -> Result<axum::Router, RouterBuildError> {
367 let startup_barrier_state = state.clone();
368 let router = try_build_router_inner(
369 route_list,
370 config,
371 state,
372 RouterContext {
373 exception_filters: Vec::new(),
374 scoped_groups: Vec::new(),
375 merge_routers: Vec::new(),
376 nest_routers: Vec::new(),
377 declared_routes: Vec::new(),
378 custom_layers,
379 static_gate_layers: Vec::new(),
380 #[cfg(feature = "maud")]
381 error_page_renderer: None,
382 session_store: None,
383 #[cfg(feature = "openapi")]
384 openapi: None,
385 #[cfg(feature = "mcp")]
386 mcp: None,
387 #[cfg(feature = "webmcp")]
388 webmcp: None,
389 },
390 )?;
391 Ok(apply_startup_barrier(
392 router,
393 config,
394 &startup_barrier_state,
395 ))
396}
397
398#[allow(dead_code)]
409pub fn build_router_merged(
410 route_list: Vec<Route>,
411 config: &AutumnConfig,
412 state: AppState,
413 merge_routers: Vec<axum::Router<AppState>>,
414 nest_routers: Vec<(String, axum::Router<AppState>)>,
415) -> axum::Router {
416 try_build_router_merged(route_list, config, state, merge_routers, nest_routers)
417 .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
418}
419
420#[allow(dead_code)]
428pub fn try_build_router_merged(
429 route_list: Vec<Route>,
430 config: &AutumnConfig,
431 state: AppState,
432 merge_routers: Vec<axum::Router<AppState>>,
433 nest_routers: Vec<(String, axum::Router<AppState>)>,
434) -> Result<axum::Router, RouterBuildError> {
435 let startup_barrier_state = state.clone();
436 let router = try_build_router_inner(
437 route_list,
438 config,
439 state,
440 RouterContext {
441 exception_filters: Vec::new(),
442 scoped_groups: Vec::new(),
443 merge_routers,
444 nest_routers,
445 declared_routes: Vec::new(),
446 custom_layers: Vec::new(),
447 static_gate_layers: Vec::new(),
448 #[cfg(feature = "maud")]
449 error_page_renderer: None,
450 session_store: None,
451 #[cfg(feature = "openapi")]
452 openapi: None,
453 #[cfg(feature = "mcp")]
454 mcp: None,
455 #[cfg(feature = "webmcp")]
456 webmcp: None,
457 },
458 )?;
459 Ok(apply_startup_barrier(
460 router,
461 config,
462 &startup_barrier_state,
463 ))
464}
465
466pub fn try_build_router_inner(
467 route_list: Vec<Route>,
468 config: &AutumnConfig,
469 state: AppState,
470 ctx: RouterContext,
471) -> Result<axum::Router, RouterBuildError> {
472 let (router, deferred_mcp) =
480 build_router_pre_state(route_list, config, &state, ctx, None, false, Vec::new())?;
481 debug_assert!(
482 deferred_mcp.is_none(),
483 "the fully-dynamic path always merges MCP internally"
484 );
485 Ok(router.with_state(state))
486}
487
488pub fn try_build_probe_only_router(
504 config: &AutumnConfig,
505 state: AppState,
506) -> Result<axum::Router, RouterBuildError> {
507 let barrier_state = state.clone();
508 let no_user_routes = std::collections::HashSet::new();
510 let (mounted_probe_paths, router) =
511 mount_probe_endpoints(axum::Router::<AppState>::new(), config, &no_user_routes);
512 let router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
513 let router = router.with_state(state);
514 let router = apply_startup_barrier(router, config, &barrier_state);
515 if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
521 return Ok(router.layer(require_client_cert));
522 }
523 Ok(router)
524}
525
526#[cfg(feature = "mcp")]
529type McpPrepared = (
530 String,
531 Vec<crate::mcp::McpToolInfo>,
532 Option<crate::mcp::McpEndpointLayer>,
533 crate::mcp::McpTaskConfig,
534 Option<String>,
535 crate::mcp::McpCatalog,
536);
537
538#[allow(clippy::too_many_lines)]
543#[cfg_attr(not(feature = "mcp"), allow(clippy::needless_pass_by_value))]
548fn build_router_pre_state(
549 route_list: Vec<Route>,
550 config: &AutumnConfig,
551 state: &AppState,
552 #[cfg_attr(not(feature = "mcp"), allow(unused_mut))] mut ctx: RouterContext,
553 opaque_app_layers_override: Option<bool>,
557 defer_security_headers: bool,
564 #[cfg_attr(not(feature = "mcp"), allow(unused_variables))] mcp_dispatch_extra_layers: Vec<
576 crate::app::CustomLayerRegistration,
577 >,
578) -> Result<
579 (
580 axum::Router<AppState>,
581 Option<axum::Router<AppState>>,
587 ),
588 RouterBuildError,
589> {
590 let versions = state.extension::<crate::app::RegisteredApiVersions>();
592 let registered_versions: std::collections::HashSet<&str> = versions
593 .as_ref()
594 .map(|v| v.0.iter().map(|av| av.version.as_str()).collect())
595 .unwrap_or_default();
596
597 reject_unregistered_api_versions(&route_list, &ctx.scoped_groups, ®istered_versions)?;
598
599 reject_duplicate_user_routes(
606 &route_list,
607 &ctx.scoped_groups,
608 &ctx.merge_routers,
609 &ctx.nest_routers,
610 &ctx.declared_routes,
611 config,
612 )?;
613
614 #[cfg(feature = "openapi")]
617 reject_declared_routes_on_context_mounts(&ctx.declared_routes, ctx.openapi.as_ref(), {
618 #[cfg(feature = "mcp")]
619 {
620 ctx.mcp.as_ref().map(|rt| rt.mount_path.as_str())
621 }
622 #[cfg(not(feature = "mcp"))]
623 {
624 None
625 }
626 })?;
627
628 #[cfg(feature = "openapi")]
632 reject_openapi_path_collisions(
633 ctx.openapi.as_ref(),
634 &route_list,
635 &ctx.scoped_groups,
636 &ctx.merge_routers,
637 &ctx.nest_routers,
638 config,
639 )?;
640
641 #[cfg(feature = "openapi")]
644 let openapi_router = build_openapi_router(
645 &route_list,
646 &ctx.scoped_groups,
647 ctx.openapi.as_ref(),
648 &config.session.cookie_name,
649 versions.as_ref().map_or(&[], |v| v.0.as_slice()),
650 )?;
651
652 #[cfg(feature = "mcp")]
658 let mcp_prepared: Option<McpPrepared> = if let Some(rt) = ctx.mcp.take() {
659 let path = rt.mount_path.as_str();
660 validate_mcp_mount_path(path)?;
661 reject_mcp_path_collisions(
666 path,
667 &route_list,
668 &ctx.scoped_groups,
669 config,
670 ctx.openapi.as_ref(),
671 &ctx.merge_routers,
672 &ctx.nest_routers,
673 )?;
674 let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
675 let tools = crate::mcp::derive_tools(&docs, rt.expose_all, ctx.openapi.as_ref());
678 let tools = crate::mcp::drop_reserved_tools(tools, rt.catalog);
681 Some((
682 rt.mount_path,
683 tools,
684 rt.endpoint_layer,
685 rt.tasks,
686 rt.instructions,
687 rt.catalog,
688 ))
689 } else {
690 None
691 };
692
693 #[cfg(feature = "webmcp")]
697 if let Some(rt) = ctx.webmcp.take() {
698 let docs = collect_openapi_docs(&route_list, &ctx.scoped_groups);
699 crate::webmcp::install(state, &docs, rt, ctx.openapi.as_ref(), config);
700 } else {
701 crate::webmcp::install_empty(state, config);
702 }
703
704 #[cfg(feature = "openapi")]
708 let aeo_openapi = ctx.openapi.as_ref();
709 #[cfg(not(feature = "openapi"))]
710 let aeo_openapi: Option<&()> = None;
711 #[cfg(feature = "mcp")]
712 let aeo_mcp = mcp_prepared.as_ref();
713 #[cfg(not(feature = "mcp"))]
714 let aeo_mcp: Option<&()> = None;
715 if config.aeo.enabled
716 && ctx.nest_routers.iter().any(|(prefix, _)| {
717 let prefix = prefix.trim_end_matches('/');
718 prefix.is_empty() || prefix == "/.well-known"
719 })
720 {
721 tracing::warn!(
722 "aeo: a router nested at /.well-known answers every path under it; the \
723 generated /.well-known documents (agent skills, ARD, server card) are hidden"
724 );
725 }
726 state.insert_extension(build_aeo_site(
727 config,
728 state,
729 &route_list,
730 &ctx.scoped_groups,
731 !ctx.merge_routers.is_empty(),
732 aeo_openapi,
733 aeo_mcp,
734 ));
735 if config.aeo.enabled && !config.aeo.paid_routes.is_empty() {
736 state.insert_extension(crate::aeo::commerce::RoutedTemplates {
737 mounted: routed_templates(&route_list, &ctx.scoped_groups, config),
738 nested: route_list
739 .iter()
740 .filter(|r| mounted_paths(r.path, config) != [r.path])
741 .map(|r| r.path.to_owned())
742 .collect(),
743 });
744 }
745
746 let route_timeouts = build_route_timeout_table(&route_list, &ctx.scoped_groups, config);
749 let criticality_layer = crate::middleware::criticality::CriticalityLayer::from_table(
750 build_route_criticality_table(&route_list, &ctx.scoped_groups, config),
751 config.server.admission.trust_criticality_header,
752 );
753
754 let idempotency_layers = build_idempotency_layers(config, state)?;
755 let opaque_app_layers_present = opaque_app_layers_override.unwrap_or_else(|| {
760 custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
761 || custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers)
762 });
763 let user_get_paths = collect_user_get_paths(&route_list, &ctx.scoped_groups);
767
768 let mut router = mount_user_routes(
769 route_list,
770 &ctx.scoped_groups,
771 &ctx.declared_routes,
772 idempotency_layers.as_ref(),
773 opaque_app_layers_present,
774 state,
775 config,
776 )?;
777
778 let dev_reload_enabled = dev::is_enabled_with_env(&crate::config::OsEnv);
779
780 router = mount_framework_routes(router, config, dev_reload_enabled);
781
782 let (mounted_probe_paths, router_with_probes) =
783 mount_probe_endpoints(router, config, &user_get_paths);
784 router = router_with_probes;
785
786 router = mount_actuator_endpoints(router, config, &mounted_probe_paths)?;
787
788 #[cfg(feature = "openapi")]
789 if let Some(openapi_router) = openapi_router {
790 router = router.merge(openapi_router);
791 }
792
793 #[cfg(feature = "embed-assets")]
800 let embedded_static = crate::assets::embedded_static_dir().is_some();
801 #[cfg(not(feature = "embed-assets"))]
802 let embedded_static = false;
803
804 if embedded_static {
805 #[cfg(feature = "embed-assets")]
806 {
807 router = router.route(
808 "/static/{*path}",
809 axum::routing::get(crate::assets::serve_embedded),
810 );
811 }
812 } else {
813 let env = crate::config::OsEnv;
814 let static_dir = crate::app::project_dir("static", &env);
815 router = router.nest_service("/static", tower_http::services::ServeDir::new(&static_dir));
816 }
817 router = router.layer(crate::assets::AssetCacheControlLayer);
818
819 router = mount_scoped_groups(
820 router,
821 ctx.scoped_groups,
822 idempotency_layers.as_ref(),
823 state,
824 );
825
826 router = mount_raw_routers(
827 router,
828 ctx.merge_routers,
829 ctx.nest_routers,
830 idempotency_layers.as_ref(),
831 );
832
833 let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
840
841 let load_shed_layer = build_load_shed_layer(config, state);
847 #[cfg(feature = "mcp")]
848 let mcp_load_shed_layer = load_shed_layer.clone();
849
850 router = apply_middleware(
851 router,
852 config,
853 state,
854 ctx.exception_filters,
855 ctx.custom_layers,
856 #[cfg(feature = "maud")]
857 ctx.error_page_renderer,
858 ctx.session_store,
859 route_timeouts,
860 criticality_layer,
861 load_shed_layer,
862 defer_security_headers,
863 )?;
864
865 if dev_reload_enabled {
866 router = router.layer((
870 axum::middleware::from_fn(dev::inject_live_reload),
871 axum::middleware::from_fn(dev::disable_static_cache),
872 ));
873 }
874
875 let is_dev_profile = crate::config::profile_is_dev(config.profile.as_deref());
878 if is_dev_profile {
879 router = router.route_layer(axum::middleware::from_fn(
882 crate::middleware::dev::capture_matched_path_middleware,
883 ));
884 }
885 if is_dev_profile {
886 let buf = crate::inspector::InspectorBuffer::new(config.dev.inspector_capacity);
887 let inspector_path = config.dev.inspector_path.clone();
888 let threshold = config.dev.inspector_n_plus_one_threshold;
889
890 let mut inspector = crate::inspector::inspector_router(buf.clone(), &inspector_path);
895 if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
896 inspector = inspector.layer(require_client_cert);
897 }
898 router = router.merge(inspector);
899 tracing::debug!(
900 path = %inspector_path,
901 "Mounted dev request inspector"
902 );
903
904 let layer = crate::inspector::InspectorLayer::new(buf, threshold, inspector_path)
907 .with_session_cookie_name(config.session.cookie_name.clone());
908 router = router.layer(layer);
909 }
910
911 #[cfg(feature = "oauth2")]
912 let http_interceptor = HttpInterceptorLayer::new(state.clone());
913 #[cfg(not(feature = "oauth2"))]
914 let http_interceptor = tower::layer::util::Identity::new();
915
916 let router = router.layer((
923 crate::events::EventAppContextLayer::new(state.clone()),
924 http_interceptor,
925 ));
926
927 #[cfg(feature = "mcp")]
971 let (router, deferred_mcp_router) =
972 if let Some((mount_path, tools, endpoint_layer, tasks, instructions, catalog)) =
973 mcp_prepared
974 {
975 let dispatch = apply_layers_in_registration_order(
984 router.clone(),
985 mcp_dispatch_extra_layers,
986 "Custom (MCP dispatch parity, static/ISR mode)",
987 )
988 .layer(crate::security::SecurityHeadersLayer::from_config(
989 &config.security.headers,
990 ))
991 .with_state(state.clone());
992 let tenant_header = (config.tenancy.enabled && config.tenancy.source == "header")
996 .then(|| config.tenancy.header_name.clone());
997 let wiring = crate::mcp::McpWiring {
998 cors: config.cors.clone(),
1001 trusted_hosts: TrustedHostPolicy::from_config_with_state(config, state),
1004 tenant_header,
1005 csrf_header: config.security.csrf.token_header.to_ascii_lowercase(),
1008 envelope_rate_limited: config.security.rate_limit.enabled,
1012 envelope_load_shed: mcp_load_shed_layer.is_some(),
1018 state: state.clone(),
1022 session_cookie: config.session.cookie_name.clone(),
1025 tasks,
1026 instructions,
1027 catalog,
1028 };
1029 let mut mcp_router =
1030 crate::mcp::build_mcp_router(&mount_path, tools, dispatch, wiring, endpoint_layer);
1031 mcp_router = mcp_router.layer(build_maintenance_layer(config, state));
1045 if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
1053 mcp_router = mcp_router.layer(require_client_cert);
1054 }
1055 if let Some(load_shed) = mcp_load_shed_layer {
1065 mcp_router = mcp_router
1072 .layer(load_shed)
1073 .layer(axum::Extension(crate::middleware::LoadShedEnvelope))
1074 .layer(axum::Extension(crate::admission::Criticality::Critical));
1075 }
1076 mcp_router = apply_trusted_proxies_middleware(mcp_router, config);
1084 mcp_router = mcp_router.layer(axum::extract::DefaultBodyLimit::max(
1091 config.security.upload.max_request_size_bytes,
1092 ));
1093 mcp_router = apply_rate_limit_middleware(mcp_router, config, state);
1108 mcp_router = apply_request_timeout_middleware(
1130 mcp_router,
1131 config,
1132 state,
1133 std::sync::Arc::new(std::collections::HashMap::new()),
1134 false,
1135 );
1136 mcp_router = mcp_router.layer(crate::security::SecurityHeadersLayer::from_config(
1144 &config.security.headers,
1145 ));
1146 mcp_router = crate::mcp::apply_mcp_cors_layer(mcp_router, &config.cors);
1151 if defer_security_headers {
1171 (router, Some(mcp_router))
1172 } else {
1173 (router.merge(mcp_router), None)
1174 }
1175 } else {
1176 (router, None)
1177 };
1178 #[cfg(not(feature = "mcp"))]
1179 let deferred_mcp_router: Option<axum::Router<AppState>> = None;
1180
1181 let router = if defer_security_headers {
1192 router
1193 } else if static_gate_layers.is_empty() {
1194 router.layer(crate::security::SecurityHeadersLayer::from_config(
1195 &config.security.headers,
1196 ))
1197 } else {
1198 tracing::debug!(
1205 count = static_gate_layers.len(),
1206 "Pre-static gate Tower layers applied"
1207 );
1208 router.layer((
1209 crate::security::SecurityHeadersLayer::from_config(&config.security.headers),
1210 ComposedRegisteredLayers::new(static_gate_layers),
1211 ))
1212 };
1213
1214 Ok((router, deferred_mcp_router))
1215}
1216
1217#[cfg(feature = "openapi")]
1223pub fn extract_path_params(path: &str) -> Vec<String> {
1224 let mut out = Vec::new();
1225 let mut remaining = path;
1226
1227 while let Some(start) = remaining.find('{') {
1228 let after_brace = &remaining[start + 1..];
1229 if let Some(rest) = after_brace.strip_prefix('{') {
1235 remaining = rest;
1236 continue;
1237 }
1238 let Some(end_rel) = after_brace.find('}') else {
1239 break;
1240 };
1241
1242 let inner = &after_brace[..end_rel];
1243 let name = inner.split(':').next().unwrap_or(inner).trim();
1246 if !name.is_empty() && !name.contains('{') && !name.contains('}') {
1252 out.push(name.to_owned());
1253 }
1254
1255 remaining = &after_brace[end_rel + 1..];
1256 }
1257
1258 out
1259}
1260
1261#[cfg(feature = "openapi")]
1264async fn serve_openapi_spec(
1265 state: axum::extract::State<AppState>,
1266 axum::extract::Extension(config): axum::extract::Extension<
1267 std::sync::Arc<crate::openapi::OpenApiConfig>,
1268 >,
1269 axum::extract::Extension(docs): axum::extract::Extension<
1270 std::sync::Arc<Vec<crate::openapi::ApiDoc>>,
1271 >,
1272) -> impl axum::response::IntoResponse {
1273 use axum::response::IntoResponse;
1274 let refs: Vec<&crate::openapi::ApiDoc> = docs.iter().collect();
1275 let now = state.clock().now();
1276 let spec = crate::openapi::generate_spec_at(&config, &refs, now);
1277 let spec_json = state.extension::<AutumnConfig>().map_or_else(
1279 || serde_json::to_string_pretty(&spec),
1280 |app| crate::aeo::commerce::spec_json_with_mpp(&spec, &app),
1281 );
1282 let spec_json =
1283 spec_json.unwrap_or_else(|e| format!("{{\"error\": \"failed to serialize spec: {e}\"}}"));
1284 (
1285 [(http::header::CONTENT_TYPE, "application/json")],
1286 spec_json,
1287 )
1288 .into_response()
1289}
1290
1291#[cfg(feature = "openapi")]
1299fn build_openapi_router(
1300 route_list: &[Route],
1301 scoped_groups: &[ScopedGroup],
1302 openapi_config: Option<&crate::openapi::OpenApiConfig>,
1303 session_cookie_name: &str,
1304 api_versions: &[crate::app::ApiVersion],
1305) -> Result<Option<axum::Router<AppState>>, RouterBuildError> {
1306 let Some(config) = openapi_config else {
1307 return Ok(None);
1308 };
1309 let mut config = config.clone();
1310 session_cookie_name.clone_into(&mut config.session_cookie_name);
1311 config.api_versions = api_versions.to_vec();
1312
1313 validate_openapi_mount_paths(&config)?;
1317
1318 let docs = collect_openapi_docs(route_list, scoped_groups);
1319
1320 let json_path = config.openapi_json_path.clone();
1321 let swagger_path = config.swagger_ui_path.clone();
1322 let title = config.title.clone();
1323
1324 let mut router = axum::Router::<AppState>::new()
1325 .route(&json_path, axum::routing::get(serve_openapi_spec))
1326 .layer(axum::extract::Extension(std::sync::Arc::new(
1327 config.clone(),
1328 )))
1329 .layer(axum::extract::Extension(std::sync::Arc::new(docs)));
1330
1331 if let Some(path) = swagger_path {
1332 router = mount_swagger_ui_routes(router, &path, &title, &json_path);
1333 }
1334
1335 tracing::debug!(
1336 openapi_json = %json_path,
1337 swagger_ui = ?config.swagger_ui_path,
1338 swagger_ui_version = crate::openapi::SWAGGER_UI_VERSION,
1339 "Mounted OpenAPI endpoints"
1340 );
1341
1342 Ok(Some(router))
1343}
1344
1345#[allow(dead_code)]
1355pub fn join_nested_path(prefix: &str, child: &str) -> String {
1356 if child == "/" || child.is_empty() {
1357 if prefix.is_empty() {
1364 "/".to_owned()
1365 } else {
1366 prefix.to_owned()
1367 }
1368 } else {
1369 let prefix_trimmed = prefix.trim_end_matches('/');
1372 if child.starts_with('/') {
1373 format!("{prefix_trimmed}{child}")
1374 } else {
1375 format!("{prefix_trimmed}/{child}")
1376 }
1377 }
1378}
1379
1380#[cfg(feature = "openapi")]
1396fn validate_route_path(field: &'static str, value: &str) -> Result<(), RouterBuildError> {
1397 let reject = |reason_fragment: &str| {
1398 Err(RouterBuildError::InvalidOpenApiPath {
1399 field,
1400 value: format!("{value:?} {reason_fragment}"),
1401 })
1402 };
1403
1404 if value.is_empty() {
1405 return reject("(must be non-empty)");
1406 }
1407 if !value.starts_with('/') {
1408 return reject("(must start with '/')");
1409 }
1410 if value.contains("//") {
1415 return reject("(must not contain '//')");
1416 }
1417
1418 let mut depth: i32 = 0;
1419 for ch in value.chars() {
1420 match ch {
1421 '{' => depth += 1,
1422 '}' => {
1423 depth -= 1;
1424 if depth < 0 {
1425 return reject("(unbalanced '}')");
1426 }
1427 }
1428 '*' => return reject("(wildcard '*' is not allowed in an OpenAPI mount path)"),
1429 _ => {}
1430 }
1431 }
1432 if depth != 0 {
1433 return reject("(unbalanced '{')");
1434 }
1435 if value.contains('{') {
1436 return reject("(OpenAPI mount paths must be static; `{…}` captures are not allowed)");
1437 }
1438 Ok(())
1439}
1440
1441#[cfg(feature = "http-client")]
1443fn aeo_x402_layer(
1444 config: &AutumnConfig,
1445 state: &AppState,
1446 static_paths: crate::aeo::commerce::StaticPaths,
1447) -> tower::util::Either<crate::aeo::commerce::X402Layer, tower::layer::util::Identity> {
1448 tower::util::option_layer(
1449 crate::aeo::commerce::X402Layer::shared(config, state).map(|layer| match static_paths {
1450 Some(paths) => layer.for_static_pages(paths),
1451 None => layer,
1452 }),
1453 )
1454}
1455
1456#[cfg(not(feature = "http-client"))]
1458fn aeo_x402_layer(
1459 config: &AutumnConfig,
1460 state: &AppState,
1461 static_paths: crate::aeo::commerce::StaticPaths,
1462) -> tower::util::Either<crate::aeo::commerce::X402Unavailable, tower::layer::util::Identity> {
1463 tower::util::option_layer(
1464 crate::aeo::commerce::X402Unavailable::from_config(config, state).map(|layer| {
1465 match static_paths {
1466 Some(paths) => layer.for_static_pages(paths),
1467 None => layer,
1468 }
1469 }),
1470 )
1471}
1472
1473struct HomeRoutes {
1476 routed: Option<bool>,
1478 scoped: Option<bool>,
1480}
1481
1482impl HomeRoutes {
1483 fn find(route_list: &[Route], scoped_groups: &[ScopedGroup]) -> Self {
1484 let routed: Vec<&Route> = route_list
1485 .iter()
1486 .filter(|r| r.method == http::Method::GET && r.path == "/")
1487 .collect();
1488 let scoped: Vec<&Route> = scoped_groups
1489 .iter()
1490 .flat_map(|g| {
1491 g.routes.iter().filter(|r| {
1492 r.method == http::Method::GET && join_nested_path(&g.prefix, r.path) == "/"
1493 })
1494 })
1495 .collect();
1496 let home = |routes: &[&Route]| {
1497 (!routes.is_empty()).then(|| {
1498 routes.iter().any(|r| {
1499 r.seo
1500 .robots
1501 .is_some_and(crate::seo::robots_directive_is_noindex)
1502 })
1503 })
1504 };
1505 Self {
1506 routed: home(&routed),
1507 scoped: home(&scoped),
1508 }
1509 }
1510}
1511
1512#[cfg_attr(
1515 not(all(feature = "openapi", feature = "mcp")),
1516 allow(clippy::needless_pass_by_value, unused_variables)
1517)]
1518fn build_aeo_site<O: AeoOpenApiFacts, M: AeoMcpFacts>(
1519 config: &AutumnConfig,
1520 state: &AppState,
1521 route_list: &[Route],
1522 scoped_groups: &[ScopedGroup],
1523 merged_routers: bool,
1524 openapi: Option<&O>,
1525 mcp: Option<&M>,
1526) -> crate::aeo::AeoSite {
1527 use crate::aeo::documents::{PageFacts, SiteFacts};
1528
1529 let page = |path: String, seo: crate::seo::SeoRouteDefaults| {
1530 let title = seo.title?;
1531 let indexable = !seo
1532 .robots
1533 .is_some_and(crate::seo::robots_directive_is_noindex);
1534 (indexable && !path.contains('{') && !path.contains('*')).then(|| PageFacts {
1535 path,
1536 title: title.to_owned(),
1537 description: seo.description.map(str::to_owned),
1538 })
1539 };
1540 let pages: Vec<PageFacts> = route_list
1541 .iter()
1542 .filter(|r| r.method == http::Method::GET)
1543 .filter_map(|r| page(r.path.to_owned(), r.seo))
1544 .collect();
1545 #[cfg(feature = "i18n")]
1550 let locales = if config.i18n.locale_prefix_enabled {
1551 validated_locale_prefix_locales(&config.i18n)
1553 } else {
1554 Vec::new()
1555 };
1556 #[cfg(feature = "i18n")]
1557 let localize = |pages: Vec<PageFacts>| {
1558 crate::aeo::localize_pages(
1559 pages,
1560 &locales,
1561 &config.i18n.locale_prefix_exclude,
1562 &config.i18n.locale_prefix_exclude_exact,
1563 )
1564 };
1565 #[cfg(not(feature = "i18n"))]
1566 let localize = |pages: Vec<PageFacts>| pages;
1567 let mut pages = localize(pages);
1568 let home_paths: Vec<String> = localize(vec![PageFacts {
1571 path: "/".to_owned(),
1572 ..PageFacts::default()
1573 }])
1574 .into_iter()
1575 .map(|p| p.path)
1576 .collect();
1577 for group in scoped_groups {
1578 pages.extend(
1579 group
1580 .routes
1581 .iter()
1582 .filter(|r| r.method == http::Method::GET)
1583 .filter_map(|r| page(join_nested_path(&group.prefix, r.path), r.seo)),
1584 );
1585 }
1586 pages.sort_by(|a, b| a.path.cmp(&b.path));
1587 pages.dedup_by(|a, b| a.path == b.path);
1588
1589 let home = HomeRoutes::find(route_list, scoped_groups);
1590 let has_home = merged_routers || home.routed.is_some() || home.scoped.is_some();
1594 let mut noindex_homes = Vec::new();
1599 if home.routed == Some(true) {
1600 noindex_homes.clone_from(&home_paths);
1601 }
1602 if home.scoped == Some(true) {
1603 noindex_homes.push("/".to_owned());
1604 }
1605 let mut home_paths = if home.routed.is_some() {
1606 home_paths
1607 } else {
1608 Vec::new()
1609 };
1610 if home.routed.is_none() || home.scoped.is_some() {
1611 home_paths.push("/".to_owned());
1612 }
1613 home_paths.dedup();
1614 let mut facts = SiteFacts {
1615 pages,
1616 home_paths,
1617 noindex_homes,
1618 no_home: !has_home,
1619 health_path: config.health.enabled.then(|| config.health.path.clone()),
1620 skills: state
1621 .extension::<crate::aeo::RegisteredAgentSkills>()
1622 .map(|s| s.0.clone())
1623 .unwrap_or_default(),
1624 commerce: state
1625 .extension::<crate::aeo::RegisteredCommerceDocs>()
1626 .map(|c| (*c).clone())
1627 .unwrap_or_default(),
1628 ..SiteFacts::default()
1629 };
1630 #[cfg(feature = "webmcp")]
1631 if let Some(manifest) = state.extension::<crate::webmcp::WebMcpManifest>() {
1632 facts.webmcp_projected = manifest.names().to_vec();
1633 }
1634 if let Some(api) = openapi {
1635 api.fill(&mut facts);
1636 }
1637 if let Some(mcp) = mcp {
1638 mcp.fill(&mut facts);
1639 }
1640 crate::aeo::AeoSite::new(config, facts)
1641}
1642
1643trait AeoOpenApiFacts {
1646 fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts);
1647}
1648
1649impl AeoOpenApiFacts for () {
1650 fn fill(&self, _: &mut crate::aeo::documents::SiteFacts) {}
1651}
1652
1653#[cfg(feature = "openapi")]
1654impl AeoOpenApiFacts for crate::openapi::OpenApiConfig {
1655 fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts) {
1656 facts.name = Some(self.title.clone()).filter(|t| !t.trim().is_empty());
1657 facts.description.clone_from(&self.description);
1658 facts.openapi = Some(crate::aeo::documents::OpenApiFacts {
1659 json_path: self.openapi_json_path.clone(),
1660 docs_path: self.swagger_ui_path.clone(),
1661 version: self.version.clone(),
1662 });
1663 }
1664}
1665
1666trait AeoMcpFacts {
1668 fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts);
1669}
1670
1671impl AeoMcpFacts for () {
1672 fn fill(&self, _: &mut crate::aeo::documents::SiteFacts) {}
1673}
1674
1675#[cfg(feature = "mcp")]
1676impl AeoMcpFacts for McpPrepared {
1677 fn fill(&self, facts: &mut crate::aeo::documents::SiteFacts) {
1678 let (path, tools, endpoint_layer, ..) = self;
1679 let public_tools = endpoint_layer.is_none();
1681 let tools = if public_tools {
1682 tools
1683 .iter()
1684 .map(|t| crate::aeo::documents::ToolFacts {
1685 name: t.name().to_owned(),
1686 description: t.description().map(str::to_owned),
1687 input_schema: t.input_schema().clone(),
1688 annotations: t.annotations().clone(),
1689 })
1690 .collect()
1691 } else {
1692 Vec::new()
1693 };
1694 facts.mcp = Some(crate::aeo::documents::McpFacts {
1695 path: path.clone(),
1696 tools,
1697 public_tools,
1698 });
1699 }
1700}
1701
1702fn collect_user_get_paths(
1717 route_list: &[Route],
1718 scoped_groups: &[ScopedGroup],
1719) -> std::collections::HashSet<String> {
1720 let mut owned: std::collections::HashSet<String> = std::collections::HashSet::new();
1721 for route in route_list {
1722 if route.method == http::Method::GET || route.method.as_str() == "WS" {
1723 owned.insert(route.path.to_owned());
1724 }
1725 }
1726 for group in scoped_groups {
1727 for route in &group.routes {
1728 if route.method == http::Method::GET || route.method.as_str() == "WS" {
1729 owned.insert(join_nested_path(&group.prefix, route.path));
1730 }
1731 }
1732 }
1733 owned
1734}
1735
1736const fn framework_namespaces() -> &'static [&'static str] {
1752 &["/static", "/_autumn"]
1753}
1754
1755fn path_is_under_namespace(path: &str, namespace: &str) -> bool {
1758 path == namespace
1759 || path
1760 .strip_prefix(namespace)
1761 .is_some_and(|rest| rest.starts_with('/'))
1762}
1763
1764fn collect_framework_get_paths(config: &AutumnConfig) -> std::collections::HashSet<String> {
1773 let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
1774 if config.health.enabled {
1779 claimed.insert(config.health.path.clone());
1780 claimed.insert(config.health.live_path.clone());
1781 claimed.insert(config.health.ready_path.clone());
1782 claimed.insert(config.health.startup_path.clone());
1783 }
1784 let actuator_mutating = crate::actuator::actuator_mutating_routes(
1791 &config.actuator.prefix,
1792 config.actuator.sensitive,
1793 );
1794 let mutating_paths: std::collections::HashSet<&str> = actuator_mutating
1795 .iter()
1796 .map(|(_, path)| path.as_str())
1797 .collect();
1798 for path in crate::actuator::actuator_endpoint_paths(
1799 &config.actuator.prefix,
1800 config.actuator.sensitive,
1801 config.actuator.prometheus,
1802 ) {
1803 if mutating_paths.contains(path.as_str()) {
1804 continue;
1805 }
1806 claimed.insert(path);
1807 }
1808 #[cfg(feature = "htmx")]
1809 {
1810 for asset in crate::htmx::framework_scripts() {
1814 claimed.insert(asset.plain_url().to_owned());
1815 claimed.insert(asset.url().to_owned());
1816 }
1817 }
1818 #[cfg(feature = "flash")]
1824 claimed.insert(crate::flash::FLASH_CSS_PATH.to_owned());
1825 #[cfg(feature = "maud")]
1826 claimed.insert(crate::ui::WIDGETS_CSS_PATH.to_owned());
1827 if dev::is_enabled_with_env(&crate::config::OsEnv) {
1831 claimed.insert(dev::LIVE_RELOAD_PATH.to_owned());
1832 claimed.insert(dev::LIVE_RELOAD_SCRIPT_PATH.to_owned());
1833 }
1834 if matches!(config.profile.as_deref(), Some("dev" | "development")) {
1839 claimed.extend(crate::inspector::inspector_endpoint_paths(
1844 &config.dev.inspector_path,
1845 ));
1846 }
1847 #[cfg(feature = "mail")]
1848 if config
1849 .mail
1850 .preview_routes_enabled(config.profile.as_deref())
1851 {
1852 claimed.insert(crate::mail::MAIL_PREVIEW_PATH.to_owned());
1853 claimed.insert("/_autumn/mail/messages/{message_id}".to_owned());
1854 claimed.insert("/_autumn/mail/previews/{mailer}/{method}".to_owned());
1855 }
1856 #[cfg(feature = "maud")]
1862 if config.stories.enabled {
1863 claimed.insert(crate::stories::STORIES_PATH.to_owned());
1864 claimed.insert("/_stories/{slug}".to_owned());
1865 claimed.insert("/_stories/demo/search".to_owned());
1870 claimed.insert("/_stories/demo/tags/search".to_owned());
1871 claimed.insert("/_stories/demo/posts/feed".to_owned());
1872 }
1873 #[cfg(feature = "mail")]
1878 if config.mail.should_mount_unsubscribe_endpoint() {
1879 claimed.insert(crate::mail::UNSUBSCRIBE_PATH.to_owned());
1880 }
1881 if config.jobs.tracking.route_enabled {
1886 claimed.insert(crate::job_tracking::JOB_STATUS_ROUTE_PATH.to_owned());
1887 }
1888 claimed
1889}
1890
1891#[cfg(feature = "openapi")]
1897fn collect_claimed_get_paths(
1898 route_list: &[Route],
1899 scoped_groups: &[ScopedGroup],
1900 config: &AutumnConfig,
1901) -> std::collections::HashSet<String> {
1902 let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
1903 for route in route_list {
1904 if route.method == http::Method::GET || route.method.as_str() == "WS" {
1905 claimed.insert(route.path.to_owned());
1906 }
1907 }
1908 for group in scoped_groups {
1909 for route in &group.routes {
1910 if route.method == http::Method::GET || route.method.as_str() == "WS" {
1911 claimed.insert(join_nested_path(&group.prefix, route.path));
1912 }
1913 }
1914 }
1915 claimed.extend(collect_framework_get_paths(config));
1916 claimed
1917}
1918
1919#[cfg(feature = "mcp")]
1929pub fn reject_mcp_path_collisions(
1930 mount_path: &str,
1931 route_list: &[Route],
1932 scoped_groups: &[ScopedGroup],
1933 config: &AutumnConfig,
1934 openapi: Option<&crate::openapi::OpenApiConfig>,
1935 merge_routers: &[axum::Router<AppState>],
1936 nest_routers: &[(String, axum::Router<AppState>)],
1937) -> Result<(), RouterBuildError> {
1938 let mut claimed_get = collect_claimed_get_paths(route_list, scoped_groups, config);
1939 if let Some(openapi) = openapi {
1942 claimed_get.insert(openapi.openapi_json_path.clone());
1943 if let Some(ui_path) = &openapi.swagger_ui_path {
1944 claimed_get.insert(ui_path.clone());
1945 claimed_get.extend(crate::openapi::swagger_ui_asset_paths(ui_path));
1946 }
1947 }
1948 if claimed_get.contains(mount_path) {
1949 return Err(RouterBuildError::McpPathCollision {
1950 path: mount_path.to_owned(),
1951 method: "GET".to_owned(),
1952 });
1953 }
1954 let post_owns_path = route_list
1956 .iter()
1957 .any(|route| route.method == http::Method::POST && route.path == mount_path)
1958 || scoped_groups.iter().any(|group| {
1959 group.routes.iter().any(|route| {
1960 route.method == http::Method::POST
1961 && join_nested_path(&group.prefix, route.path) == mount_path
1962 })
1963 });
1964 if post_owns_path {
1965 return Err(RouterBuildError::McpPathCollision {
1966 path: mount_path.to_owned(),
1967 method: "POST".to_owned(),
1968 });
1969 }
1970 let nest_prefixes = nest_routers
1977 .iter()
1978 .map(|(prefix, _)| prefix.as_str())
1979 .chain(std::iter::once("/static"));
1980 for prefix in nest_prefixes {
1981 let prefix_slash = format!("{prefix}/");
1982 if mount_path == prefix || mount_path.starts_with(&prefix_slash) {
1983 return Err(RouterBuildError::McpPathCollision {
1984 path: mount_path.to_owned(),
1985 method: "nested router".to_owned(),
1986 });
1987 }
1988 }
1989 if !merge_routers.is_empty() {
1993 tracing::warn!(
1994 mcp_mount_path = %mount_path,
1995 merged_routers = merge_routers.len(),
1996 "MCP mount collision check skipped for AppBuilder::merge routers: \
1997 axum does not expose their route table, so an overlapping handler \
1998 will still panic at startup. Choose an MCP mount path that doesn't \
1999 overlap with any merged router's handlers."
2000 );
2001 }
2002 Ok(())
2003}
2004
2005#[cfg(feature = "openapi")]
2027pub fn reject_openapi_path_collisions(
2028 openapi_config: Option<&crate::openapi::OpenApiConfig>,
2029 route_list: &[Route],
2030 scoped_groups: &[ScopedGroup],
2031 merge_routers: &[axum::Router<AppState>],
2032 nest_routers: &[(String, axum::Router<AppState>)],
2033 config: &AutumnConfig,
2034) -> Result<(), RouterBuildError> {
2035 let Some(openapi) = openapi_config else {
2036 return Ok(());
2037 };
2038
2039 let claimed = collect_claimed_get_paths(route_list, scoped_groups, config);
2042
2043 check_openapi_path_against(
2044 "openapi_json_path",
2045 &openapi.openapi_json_path,
2046 &claimed,
2047 nest_routers,
2048 )?;
2049 if let Some(path) = &openapi.swagger_ui_path {
2050 check_openapi_path_against("swagger_ui_path", path, &claimed, nest_routers)?;
2051 let mut claimed_with_openapi = claimed;
2052 claimed_with_openapi.insert(openapi.openapi_json_path.clone());
2053 for asset_path in crate::openapi::swagger_ui_asset_paths(path) {
2054 check_openapi_path_against(
2055 "swagger_ui_path",
2056 &asset_path,
2057 &claimed_with_openapi,
2058 nest_routers,
2059 )?;
2060 }
2061 }
2062
2063 if !merge_routers.is_empty() {
2067 tracing::warn!(
2068 openapi_json_path = %openapi.openapi_json_path,
2069 swagger_ui_path = ?openapi.swagger_ui_path,
2070 merged_routers = merge_routers.len(),
2071 "OpenAPI mount collision check skipped for AppBuilder::merge routers: \
2072 axum does not expose their route table, so overlapping GET handlers \
2073 will still panic at startup. Choose OpenAPI paths that don't overlap \
2074 with any merged router's handlers."
2075 );
2076 }
2077
2078 Ok(())
2079}
2080
2081#[cfg(feature = "openapi")]
2085fn check_openapi_path_against(
2086 field: &'static str,
2087 path: &str,
2088 claimed: &std::collections::HashSet<String>,
2089 nest_routers: &[(String, axum::Router<AppState>)],
2090) -> Result<(), RouterBuildError> {
2091 if claimed.contains(path) {
2092 return Err(RouterBuildError::OpenApiPathCollision {
2093 field,
2094 path: path.to_owned(),
2095 });
2096 }
2097 for (prefix, _) in nest_routers {
2103 let prefix_slash = format!("{prefix}/");
2104 if path == prefix || path.starts_with(&prefix_slash) {
2105 return Err(RouterBuildError::OpenApiPathCollision {
2106 field,
2107 path: path.to_owned(),
2108 });
2109 }
2110 }
2111 Ok(())
2112}
2113
2114fn effective_mount_method(method: &http::Method) -> http::Method {
2124 if method.as_str() == "WS" {
2125 http::Method::GET
2126 } else {
2127 method.clone()
2128 }
2129}
2130
2131fn paths_conflict_under_matchit(existing: &str, incoming: &str) -> bool {
2138 let mut probe: matchit::Router<()> = matchit::Router::new();
2139 if probe.insert(existing, ()).is_err() {
2143 return false;
2144 }
2145 matches!(
2146 probe.insert(incoming, ()),
2147 Err(matchit::InsertError::Conflict { .. })
2148 )
2149}
2150
2151fn reject_duplicate_nest_prefixes(
2162 nest_routers: &[(String, axum::Router<AppState>)],
2163 declared_routes: &[crate::route_listing::RouteInfo],
2164) -> Result<(), RouterBuildError> {
2165 let mut nested_at: std::collections::HashSet<&str> = std::collections::HashSet::new();
2166 for (prefix, _) in nest_routers {
2167 if nested_at.insert(prefix.as_str()) {
2168 continue;
2169 }
2170 let mut owners: Vec<&str> = declared_routes
2174 .iter()
2175 .filter(|declared| declared.path.starts_with(prefix.as_str()))
2176 .map(|declared| declared.handler.as_str())
2177 .collect();
2178 owners.sort_unstable();
2179 owners.dedup();
2180 let owners = if owners.is_empty() {
2181 "two undeclared nested routers".to_owned()
2182 } else {
2183 owners.join(", ")
2184 };
2185 return Err(RouterBuildError::DuplicateNestPrefix {
2186 prefix: prefix.clone(),
2187 owners,
2188 });
2189 }
2190 Ok(())
2191}
2192
2193fn framework_route_clashes(
2207 framework_path: &str,
2208 framework_method: &str,
2209 declared_path: &str,
2210 declared_method: &str,
2211) -> bool {
2212 if framework_path == declared_path {
2213 return declared_method.eq_ignore_ascii_case(framework_method);
2214 }
2215 paths_conflict_under_matchit(framework_path, declared_path)
2216}
2217
2218#[cfg(feature = "openapi")]
2262pub fn validate_openapi_mount_paths(
2263 config: &crate::openapi::OpenApiConfig,
2264) -> Result<(), RouterBuildError> {
2265 validate_route_path("openapi_json_path", &config.openapi_json_path)?;
2266 if let Some(path) = &config.swagger_ui_path {
2267 validate_route_path("swagger_ui_path", path)?;
2268 if path == &config.openapi_json_path {
2272 return Err(RouterBuildError::DuplicateOpenApiPath { path: path.clone() });
2273 }
2274 }
2275 Ok(())
2276}
2277
2278#[cfg(feature = "mcp")]
2292pub fn validate_mcp_mount_path(path: &str) -> Result<(), RouterBuildError> {
2293 if path.is_empty()
2294 || !path.starts_with('/')
2295 || path.contains("//")
2296 || path.contains('{')
2297 || path.contains('*')
2298 || path.split('/').any(|segment| segment.starts_with(':'))
2299 {
2300 return Err(RouterBuildError::InvalidMcpPath {
2301 value: path.to_owned(),
2302 });
2303 }
2304 Ok(())
2305}
2306
2307pub fn reject_unregistered_api_versions(
2308 route_list: &[Route],
2309 scoped_groups: &[ScopedGroup],
2310 registered_versions: &std::collections::HashSet<&str>,
2311) -> Result<(), RouterBuildError> {
2312 let check = |route: &Route| -> Result<(), RouterBuildError> {
2313 if let Some(version) = route
2314 .api_version
2315 .filter(|ver| !registered_versions.contains(*ver))
2316 {
2317 return Err(RouterBuildError::UnregisteredApiVersion {
2318 route_name: route.name.to_string(),
2319 version: version.to_string(),
2320 });
2321 }
2322 Ok(())
2323 };
2324
2325 for route in route_list {
2326 check(route)?;
2327 }
2328 for group in scoped_groups {
2329 for route in &group.routes {
2330 check(route)?;
2331 }
2332 }
2333 Ok(())
2334}
2335
2336pub fn reject_duplicate_user_routes(
2344 route_list: &[Route],
2345 scoped_groups: &[ScopedGroup],
2346 merge_routers: &[axum::Router<AppState>],
2347 nest_routers: &[(String, axum::Router<AppState>)],
2348 declared_routes: &[crate::route_listing::RouteInfo],
2349 config: &AutumnConfig,
2350) -> Result<(), RouterBuildError> {
2351 let mut claimed: std::collections::HashMap<(String, String), String> =
2361 std::collections::HashMap::new();
2362
2363 let mut shape_router: matchit::Router<String> = matchit::Router::new();
2380 let mut inserted_shapes: Vec<(String, String)> = Vec::new();
2387
2388 let mut record =
2389 |method: &http::Method, path: String, name: &str| -> Result<(), RouterBuildError> {
2390 let effective_method = effective_mount_method(method).to_string();
2391
2392 let already_inserted = inserted_shapes.iter().any(|(p, _)| p == &path);
2397 if !already_inserted {
2398 match shape_router.insert(&path, name.to_owned()) {
2399 Ok(()) => inserted_shapes.push((path.clone(), name.to_owned())),
2400 Err(matchit::InsertError::Conflict { .. }) => {
2401 let (existing_path, existing_name) = inserted_shapes
2403 .iter()
2404 .find(|(prior, _)| paths_conflict_under_matchit(prior, &path))
2405 .cloned()
2406 .unwrap_or_else(|| inserted_shapes[0].clone());
2410 return Err(RouterBuildError::ConflictingRouteShape {
2411 existing: existing_name,
2412 existing_path,
2413 incoming: name.to_owned(),
2414 incoming_path: path,
2415 });
2416 }
2417 Err(_) => {}
2422 }
2423 }
2424
2425 let key = (effective_method.clone(), path.clone());
2429 if let Some(existing) = claimed.get(&key) {
2430 return Err(RouterBuildError::DuplicateUserRoute {
2431 method: effective_method,
2432 path,
2433 existing: existing.clone(),
2434 incoming: name.to_owned(),
2435 });
2436 }
2437 claimed.insert(key, name.to_owned());
2438 Ok(())
2439 };
2440
2441 for route in route_list {
2442 record(&route.method, route.path.to_owned(), route.name)?;
2443 }
2444 for group in scoped_groups {
2445 for route in &group.routes {
2446 record(
2447 &route.method,
2448 join_nested_path(&group.prefix, route.path),
2449 route.name,
2450 )?;
2451 }
2452 }
2453
2454 for declared in declared_routes {
2471 let Ok(method) = http::Method::from_bytes(declared.method.as_bytes()) else {
2475 continue;
2476 };
2477 record(&method, declared.path.clone(), &declared.handler)?;
2478 }
2479
2480 reject_declared_framework_collisions(declared_routes, config)?;
2481
2482 if !merge_routers.is_empty() {
2486 tracing::warn!(
2487 merged_routers = merge_routers.len(),
2488 "duplicate-route preflight (#1012) skipped for AppBuilder::merge routers: \
2489 axum does not expose their route table, so an overlapping handler on a \
2490 method+path Autumn already owns will still panic at startup. Keep merged \
2491 routers on disjoint paths from your `.routes()`/`.scoped()` registrations."
2492 );
2493 }
2494 reject_duplicate_nest_prefixes(nest_routers, declared_routes)?;
2495
2496 if !nest_routers.is_empty() {
2497 tracing::warn!(
2498 nested_routers = nest_routers.len(),
2499 declared_routes = declared_routes.len(),
2500 "duplicate-route preflight (#1012) covers AppBuilder::nest routers only \
2501 through declare_plugin_routes: axum does not expose a nested router's \
2502 route table, so any handler it serves that was NOT declared can still \
2503 panic at startup if it overlaps a method+path Autumn already owns. \
2504 Declared routes — a sandboxed plugin declares its whole manifest — ARE \
2505 checked; for anything else keep nested routers on disjoint prefixes \
2506 from your `.routes()`/`.scoped()` registrations."
2507 );
2508 }
2509
2510 Ok(())
2511}
2512
2513#[cfg(feature = "openapi")]
2520pub fn reject_declared_routes_on_context_mounts(
2521 declared_routes: &[crate::route_listing::RouteInfo],
2522 openapi: Option<&crate::openapi::OpenApiConfig>,
2523 mcp_mount_path: Option<&str>,
2524) -> Result<(), RouterBuildError> {
2525 if declared_routes.is_empty() {
2526 return Ok(());
2527 }
2528 let mut mounts: Vec<(&'static str, String)> = Vec::new();
2530 if let Some(openapi) = openapi {
2531 mounts.push(("GET", openapi.openapi_json_path.clone()));
2532 if let Some(ui_path) = &openapi.swagger_ui_path {
2533 mounts.push(("GET", ui_path.clone()));
2534 mounts.extend(
2535 crate::openapi::swagger_ui_asset_paths(ui_path)
2536 .into_iter()
2537 .map(|path| ("GET", path)),
2538 );
2539 }
2540 }
2541 if let Some(path) = mcp_mount_path {
2542 for method in ["GET", "POST", "OPTIONS"] {
2543 mounts.push((method, path.to_owned()));
2544 }
2545 }
2546 for declared in declared_routes {
2547 let method = if declared.method.eq_ignore_ascii_case("WS") {
2549 "GET"
2550 } else {
2551 declared.method.as_str()
2552 };
2553 if let Some((_, path)) = mounts
2554 .iter()
2555 .find(|(owned, path)| framework_route_clashes(path, owned, &declared.path, method))
2556 {
2557 return Err(RouterBuildError::DuplicateUserRoute {
2558 method: declared.method.clone(),
2559 path: declared.path.clone(),
2560 existing: format!("autumn framework route {path}"),
2561 incoming: declared.handler.clone(),
2562 });
2563 }
2564 }
2565 Ok(())
2566}
2567
2568fn reject_declared_framework_collisions(
2575 declared_routes: &[crate::route_listing::RouteInfo],
2576 config: &AutumnConfig,
2577) -> Result<(), RouterBuildError> {
2578 let framework_get_paths = collect_framework_get_paths(config);
2594 let actuator_mutating_routes = crate::actuator::actuator_mutating_routes(
2598 &config.actuator.prefix,
2599 config.actuator.sensitive,
2600 );
2601 for declared in declared_routes {
2602 if let Some(namespace) = framework_namespaces()
2615 .iter()
2616 .find(|namespace| path_is_under_namespace(&declared.path, namespace))
2617 && !crate::assets::plugin::is_framework_asset_route(declared)
2618 {
2619 return Err(RouterBuildError::DuplicateUserRoute {
2620 method: declared.method.clone(),
2621 path: declared.path.clone(),
2622 existing: format!("autumn framework namespace {namespace}"),
2623 incoming: declared.handler.clone(),
2624 });
2625 }
2626 let effective_method = if declared.method.eq_ignore_ascii_case("GET")
2629 || declared.method.eq_ignore_ascii_case("WS")
2630 {
2631 "GET"
2632 } else {
2633 declared.method.as_str()
2634 };
2635 for (framework_method, framework_path) in &actuator_mutating_routes {
2641 if framework_route_clashes(
2642 framework_path,
2643 framework_method,
2644 &declared.path,
2645 effective_method,
2646 ) {
2647 return Err(RouterBuildError::DuplicateUserRoute {
2648 method: declared.method.clone(),
2649 path: declared.path.clone(),
2650 existing: format!("autumn framework route {framework_path}"),
2651 incoming: declared.handler.clone(),
2652 });
2653 }
2654 }
2655 let collision = framework_get_paths.iter().find(|framework_path| {
2663 framework_route_clashes(framework_path, "GET", &declared.path, effective_method)
2664 });
2665 if let Some(framework_path) = collision {
2666 return Err(RouterBuildError::DuplicateUserRoute {
2673 method: declared.method.clone(),
2674 path: declared.path.clone(),
2675 existing: format!("autumn framework route {framework_path}"),
2679 incoming: declared.handler.clone(),
2680 });
2681 }
2682 }
2683 Ok(())
2684}
2685
2686fn attach_seo_defaults(
2705 handler: axum::routing::MethodRouter<AppState>,
2706 seo: crate::seo::SeoRouteDefaults,
2707) -> axum::routing::MethodRouter<AppState> {
2708 if seo.is_empty() {
2709 return handler;
2710 }
2711 handler.layer(axum::Extension(seo))
2712}
2713
2714fn group_and_mount_routes(
2715 route_list: Vec<Route>,
2716 idempotency_layers: Option<&BuiltIdempotencyLayers>,
2717 opaque_app_layers_present: bool,
2718 state: &AppState,
2719) -> axum::Router<AppState> {
2720 let mut grouped: indexmap::IndexMap<&str, axum::routing::MethodRouter<AppState>> =
2725 indexmap::IndexMap::new();
2726 for route in &route_list {
2727 tracing::debug!(
2728 method = %route.method,
2729 path = route.path,
2730 name = route.name,
2731 "Mounted route"
2732 );
2733 }
2734 for route in route_list {
2735 let selected_layer = idempotency_layers.and_then(|layers| {
2736 idempotency_layer_for_route(&route, layers, opaque_app_layers_present)
2737 });
2738 let mut handler = route.handler;
2739 if let Some(layer) = selected_layer {
2740 handler = handler.layer(layer.clone());
2741 }
2742 handler = attach_seo_defaults(handler, route.seo);
2743 if let Some(version) = route.api_version {
2744 handler = handler.layer(axum::middleware::from_fn_with_state(
2745 state.clone(),
2746 api_versioning_middleware,
2747 ));
2748 handler = handler.layer(axum::Extension(RouteVersionMetadata {
2749 version: version.to_string(),
2750 sunset_opt_out: route.sunset_opt_out,
2751 secured: route.api_doc.secured,
2752 required_roles: route.api_doc.required_roles,
2753 has_policy: route.api_doc.has_policy,
2754 }));
2755 }
2756 grouped
2757 .entry(route.path)
2758 .and_modify(|existing| {
2759 *existing = std::mem::take(existing).merge(handler.clone());
2760 })
2761 .or_insert(handler);
2762 }
2763
2764 let mut router = axum::Router::new();
2765 for (path, method_router) in grouped {
2766 router = router.route(path, method_router);
2767 }
2768 router
2769}
2770
2771#[cfg(feature = "i18n")]
2776fn mount_user_routes(
2777 route_list: Vec<Route>,
2778 scoped_groups: &[ScopedGroup],
2779 declared_routes: &[crate::route_listing::RouteInfo],
2780 idempotency_layers: Option<&BuiltIdempotencyLayers>,
2781 opaque_app_layers_present: bool,
2782 state: &AppState,
2783 config: &AutumnConfig,
2784) -> Result<axum::Router<AppState>, RouterBuildError> {
2785 if !config.i18n.locale_prefix_enabled {
2786 return Ok(group_and_mount_routes(
2787 route_list,
2788 idempotency_layers,
2789 opaque_app_layers_present,
2790 state,
2791 ));
2792 }
2793
2794 let (included, excluded) = partition_routes_for_locale_prefix(
2795 route_list,
2796 &config.i18n.locale_prefix_exclude,
2797 &config.i18n.locale_prefix_exclude_exact,
2798 );
2799
2800 let included_path_methods = route_list_path_methods(&included);
2801 let excluded_path_methods = route_list_path_methods(&excluded);
2802 let scoped_path_methods = scoped_group_path_methods(scoped_groups);
2803 let framework_path_methods = framework_probe_path_methods(config);
2804 let declared_path_methods = declared_path_methods(declared_routes);
2805
2806 let valid_locales = validated_locale_prefix_locales(&config.i18n);
2807
2808 if let Some(err) = detect_locale_prefix_path_collision(
2809 &included_path_methods,
2810 &excluded_path_methods,
2811 &scoped_path_methods,
2812 &framework_path_methods,
2813 &declared_path_methods,
2814 &valid_locales,
2815 ) {
2816 return Err(err);
2817 }
2818
2819 let path_method_filters = path_method_filters(&included_path_methods);
2820
2821 let content_router = group_and_mount_routes(
2822 included,
2823 idempotency_layers,
2824 opaque_app_layers_present,
2825 state,
2826 );
2827 let excluded_router = group_and_mount_routes(
2828 excluded,
2829 idempotency_layers,
2830 opaque_app_layers_present,
2831 state,
2832 );
2833 Ok(apply_locale_prefix_routing(
2834 excluded_router,
2835 &content_router,
2836 &path_method_filters,
2837 &config.i18n,
2838 &valid_locales,
2839 ))
2840}
2841
2842#[cfg(not(feature = "i18n"))]
2847#[allow(clippy::unnecessary_wraps)]
2848fn mount_user_routes(
2849 route_list: Vec<Route>,
2850 _scoped_groups: &[ScopedGroup],
2851 _declared_routes: &[crate::route_listing::RouteInfo],
2852 idempotency_layers: Option<&BuiltIdempotencyLayers>,
2853 opaque_app_layers_present: bool,
2854 state: &AppState,
2855 _config: &AutumnConfig,
2856) -> Result<axum::Router<AppState>, RouterBuildError> {
2857 Ok(group_and_mount_routes(
2858 route_list,
2859 idempotency_layers,
2860 opaque_app_layers_present,
2861 state,
2862 ))
2863}
2864
2865#[cfg(feature = "i18n")]
2880fn partition_routes_for_locale_prefix(
2881 route_list: Vec<Route>,
2882 exclude_prefixes: &[String],
2883 exclude_exact: &[String],
2884) -> (Vec<Route>, Vec<Route>) {
2885 let mut included = Vec::new();
2886 let mut excluded = Vec::new();
2887 for route in route_list {
2888 if exclude_exact.iter().any(|p| p == route.path)
2889 || matches_locale_exclude_prefix(route.path, exclude_prefixes)
2890 {
2891 excluded.push(route);
2892 } else {
2893 included.push(route);
2894 }
2895 }
2896 (included, excluded)
2897}
2898
2899#[cfg(feature = "i18n")]
2915pub fn matches_locale_exclude_prefix(path: &str, prefixes: &[String]) -> bool {
2916 prefixes.iter().any(|raw| {
2917 let prefix = raw.strip_suffix("/*").unwrap_or(raw.as_str());
2918 let prefix = if prefix == "/" {
2919 prefix
2920 } else {
2921 prefix.strip_suffix('/').unwrap_or(prefix)
2922 };
2923 !prefix.is_empty() && (path == prefix || path.starts_with(&format!("{prefix}/")))
2924 })
2925}
2926
2927#[cfg(feature = "i18n")]
2942fn is_valid_locale_segment(locale: &str) -> bool {
2943 !locale.is_empty()
2944 && !locale.starts_with(':')
2945 && !locale.contains(['/', '{', '}', '*', '?', '#'])
2946 && !locale.chars().any(char::is_whitespace)
2947}
2948
2949#[cfg(feature = "i18n")]
2958pub fn validated_locale_prefix_locales(i18n: &crate::i18n::I18nConfig) -> Vec<String> {
2959 let mut seen = std::collections::HashSet::new();
2960 i18n.supported_locales
2961 .iter()
2962 .filter(|locale| is_valid_locale_segment(locale) && seen.insert(locale.as_str()))
2963 .cloned()
2964 .collect()
2965}
2966
2967#[cfg(feature = "i18n")]
2974fn method_filter_for(method: &http::Method) -> axum::routing::MethodFilter {
2975 use axum::routing::MethodFilter;
2976 match method.as_str() {
2977 "POST" => MethodFilter::POST,
2978 "PUT" => MethodFilter::PUT,
2979 "DELETE" => MethodFilter::DELETE,
2980 "PATCH" => MethodFilter::PATCH,
2981 "HEAD" => MethodFilter::HEAD,
2982 "OPTIONS" => MethodFilter::OPTIONS,
2983 "TRACE" => MethodFilter::TRACE,
2984 "CONNECT" => MethodFilter::CONNECT,
2985 _ => MethodFilter::GET,
2986 }
2987}
2988
2989#[cfg(feature = "i18n")]
2996fn declared_path_methods(
2997 declared: &[crate::route_listing::RouteInfo],
2998) -> std::collections::HashMap<String, Vec<http::Method>> {
2999 let mut map: std::collections::HashMap<String, Vec<http::Method>> =
3000 std::collections::HashMap::new();
3001 for route in declared {
3002 let Ok(parsed) = http::Method::from_bytes(route.method.as_bytes()) else {
3003 if route.method.eq_ignore_ascii_case("WS") {
3005 let methods = map.entry(route.path.clone()).or_default();
3006 if !methods.contains(&http::Method::GET) {
3007 methods.push(http::Method::GET);
3008 }
3009 if !methods.contains(&http::Method::HEAD) {
3010 methods.push(http::Method::HEAD);
3011 }
3012 }
3013 continue;
3014 };
3015 let effective = effective_mount_method(&parsed);
3016 let methods = map.entry(route.path.clone()).or_default();
3017 if !methods.contains(&effective) {
3018 methods.push(effective.clone());
3019 }
3020 if effective == http::Method::GET && !methods.contains(&http::Method::HEAD) {
3021 methods.push(http::Method::HEAD);
3022 }
3023 }
3024 map
3025}
3026
3027#[cfg(feature = "i18n")]
3033fn route_list_path_methods(
3034 routes: &[Route],
3035) -> std::collections::HashMap<String, Vec<http::Method>> {
3036 let mut map: std::collections::HashMap<String, Vec<http::Method>> =
3037 std::collections::HashMap::new();
3038 for route in routes {
3039 let effective = effective_mount_method(&route.method);
3040 let methods = map.entry(route.path.to_owned()).or_default();
3041 if !methods.contains(&effective) {
3042 methods.push(effective.clone());
3043 }
3044 if effective == http::Method::GET && !methods.contains(&http::Method::HEAD) {
3045 methods.push(http::Method::HEAD);
3046 }
3047 if route.method.as_str() == "WS" && !methods.contains(&http::Method::CONNECT) {
3048 methods.push(http::Method::CONNECT);
3049 }
3050 }
3051 map
3052}
3053
3054#[cfg(feature = "i18n")]
3063fn scoped_group_path_methods(
3064 scoped_groups: &[ScopedGroup],
3065) -> std::collections::HashMap<String, Vec<http::Method>> {
3066 let mut map: std::collections::HashMap<String, Vec<http::Method>> =
3067 std::collections::HashMap::new();
3068 for group in scoped_groups {
3069 for route in &group.routes {
3070 let resolved = join_nested_path(&group.prefix, route.path);
3071 let effective = effective_mount_method(&route.method);
3072 let methods = map.entry(resolved).or_default();
3073 if !methods.contains(&effective) {
3074 methods.push(effective.clone());
3075 }
3076 if effective == http::Method::GET && !methods.contains(&http::Method::HEAD) {
3077 methods.push(http::Method::HEAD);
3078 }
3079 if route.method.as_str() == "WS" && !methods.contains(&http::Method::CONNECT) {
3080 methods.push(http::Method::CONNECT);
3081 }
3082 }
3083 }
3084 map
3085}
3086
3087#[cfg(feature = "i18n")]
3101fn framework_probe_path_methods(
3102 config: &AutumnConfig,
3103) -> std::collections::HashMap<String, Vec<http::Method>> {
3104 let mut map = std::collections::HashMap::new();
3105 if !config.health.enabled {
3106 return map;
3107 }
3108 for path in [
3109 &config.health.path,
3110 &config.health.live_path,
3111 &config.health.ready_path,
3112 &config.health.startup_path,
3113 ] {
3114 if path.is_empty() {
3115 continue;
3116 }
3117 map.entry(path.clone())
3118 .or_insert_with(|| vec![http::Method::GET, http::Method::HEAD]);
3119 }
3120 map
3121}
3122
3123#[cfg(feature = "i18n")]
3126fn path_method_filter(methods: &[http::Method]) -> axum::routing::MethodFilter {
3127 methods
3128 .iter()
3129 .map(method_filter_for)
3130 .reduce(axum::routing::MethodFilter::or)
3131 .unwrap_or(axum::routing::MethodFilter::GET)
3132}
3133
3134#[cfg(feature = "i18n")]
3145fn path_method_filters(
3146 included_path_methods: &std::collections::HashMap<String, Vec<http::Method>>,
3147) -> Vec<(String, axum::routing::MethodFilter)> {
3148 let mut result: Vec<_> = included_path_methods
3149 .iter()
3150 .map(|(path, methods)| (path.clone(), path_method_filter(methods)))
3151 .collect();
3152 result.sort_unstable_by(|(a, _), (b, _)| a.cmp(b));
3153 result
3154}
3155
3156#[cfg(feature = "i18n")]
3183fn detect_locale_prefix_path_collision(
3184 included: &std::collections::HashMap<String, Vec<http::Method>>,
3185 excluded: &std::collections::HashMap<String, Vec<http::Method>>,
3186 scoped: &std::collections::HashMap<String, Vec<http::Method>>,
3187 framework: &std::collections::HashMap<String, Vec<http::Method>>,
3188 declared: &std::collections::HashMap<String, Vec<http::Method>>,
3189 valid_locales: &[String],
3190) -> Option<RouterBuildError> {
3191 let all_other_paths: Vec<&String> = excluded
3192 .keys()
3193 .chain(scoped.keys())
3194 .chain(framework.keys())
3195 .chain(declared.keys())
3196 .chain(included.keys())
3197 .collect();
3198
3199 for locale in valid_locales {
3200 for (path, methods) in included {
3201 let generated = if path == "/" {
3202 format!("/{locale}")
3203 } else {
3204 format!("/{locale}{path}")
3205 };
3206
3207 let exact_conflict = excluded
3208 .get(&generated)
3209 .into_iter()
3210 .chain(scoped.get(&generated))
3211 .chain(framework.get(&generated))
3212 .chain(declared.get(&generated))
3216 .chain(included.get(&generated))
3217 .any(|other_methods| methods.iter().any(|m| other_methods.contains(m)));
3218
3219 let shape_conflict = all_other_paths
3220 .iter()
3221 .any(|p| **p != generated && paths_conflict_under_matchit(p, &generated));
3222
3223 if exact_conflict || shape_conflict {
3224 return Some(RouterBuildError::LocalePrefixPathCollision {
3225 locale: locale.clone(),
3226 path: path.clone(),
3227 generated,
3228 });
3229 }
3230 }
3231 }
3232 None
3233}
3234
3235#[cfg(feature = "i18n")]
3252fn apply_locale_prefix_routing(
3253 excluded_router: axum::Router<AppState>,
3254 content_router: &axum::Router<AppState>,
3255 path_method_filters: &[(String, axum::routing::MethodFilter)],
3256 i18n: &crate::i18n::I18nConfig,
3257 valid_locales: &[String],
3258) -> axum::Router<AppState> {
3259 let mut router = excluded_router;
3260
3261 if !path_method_filters.is_empty() && !valid_locales.is_empty() {
3262 let mut redirect_router = axum::Router::<AppState>::new();
3263 for (path, filter) in path_method_filters {
3264 redirect_router = redirect_router.route(
3265 path,
3266 axum::routing::on(*filter, locale_prefix_redirect_handler),
3267 );
3268 }
3269 router = router.merge(redirect_router);
3270 }
3271
3272 let prefix_chain = i18n.resolved_fallback_chain();
3285 for locale in valid_locales {
3286 let nested = content_router
3287 .clone()
3288 .fallback(crate::middleware::error_page_filter::fallback_404_handler)
3289 .layer(crate::i18n::LocalePrefixScopeLayer::new(
3290 locale.clone(),
3291 prefix_chain.clone(),
3292 &i18n.default_locale,
3293 ))
3294 .layer(axum::Extension(crate::i18n::UriPrefixedLocale(
3295 locale.clone(),
3296 )));
3297 router = router.nest(&format!("/{locale}"), nested);
3298 }
3299
3300 let effective_default_locale = if valid_locales.contains(&i18n.default_locale) {
3315 i18n.default_locale.clone()
3316 } else {
3317 valid_locales
3318 .first()
3319 .cloned()
3320 .unwrap_or_else(|| i18n.default_locale.clone())
3321 };
3322 router.layer(axum::Extension(crate::i18n::LocaleRoutingConfig {
3323 supported_locales: valid_locales.to_vec(),
3324 default_locale: effective_default_locale,
3325 }))
3326}
3327
3328#[cfg(feature = "i18n")]
3351async fn locale_prefix_redirect_handler(
3352 locale: crate::i18n::Locale,
3353 uri: axum::http::Uri,
3354) -> axum::response::Redirect {
3355 let path = uri.path();
3356 let mut target = if path == "/" {
3361 format!("/{}", locale.tag())
3362 } else {
3363 format!("/{}{path}", locale.tag())
3364 };
3365 if let Some(query) = uri.query() {
3366 target.push('?');
3367 target.push_str(query);
3368 }
3369 axum::response::Redirect::permanent(&target)
3370}
3371
3372const fn idempotency_layer_for_route<'a>(
3377 route: &Route,
3378 layers: &'a BuiltIdempotencyLayers,
3379 opaque_app_layers_present: bool,
3380) -> Option<&'a IdempotencyLayer> {
3381 if matches!(route.idempotency, crate::route::RouteIdempotency::Off) {
3382 None
3383 } else if opaque_app_layers_present {
3384 Some(&layers.manual)
3385 } else if route_uses_generated_replay_stop(route) {
3386 Some(&layers.route)
3387 } else {
3388 Some(&layers.manual)
3389 }
3390}
3391
3392const fn route_uses_generated_replay_stop(route: &Route) -> bool {
3393 matches!(
3394 route.idempotency,
3395 crate::route::RouteIdempotency::ReplayThroughInner
3396 )
3397}
3398
3399fn custom_layers_require_fail_closed_idempotency(
3400 custom_layers: &[crate::app::CustomLayerRegistration],
3401) -> bool {
3402 custom_layers
3403 .iter()
3404 .any(|registered| !is_idempotency_transparent_app_layer(registered))
3405}
3406
3407fn is_idempotency_transparent_app_layer(registered: &crate::app::CustomLayerRegistration) -> bool {
3408 registered
3409 .type_name
3410 .starts_with("autumn_web::session::SessionLayer<")
3411 || registered
3412 .type_name
3413 .starts_with("autumn::session::SessionLayer<")
3414 || registered.type_id
3415 == std::any::TypeId::of::<crate::session::SessionLayer<crate::session::MemoryStore>>()
3416 || is_i18n_bundle_extension_layer(registered.type_id)
3417 || is_edge_fallthrough_sentinel_strip_layer(registered.type_id)
3418 || is_require_api_token_layer(registered.type_id)
3419}
3420
3421fn is_require_api_token_layer(type_id: std::any::TypeId) -> bool {
3432 type_id == std::any::TypeId::of::<crate::auth::RequireApiToken>()
3433}
3434
3435#[cfg(feature = "edge")]
3450fn is_edge_fallthrough_sentinel_strip_layer(type_id: std::any::TypeId) -> bool {
3451 type_id == std::any::TypeId::of::<crate::app::StripEdgeFallthroughSentinelLayer>()
3452}
3453
3454#[cfg(not(feature = "edge"))]
3455const fn is_edge_fallthrough_sentinel_strip_layer(_type_id: std::any::TypeId) -> bool {
3456 false
3457}
3458
3459#[cfg(feature = "i18n")]
3460fn is_i18n_bundle_extension_layer(type_id: std::any::TypeId) -> bool {
3461 type_id == std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>()
3462}
3463
3464#[cfg(not(feature = "i18n"))]
3465const fn is_i18n_bundle_extension_layer(_type_id: std::any::TypeId) -> bool {
3466 false
3467}
3468
3469#[cfg_attr(not(feature = "mail"), allow(unused_variables))]
3470#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
3471fn mount_framework_routes(
3472 mut router: axum::Router<AppState>,
3473 config: &AutumnConfig,
3474 dev_reload_enabled: bool,
3475) -> axum::Router<AppState> {
3476 #[cfg(not(feature = "mail"))]
3477 let _ = config;
3478
3479 #[cfg(feature = "htmx")]
3485 {
3486 if crate::assets::htmx_is_vendored() {
3491 tracing::debug!(
3492 path = crate::htmx::HTMX_JS_PATH,
3493 "htmx vendored via `autumn assets`; built-in handler skipped, ServeDir serves it"
3494 );
3495 }
3496 router = router.merge(framework_scripts_router());
3497 for asset in crate::htmx::framework_scripts() {
3498 tracing::debug!(
3499 method = "GET",
3500 path = asset.plain_url(),
3501 fingerprinted = asset.url(),
3502 "Mounted route"
3503 );
3504 }
3505 }
3506
3507 #[cfg(feature = "flash")]
3511 {
3512 router = router.route(
3513 crate::flash::FLASH_CSS_PATH,
3514 axum::routing::get(flash_css_handler),
3515 );
3516 tracing::debug!(
3517 method = "GET",
3518 path = crate::flash::FLASH_CSS_PATH,
3519 name = "autumn flash stylesheet",
3520 "Mounted route"
3521 );
3522 }
3523
3524 #[cfg(feature = "maud")]
3528 {
3529 router = router.route(
3530 crate::ui::WIDGETS_CSS_PATH,
3531 axum::routing::get(widgets_css_handler),
3532 );
3533 tracing::debug!(
3534 method = "GET",
3535 path = crate::ui::WIDGETS_CSS_PATH,
3536 name = "autumn widget stylesheet",
3537 "Mounted route"
3538 );
3539 }
3540
3541 if dev_reload_enabled {
3542 router = router.route(
3543 dev::LIVE_RELOAD_PATH,
3544 axum::routing::get(dev::live_reload_state_handler),
3545 );
3546 router = router.route(
3547 dev::LIVE_RELOAD_SCRIPT_PATH,
3548 axum::routing::get(dev::live_reload_script_handler),
3549 );
3550 tracing::debug!(
3551 state_path = dev::LIVE_RELOAD_PATH,
3552 script_path = dev::LIVE_RELOAD_SCRIPT_PATH,
3553 "Mounted dev live reload endpoints"
3554 );
3555 }
3556
3557 #[cfg(feature = "mail")]
3558 if config
3559 .mail
3560 .preview_routes_enabled(config.profile.as_deref())
3561 {
3562 router = router.merge(crate::mail::mail_preview_router(
3563 config.mail.file_dir.clone(),
3564 ));
3565 tracing::debug!(
3566 path = crate::mail::MAIL_PREVIEW_PATH,
3567 "Mounted dev mail preview endpoints"
3568 );
3569 }
3570
3571 #[cfg(feature = "maud")]
3576 if config.stories.enabled {
3577 router = router.merge(crate::stories::story_router());
3578 tracing::debug!(
3579 path = crate::stories::STORIES_PATH,
3580 "Mounted story gallery endpoints"
3581 );
3582 }
3583
3584 #[cfg(feature = "mail")]
3588 if config.mail.should_mount_unsubscribe_endpoint() {
3589 router = router.merge(crate::mail::unsubscribe_router());
3590 tracing::debug!(
3591 path = crate::mail::UNSUBSCRIBE_PATH,
3592 "Mounted default unsubscribe endpoint"
3593 );
3594 }
3595
3596 if config.jobs.tracking.route_enabled {
3599 router = router.merge(crate::job_tracking::status_router());
3600 tracing::debug!(
3601 path = crate::job_tracking::JOB_STATUS_ROUTE_PATH,
3602 "Mounted tracked-job status endpoint"
3603 );
3604 }
3605
3606 router
3607}
3608
3609fn mount_probe_endpoints<S>(
3610 mut router: axum::Router<S>,
3611 config: &AutumnConfig,
3612 user_get_paths: &std::collections::HashSet<String>,
3613) -> (std::collections::HashSet<String>, axum::Router<S>)
3614where
3615 S: Clone + Send + Sync + 'static,
3616 AppState: axum::extract::FromRef<S>,
3617{
3618 let mut mounted_probe_paths = std::collections::HashSet::new();
3625
3626 if !config.health.enabled {
3632 tracing::info!("health.enabled = false; built-in probe endpoints are not auto-mounted");
3633 return (mounted_probe_paths, router);
3634 }
3635
3636 let mut mount_probe = |mut router: axum::Router<S>,
3637 path: &str,
3638 label: &'static str,
3639 handler: axum::routing::MethodRouter<S>|
3640 -> axum::Router<S> {
3641 if user_get_paths.contains(path) {
3642 tracing::info!(
3643 probe = label,
3644 path,
3645 "a user route already owns this path; the built-in probe was \
3646 not auto-mounted (the user handler wins)"
3647 );
3648 mounted_probe_paths.insert(path.to_owned());
3656 return router;
3657 }
3658 if mounted_probe_paths.insert(path.to_owned()) {
3659 router = router.route(path, handler);
3660 }
3661 router
3662 };
3663
3664 router = mount_probe(
3665 router,
3666 &config.health.live_path,
3667 "liveness",
3668 axum::routing::get(crate::probe::live_handler::<AppState>),
3669 );
3670 router = mount_probe(
3671 router,
3672 &config.health.ready_path,
3673 "readiness",
3674 axum::routing::get(crate::probe::ready_handler::<AppState>),
3675 );
3676 router = mount_probe(
3677 router,
3678 &config.health.startup_path,
3679 "startup",
3680 axum::routing::get(crate::probe::startup_handler::<AppState>),
3681 );
3682 router = mount_probe(
3683 router,
3684 &config.health.path,
3685 "health",
3686 axum::routing::get(crate::health::handler::<AppState>),
3687 );
3688 tracing::debug!(
3689 health = %config.health.path,
3690 live = %config.health.live_path,
3691 ready = %config.health.ready_path,
3692 startup = %config.health.startup_path,
3693 "Mounted probe endpoints"
3694 );
3695
3696 (mounted_probe_paths, router)
3697}
3698
3699fn mount_actuator_endpoints(
3700 mut router: axum::Router<AppState>,
3701 config: &AutumnConfig,
3702 mounted_probe_paths: &std::collections::HashSet<String>,
3703) -> Result<axum::Router<AppState>, RouterBuildError> {
3704 let actuator_sensitive = config.actuator.sensitive;
3706 let actuator_prometheus = config.actuator.prometheus;
3707 let actuator_paths = crate::actuator::actuator_endpoint_paths(
3708 &config.actuator.prefix,
3709 actuator_sensitive,
3710 actuator_prometheus,
3711 );
3712 if let Some(path) = actuator_paths
3713 .iter()
3714 .find(|path| mounted_probe_paths.contains(path.as_str()))
3715 {
3716 return Err(RouterBuildError::FrameworkRouteOverlap {
3717 path: path.clone(),
3718 existing: "probe endpoint",
3719 incoming: "actuator endpoint",
3720 });
3721 }
3722 router = router.merge(crate::actuator::actuator_router_with_prefix(
3723 &config.actuator.prefix,
3724 actuator_sensitive,
3725 actuator_prometheus,
3726 ));
3727 tracing::debug!(
3728 sensitive = actuator_sensitive,
3729 prometheus = actuator_prometheus,
3730 prefix = %config.actuator.prefix,
3731 "Mounted actuator endpoints"
3732 );
3733 if !actuator_prometheus {
3734 tracing::info!(
3735 config_key = "actuator.prometheus",
3736 "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"
3737 );
3738 }
3739 Ok(router)
3740}
3741
3742fn mount_scoped_groups(
3743 mut router: axum::Router<AppState>,
3744 scoped_groups: Vec<ScopedGroup>,
3745 idempotency_layers: Option<&BuiltIdempotencyLayers>,
3746 state: &AppState,
3747) -> axum::Router<AppState> {
3748 for group in scoped_groups {
3750 let mut sub_router = axum::Router::new();
3751 for route in group.routes {
3752 tracing::debug!(
3753 method = %route.method,
3754 path = route.path,
3755 name = route.name,
3756 scope = %group.prefix,
3757 "Mounted scoped route"
3758 );
3759 let selected_layer = idempotency_layers
3767 .filter(|_| !matches!(route.idempotency, crate::route::RouteIdempotency::Off))
3768 .map(|layers| &layers.manual);
3769 let seo = route.seo;
3770 let mut handler = route.handler;
3771 if let Some(layer) = selected_layer {
3772 handler = handler.layer(layer.clone());
3773 }
3774 handler = attach_seo_defaults(handler, seo);
3775 if let Some(version) = route.api_version {
3776 handler = handler.layer(axum::middleware::from_fn_with_state(
3777 state.clone(),
3778 api_versioning_middleware,
3779 ));
3780 handler = handler.layer(axum::Extension(RouteVersionMetadata {
3781 version: version.to_string(),
3782 sunset_opt_out: route.sunset_opt_out,
3783 secured: route.api_doc.secured,
3784 required_roles: route.api_doc.required_roles,
3785 has_policy: route.api_doc.has_policy,
3786 }));
3787 }
3788 sub_router = sub_router.route(route.path, handler);
3789 }
3790 sub_router = (group.apply_layer)(sub_router);
3791 router = router.nest(&group.prefix, sub_router);
3792 }
3793 router
3794}
3795
3796fn mount_raw_routers(
3797 mut router: axum::Router<AppState>,
3798 merge_routers: Vec<axum::Router<AppState>>,
3799 nest_routers: Vec<(String, axum::Router<AppState>)>,
3800 idempotency_layers: Option<&BuiltIdempotencyLayers>,
3801) -> axum::Router<AppState> {
3802 for raw_router in merge_routers {
3805 tracing::debug!("Merged raw Axum router");
3806 let raw_router = if let Some(layers) = idempotency_layers {
3807 raw_router.layer(layers.manual.clone())
3808 } else {
3809 raw_router
3810 };
3811 router = router.merge(raw_router);
3812 }
3813
3814 for (prefix, raw_router) in nest_routers {
3816 tracing::debug!(prefix = %prefix, "Nested raw Axum router");
3817 let nested_router =
3820 raw_router.fallback(crate::middleware::error_page_filter::fallback_404_handler);
3821 let nested_router = if let Some(layers) = idempotency_layers {
3822 nested_router.layer(layers.manual.clone())
3823 } else {
3824 nested_router
3825 };
3826 router = router.nest(&prefix, nested_router);
3827 }
3828 router
3829}
3830
3831const COMPRESSION_EXCLUDED_CONTENT_TYPES: &[&str] = &[
3837 "audio/",
3839 "video/",
3840 "application/octet-stream",
3841 "application/zip",
3843 "application/gzip",
3844 "application/x-gzip",
3845 "application/zstd",
3846 "application/x-bzip2",
3847 "application/x-bzip",
3848 "application/x-rar-compressed",
3849 "application/vnd.rar",
3850 "application/x-7z-compressed",
3851 "font/woff",
3856 "font/woff2",
3857];
3858
3859#[derive(Clone)]
3880struct CompressionPredicate {
3881 default: tower_http::compression::predicate::DefaultPredicate,
3882}
3883
3884impl tower_http::compression::predicate::Predicate for CompressionPredicate {
3885 fn should_compress<B>(&self, response: &http::Response<B>) -> bool
3886 where
3887 B: http_body::Body,
3888 {
3889 let headers = response.headers();
3892 self.default.should_compress(response)
3893 && !headers.contains_key("content-digest")
3894 && !headers.contains_key("repr-digest")
3895 && response
3896 .headers()
3897 .get(http::header::CONTENT_TYPE)
3898 .and_then(|value| value.to_str().ok())
3899 .is_none_or(|content_type| !is_compression_excluded(content_type))
3900 }
3901}
3902
3903fn is_compression_excluded(content_type: &str) -> bool {
3906 COMPRESSION_EXCLUDED_CONTENT_TYPES.iter().any(|excluded| {
3907 content_type
3908 .as_bytes()
3909 .get(..excluded.len())
3910 .is_some_and(|head| head.eq_ignore_ascii_case(excluded.as_bytes()))
3911 })
3912}
3913
3914fn compression_predicate() -> CompressionPredicate {
3915 CompressionPredicate {
3916 default: tower_http::compression::predicate::DefaultPredicate::new(),
3917 }
3918}
3919
3920fn apply_compression_middleware<S>(
3932 mut router: axum::Router<S>,
3933 config: &AutumnConfig,
3934) -> axum::Router<S>
3935where
3936 S: Clone + Send + Sync + 'static,
3937{
3938 if config.compression.enabled {
3939 router = router.layer(
3940 tower_http::compression::CompressionLayer::new().compress_when(compression_predicate()),
3941 );
3942 tracing::info!("Response compression enabled (gzip/brotli)");
3943 }
3944 router
3945}
3946
3947#[cfg(test)]
3950fn apply_cors_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
3951where
3952 S: Clone + Send + Sync + 'static,
3953{
3954 match build_ingress_cors_layer(config) {
3955 Some(layer) => router.layer(layer),
3956 None => router,
3957 }
3958}
3959
3960fn build_ingress_cors_layer(config: &AutumnConfig) -> Option<tower_http::cors::CorsLayer> {
3965 if config.cors.allowed_origins.is_empty() {
3967 return None;
3968 }
3969 let pays = config.aeo.enabled
3972 && config
3973 .aeo
3974 .paid_routes
3975 .iter()
3976 .any(crate::aeo::commerce::PaidRoute::is_x402)
3977 && !config.cors.allowed_headers.iter().any(|h| {
3979 (h == "*" && !config.cors.allow_credentials)
3980 || h.eq_ignore_ascii_case("payment-signature")
3981 });
3982 let cors = if pays {
3983 let mut cors = config.cors.clone();
3984 cors.allowed_headers.push("payment-signature".to_owned());
3985 build_cors_layer(&cors)
3986 } else {
3987 build_cors_layer(&config.cors)
3988 };
3989 tracing::info!(
3990 origins = ?config.cors.allowed_origins,
3991 credentials = config.cors.allow_credentials,
3992 "CORS enabled"
3993 );
3994 Some(cors)
3995}
3996
3997#[cfg(test)]
4000fn apply_csrf_middleware<S>(
4001 router: axum::Router<S>,
4002 config: &AutumnConfig,
4003 signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
4004) -> axum::Router<S>
4005where
4006 S: Clone + Send + Sync + 'static,
4007{
4008 match build_csrf_layer(config, signing_keys) {
4009 Some(layer) => router.layer(layer),
4010 None => router,
4011 }
4012}
4013
4014#[cfg(feature = "tls")]
4024fn build_client_cert_requirement_layer(
4025 config: &crate::config::AutumnConfig,
4026) -> Option<crate::tls::client_auth::RequireClientCertLayer> {
4027 let client_auth = config.server.tls.as_ref()?.client_auth.as_ref()?;
4028 if !client_auth.mode.requests_certificate() || client_auth.required_paths.is_empty() {
4029 return None;
4030 }
4031 Some(crate::tls::client_auth::RequireClientCertLayer::for_paths(
4032 client_auth.required_paths.clone(),
4033 ))
4034}
4035
4036#[cfg(not(feature = "tls"))]
4039const fn build_client_cert_requirement_layer(
4040 _config: &crate::config::AutumnConfig,
4041) -> Option<tower::layer::util::Identity> {
4042 None
4043}
4044
4045fn build_csrf_layer(
4048 config: &AutumnConfig,
4049 signing_keys: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>>,
4050) -> Option<crate::security::CsrfLayer> {
4051 if config.security.csrf.enabled {
4053 let effective_scan_bytes = config
4067 .security
4068 .csrf
4069 .token_scan_bytes
4070 .min(config.security.upload.max_request_size_bytes);
4071 let mut csrf_layer = crate::security::CsrfLayer::from_config(&config.security.csrf)
4072 .with_max_scan_bytes(effective_scan_bytes);
4073 if let Some(keys) = signing_keys {
4074 csrf_layer = csrf_layer.with_signing_keys(keys);
4075 }
4076 for endpoint in &config.security.webhooks.endpoints {
4077 csrf_layer = csrf_layer.with_exempt_path(&endpoint.path);
4078 }
4079 #[cfg(feature = "mail")]
4083 if config.mail.should_mount_unsubscribe_endpoint() {
4084 csrf_layer = csrf_layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
4085 }
4086 tracing::info!("CSRF protection enabled");
4087 Some(csrf_layer)
4088 } else {
4089 None
4090 }
4091}
4092
4093#[cfg(test)]
4105fn apply_submit_token_middleware<S>(
4106 router: axum::Router<S>,
4107 config: &AutumnConfig,
4108 is_production: bool,
4109) -> Result<axum::Router<S>, RouterBuildError>
4110where
4111 S: Clone + Send + Sync + 'static,
4112{
4113 Ok(
4114 match build_submit_token_layer(config, &AppState::for_test(), is_production)? {
4115 Some(layer) => router.layer(layer),
4116 None => router,
4117 },
4118 )
4119}
4120
4121fn build_submit_token_layer(
4128 config: &AutumnConfig,
4129 state: &AppState,
4130 is_production: bool,
4131) -> Result<Option<crate::security::SubmitTokenLayer>, RouterBuildError> {
4132 let cfg = &config.security.submit_token;
4133 if !cfg.enabled {
4134 return Ok(None);
4135 }
4136
4137 match cfg.production_memory_guard(config.idempotency.backend, is_production) {
4145 crate::security::config::SubmitTokenMemoryGuard::Ok => {}
4146 crate::security::config::SubmitTokenMemoryGuard::WarnInherited => {
4147 tracing::warn!(
4148 "[security.submit_token].backend resolved to the in-memory store in production \
4149 (inherited from [idempotency].backend, which is unset or memory). \
4150 Single-replica deployments are fine, but multi-replica deployments need a shared \
4151 backend: configure [idempotency] with backend = \"redis\" (or set \
4152 [security.submit_token].backend = \"redis\") so consumed tokens are shared across \
4153 replicas — otherwise a duplicate submit can slip through on a different replica."
4154 );
4155 }
4156 crate::security::config::SubmitTokenMemoryGuard::FailExplicit => {
4157 return Err(RouterBuildError::InvalidSubmitTokenBackend(
4158 "the in-memory submit-token backend is not safe for multi-replica production use. \
4159 Set `[security.submit_token].backend = \"redis\"` in autumn.toml (it reuses the \
4160 [idempotency.redis] connection settings), or remove the explicit `backend` \
4161 override to inherit `[idempotency].backend`."
4162 .to_owned(),
4163 ));
4164 }
4165 }
4166
4167 let ttl = Duration::from_secs(cfg.ttl_secs);
4168 let backend = cfg.resolved_backend(config.idempotency.backend);
4175 let store = build_idempotency_store(backend, config, state, ttl, "submit_token")?;
4176
4177 let mut layer = crate::security::SubmitTokenLayer::new(store, cfg)
4178 .with_max_scan_bytes(config.security.upload.max_request_size_bytes);
4179 for endpoint in &config.security.webhooks.endpoints {
4180 layer = layer.with_exempt_path(&endpoint.path);
4181 }
4182 #[cfg(feature = "mail")]
4183 if config.mail.should_mount_unsubscribe_endpoint() {
4184 layer = layer.with_exempt_path(crate::mail::UNSUBSCRIBE_PATH);
4185 }
4186 tracing::info!(
4187 backend = ?backend,
4188 inherited = cfg.backend.is_none(),
4189 ttl_secs = cfg.ttl_secs,
4190 "One-time submit-token protection enabled"
4191 );
4192 Ok(Some(layer))
4193}
4194
4195fn build_bot_protection_layer(
4203 config: &AutumnConfig,
4204) -> Option<crate::security::captcha::BotProtectionLayer> {
4205 if config.bot_protection.enabled {
4206 let mut exempt = config.security.captcha_exempt_paths.clone();
4210 for endpoint in &config.security.webhooks.endpoints {
4211 exempt.push(endpoint.path.clone());
4212 }
4213 #[cfg(feature = "mail")]
4216 if config.mail.should_mount_unsubscribe_endpoint() {
4217 exempt.push(crate::mail::UNSUBSCRIBE_PATH.to_owned());
4218 }
4219 let layer =
4220 crate::security::captcha::BotProtectionLayer::from_config(&config.bot_protection)
4221 .with_max_scan_bytes(config.security.upload.max_request_size_bytes)
4222 .with_exempt_paths(exempt);
4223 tracing::info!(
4224 provider = ?config.bot_protection.provider,
4225 dev_bypass = config.bot_protection.dev_bypass,
4226 "Bot protection (CAPTCHA) enabled"
4227 );
4228 Some(layer)
4229 } else {
4230 None
4231 }
4232}
4233
4234async fn populate_rate_limit_principal(
4235 axum::extract::State(state): axum::extract::State<AppState>,
4236 mut req: axum::extract::Request,
4237 next: axum::middleware::Next,
4238) -> axum::response::Response {
4239 if let Some(session) = req.extensions().get::<crate::session::Session>() {
4251 let auth_session_key = state.auth_session_key();
4252 if let Some(user_id) =
4255 crate::auth::impersonation::throttle_principal_id(session, auth_session_key).await
4256 {
4257 req.extensions_mut()
4258 .insert(crate::security::RateLimitPrincipal(user_id));
4259 }
4260 }
4261 next.run(req).await
4262}
4263
4264#[cfg(feature = "mcp")]
4267fn apply_trusted_proxies_middleware<S>(
4268 router: axum::Router<S>,
4269 config: &AutumnConfig,
4270) -> axum::Router<S>
4271where
4272 S: Clone + Send + Sync + 'static,
4273{
4274 router.layer(build_trusted_proxies_layer(config))
4275}
4276
4277fn build_trusted_proxies_layer(config: &AutumnConfig) -> crate::security::TrustedProxiesLayer {
4285 let tp = &config.security.trusted_proxies;
4286 if tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some() {
4287 tracing::info!(
4288 ranges = ?tp.ranges,
4289 trusted_hops = ?tp.trusted_hops,
4290 "Centralized trusted-proxy resolution enabled"
4291 );
4292 }
4293 crate::security::TrustedProxiesLayer::from_config(tp)
4294}
4295
4296#[cfg(any(test, feature = "mcp"))]
4300fn apply_rate_limit_middleware(
4301 mut router: axum::Router<AppState>,
4302 config: &AutumnConfig,
4303 state: &AppState,
4304) -> axum::Router<AppState> {
4305 let (limiter, principal_keying) = build_rate_limit_layers(config, state);
4306 if let Some(limiter) = limiter {
4307 router = router.layer(limiter);
4308 }
4309 if principal_keying {
4312 router = router.layer(axum::middleware::from_fn_with_state(
4313 state.clone(),
4314 populate_rate_limit_principal,
4315 ));
4316 }
4317 router
4318}
4319
4320fn build_rate_limit_layers(
4330 config: &AutumnConfig,
4331 state: &AppState,
4332) -> (Option<crate::security::RateLimitLayer>, bool) {
4333 if config.security.rate_limit.enabled {
4334 let tp = &config.security.trusted_proxies;
4335 let rl = &config.security.rate_limit;
4336 let has_top_level_proxy_config =
4337 tp.trust_forwarded_headers || !tp.ranges.is_empty() || tp.trusted_hops.is_some();
4338 let has_rate_limit_proxy_config =
4343 rl.trust_forwarded_headers || !rl.trusted_proxies.is_empty();
4344 let mut layer = crate::security::RateLimitLayer::from_config(rl)
4349 .honoring_mcp_exempt()
4350 .with_clock(state.clock.clone());
4351 if has_top_level_proxy_config && !has_rate_limit_proxy_config {
4352 let resolver = crate::security::ProxyResolver::from_config(tp);
4353 layer = layer.with_proxy_resolver(resolver);
4354 }
4355 tracing::info!(
4356 rps = config.security.rate_limit.requests_per_second,
4357 burst = config.security.rate_limit.burst,
4358 "Rate limiting enabled"
4359 );
4360 let principal_keying = config.security.rate_limit.key_strategy
4361 == crate::security::KeyStrategy::AuthenticatedPrincipal;
4362 (Some(layer), principal_keying)
4363 } else {
4364 (None, false)
4365 }
4366}
4367
4368#[cfg(test)]
4371fn apply_upload_middleware<S>(router: axum::Router<S>, config: &AutumnConfig) -> axum::Router<S>
4372where
4373 S: Clone + Send + Sync + 'static,
4374{
4375 let (body_limit, upload_config) = build_upload_layers(config);
4376 router
4381 .layer(body_limit)
4382 .layer(axum::Extension(upload_config))
4383}
4384
4385fn build_upload_layers(
4392 config: &AutumnConfig,
4393) -> (
4394 axum::extract::DefaultBodyLimit,
4395 crate::security::config::UploadConfig,
4396) {
4397 let upload_config = config.security.upload.clone();
4398 let max_request_size = upload_config.max_request_size_bytes;
4399 tracing::info!(
4400 max_request_size_bytes = max_request_size,
4401 max_file_size_bytes = upload_config.max_file_size_bytes,
4402 allowed_mime_types = ?upload_config.allowed_mime_types,
4403 "Request body size limits enabled (applies to all content types)"
4404 );
4405 (
4413 axum::extract::DefaultBodyLimit::max(max_request_size),
4414 upload_config,
4415 )
4416}
4417
4418fn probe_bypass_paths(config: &AutumnConfig) -> Vec<String> {
4425 vec![
4426 config.health.path.clone(),
4427 config.health.live_path.clone(),
4428 config.health.ready_path.clone(),
4429 config.health.startup_path.clone(),
4430 crate::actuator::actuator_route_path(&config.actuator.prefix, "/health"),
4431 ]
4432}
4433
4434fn build_maintenance_layer(
4443 config: &AutumnConfig,
4444 state: &AppState,
4445) -> crate::middleware::maintenance::MaintenanceLayer {
4446 let maintenance_state = state
4447 .extension::<crate::maintenance::MaintenanceState>()
4448 .map(|s| (*s).clone())
4449 .unwrap_or_default();
4450 crate::middleware::maintenance::MaintenanceLayer::new(maintenance_state)
4451 .with_health_prefix(config.actuator.prefix.clone())
4452 .with_probe_paths(probe_bypass_paths(config))
4453}
4454
4455fn build_load_shed_layer(
4469 config: &AutumnConfig,
4470 state: &AppState,
4471) -> Option<crate::middleware::LoadShedLayer> {
4472 let resolved = crate::capacity::resolve_configured_admission_limit_for_profile(
4479 config.server.max_concurrent_requests,
4480 config.server.capacity_contract.as_deref(),
4481 config.profile_admission_default(),
4482 config.profile.as_deref(),
4483 );
4484 let admission = &config.server.admission;
4485 let shares = admission.partition_shares().unwrap_or_else(|error| {
4486 tracing::warn!(%error, "invalid server.admission.partitions; using the defaults");
4489 crate::admission::PartitionShares::default()
4490 });
4491 if admission.mode == crate::config::AdmissionMode::Adaptive {
4492 match crate::admission::AdaptiveLimiter::from_config(admission, resolved.limit()) {
4493 Ok(limiter) => {
4494 tracing::info!(
4495 algorithm = ?admission.algorithm,
4496 initial_limit = limiter.limit(),
4497 "admission control uses an adaptive limit"
4498 );
4499 return Some(finish_load_shed_layer(
4500 crate::middleware::LoadShedLayer::adaptive(limiter, state.metrics.clone()),
4501 shares,
4502 config,
4503 ));
4504 }
4505 Err(error) => tracing::warn!(
4506 %error,
4507 "invalid server.admission limits; using the static ceiling"
4508 ),
4509 }
4510 }
4511 let limit = resolved.limit()?;
4512 match resolved {
4513 crate::capacity::AdmissionLimit::Contract(_) => tracing::info!(
4514 limit,
4515 source = resolved.source(),
4516 contract = config
4517 .server
4518 .capacity_contract
4519 .as_deref()
4520 .unwrap_or_default(),
4521 "admission control sourced from the committed capacity contract"
4522 ),
4523 crate::capacity::AdmissionLimit::ProfileDefault(_) => tracing::info!(
4524 limit,
4525 source = resolved.source(),
4526 "admission control uses the profile default ceiling; set \
4527 server.max_concurrent_requests to tune it, or 0 to turn it off"
4528 ),
4529 _ => {}
4530 }
4531 Some(finish_load_shed_layer(
4539 crate::middleware::LoadShedLayer::new(limit, state.metrics.clone()),
4540 shares,
4541 config,
4542 ))
4543}
4544
4545fn finish_load_shed_layer(
4548 layer: crate::middleware::LoadShedLayer,
4549 shares: crate::admission::PartitionShares,
4550 config: &AutumnConfig,
4551) -> crate::middleware::LoadShedLayer {
4552 let cors =
4553 (!config.cors.allowed_origins.is_empty()).then(|| std::sync::Arc::new(config.cors.clone()));
4554 layer
4555 .with_partitions(shares)
4556 .with_health_prefix(config.actuator.prefix.clone())
4557 .with_probe_paths(probe_bypass_paths(config))
4558 .with_cors(cors)
4559}
4560
4561fn fault_injection_exempt_paths(config: &AutumnConfig) -> Vec<String> {
4573 let mut paths = probe_bypass_paths(config);
4574 paths.extend(crate::actuator::actuator_endpoint_paths(
4575 &config.actuator.prefix,
4576 config.actuator.sensitive,
4577 config.actuator.prometheus,
4578 ));
4579 paths
4580}
4581
4582fn install_outer_fault_injection(
4587 router: axum::Router<AppState>,
4588 config: &AutumnConfig,
4589 state: &AppState,
4590) -> axum::Router<AppState> {
4591 let Some(handle) = state.extension::<crate::fault_injection::FaultInjection>() else {
4592 return router;
4593 };
4594 let deadline = config
4595 .server
4596 .timeouts
4597 .request_timeout_ms
4598 .filter(|ms| *ms > 0)
4599 .map(std::time::Duration::from_millis);
4600 router.layer(handle.layers(deadline))
4601}
4602
4603fn build_shadow_layer(
4604 config: &AutumnConfig,
4605 state: &AppState,
4606) -> Option<crate::shadow::ShadowMirrorLayer> {
4607 if !config.shadow.is_active() {
4608 return None;
4609 }
4610 let target_base = config.shadow.target_base()?.to_owned();
4611
4612 #[cfg(not(feature = "http-client"))]
4616 {
4617 tracing::warn!(
4618 "[shadow] enabled but this build has no `http-client` feature; \
4619 traffic mirroring is inactive"
4620 );
4621 state.insert_extension(crate::shadow::ShadowHandle::inactive(target_base));
4626 return None;
4627 }
4628
4629 #[cfg(feature = "http-client")]
4630 {
4631 let timeout = std::time::Duration::from_millis(config.shadow.timeout_ms);
4632 let transport = match crate::shadow::transport::HttpShadowTransport::new(
4633 timeout,
4634 config.shadow.max_body_bytes,
4635 ) {
4636 Ok(transport) => std::sync::Arc::new(transport),
4637 Err(error) => {
4638 tracing::warn!(
4639 %error,
4640 "[shadow] could not build the mirroring HTTP client; traffic mirroring \
4641 is inactive"
4642 );
4643 return None;
4644 }
4645 };
4646
4647 let registry = crate::shadow::ShadowRegistry::new(config.shadow.max_records);
4648 state.insert_extension(crate::shadow::ShadowHandle {
4649 registry: registry.clone(),
4650 enabled: true,
4651 target: Some(target_base.clone()),
4652 });
4653
4654 let mut filter_parameters = config.log.filter_parameters.clone();
4657 filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
4658 filter_parameters.extend(crate::confidential::registered_confidential_column_names());
4659 let filter = Arc::new(crate::log::filter::ParameterFilter::new(
4660 &filter_parameters,
4661 &config.log.unfilter_parameters,
4662 ));
4663
4664 let mut exempt_paths = probe_bypass_paths(config);
4672 exempt_paths.extend(crate::actuator::actuator_endpoint_paths(
4673 &config.actuator.prefix,
4674 config.actuator.sensitive,
4675 config.actuator.prometheus,
4676 ));
4677
4678 let selector = crate::shadow::MirrorSelector::new(
4679 config.shadow.sample_rate,
4680 &config.shadow.routes,
4681 &config.actuator.prefix,
4682 &exempt_paths,
4683 );
4684
4685 tracing::info!(
4686 target = %target_base,
4687 sample_rate = config.shadow.sample_rate,
4688 routes = ?config.shadow.routes,
4689 "Shadow traffic mirroring enabled (GET/HEAD only)"
4690 );
4691
4692 Some(crate::shadow::ShadowMirrorLayer::new(
4693 crate::shadow::MirrorSettings {
4694 target_base,
4695 timeout,
4696 max_in_flight: config.shadow.max_in_flight,
4697 max_body_bytes: config.shadow.max_body_bytes,
4698 max_sample_bytes: config.shadow.max_sample_bytes,
4699 },
4700 selector,
4701 registry,
4702 transport,
4703 filter,
4704 state.entropy_arc(),
4705 state.clock_arc(),
4706 ))
4707 }
4708}
4709
4710type RouteTimeoutTable = RouteAttrTable<crate::route::RouteTimeout>;
4719
4720pub type RouteAttrTable<T> =
4723 std::sync::Arc<std::collections::HashMap<String, std::collections::HashMap<http::Method, T>>>;
4724
4725#[derive(Debug)]
4730struct RequestDeadlineExceeded {
4731 timeout_ms: u64,
4732}
4733
4734impl std::fmt::Display for RequestDeadlineExceeded {
4735 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4736 write!(
4737 f,
4738 "the server did not produce a response within the configured {}ms deadline",
4739 self.timeout_ms
4740 )
4741 }
4742}
4743
4744impl std::error::Error for RequestDeadlineExceeded {}
4745
4746#[derive(Clone, Copy, Debug)]
4757pub struct RequestDeadlineCancelled;
4758
4759fn build_route_timeout_table(
4767 route_list: &[Route],
4768 scoped_groups: &[ScopedGroup],
4769 config: &AutumnConfig,
4770) -> RouteTimeoutTable {
4771 build_route_attr_table(route_list, scoped_groups, config, |route| {
4773 (!matches!(route.timeout, crate::route::RouteTimeout::Inherit)).then_some(route.timeout)
4774 })
4775}
4776
4777fn build_route_criticality_table(
4781 route_list: &[Route],
4782 scoped_groups: &[ScopedGroup],
4783 config: &AutumnConfig,
4784) -> RouteAttrTable<crate::admission::Criticality> {
4785 build_route_attr_table(route_list, scoped_groups, config, |route| {
4786 (route.criticality != crate::admission::Criticality::Default).then_some(route.criticality)
4787 })
4788}
4789
4790fn build_route_attr_table<T: Copy>(
4793 route_list: &[Route],
4794 scoped_groups: &[ScopedGroup],
4795 #[cfg_attr(not(feature = "i18n"), allow(unused_variables))] config: &AutumnConfig,
4796 value: impl Fn(&Route) -> Option<T>,
4797) -> RouteAttrTable<T> {
4798 let mut table: std::collections::HashMap<String, std::collections::HashMap<http::Method, T>> =
4799 std::collections::HashMap::new();
4800 let mut insert = |path: String, method: &http::Method, timeout: T| {
4801 let by_method = table.entry(path).or_default();
4812 by_method.insert(effective_mount_method(method), timeout);
4817 if *method == http::Method::GET {
4820 by_method.insert(http::Method::HEAD, timeout);
4821 }
4822 if method.as_str() == "WS" {
4824 by_method.insert(http::Method::CONNECT, timeout);
4825 }
4826 };
4827 for route in route_list {
4828 if let Some(v) = value(route) {
4829 insert(route.path.to_owned(), &route.method, v);
4830 }
4831 }
4832 for group in scoped_groups {
4833 for route in &group.routes {
4834 if let Some(v) = value(route) {
4835 insert(
4836 join_nested_path(&group.prefix, route.path),
4837 &route.method,
4838 v,
4839 );
4840 }
4841 }
4842 }
4843 #[cfg(feature = "i18n")]
4844 expand_route_timeout_table_for_locale_prefix(&mut table, route_list, &config.i18n);
4845 std::sync::Arc::new(table)
4846}
4847
4848#[cfg(feature = "i18n")]
4860fn expand_route_timeout_table_for_locale_prefix<T: Clone>(
4861 table: &mut std::collections::HashMap<String, std::collections::HashMap<http::Method, T>>,
4862 route_list: &[Route],
4863 i18n: &crate::i18n::I18nConfig,
4864) {
4865 if !i18n.locale_prefix_enabled || i18n.supported_locales.is_empty() {
4866 return;
4867 }
4868 for route in route_list {
4869 if i18n
4870 .locale_prefix_exclude_exact
4871 .iter()
4872 .any(|p| p == route.path)
4873 || matches_locale_exclude_prefix(route.path, &i18n.locale_prefix_exclude)
4874 {
4875 continue;
4876 }
4877 let Some(by_method) = table.get(route.path).cloned() else {
4878 continue;
4879 };
4880 for locale in &i18n.supported_locales {
4881 let prefixed_path = if route.path == "/" {
4882 format!("/{locale}")
4883 } else {
4884 format!("/{locale}{}", route.path)
4885 };
4886 table
4887 .entry(prefixed_path)
4888 .or_default()
4889 .extend(by_method.clone());
4890 }
4891 }
4892}
4893
4894#[cfg(any(test, feature = "mcp"))]
4927fn apply_request_timeout_middleware(
4928 router: axum::Router<AppState>,
4929 config: &AutumnConfig,
4930 state: &AppState,
4931 route_timeouts: RouteTimeoutTable,
4932 mirror_cors: bool,
4933) -> axum::Router<AppState> {
4934 let Some(settings) = build_request_timeout_settings(config, state, route_timeouts, mirror_cors)
4935 else {
4936 return router;
4937 };
4938 router.layer(RequestTimeoutLayer::new(settings))
4944}
4945
4946struct RequestTimeoutSettings {
4954 global: Option<Duration>,
4955 route_timeouts: RouteTimeoutTable,
4956 metrics: crate::middleware::MetricsCollector,
4957 cors: Option<std::sync::Arc<crate::config::CorsConfig>>,
4958 accept_deadline_header: bool,
4960 entropy: Arc<dyn crate::entropy::Entropy>,
4962}
4963
4964fn build_request_timeout_settings(
4976 config: &AutumnConfig,
4977 state: &AppState,
4978 route_timeouts: RouteTimeoutTable,
4979 mirror_cors: bool,
4980) -> Option<RequestTimeoutSettings> {
4981 let global = config
4982 .server
4983 .timeouts
4984 .request_timeout_ms
4985 .filter(|ms| *ms > 0)
4986 .map(std::time::Duration::from_millis);
4987 let has_override = route_timeouts
4988 .values()
4989 .flat_map(std::collections::HashMap::values)
4990 .any(|t| matches!(t, crate::route::RouteTimeout::Override(_)));
4991 let has_off_for_admission = config.server.admission.mode
4994 == crate::config::AdmissionMode::Adaptive
4995 && route_timeouts
4996 .values()
4997 .flat_map(std::collections::HashMap::values)
4998 .any(|t| matches!(t, crate::route::RouteTimeout::Disabled));
4999 if global.is_none() && !has_override && !has_off_for_admission {
5000 return None;
5001 }
5002 if let Some(duration) = global {
5003 tracing::info!(
5004 timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
5005 "Inbound request timeout enabled"
5006 );
5007 }
5008 let cors = (mirror_cors && !config.cors.allowed_origins.is_empty())
5011 .then(|| std::sync::Arc::new(config.cors.clone()));
5012 Some(RequestTimeoutSettings {
5013 global,
5014 route_timeouts,
5015 metrics: state.metrics.clone(),
5016 cors,
5017 accept_deadline_header: config.server.timeouts.accept_deadline_header,
5018 entropy: state.entropy_arc(),
5019 })
5020}
5021
5022#[derive(Clone)]
5032pub struct RequestTimeoutLayer {
5033 settings: Arc<RequestTimeoutSettings>,
5034}
5035
5036impl RequestTimeoutLayer {
5037 fn new(settings: RequestTimeoutSettings) -> Self {
5038 Self {
5039 settings: Arc::new(settings),
5040 }
5041 }
5042}
5043
5044impl<S> tower::Layer<S> for RequestTimeoutLayer {
5045 type Service = RequestTimeoutService<S>;
5046
5047 fn layer(&self, inner: S) -> Self::Service {
5048 RequestTimeoutService {
5049 inner,
5050 settings: Arc::clone(&self.settings),
5051 }
5052 }
5053}
5054
5055#[derive(Clone)]
5057pub struct RequestTimeoutService<S> {
5058 inner: S,
5059 settings: Arc<RequestTimeoutSettings>,
5060}
5061
5062impl<S> RequestTimeoutService<S> {
5063 fn deadline_for<B>(&self, req: &Request<B>) -> Option<Duration> {
5068 if req
5074 .extensions()
5075 .get::<crate::static_gen::RenderDeadlineExempt>()
5076 .is_some()
5077 {
5078 return None;
5079 }
5080
5081 let route_timeout = req
5084 .extensions()
5085 .get::<axum::extract::MatchedPath>()
5086 .map(axum::extract::MatchedPath::as_str)
5087 .and_then(|p| self.settings.route_timeouts.get(p))
5088 .and_then(|by_method| by_method.get(req.method()))
5089 .copied()
5090 .unwrap_or(crate::route::RouteTimeout::Inherit);
5091 let route_deadline = match route_timeout {
5092 crate::route::RouteTimeout::Disabled => None,
5093 crate::route::RouteTimeout::Override(d) => Some(d),
5094 crate::route::RouteTimeout::Inherit => self.settings.global,
5095 }?;
5096 let caller_deadline = self
5098 .settings
5099 .accept_deadline_header
5100 .then(|| req.headers().get(crate::deadline::DEADLINE_HEADER))
5101 .flatten()
5102 .and_then(crate::deadline::parse_header);
5103 Some(caller_deadline.map_or(route_deadline, |caller| caller.min(route_deadline)))
5104 }
5105
5106 fn route_timeout_is_off<B>(&self, req: &Request<B>) -> bool {
5108 req.extensions()
5109 .get::<axum::extract::MatchedPath>()
5110 .and_then(|p| self.settings.route_timeouts.get(p.as_str()))
5111 .and_then(|by_method| by_method.get(req.method()))
5112 .is_some_and(|t| matches!(t, crate::route::RouteTimeout::Disabled))
5113 }
5114}
5115
5116impl<S> tower::Service<Request<axum::body::Body>> for RequestTimeoutService<S>
5117where
5118 S: tower::Service<Request<axum::body::Body>, Response = axum::response::Response>,
5119{
5120 type Response = S::Response;
5121 type Error = S::Error;
5122 type Future = RequestTimeoutFuture<S::Future>;
5123
5124 fn poll_ready(
5125 &mut self,
5126 cx: &mut std::task::Context<'_>,
5127 ) -> std::task::Poll<Result<(), Self::Error>> {
5128 self.inner.poll_ready(cx)
5129 }
5130
5131 fn call(&mut self, mut req: Request<axum::body::Body>) -> Self::Future {
5132 let Some(duration) = self.deadline_for(&req) else {
5133 if self.route_timeout_is_off(&req) {
5136 req.extensions_mut()
5139 .insert(crate::admission::InboundDeadline::Off);
5140 }
5141 return RequestTimeoutFuture::Unbounded {
5142 inner: self.inner.call(req),
5143 };
5144 };
5145 let duration =
5148 crate::fault_injection::time_left().map_or(duration, |left| duration.min(left));
5149 if let Some(at) = tokio::time::Instant::now().checked_add(duration) {
5152 req.extensions_mut()
5153 .insert(crate::admission::InboundDeadline::At(at));
5154 }
5155
5156 let matched_path = req
5160 .extensions()
5161 .get::<axum::extract::MatchedPath>()
5162 .cloned();
5163 let request_id = req
5164 .extensions()
5165 .get::<crate::middleware::RequestId>()
5166 .cloned();
5167 let cors_origin = self
5171 .settings
5172 .cors
5173 .as_ref()
5174 .and_then(|_| req.headers().get(http::header::ORIGIN).cloned());
5175
5176 let start = crate::time::ambient_instant();
5191 if duration.is_zero() {
5194 return RequestTimeoutFuture::Elapsed {
5195 response: Some(deadline_exceeded_response(
5196 &self.settings,
5197 duration,
5198 matched_path
5199 .as_ref()
5200 .map(axum::extract::MatchedPath::as_str),
5201 request_id.as_ref(),
5202 cors_origin.as_ref(),
5203 start,
5204 )),
5205 };
5206 }
5207 let deadline = crate::deadline::Deadline::after(duration);
5208 let inner = deadline.sync_scope(|| self.inner.call(req));
5209
5210 RequestTimeoutFuture::Bounded {
5211 inner: crate::deadline::Bounded::until(deadline, inner),
5212 settings: Arc::clone(&self.settings),
5213 duration,
5214 matched_path,
5215 request_id,
5216 cors_origin,
5217 start,
5218 }
5219 }
5220}
5221
5222pin_project_lite::pin_project! {
5223 #[project = RequestTimeoutFutureProj]
5243 pub enum RequestTimeoutFuture<F> {
5244 Unbounded {
5245 #[pin]
5246 inner: F,
5247 },
5248 Bounded {
5249 #[pin]
5250 inner: crate::deadline::Bounded<F>,
5251 settings: Arc<RequestTimeoutSettings>,
5252 duration: Duration,
5253 matched_path: Option<axum::extract::MatchedPath>,
5254 request_id: Option<crate::middleware::RequestId>,
5255 cors_origin: Option<http::HeaderValue>,
5256 start: std::time::Instant,
5257 },
5258 Elapsed {
5259 response: Option<axum::response::Response>,
5260 },
5261 }
5262}
5263
5264impl<F, E> std::future::Future for RequestTimeoutFuture<F>
5265where
5266 F: std::future::Future<Output = Result<axum::response::Response, E>>,
5267{
5268 type Output = Result<axum::response::Response, E>;
5269
5270 #[allow(
5271 clippy::expect_used,
5272 reason = "unreachable: future not polled after Ready"
5273 )]
5274 fn poll(
5275 mut self: std::pin::Pin<&mut Self>,
5276 cx: &mut std::task::Context<'_>,
5277 ) -> std::task::Poll<Self::Output> {
5278 loop {
5279 let elapsed = match self.as_mut().project() {
5280 RequestTimeoutFutureProj::Unbounded { inner } => return inner.poll(cx),
5281 RequestTimeoutFutureProj::Bounded {
5282 inner,
5283 settings,
5284 duration,
5285 matched_path,
5286 request_id,
5287 cors_origin,
5288 start,
5289 } => match std::task::ready!(inner.poll(cx)) {
5290 Ok(response) => return std::task::Poll::Ready(response),
5291 Err(crate::deadline::DeadlineExceeded) => Self::Elapsed {
5292 response: Some(deadline_exceeded_response(
5293 settings,
5294 *duration,
5295 matched_path
5296 .as_ref()
5297 .map(axum::extract::MatchedPath::as_str),
5298 request_id.as_ref(),
5299 cors_origin.as_ref(),
5300 *start,
5301 )),
5302 },
5303 },
5304 RequestTimeoutFutureProj::Elapsed { response } => {
5305 return std::task::Poll::Ready(Ok(response
5306 .take()
5307 .expect("RequestTimeoutFuture polled after completion")));
5308 }
5309 };
5310 self.as_mut().set(elapsed);
5314 }
5315 }
5316}
5317
5318fn deadline_exceeded_response(
5324 settings: &RequestTimeoutSettings,
5325 duration: Duration,
5326 matched_path: Option<&str>,
5327 request_id: Option<&crate::middleware::RequestId>,
5328 cors_origin: Option<&http::HeaderValue>,
5329 start: std::time::Instant,
5330) -> axum::response::Response {
5331 let elapsed_ms = u64::try_from(
5332 crate::time::ambient_instant()
5333 .saturating_duration_since(start)
5334 .as_millis(),
5335 )
5336 .unwrap_or(u64::MAX);
5337 let route = matched_path.unwrap_or("<unmatched>");
5338 tracing::warn!(
5341 target: "autumn::timeout",
5342 route = route,
5343 elapsed_ms = elapsed_ms,
5344 timeout_ms = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
5345 request_id = request_id.map(ToString::to_string),
5346 "inbound request exceeded deadline"
5347 );
5348 settings.metrics.record_request_timeout();
5349 let mut response = crate::error::AutumnError::service_unavailable(RequestDeadlineExceeded {
5353 timeout_ms: u64::try_from(duration.as_millis()).unwrap_or(u64::MAX),
5354 })
5355 .into_response();
5356 response.extensions_mut().insert(RequestDeadlineCancelled);
5359 response.headers_mut().insert(
5361 http::header::RETRY_AFTER,
5362 http::HeaderValue::from(timeout_retry_after_secs(settings.entropy.as_ref())),
5363 );
5364 if let Some(cors) = settings.cors.as_deref() {
5369 mirror_cors_headers(cors, cors_origin, &mut response);
5370 }
5371 response
5372}
5373
5374fn build_idempotency_store(
5376 backend: crate::config::IdempotencyBackend,
5377 config: &AutumnConfig,
5378 state: &AppState,
5379 ttl: Duration,
5380 label: &str,
5381) -> Result<std::sync::Arc<dyn IdempotencyStore>, RouterBuildError> {
5382 #[cfg(not(feature = "db"))]
5383 let _ = state;
5384 match backend {
5385 crate::config::IdempotencyBackend::Memory => {
5386 Ok(std::sync::Arc::new(MemoryIdempotencyStore::new(ttl)))
5387 }
5388 #[cfg(feature = "redis")]
5389 crate::config::IdempotencyBackend::Redis => {
5390 crate::idempotency::RedisIdempotencyStore::from_config(&config.idempotency)
5391 .map(|store| std::sync::Arc::new(store) as std::sync::Arc<dyn IdempotencyStore>)
5392 .map_err(RouterBuildError::InvalidIdempotencyBackend)
5393 }
5394 #[cfg(not(feature = "redis"))]
5395 crate::config::IdempotencyBackend::Redis => {
5396 let _ = config;
5397 Err(RouterBuildError::InvalidIdempotencyBackend(format!(
5398 "{label} backend 'redis' requires the autumn-web 'redis' feature \
5399 flag; rebuild with --features redis or switch to backend = \"memory\""
5400 )))
5401 }
5402 #[cfg(feature = "db")]
5403 crate::config::IdempotencyBackend::Database => state.pool().map_or_else(
5404 || {
5405 Err(RouterBuildError::InvalidIdempotencyBackend(format!(
5406 "{label} backend 'database' requires a configured database; \
5407 set [database] url or switch to backend = \"memory\""
5408 )))
5409 },
5410 |pool| {
5411 Ok(std::sync::Arc::new(
5412 crate::idempotency::DbIdempotencyStore::new(pool.clone(), ttl)
5413 .with_legal_holds(state),
5414 ) as std::sync::Arc<dyn IdempotencyStore>)
5415 },
5416 ),
5417 #[cfg(not(feature = "db"))]
5418 crate::config::IdempotencyBackend::Database => {
5419 Err(RouterBuildError::InvalidIdempotencyBackend(format!(
5420 "{label} backend 'database' requires the autumn-web 'db' feature"
5421 )))
5422 }
5423 }
5424}
5425
5426fn warn_on_short_in_flight_ttl(config: &AutumnConfig) {
5430 let in_flight_secs = config.idempotency.in_flight_ttl_secs;
5431 match config.server.timeouts.request_timeout_ms {
5432 None => tracing::info!(
5433 in_flight_ttl_secs = in_flight_secs,
5434 "no request timeout is set; an idempotent request that runs longer than \
5435 idempotency.in_flight_ttl_secs can run twice"
5436 ),
5437 Some(timeout_ms) if in_flight_secs.saturating_mul(1000) < timeout_ms => tracing::warn!(
5438 in_flight_ttl_secs = in_flight_secs,
5439 request_timeout_ms = timeout_ms,
5440 "idempotency.in_flight_ttl_secs is shorter than the request timeout; \
5441 a retry can run while the first request still runs"
5442 ),
5443 Some(_) => {}
5444 }
5445}
5446
5447const TIMEOUT_RETRY_AFTER_SECS: std::ops::RangeInclusive<u64> = 1..=3;
5449
5450fn timeout_retry_after_secs(entropy: &dyn crate::entropy::Entropy) -> u64 {
5453 let (low, high) = TIMEOUT_RETRY_AFTER_SECS.into_inner();
5454 low + entropy.next_u64() % (high - low + 1)
5455}
5456
5457struct BuiltIdempotencyLayers {
5458 route: crate::idempotency::IdempotencyLayer,
5459 manual: crate::idempotency::IdempotencyLayer,
5460}
5461
5462fn build_idempotency_layers(
5463 config: &AutumnConfig,
5464 state: &AppState,
5465) -> Result<Option<BuiltIdempotencyLayers>, RouterBuildError> {
5466 if !config.idempotency.enabled.unwrap_or(false) {
5467 return Ok(None);
5468 }
5469
5470 let ttl = Duration::from_secs(config.idempotency.ttl_secs);
5471 let in_flight_ttl = Duration::from_secs(config.idempotency.in_flight_ttl_secs);
5472 let store = build_idempotency_store(
5473 config.idempotency.backend,
5474 config,
5475 state,
5476 ttl,
5477 "idempotency",
5478 )?;
5479 warn_on_short_in_flight_ttl(config);
5480
5481 tracing::debug!(
5482 backend = ?config.idempotency.backend,
5483 ttl_secs = config.idempotency.ttl_secs,
5484 in_flight_ttl_secs = config.idempotency.in_flight_ttl_secs,
5485 "Idempotency-key middleware enabled"
5486 );
5487
5488 let base = IdempotencyLayer::new(store)
5489 .with_ttl(ttl)
5490 .with_in_flight_ttl(in_flight_ttl)
5491 .with_metrics(state.metrics.clone())
5492 .with_entropy(state.entropy_arc());
5493
5494 Ok(Some(BuiltIdempotencyLayers {
5495 route: base.clone().replay_through_inner(),
5496 manual: base.fail_closed_on_replay(),
5497 }))
5498}
5499
5500#[derive(Clone)]
5539struct ComposedRegisteredLayers(Vec<crate::app::ErasedAppLayer>);
5540
5541impl ComposedRegisteredLayers {
5542 fn new(registrations: Vec<crate::app::CustomLayerRegistration>) -> Self {
5544 Self(registrations.into_iter().map(|reg| reg.layer).collect())
5545 }
5546}
5547
5548impl<S> tower::Layer<S> for ComposedRegisteredLayers
5549where
5550 S: tower::Service<
5551 axum::extract::Request,
5552 Response = axum::response::Response,
5553 Error = std::convert::Infallible,
5554 > + Clone
5555 + Send
5556 + Sync
5557 + 'static,
5558 S::Future: Send + 'static,
5559{
5560 type Service = crate::app::ErasedAppService;
5561
5562 fn layer(&self, inner: S) -> Self::Service {
5563 let mut svc = crate::app::ErasedAppService::new(inner);
5580 for registered in self.0.iter().rev() {
5581 svc = registered.layer(svc);
5582 }
5583 svc
5584 }
5585}
5586
5587#[derive(Clone, Copy)]
5609struct NormalizeBodyLayer;
5610
5611impl<S> tower::Layer<S> for NormalizeBodyLayer {
5612 type Service = NormalizeBody<S>;
5613
5614 fn layer(&self, inner: S) -> Self::Service {
5615 NormalizeBody(inner)
5616 }
5617}
5618
5619#[derive(Clone, Copy)]
5621struct NormalizeBody<S>(S);
5622
5623fn into_response_result<B, E>(
5626 result: Result<http::Response<B>, E>,
5627) -> Result<axum::response::Response, E>
5628where
5629 http::Response<B>: axum::response::IntoResponse,
5630{
5631 result.map(axum::response::IntoResponse::into_response)
5632}
5633
5634impl<S, B> tower::Service<axum::extract::Request> for NormalizeBody<S>
5635where
5636 S: tower::Service<axum::extract::Request, Response = http::Response<B>>,
5637 http::Response<B>: axum::response::IntoResponse,
5638{
5639 type Response = axum::response::Response;
5640 type Error = S::Error;
5641 type Future = futures::future::Map<
5642 S::Future,
5643 fn(Result<http::Response<B>, S::Error>) -> Result<axum::response::Response, S::Error>,
5644 >;
5645
5646 fn poll_ready(
5647 &mut self,
5648 cx: &mut std::task::Context<'_>,
5649 ) -> std::task::Poll<Result<(), Self::Error>> {
5650 self.0.poll_ready(cx)
5651 }
5652
5653 fn call(&mut self, req: axum::extract::Request) -> Self::Future {
5654 futures::FutureExt::map(
5655 self.0.call(req),
5656 into_response_result::<B, S::Error> as fn(_) -> _,
5657 )
5658 }
5659}
5660
5661#[allow(
5662 clippy::cognitive_complexity,
5663 clippy::too_many_lines,
5664 clippy::too_many_arguments
5665)]
5666fn apply_middleware(
5667 mut router: axum::Router<AppState>,
5668 config: &AutumnConfig,
5669 state: &AppState,
5670 exception_filters: Vec<Arc<dyn ExceptionFilter>>,
5671 custom_layers: Vec<crate::app::CustomLayerRegistration>,
5672 #[cfg(feature = "maud")] error_page_renderer: Option<SharedRenderer>,
5673 session_store: Option<Arc<dyn crate::session::BoxedSessionStore>>,
5674 route_timeouts: RouteTimeoutTable,
5675 criticality_layer: crate::middleware::criticality::CriticalityLayer,
5678 load_shed_layer: Option<crate::middleware::LoadShedLayer>,
5684 defer_shadow: bool,
5690) -> Result<axum::Router<AppState>, RouterBuildError> {
5691 let aeo_site = state.extension::<crate::aeo::AeoSite>();
5699 let fallback_site = aeo_site.clone();
5700 router = router.fallback(
5701 move |axum::extract::State(app): axum::extract::State<AppState>,
5702 req: axum::extract::Request| {
5703 let site = fallback_site.clone();
5704 async move {
5705 #[cfg(feature = "plugin-sandbox")]
5706 if app
5707 .extension::<crate::plugin_sandbox::PluginMounts>()
5708 .is_some_and(|mounts| mounts.owns(req.uri().path()))
5709 {
5710 return crate::plugin_sandbox::mounts_fallback(axum::extract::State(app), req)
5711 .await;
5712 }
5713 #[cfg(feature = "webmcp")]
5714 if let Some(res) = crate::webmcp::serve_manifest(
5715 app.extension::<crate::webmcp::WebMcpManifest>(),
5716 &req,
5717 ) {
5718 return res;
5719 }
5720 #[cfg(not(feature = "plugin-sandbox"))]
5721 let _ = app;
5722 match crate::aeo::serve_document(site, &req) {
5723 Some(res) => res,
5724 None => {
5725 crate::middleware::error_page_filter::fallback_404_handler(
5726 req.method().clone(),
5727 req.uri().clone(),
5728 )
5729 .await
5730 }
5731 }
5732 }
5733 },
5734 );
5735
5736 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
5738 let signing_keys = std::sync::Arc::new(crate::security::config::resolve_signing_keys(
5739 &config.security.signing_secret,
5740 ));
5741 let signing_keys_opt: Option<std::sync::Arc<crate::security::config::ResolvedSigningKeys>> =
5745 if config.security.signing_secret.secret.is_some() || is_production {
5746 Some(signing_keys)
5747 } else {
5748 None
5749 };
5750
5751 let submit_token_layer = build_submit_token_layer(config, state, is_production)?;
5792 let (body_limit, upload_config) = build_upload_layers(config);
5793 let trusted_host_policy = TrustedHostPolicy::from_config_with_state(config, state);
5794 let (rate_limit_layer, rate_limit_principal_keying) = build_rate_limit_layers(config, state);
5795 let fault_injection = crate::fault_injection::build(
5807 config,
5808 state,
5809 fault_injection_exempt_paths(config),
5810 &route_timeouts,
5811 );
5812 let (fault_scope_layer, fault_route_layer, fault_handle) = match fault_injection {
5813 Some((scope, route, handle)) => (Some(scope), Some(route), Some(handle)),
5814 None => (None, None, None),
5815 };
5816
5817 let inner_stack = (
5818 axum::Extension(upload_config),
5821 body_limit,
5823 crate::webhook::WebhookReplayCleanupLayer,
5824 criticality_layer,
5834 tower::util::option_layer(load_shed_layer),
5835 build_maintenance_layer(config, state),
5838 tower::util::option_layer(rate_limit_principal_keying.then(|| {
5841 axum::middleware::from_fn_with_state(state.clone(), populate_rate_limit_principal)
5842 })),
5843 tower::util::option_layer(rate_limit_layer),
5844 crate::middleware::method_override::MethodOverrideRejectionLayer,
5855 tower::util::option_layer(build_client_cert_requirement_layer(config)),
5867 tower::util::option_layer(build_bot_protection_layer(config)),
5868 (
5869 tower::util::option_layer(
5874 config
5875 .security
5876 .csrf
5877 .enabled
5878 .then(|| crate::aeo::commerce::X402CsrfLayer::from_config(config, state))
5879 .flatten(),
5880 ),
5881 tower::util::option_layer(build_csrf_layer(config, signing_keys_opt.clone())),
5882 ),
5883 tower::util::option_layer(submit_token_layer),
5887 TrustedHostLayer::new(trusted_host_policy),
5888 tower::util::option_layer(build_ingress_cors_layer(config)),
5889 (
5890 tower::util::option_layer(fault_route_layer),
5891 aeo_x402_layer(config, state, None),
5896 ),
5897 );
5898
5899 let custom_layer_count = custom_layers.len();
5912 if custom_layer_count > 0 {
5913 tracing::debug!(count = custom_layer_count, "Custom Tower layers applied");
5914 }
5915
5916 let timeout_layer = build_request_timeout_settings(config, state, route_timeouts, true)
5951 .map(RequestTimeoutLayer::new);
5952
5953 #[cfg(feature = "reporting")]
5962 let capture_layer = tower::util::option_layer(config.failure_capture.enabled.then(|| {
5963 let mut capture_filter_parameters = config.log.filter_parameters.clone();
5966 capture_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
5967 capture_filter_parameters
5968 .extend(crate::confidential::registered_confidential_column_names());
5969 let capture_filter = Arc::new(crate::log::filter::ParameterFilter::new(
5970 &capture_filter_parameters,
5971 &config.log.unfilter_parameters,
5972 ));
5973 #[cfg_attr(
5976 any(not(feature = "db"), feature = "sqlite"),
5977 allow(
5978 unused_mut,
5979 reason = "the role assignment below is compiled out on these builds"
5980 )
5981 )]
5982 let mut capture_settings = crate::capsule::settings_from_config(config);
5983 #[cfg(all(feature = "db", not(feature = "sqlite")))]
5990 {
5991 capture_settings.db_roles = crate::capsule::observed_db_roles(
5992 state.pool().is_some(),
5993 state.replica_pool().is_some(),
5994 );
5995 }
5996 let probe_state = state.clone();
5997 crate::capsule::CaptureLayer::new(capture_settings, capture_filter)
5998 .with_state_cache_probe(Arc::new(move || probe_state.has_cache()))
5999 }));
6000 #[cfg(not(feature = "reporting"))]
6001 let capture_layer = tower::layer::util::Identity::new();
6002
6003 let mut log_context_filter_parameters = config.log.filter_parameters.clone();
6010 log_context_filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
6011 log_context_filter_parameters
6012 .extend(crate::confidential::registered_confidential_column_names());
6013 let log_context_filter = Arc::new(crate::log::filter::ParameterFilter::new(
6014 &log_context_filter_parameters,
6015 &config.log.unfilter_parameters,
6016 ));
6017
6018 #[cfg(feature = "reporting")]
6026 let reporting_layer = crate::reporting::ReportingLayer::new(
6027 state.error_reporters(),
6028 config.reporting.enabled,
6029 config.reporting.sample_rate,
6030 );
6031 #[cfg(not(feature = "reporting"))]
6032 let reporting_layer = tower::layer::util::Identity::new();
6033
6034 let access_log_layer = config
6043 .log
6044 .access_log
6045 .then(|| crate::middleware::AccessLogLayer::new(config.log.access_log_exclude.clone()));
6046
6047 let server_timing_layer = crate::config::server_timing_enabled(config)
6054 .then(|| crate::middleware::ServerTimingLayer::new(true));
6055
6056 let cost_layer = config
6061 .cost
6062 .enabled
6063 .then(|| state.extension::<crate::cost::CostAccountant>())
6064 .flatten()
6065 .map(|accountant| {
6066 crate::middleware::CostLayer::new(
6067 (*accountant).clone(),
6068 crate::config::server_timing_enabled(config),
6069 )
6070 });
6071
6072 let tenancy_layer = config.tenancy.enabled.then(|| {
6073 tracing::debug!("Multi-tenancy middleware enabled");
6074 axum::middleware::from_fn_with_state(state.clone(), crate::tenancy::tenancy_middleware)
6075 });
6076
6077 #[cfg(all(feature = "db", not(feature = "sqlite")))]
6083 let tx_timeouts_layer = {
6084 let timeouts = crate::db::TxTimeouts::from_config(&config.database);
6085 Some(axum::middleware::from_fn(
6086 move |req: axum::extract::Request, next: axum::middleware::Next| {
6087 timeouts.scope_request(next.run(req))
6088 },
6089 ))
6090 };
6091 #[cfg(not(all(feature = "db", not(feature = "sqlite"))))]
6092 let tx_timeouts_layer: Option<tower::layer::util::Identity> = None;
6093
6094 let trusted_proxies_layer = build_trusted_proxies_layer(config);
6106 let middle_stack = (
6107 RequestIdLayer::with_entropy(state.entropy_arc())
6108 .with_inbound_trust(trusted_proxies_layer.resolver()),
6109 crate::middleware::LogContextLayer::new(log_context_filter),
6110 tower::util::option_layer(server_timing_layer),
6111 tower::util::option_layer(cost_layer),
6112 tower::util::option_layer(access_log_layer),
6113 capture_layer,
6114 reporting_layer,
6115 tower::util::option_layer(timeout_layer),
6116 tower::util::option_layer(tenancy_layer),
6117 tower::util::option_layer(tx_timeouts_layer),
6118 trusted_proxies_layer,
6119 );
6120
6121 #[cfg(feature = "db")]
6124 let signing_keys_for_ryw = signing_keys_opt.clone();
6125
6126 let tenancy_session_key = (config.tenancy.enabled && config.tenancy.source == "session")
6136 .then(|| Arc::<str>::from(config.tenancy.session_key.as_str()));
6137 let session_layer = crate::session::build_session_layer(
6138 &config.session,
6139 config.profile.as_deref(),
6140 session_store,
6141 signing_keys_opt,
6142 &state.entropy_arc(),
6143 tenancy_session_key,
6144 )?
6145 .with_rotation_hooks(state.session_rotation_hooks());
6146 tracing::debug!(backend = ?config.session.backend, "Session management enabled");
6147
6148 #[cfg(feature = "db")]
6155 let ryw_layer = tower::util::option_layer(
6156 (config.database.read_your_writes != crate::config::ReadYourWrites::Off).then(|| {
6157 let ryw_mode = config.database.read_your_writes;
6158 let window_secs = config.database.pin_after_write_secs;
6159 let keys = signing_keys_for_ryw;
6160 if ryw_mode == crate::config::ReadYourWrites::Session && keys.is_none() {
6161 tracing::warn!(
6162 "read_your_writes = \"session\" requires a configured \
6163 security.signing_secret to sign the autumn.ryw cookie; \
6164 cross-request pinning is disabled until a secret is set"
6165 );
6166 }
6167 let metrics = state.metrics().clone();
6168 crate::read_your_writes::ReadYourWritesLayer::new(ryw_mode, window_secs, keys, metrics)
6169 }),
6170 );
6171 #[cfg(not(feature = "db"))]
6172 let ryw_layer = tower::layer::util::Identity::new();
6173
6174 let is_dev = crate::config::profile_is_dev(config.profile.as_deref());
6180
6181 let mut all_filters: Vec<Arc<dyn ExceptionFilter>> =
6188 vec![Arc::new(ProblemDetailsFilter { is_dev })];
6189 #[cfg(feature = "maud")]
6190 {
6191 let mut filter_parameters = config.log.filter_parameters.clone();
6195 filter_parameters.extend(crate::encryption::registered_encrypted_column_names());
6196 filter_parameters.extend(crate::confidential::registered_confidential_column_names());
6197 let renderer = error_page_renderer.unwrap_or_else(error_pages::default_renderer);
6198 let error_page_filter = crate::middleware::error_page_filter::ErrorPageFilter {
6199 renderer,
6200 is_dev,
6201 parameter_filter: crate::log::filter::ParameterFilter::new(
6202 &filter_parameters,
6203 &config.log.unfilter_parameters,
6204 ),
6205 };
6206 all_filters.push(Arc::new(error_page_filter));
6207 }
6208 all_filters.extend(exception_filters);
6209
6210 let count = all_filters.len();
6211 tracing::debug!(
6212 count,
6213 "Registered exception filters (including error page filter)"
6214 );
6215
6216 let compression_layer = tower::util::option_layer(config.compression.enabled.then(|| {
6280 tracing::info!("Response compression enabled (gzip/brotli)");
6281 (
6282 NormalizeBodyLayer,
6283 tower_http::compression::CompressionLayer::new().compress_when(compression_predicate()),
6284 )
6285 }));
6286
6287 let outer_stack = (
6289 tower::util::option_layer(
6297 (!defer_shadow)
6298 .then(|| build_shadow_layer(config, state))
6299 .flatten(),
6300 ),
6301 tower::util::option_layer(
6307 (!defer_shadow)
6308 .then(|| aeo_site.as_ref().and_then(|site| site.layer()))
6309 .flatten(),
6310 ),
6311 crate::middleware::MetricsLayer::new(state.metrics.clone()),
6312 tower::util::option_layer(fault_scope_layer),
6315 ExceptionFilterLayer::new(all_filters),
6316 crate::middleware::error_page_filter::ErrorPageContextLayer { is_dev },
6317 ryw_layer,
6318 );
6319
6320 let router = router.layer((
6339 compression_layer,
6340 outer_stack,
6341 session_layer,
6342 NormalizeBodyLayer,
6343 middle_stack,
6344 ComposedRegisteredLayers::new(custom_layers),
6345 inner_stack,
6346 ));
6347
6348 if let Some(handle) = fault_handle {
6354 crate::fault_injection::announce(&handle);
6355 state.insert_extension(handle);
6356 }
6357 Ok(router)
6358}
6359
6360fn apply_layers_in_registration_order(
6372 router: axum::Router<AppState>,
6373 layers: Vec<crate::app::CustomLayerRegistration>,
6374 what: &str,
6375) -> axum::Router<AppState> {
6376 let count = layers.len();
6377 if count == 0 {
6378 return router;
6379 }
6380 tracing::debug!(count, "{what} Tower layers applied");
6381 router.layer(ComposedRegisteredLayers::new(layers))
6382}
6383
6384fn trusted_host_rejection<B>(
6393 req: &Request<B>,
6394 policy: &TrustedHostPolicy,
6395) -> Option<axum::response::Response> {
6396 let path = req.uri().path();
6397 if (req.method() == http::Method::GET || req.method() == http::Method::HEAD)
6398 && policy.probe_bypass_paths.contains(path)
6399 {
6400 return None;
6401 }
6402 let authority = req.uri().authority().map(http::uri::Authority::as_str);
6403 let host_header = req
6404 .headers()
6405 .get(http::header::HOST)
6406 .and_then(|v| v.to_str().ok());
6407 let raw_host = authority.or(host_header);
6408 let parsed_host = raw_host.and_then(extract_host_without_port);
6409 let host = parsed_host.and_then(normalize_trusted_host);
6410 let host_source_present = raw_host.is_some();
6411 if host.is_none() && !host_source_present && policy.allow_missing_host {
6412 return None;
6413 }
6414 if !host_source_present
6418 && req
6419 .extensions()
6420 .get::<crate::static_gen::RenderDeadlineExempt>()
6421 .is_some()
6422 {
6423 return None;
6424 }
6425 if host.as_deref().is_some_and(|host| policy.allows_host(host)) {
6426 return None;
6427 }
6428 tracing::warn!(host = ?host, "trusted host rejected request");
6429 let body = crate::error::problem_details_json_string(
6430 StatusCode::BAD_REQUEST,
6431 "Invalid Host header",
6432 None,
6433 None,
6434 None,
6435 None,
6436 true,
6437 );
6438 Some(
6439 (
6440 StatusCode::BAD_REQUEST,
6441 [(http::header::CONTENT_TYPE, "application/problem+json")],
6442 body,
6443 )
6444 .into_response(),
6445 )
6446}
6447
6448fn normalize_trusted_host(host: &str) -> Option<std::borrow::Cow<'_, str>> {
6458 let host = host.trim_end_matches('.');
6459 if host.is_empty() {
6460 return None;
6461 }
6462 Some(if host.bytes().any(|b| b.is_ascii_uppercase()) {
6463 std::borrow::Cow::Owned(host.to_ascii_lowercase())
6464 } else {
6465 std::borrow::Cow::Borrowed(host)
6466 })
6467}
6468
6469#[derive(Clone, Debug)]
6480pub struct TrustedHostLayer {
6481 policy: TrustedHostPolicy,
6482}
6483
6484impl TrustedHostLayer {
6485 pub(crate) const fn new(policy: TrustedHostPolicy) -> Self {
6486 Self { policy }
6487 }
6488}
6489
6490impl<S> tower::Layer<S> for TrustedHostLayer {
6491 type Service = TrustedHostService<S>;
6492
6493 fn layer(&self, inner: S) -> Self::Service {
6494 TrustedHostService {
6495 inner,
6496 policy: self.policy.clone(),
6497 }
6498 }
6499}
6500
6501#[derive(Clone, Debug)]
6503pub struct TrustedHostService<S> {
6504 inner: S,
6505 policy: TrustedHostPolicy,
6506}
6507
6508impl<S, ReqBody> tower::Service<Request<ReqBody>> for TrustedHostService<S>
6509where
6510 S: tower::Service<Request<ReqBody>, Response = axum::response::Response>,
6511{
6512 type Response = S::Response;
6513 type Error = S::Error;
6514 type Future = crate::middleware::short_circuit::ShortCircuitFuture<S::Future>;
6515
6516 fn poll_ready(
6517 &mut self,
6518 cx: &mut std::task::Context<'_>,
6519 ) -> std::task::Poll<Result<(), Self::Error>> {
6520 self.inner.poll_ready(cx)
6521 }
6522
6523 fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
6524 use crate::middleware::short_circuit::ShortCircuitFuture;
6525 trusted_host_rejection(&req, &self.policy).map_or_else(
6526 || ShortCircuitFuture::forward(self.inner.call(req)),
6527 ShortCircuitFuture::short_circuit,
6528 )
6529 }
6530}
6531
6532pub fn extract_host_without_port(header: &str) -> Option<&str> {
6533 let host = header.trim();
6534 if host.is_empty() {
6535 return None;
6536 }
6537 if host.starts_with('[') {
6538 let end = host.find(']')?;
6539 let literal = host.get(1..end)?;
6540 if literal.is_empty() || literal.parse::<std::net::IpAddr>().is_err() {
6541 return None;
6542 }
6543
6544 let remainder = host.get(end + 1..)?;
6545 if remainder.is_empty() {
6546 return Some(literal);
6547 }
6548
6549 let maybe_port = remainder.strip_prefix(':')?;
6550 if !maybe_port.is_empty() && maybe_port.chars().all(|c| c.is_ascii_digit()) {
6551 return Some(literal);
6552 }
6553
6554 return None;
6555 }
6556 let Some((candidate, maybe_port)) = host.rsplit_once(':') else {
6557 return Some(host);
6558 };
6559 if candidate.contains(':') {
6560 return Some(host);
6562 }
6563 if !maybe_port.is_empty()
6564 && maybe_port.chars().all(|c| c.is_ascii_digit())
6565 && !candidate.is_empty()
6566 {
6567 Some(candidate)
6568 } else {
6569 None
6570 }
6571}
6572
6573#[allow(dead_code)]
6595pub fn build_router_with_static(
6596 route_list: Vec<Route>,
6597 config: &AutumnConfig,
6598 state: AppState,
6599 dist_dir: Option<&std::path::Path>,
6600) -> axum::Router {
6601 try_build_router_with_static(route_list, config, state, dist_dir)
6602 .unwrap_or_else(|error| panic!("invalid router configuration: {error}"))
6603}
6604
6605#[allow(dead_code)]
6613pub fn try_build_router_with_static(
6614 route_list: Vec<Route>,
6615 config: &AutumnConfig,
6616 state: AppState,
6617 dist_dir: Option<&std::path::Path>,
6618) -> Result<axum::Router, RouterBuildError> {
6619 try_build_router_with_static_inner(
6620 route_list,
6621 config,
6622 state,
6623 dist_dir,
6624 RouterContext {
6625 exception_filters: Vec::new(),
6626 scoped_groups: Vec::new(),
6627 merge_routers: Vec::new(),
6628 nest_routers: Vec::new(),
6629 declared_routes: Vec::new(),
6630 custom_layers: Vec::new(),
6631 static_gate_layers: Vec::new(),
6632 #[cfg(feature = "maud")]
6633 error_page_renderer: None,
6634 session_store: None,
6635 #[cfg(feature = "openapi")]
6636 openapi: None,
6637 #[cfg(feature = "mcp")]
6638 mcp: None,
6639 #[cfg(feature = "webmcp")]
6640 webmcp: None,
6641 },
6642 )
6643}
6644
6645#[cfg(feature = "i18n")]
6669pub fn partition_custom_layers_for_static_render(
6670 custom_layers: Vec<crate::app::CustomLayerRegistration>,
6671) -> (
6672 Vec<crate::app::CustomLayerRegistration>,
6673 Vec<crate::app::CustomLayerRegistration>,
6674) {
6675 let keep_type_ids = [
6694 std::any::TypeId::of::<crate::i18n::AmbientLocaleLayer>(),
6695 std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>(),
6696 ];
6697 custom_layers
6698 .into_iter()
6699 .partition(|r| keep_type_ids.contains(&r.type_id))
6700}
6701
6702#[cfg(not(feature = "i18n"))]
6705pub const fn partition_custom_layers_for_static_render(
6706 custom_layers: Vec<crate::app::CustomLayerRegistration>,
6707) -> (
6708 Vec<crate::app::CustomLayerRegistration>,
6709 Vec<crate::app::CustomLayerRegistration>,
6710) {
6711 (Vec::new(), custom_layers)
6712}
6713
6714fn static_page_routes<'a>(
6719 pages: impl Iterator<Item = &'a String>,
6720 route_list: &[Route],
6721 scoped_groups: &[ScopedGroup],
6722 config: &AutumnConfig,
6723) -> std::collections::HashMap<String, Option<String>> {
6724 let mut mounted: Vec<String> = Vec::new();
6728 for route in route_list.iter().filter(|r| r.method == http::Method::GET) {
6729 mounted.extend(mounted_paths(route.path, config));
6730 }
6731 mounted.extend(scoped_groups.iter().flat_map(|g| {
6732 g.routes
6733 .iter()
6734 .filter(|r| r.method == http::Method::GET)
6735 .map(|r| join_nested_path(&g.prefix, r.path))
6736 }));
6737 let mut matcher: matchit::Router<String> = matchit::Router::new();
6738 for path in mounted {
6739 let _ = matcher.insert(path.clone(), path);
6741 }
6742 pages
6743 .map(|page| {
6744 let route = matcher.at(page).ok().map(|hit| hit.value.clone());
6745 (page.clone(), route)
6746 })
6747 .collect()
6748}
6749
6750fn routed_templates(
6753 route_list: &[Route],
6754 scoped_groups: &[ScopedGroup],
6755 config: &AutumnConfig,
6756) -> Vec<String> {
6757 let mut mounted: Vec<String> = route_list
6758 .iter()
6759 .flat_map(|r| mounted_paths(r.path, config))
6760 .collect();
6761 mounted.extend(
6762 scoped_groups
6763 .iter()
6764 .flat_map(|g| g.routes.iter().map(|r| join_nested_path(&g.prefix, r.path))),
6765 );
6766 mounted.sort_unstable();
6767 mounted.dedup();
6768 mounted
6769}
6770
6771#[cfg_attr(not(feature = "i18n"), allow(clippy::unnecessary_wraps))]
6774fn mounted_paths(path: &str, config: &AutumnConfig) -> Vec<String> {
6775 #[cfg(feature = "i18n")]
6776 {
6777 let i18n = &config.i18n;
6778 let excluded = i18n.locale_prefix_exclude_exact.iter().any(|p| p == path)
6779 || matches_locale_exclude_prefix(path, &i18n.locale_prefix_exclude);
6780 if i18n.locale_prefix_enabled && !excluded {
6781 return validated_locale_prefix_locales(i18n)
6782 .iter()
6783 .map(|locale| join_nested_path(&format!("/{locale}"), path))
6784 .collect();
6785 }
6786 }
6787 #[cfg(not(feature = "i18n"))]
6788 let _ = config;
6789 vec![path.to_owned()]
6790}
6791
6792#[allow(clippy::too_many_lines)]
6793pub fn try_build_router_with_static_inner(
6794 route_list: Vec<Route>,
6795 config: &AutumnConfig,
6796 state: AppState,
6797 dist_dir: Option<&std::path::Path>,
6798 mut ctx: RouterContext,
6799) -> Result<axum::Router, RouterBuildError> {
6800 let startup_barrier_state = state.clone();
6801
6802 let Some(dist) = dist_dir else {
6803 let app_router = try_build_router_inner(route_list, config, state, ctx)?;
6804 return Ok(apply_startup_barrier(
6805 app_router,
6806 config,
6807 &startup_barrier_state,
6808 ));
6809 };
6810
6811 let Some(layer) = crate::static_gen::StaticFileLayer::new(dist) else {
6812 tracing::debug!(
6813 dist = %dist.display(),
6814 "No valid manifest.json in dist dir; skipping static file layer"
6815 );
6816 let app_router = try_build_router_inner(route_list, config, state, ctx)?;
6817 return Ok(apply_startup_barrier(
6818 app_router,
6819 config,
6820 &startup_barrier_state,
6821 ));
6822 };
6823
6824 for (route, entry) in &layer.manifest().routes {
6825 tracing::debug!(
6826 route = %route,
6827 file = %entry.file,
6828 revalidate = ?entry.revalidate,
6829 "Static route"
6830 );
6831 }
6832
6833 let opaque_present = Some(
6863 custom_layers_require_fail_closed_idempotency(&ctx.custom_layers)
6864 || custom_layers_require_fail_closed_idempotency(&ctx.static_gate_layers),
6865 );
6866 let custom_layers = std::mem::take(&mut ctx.custom_layers);
6867
6868 let (session_scoped, custom_layers) = partition_custom_layers_for_static_render(custom_layers);
6881 ctx.custom_layers = session_scoped;
6882
6883 let static_gate_layers = std::mem::take(&mut ctx.static_gate_layers);
6890
6891 #[cfg(feature = "mcp")]
6932 let mcp_dispatch_extra_layers = custom_layers.clone();
6933 #[cfg(not(feature = "mcp"))]
6934 let mcp_dispatch_extra_layers = Vec::new();
6935 let static_paths = Arc::new(static_page_routes(
6936 layer.manifest().routes.keys(),
6937 &route_list,
6938 &ctx.scoped_groups,
6939 config,
6940 ));
6941 let (inner_router, deferred_mcp_router) = build_router_pre_state(
6942 route_list,
6943 config,
6944 &state,
6945 ctx,
6946 opaque_present,
6947 true,
6948 mcp_dispatch_extra_layers,
6949 )?;
6950 let (priced_static, unrouted_static) =
6952 crate::aeo::commerce::priced_static_pages(config, &state, &static_paths);
6953 if !unrouted_static.is_empty() {
6954 tracing::warn!(
6955 pages = ?unrouted_static.keys().collect::<Vec<_>>(),
6956 "aeo: x402 prices pre-rendered pages no route renders; they are served from \
6957 the manifest, before rate limiting, bot protection, load shedding and CORS"
6958 );
6959 }
6960 let priced_static = Arc::new(priced_static);
6961
6962 let has_isr = layer
6967 .manifest()
6968 .routes
6969 .values()
6970 .any(|e| e.revalidate.is_some());
6971 let layer = if has_isr {
6972 let regen_router = inner_router
6982 .clone()
6983 .layer(crate::security::SecurityHeadersLayer::from_config(
6984 &config.security.headers,
6985 ))
6986 .with_state(state.clone());
6987 layer.with_router(regen_router)
6988 } else {
6989 layer
6990 };
6991 let layer = Arc::new(layer);
6992
6993 let static_layer = layer;
7001 let mut router: axum::Router<AppState> = inner_router.layer(axum::middleware::from_fn(
7002 move |req: axum::extract::Request, next: axum::middleware::Next| {
7003 let static_layer = static_layer.clone();
7004 let priced_static = Arc::clone(&priced_static);
7005 async move {
7006 let is_get = req.method() == http::Method::GET;
7007 let is_head = req.method() == http::Method::HEAD;
7008 let head_for_markdown =
7013 is_head && crate::aeo::negotiate::prefers_markdown(req.headers());
7014 if is_get || is_head {
7015 let path = req.uri().path();
7016 let normalized = if path.len() > 1 && path.ends_with('/') {
7018 &path[..path.len() - 1]
7019 } else {
7020 path
7021 };
7022 if priced_static.contains(normalized) {
7025 return next.run(req).await;
7026 }
7027 if let Some(hit) = static_layer.resolve_entry(normalized)
7028 && let Ok(contents) = tokio::fs::read(&hit.file_path).await
7029 {
7030 let len = contents.len();
7043 let empty_head = is_head && !head_for_markdown;
7044 let body = if empty_head {
7045 axum::body::Body::empty()
7046 } else {
7047 axum::body::Body::from(contents)
7048 };
7049 let mut response = http::Response::builder()
7050 .status(http::StatusCode::OK)
7051 .header(http::header::CONTENT_TYPE, hit.content_type);
7052 if empty_head {
7053 response = response.header(http::header::CONTENT_LENGTH, len);
7054 }
7055 return response.body(body).expect("infallible response builder");
7056 }
7057 }
7058 next.run(req).await
7059 }
7060 },
7061 ));
7062
7063 router = apply_layers_in_registration_order(
7069 router,
7070 custom_layers,
7071 "Custom (outside static middleware)",
7072 );
7073
7074 router = install_outer_fault_injection(router, config, &state);
7092
7093 if let Some(mcp_router) = deferred_mcp_router {
7094 router = router.merge(mcp_router);
7095 }
7096
7097 router = router.layer((
7111 tower::util::option_layer(build_shadow_layer(config, &state)),
7112 tower::util::option_layer(
7113 state
7114 .extension::<crate::aeo::AeoSite>()
7115 .and_then(|site| site.layer()),
7116 ),
7117 aeo_x402_layer(config, &state, Some(Arc::new(unrouted_static))),
7121 ));
7122
7123 router = apply_compression_middleware(router, config);
7128
7129 router = apply_layers_in_registration_order(
7136 router,
7137 static_gate_layers,
7138 "Pre-static gate (outside static middleware)",
7139 );
7140
7141 if let Some(require_client_cert) = build_client_cert_requirement_layer(config) {
7155 router = router.layer(require_client_cert);
7156 }
7157
7158 let router = router.layer(crate::security::SecurityHeadersLayer::from_config(
7164 &config.security.headers,
7165 ));
7166
7167 Ok(apply_startup_barrier(
7168 router.with_state(state),
7169 config,
7170 &startup_barrier_state,
7171 ))
7172}
7173
7174#[derive(Clone)]
7175pub struct StartupBarrierState {
7176 app_state: AppState,
7177 probe_paths: Vec<String>,
7181 actuator_paths: Vec<String>,
7182 actuator_subtree_paths: Vec<String>,
7183}
7184
7185impl StartupBarrierState {
7186 fn from_config(config: &AutumnConfig, app_state: &AppState) -> Self {
7187 let actuator_subtree_paths = if config.actuator.sensitive {
7188 vec![crate::actuator::actuator_route_path(
7189 &config.actuator.prefix,
7190 "/loggers",
7191 )]
7192 } else {
7193 Vec::new()
7194 };
7195
7196 Self {
7197 app_state: app_state.clone(),
7198 probe_paths: probe_bypass_paths(config),
7199 actuator_paths: crate::actuator::actuator_endpoint_paths(
7200 &config.actuator.prefix,
7201 config.actuator.sensitive,
7202 config.actuator.prometheus,
7203 ),
7204 actuator_subtree_paths,
7205 }
7206 }
7207
7208 fn allows_path(&self, path: &str) -> bool {
7209 self.probe_paths.iter().any(|allowed| path == allowed)
7210 || self.actuator_paths.iter().any(|allowed| path == allowed)
7211 || self
7212 .actuator_subtree_paths
7213 .iter()
7214 .any(|allowed| path_matches_route_prefix(path, allowed))
7215 }
7216}
7217
7218fn apply_startup_barrier(
7219 router: axum::Router,
7220 config: &AutumnConfig,
7221 state: &AppState,
7222) -> axum::Router {
7223 let barrier_state = StartupBarrierState::from_config(config, state);
7224
7225 #[cfg(feature = "telemetry-otlp")]
7241 let trace_context = crate::middleware::TraceContextLayer;
7242 #[cfg(not(feature = "telemetry-otlp"))]
7243 let trace_context = tower::layer::util::Identity::new();
7244
7245 let server_timing_fallback = crate::config::server_timing_enabled(config)
7253 .then(|| crate::middleware::ServerTimingLayer::fallback(true));
7254
7255 let access_log_fallback = config.log.access_log.then(|| {
7265 crate::middleware::AccessLogLayer::fallback(config.log.access_log_exclude.clone())
7266 });
7267
7268 let cost_fallback = config
7272 .cost
7273 .enabled
7274 .then(|| state.extension::<crate::cost::CostAccountant>())
7275 .flatten()
7276 .map(|accountant| {
7277 crate::middleware::CostLayer::fallback(
7278 (*accountant).clone(),
7279 crate::config::server_timing_enabled(config),
7280 )
7281 });
7282
7283 router.layer((
7284 trace_context,
7285 tower::util::option_layer(server_timing_fallback),
7286 tower::util::option_layer(cost_fallback),
7287 tower::util::option_layer(access_log_fallback),
7288 StartupBarrierLayer::new(barrier_state),
7289 ))
7290}
7291
7292#[derive(Clone)]
7309pub struct StartupBarrierLayer {
7310 state: Arc<StartupBarrierState>,
7311}
7312
7313impl StartupBarrierLayer {
7314 pub(crate) fn new(state: StartupBarrierState) -> Self {
7315 Self {
7316 state: Arc::new(state),
7317 }
7318 }
7319}
7320
7321impl<S> tower::Layer<S> for StartupBarrierLayer {
7322 type Service = StartupBarrierService<S>;
7323
7324 fn layer(&self, inner: S) -> Self::Service {
7325 StartupBarrierService {
7326 inner,
7327 state: Arc::clone(&self.state),
7328 }
7329 }
7330}
7331
7332#[derive(Clone)]
7334pub struct StartupBarrierService<S> {
7335 inner: S,
7336 state: Arc<StartupBarrierState>,
7337}
7338
7339impl<S, ReqBody> tower::Service<Request<ReqBody>> for StartupBarrierService<S>
7340where
7341 S: tower::Service<Request<ReqBody>, Response = axum::response::Response>,
7342{
7343 type Response = S::Response;
7344 type Error = S::Error;
7345 type Future = crate::middleware::short_circuit::ShortCircuitFuture<S::Future>;
7346
7347 fn poll_ready(
7348 &mut self,
7349 cx: &mut std::task::Context<'_>,
7350 ) -> std::task::Poll<Result<(), Self::Error>> {
7351 self.inner.poll_ready(cx)
7352 }
7353
7354 fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
7355 use crate::middleware::short_circuit::ShortCircuitFuture;
7356 if crate::app::is_static_build_mode()
7357 || self.state.app_state.probes().is_startup_complete()
7358 || self.state.allows_path(req.uri().path())
7359 {
7360 ShortCircuitFuture::forward(self.inner.call(req))
7361 } else {
7362 ShortCircuitFuture::short_circuit(
7363 (
7364 StatusCode::SERVICE_UNAVAILABLE,
7365 "Service is still starting up",
7366 )
7367 .into_response(),
7368 )
7369 }
7370 }
7371}
7372
7373pub fn path_matches_route_prefix(path: &str, prefix: &str) -> bool {
7374 path == prefix
7375 || path
7376 .strip_prefix(prefix)
7377 .is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
7378}
7379
7380pub fn build_cors_layer(cors: &crate::config::CorsConfig) -> tower_http::cors::CorsLayer {
7384 use http::header::HeaderName;
7385 use tower_http::cors::{AllowOrigin, CorsLayer};
7386
7387 let layer = if cors.allowed_origins.iter().any(|o| o == "*") {
7388 CorsLayer::new().allow_origin(AllowOrigin::any())
7389 } else {
7390 let origins: Vec<http::HeaderValue> = cors
7391 .allowed_origins
7392 .iter()
7393 .filter_map(|o| match o.parse() {
7394 Ok(v) => Some(v),
7395 Err(e) => {
7396 tracing::warn!(origin = %o, error = %e, "CORS: ignoring malformed allowed_origin");
7397 None
7398 }
7399 })
7400 .collect();
7401 CorsLayer::new().allow_origin(origins)
7402 };
7403
7404 let methods: Vec<http::Method> = cors
7405 .allowed_methods
7406 .iter()
7407 .filter_map(|m| match m.parse() {
7408 Ok(v) => Some(v),
7409 Err(e) => {
7410 tracing::warn!(method = %m, error = %e, "CORS: ignoring malformed allowed_method");
7411 None
7412 }
7413 })
7414 .collect();
7415
7416 let headers: Vec<HeaderName> = cors
7417 .allowed_headers
7418 .iter()
7419 .filter_map(|h| match h.parse() {
7420 Ok(v) => Some(v),
7421 Err(e) => {
7422 tracing::warn!(header = %h, error = %e, "CORS: ignoring malformed allowed_header");
7423 None
7424 }
7425 })
7426 .collect();
7427
7428 layer
7429 .allow_methods(methods)
7430 .allow_headers(headers)
7431 .allow_credentials(cors.allow_credentials)
7432 .max_age(std::time::Duration::from_secs(cors.max_age_secs))
7433}
7434
7435pub fn mirror_cors_headers(
7454 cors: &crate::config::CorsConfig,
7455 origin: Option<&http::HeaderValue>,
7456 response: &mut axum::response::Response,
7457) {
7458 use http::header;
7459 let allow_any = cors.allowed_origins.iter().any(|o| o == "*");
7460 let allow_origin = if allow_any {
7461 Some(http::HeaderValue::from_static("*"))
7462 } else {
7463 origin.and_then(|value| {
7466 let value_str = value.to_str().ok()?;
7467 cors.allowed_origins
7468 .iter()
7469 .any(|allowed| allowed == value_str)
7470 .then(|| value.clone())
7471 })
7472 };
7473 let Some(allow_origin) = allow_origin else {
7474 return;
7476 };
7477 let headers = response.headers_mut();
7478 headers.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, allow_origin);
7479 if !allow_any {
7480 headers.insert(header::VARY, http::HeaderValue::from_static("origin"));
7484 }
7485 if cors.allow_credentials {
7486 headers.insert(
7487 header::ACCESS_CONTROL_ALLOW_CREDENTIALS,
7488 http::HeaderValue::from_static("true"),
7489 );
7490 }
7491}
7492
7493#[cfg(feature = "htmx")]
7496fn framework_scripts_router() -> axum::Router<AppState> {
7497 crate::assets::plugin::routes_for(crate::htmx::framework_scripts(), "/static", "/static")
7498}
7499
7500#[cfg(any(feature = "flash", feature = "maud"))]
7506struct PrecompressedCss {
7507 gzip: bytes::Bytes,
7508 brotli: bytes::Bytes,
7509}
7510
7511#[cfg(any(feature = "flash", feature = "maud"))]
7512impl PrecompressedCss {
7513 fn compute(body: &'static str) -> Self {
7514 use std::io::Write as _;
7515
7516 let mut gzip_encoder =
7517 flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
7518 gzip_encoder
7519 .write_all(body.as_bytes())
7520 .expect("in-memory gzip encoding cannot fail");
7521 let gzip = gzip_encoder
7522 .finish()
7523 .expect("in-memory gzip encoding cannot fail");
7524
7525 let mut brotli_writer = brotli::CompressorWriter::new(Vec::new(), 4096, 11, 22);
7526 brotli_writer
7527 .write_all(body.as_bytes())
7528 .expect("in-memory brotli encoding cannot fail");
7529 let brotli = brotli_writer.into_inner();
7530
7531 Self {
7532 gzip: gzip.into(),
7533 brotli: brotli.into(),
7534 }
7535 }
7536}
7537
7538#[cfg(any(feature = "flash", feature = "maud"))]
7543fn accepts_encoding(headers: &http::HeaderMap, coding: &str) -> bool {
7544 let Some(value) = headers
7545 .get(http::header::ACCEPT_ENCODING)
7546 .and_then(|v| v.to_str().ok())
7547 else {
7548 return false;
7549 };
7550 value.split(',').any(|part| {
7551 let mut segments = part.split(';');
7552 let name = segments.next().unwrap_or("").trim();
7553 name.eq_ignore_ascii_case(coding)
7554 && segments
7555 .find_map(|q| q.trim().strip_prefix("q="))
7556 .and_then(|q| q.parse::<f32>().ok())
7557 .is_none_or(|q| q > 0.0)
7558 })
7559}
7560
7561#[cfg(any(feature = "flash", feature = "maud"))]
7570fn static_css_response(
7571 headers: &http::HeaderMap,
7572 body: &'static str,
7573 precompressed: &'static PrecompressedCss,
7574) -> axum::response::Response {
7575 use crate::etag::IntoETag as _;
7576 use axum::response::IntoResponse;
7577
7578 let (encoded_body, content_encoding): (axum::body::Body, Option<&'static str>) =
7579 if accepts_encoding(headers, "br") {
7580 (precompressed.brotli.clone().into(), Some("br"))
7581 } else if accepts_encoding(headers, "gzip") {
7582 (precompressed.gzip.clone().into(), Some("gzip"))
7583 } else {
7584 (body.into(), None)
7585 };
7586
7587 let mut response_headers = http::HeaderMap::new();
7588 response_headers.insert(
7589 http::header::CONTENT_TYPE,
7590 http::HeaderValue::from_static("text/css; charset=utf-8"),
7591 );
7592 response_headers.insert(
7593 http::header::CACHE_CONTROL,
7594 http::HeaderValue::from_static("public, max-age=31536000, immutable"),
7595 );
7596 response_headers.insert(
7599 http::header::VARY,
7600 http::HeaderValue::from_static("Accept-Encoding"),
7601 );
7602 if let Some(encoding) = content_encoding {
7603 response_headers.insert(
7604 http::header::CONTENT_ENCODING,
7605 http::HeaderValue::from_static(encoding),
7606 );
7607 }
7608
7609 let etag = crate::etag::ETag::weak(body.into_etag().tag().to_owned());
7615
7616 crate::etag::fresh_when(headers, etag)
7617 .or((response_headers, encoded_body))
7618 .into_response()
7619}
7620
7621#[cfg(feature = "flash")]
7624pub async fn flash_css_handler(headers: http::HeaderMap) -> axum::response::Response {
7625 static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
7626 static_css_response(
7627 &headers,
7628 crate::flash::FLASH_CSS,
7629 PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::flash::FLASH_CSS)),
7630 )
7631}
7632
7633#[cfg(feature = "maud")]
7636pub async fn widgets_css_handler(headers: http::HeaderMap) -> axum::response::Response {
7637 static PRECOMPRESSED: std::sync::OnceLock<PrecompressedCss> = std::sync::OnceLock::new();
7638 static_css_response(
7639 &headers,
7640 crate::ui::WIDGETS_CSS,
7641 PRECOMPRESSED.get_or_init(|| PrecompressedCss::compute(crate::ui::WIDGETS_CSS)),
7642 )
7643}
7644
7645#[cfg(feature = "openapi")]
7646pub fn collect_openapi_docs(
7647 route_list: &[Route],
7648 scoped_groups: &[ScopedGroup],
7649) -> Vec<crate::openapi::ApiDoc> {
7650 let mut docs: Vec<crate::openapi::ApiDoc> = Vec::new();
7655 for route in route_list {
7656 let mut doc = route.api_doc.clone();
7657 doc.api_version = route.api_version;
7658 doc.sunset_opt_out = route.sunset_opt_out;
7659 docs.push(doc);
7660 }
7661 for group in scoped_groups {
7662 let prefix_params = extract_path_params(&group.prefix);
7666 for route in &group.routes {
7667 let mut doc = route.api_doc.clone();
7668 doc.api_version = route.api_version;
7669 doc.sunset_opt_out = route.sunset_opt_out;
7670 let full = join_nested_path(&group.prefix, route.api_doc.path);
7676 doc.path = Box::leak(full.into_boxed_str());
7677
7678 if !prefix_params.is_empty() {
7679 let mut merged: Vec<&'static str> = prefix_params
7680 .iter()
7681 .map(|p| &*Box::leak(p.clone().into_boxed_str()))
7682 .collect();
7683 for existing in route.api_doc.path_params {
7684 if !merged.iter().any(|n| n == existing) {
7685 merged.push(existing);
7686 }
7687 }
7688 doc.path_params = Box::leak(merged.into_boxed_slice());
7689 }
7690
7691 docs.push(doc);
7692 }
7693 }
7694 docs
7695}
7696
7697#[cfg(feature = "openapi")]
7698fn mount_swagger_ui_routes(
7699 mut router: axum::Router<AppState>,
7700 path: &str,
7701 title: &str,
7702 json_path: &str,
7703) -> axum::Router<AppState> {
7704 let [css_path, bundle_path, initializer_path] = crate::openapi::swagger_ui_asset_paths(path);
7705 let html_body = Arc::new(crate::openapi::swagger_ui_html(
7706 title,
7707 &css_path,
7708 &bundle_path,
7709 &initializer_path,
7710 ));
7711 let initializer_body = Arc::new(crate::openapi::swagger_ui_initializer_js(json_path));
7712 router = router.route(
7713 path,
7714 axum::routing::get(move || {
7715 let html = html_body.clone();
7716 async move {
7717 use axum::response::IntoResponse;
7718 (
7719 [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
7720 (*html).clone(),
7721 )
7722 .into_response()
7723 }
7724 }),
7725 );
7726 router = router.route(
7727 &css_path,
7728 axum::routing::get(|| async move {
7729 use axum::response::IntoResponse;
7730 (
7731 [(http::header::CONTENT_TYPE, "text/css; charset=utf-8")],
7732 crate::openapi::SWAGGER_UI_CSS,
7733 )
7734 .into_response()
7735 }),
7736 );
7737 router = router.route(
7738 &bundle_path,
7739 axum::routing::get(|| async move {
7740 use axum::body::Bytes;
7741 use axum::response::IntoResponse;
7742 (
7743 [(
7744 http::header::CONTENT_TYPE,
7745 "application/javascript; charset=utf-8",
7746 )],
7747 Bytes::from_static(crate::openapi::SWAGGER_UI_BUNDLE),
7748 )
7749 .into_response()
7750 }),
7751 );
7752 router = router.route(
7753 &initializer_path,
7754 axum::routing::get(move || {
7755 let js = initializer_body.clone();
7756 async move {
7757 use axum::response::IntoResponse;
7758 (
7759 [(
7760 http::header::CONTENT_TYPE,
7761 "application/javascript; charset=utf-8",
7762 )],
7763 (*js).clone(),
7764 )
7765 .into_response()
7766 }
7767 }),
7768 );
7769 router
7770}
7771
7772#[cfg(feature = "oauth2")]
7787#[derive(Clone)]
7788pub struct HttpInterceptorLayer {
7789 state: AppState,
7790}
7791
7792#[cfg(feature = "oauth2")]
7793impl HttpInterceptorLayer {
7794 pub(crate) const fn new(state: AppState) -> Self {
7795 Self { state }
7796 }
7797}
7798
7799#[cfg(feature = "oauth2")]
7800impl<S> tower::Layer<S> for HttpInterceptorLayer {
7801 type Service = HttpInterceptorService<S>;
7802
7803 fn layer(&self, inner: S) -> Self::Service {
7804 HttpInterceptorService {
7805 inner,
7806 state: self.state.clone(),
7807 }
7808 }
7809}
7810
7811#[cfg(feature = "oauth2")]
7813#[derive(Clone)]
7814pub struct HttpInterceptorService<S> {
7815 inner: S,
7816 state: AppState,
7817}
7818
7819#[cfg(feature = "oauth2")]
7820pin_project_lite::pin_project! {
7821 #[project = HttpInterceptorFutureProj]
7823 pub enum HttpInterceptorScopeFuture<F> {
7824 Plain {
7826 #[pin]
7827 inner: F,
7828 },
7829 Scoped {
7831 #[pin]
7832 inner: tokio::task::futures::TaskLocalFuture<
7833 Vec<Arc<dyn crate::interceptor::HttpInterceptor>>,
7834 F,
7835 >,
7836 },
7837 }
7838}
7839
7840#[cfg(feature = "oauth2")]
7841impl<F: std::future::Future> std::future::Future for HttpInterceptorScopeFuture<F> {
7842 type Output = F::Output;
7843
7844 fn poll(
7845 self: std::pin::Pin<&mut Self>,
7846 cx: &mut std::task::Context<'_>,
7847 ) -> std::task::Poll<Self::Output> {
7848 match self.project() {
7849 HttpInterceptorFutureProj::Plain { inner } => inner.poll(cx),
7850 HttpInterceptorFutureProj::Scoped { inner } => inner.poll(cx),
7851 }
7852 }
7853}
7854
7855#[cfg(feature = "oauth2")]
7856impl<S, ReqBody> tower::Service<Request<ReqBody>> for HttpInterceptorService<S>
7857where
7858 S: tower::Service<Request<ReqBody>>,
7859{
7860 type Response = S::Response;
7861 type Error = S::Error;
7862 type Future = HttpInterceptorScopeFuture<S::Future>;
7863
7864 fn poll_ready(
7865 &mut self,
7866 cx: &mut std::task::Context<'_>,
7867 ) -> std::task::Poll<Result<(), Self::Error>> {
7868 self.inner.poll_ready(cx)
7869 }
7870
7871 fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
7872 use crate::interceptor::{ACTIVE_HTTP_INTERCEPTORS, HttpInterceptor};
7873 let Some(interceptor_arc) = self.state.extension::<Arc<dyn HttpInterceptor>>() else {
7874 return HttpInterceptorScopeFuture::Plain {
7875 inner: self.inner.call(req),
7876 };
7877 };
7878 let interceptors = vec![(*interceptor_arc).clone()];
7879 let inner =
7880 ACTIVE_HTTP_INTERCEPTORS.sync_scope(interceptors.clone(), || self.inner.call(req));
7881 HttpInterceptorScopeFuture::Scoped {
7882 inner: ACTIVE_HTTP_INTERCEPTORS.scope(interceptors, inner),
7883 }
7884 }
7885}
7886
7887#[cfg(test)]
7888mod tests {
7889 use super::*;
7890 use axum::body::Body;
7891 use axum::http::{Request, StatusCode};
7892 use tower::ServiceExt;
7893
7894 fn test_state() -> AppState {
7895 AppState {
7896 profile: Some("test".into()),
7897 ..AppState::test_default()
7898 }
7899 }
7900
7901 #[test]
7904 fn adaptive_mode_installs_the_load_shed_layer_without_a_ceiling() {
7905 let mut config = AutumnConfig::default();
7906 assert!(build_load_shed_layer(&config, &test_state()).is_none());
7907 config.server.admission.mode = crate::config::AdmissionMode::Adaptive;
7908 let state = test_state();
7909 assert!(build_load_shed_layer(&config, &state).is_some());
7910 assert_eq!(
7911 state.metrics.snapshot().http.admission.limit,
7912 20,
7913 "the gauge shows the initial adaptive limit"
7914 );
7915 }
7916
7917 #[test]
7918 fn adaptive_mode_with_bad_bounds_falls_back_to_the_static_ceiling() {
7919 let mut config = AutumnConfig::default();
7920 config.server.admission.mode = crate::config::AdmissionMode::Adaptive;
7921 config.server.admission.min_limit = 0;
7922 assert!(build_load_shed_layer(&config, &test_state()).is_none());
7923 config.server.max_concurrent_requests = Some(7);
7924 let state = test_state();
7925 assert!(build_load_shed_layer(&config, &state).is_some());
7926 assert_eq!(state.metrics.snapshot().http.admission.limit, 7);
7927 }
7928
7929 #[test]
7930 fn prod_profile_installs_the_load_shed_layer() {
7931 let mut config = AutumnConfig {
7932 profile: Some("prod".into()),
7933 ..AutumnConfig::default()
7934 };
7935 assert!(build_load_shed_layer(&config, &test_state()).is_some());
7936
7937 config.server.max_concurrent_requests = Some(0);
7939 assert!(build_load_shed_layer(&config, &test_state()).is_none());
7940 }
7941
7942 #[test]
7943 fn other_profiles_leave_load_shedding_off() {
7944 for profile in ["dev", "staging"] {
7945 let config = AutumnConfig {
7946 profile: Some(profile.into()),
7947 ..AutumnConfig::default()
7948 };
7949 assert!(
7950 build_load_shed_layer(&config, &test_state()).is_none(),
7951 "{profile}"
7952 );
7953 }
7954 }
7955
7956 fn contract_file_under(profile: &str) -> (tempfile::TempDir, String) {
7959 use crate::capacity::{
7960 CONTRACT_VERSION, Calibration, CapacityContract, Envelope, HostProfile, Provenance,
7961 };
7962 let contract = CapacityContract {
7963 version: CONTRACT_VERSION,
7964 provenance: Provenance {
7965 autumn_version: "0.0.0".to_owned(),
7966 calibrated_at: "2026-09-01T12:00:00Z".to_owned(),
7967 git_commit: None,
7968 git_dirty: false,
7969 route_graph_digest: String::new(),
7970 },
7971 host: HostProfile::detect(),
7972 envelope: Envelope {
7973 sustained_rps: 1000.0,
7974 p99_latency_ms: 10.0,
7975 saturation_concurrency: 40,
7976 admission_limit: 48,
7977 },
7978 calibration: Calibration {
7979 profile: profile.to_owned(),
7980 ..Calibration::default()
7981 },
7982 routes: Vec::new(),
7983 };
7984 let dir = tempfile::tempdir().expect("tempdir");
7985 let path = dir.path().join(crate::capacity::CONTRACT_FILE_NAME);
7986 std::fs::write(&path, contract.to_toml().expect("serialize")).expect("write contract");
7987 (dir, path.to_str().expect("utf-8 path").to_owned())
7988 }
7989
7990 #[test]
7991 fn the_load_shed_layer_enforces_only_a_contract_for_the_running_profile() {
7992 let (_dir, path) = contract_file_under("staging");
7993 let mut config = AutumnConfig {
7994 profile: Some("prod".into()),
7995 ..AutumnConfig::default()
7996 };
7997 config.server.capacity_contract = Some(path);
7998
7999 let state = test_state();
8000 assert!(build_load_shed_layer(&config, &state).is_some());
8001 let mismatched = state.metrics.snapshot().http.admission.limit;
8002 assert_ne!(mismatched, 48, "a staging contract must not bind prod");
8003
8004 config.profile = Some("staging".into());
8005 let state = test_state();
8006 assert!(build_load_shed_layer(&config, &state).is_some());
8007 assert_eq!(state.metrics.snapshot().http.admission.limit, 48);
8008 }
8009
8010 #[test]
8013 fn submit_token_explicit_memory_in_production_fails_router_build() {
8014 let mut config = AutumnConfig::default();
8017 config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
8018 let err = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
8019 .expect_err("explicit memory submit-token backend in prod must fail router build");
8020 assert!(
8021 matches!(err, RouterBuildError::InvalidSubmitTokenBackend(_)),
8022 "expected InvalidSubmitTokenBackend, got {err:?}"
8023 );
8024 }
8025
8026 #[test]
8027 fn submit_token_inherited_memory_in_production_builds() {
8028 let mut config = AutumnConfig::default();
8031 config.security.submit_token.backend = None;
8032 config.idempotency.backend = crate::config::IdempotencyBackend::Memory;
8033 let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, true)
8034 .expect("inherited memory submit-token backend in prod must still build (warn only)");
8035 }
8036
8037 #[test]
8038 fn submit_token_memory_outside_production_builds() {
8039 let mut config = AutumnConfig::default();
8041 config.security.submit_token.backend = Some(crate::config::IdempotencyBackend::Memory);
8042 let _router = apply_submit_token_middleware(axum::Router::<()>::new(), &config, false)
8043 .expect("memory submit-token backend outside production must build");
8044 }
8045
8046 #[tokio::test]
8047 async fn build_router_mounts_actuator_at_configured_prefix() {
8048 let mut config = AutumnConfig::default();
8049 config.actuator.prefix = "/ops".to_owned();
8050 config.actuator.sensitive = true;
8051
8052 let app = build_router(Vec::new(), &config, test_state());
8053
8054 let prefixed = app
8055 .clone()
8056 .oneshot(
8057 Request::builder()
8058 .uri("/ops/health")
8059 .body(Body::empty())
8060 .unwrap(),
8061 )
8062 .await
8063 .unwrap();
8064 assert_eq!(prefixed.status(), StatusCode::OK);
8065
8066 let legacy = app
8067 .oneshot(
8068 Request::builder()
8069 .uri("/actuator/health")
8070 .body(Body::empty())
8071 .unwrap(),
8072 )
8073 .await
8074 .unwrap();
8075 assert_eq!(legacy.status(), StatusCode::NOT_FOUND);
8076 }
8077
8078 #[cfg(feature = "tls")]
8083 #[tokio::test]
8084 async fn a_worker_actuator_under_a_required_path_still_demands_a_certificate() {
8085 let mut config = AutumnConfig::default();
8086 config.server.tls = Some(crate::config::TlsConfig {
8087 cert_path: Some("cert.pem".into()),
8088 key_path: Some("key.pem".into()),
8089 reload_interval_secs: 60,
8090 handshake_timeout_secs: 10,
8091 acme: None,
8092 client_auth: Some(crate::config::ClientAuthConfig {
8093 mode: crate::config::ClientAuthMode::Optional,
8094 ca_bundle_path: Some("client-ca.pem".into()),
8095 crl_path: None,
8096 required_paths: vec!["/actuator/".to_owned()],
8097 reload_interval_secs: 60,
8098 }),
8099 });
8100
8101 let app = try_build_probe_only_router(&config, test_state())
8102 .expect("probe-only router should build");
8103
8104 let response = app
8106 .clone()
8107 .oneshot(
8108 Request::builder()
8109 .uri("/actuator/info")
8110 .body(Body::empty())
8111 .unwrap(),
8112 )
8113 .await
8114 .unwrap();
8115 assert_eq!(
8116 response.status(),
8117 StatusCode::FORBIDDEN,
8118 "a worker actuator under a required_paths prefix must demand a certificate"
8119 );
8120
8121 let identity = std::sync::Arc::new(crate::tls::client_auth::ClientIdentity::new_for_test(
8124 "svc-ops",
8125 Vec::new(),
8126 ));
8127 let mut request = Request::builder()
8128 .uri("/actuator/info")
8129 .body(Body::empty())
8130 .unwrap();
8131 request.extensions_mut().insert(identity);
8132 let response = app.clone().oneshot(request).await.unwrap();
8133 assert_ne!(response.status(), StatusCode::FORBIDDEN);
8134
8135 let response = app
8138 .oneshot(
8139 Request::builder()
8140 .uri(config.health.live_path.as_str())
8141 .body(Body::empty())
8142 .unwrap(),
8143 )
8144 .await
8145 .unwrap();
8146 assert_ne!(response.status(), StatusCode::FORBIDDEN);
8147 }
8148
8149 #[tokio::test]
8152 async fn probe_only_router_mounts_probes_and_actuator_but_no_user_routes() {
8153 let config = AutumnConfig::default();
8154 let app = try_build_probe_only_router(&config, test_state())
8155 .expect("probe-only router should build");
8156
8157 for path in [
8159 config.health.live_path.as_str(),
8160 config.health.ready_path.as_str(),
8161 config.health.startup_path.as_str(),
8162 config.health.path.as_str(),
8163 "/actuator/health",
8164 "/actuator/info",
8165 ] {
8166 let response = app
8167 .clone()
8168 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8169 .await
8170 .unwrap();
8171 assert_ne!(
8172 response.status(),
8173 StatusCode::NOT_FOUND,
8174 "probe-only router should serve {path}"
8175 );
8176 }
8177
8178 let missing = app
8180 .oneshot(
8181 Request::builder()
8182 .uri("/definitely-not-a-user-route")
8183 .body(Body::empty())
8184 .unwrap(),
8185 )
8186 .await
8187 .unwrap();
8188 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
8189 }
8190
8191 #[tokio::test]
8197 async fn user_route_at_health_path_overrides_builtin_probe() {
8198 async fn user_health() -> &'static str {
8199 "user-health-handler"
8200 }
8201
8202 let config = AutumnConfig::default();
8203 assert_eq!(config.health.path, "/health");
8205
8206 let route = Route {
8207 method: http::Method::GET,
8208 path: "/health",
8209 handler: axum::routing::get(user_health),
8210 name: "user_health",
8211 api_doc: crate::openapi::ApiDoc {
8212 method: "GET",
8213 path: "/health",
8214 operation_id: "user_health",
8215 success_status: 200,
8216 ..Default::default()
8217 },
8218 repository: None,
8219 idempotency: crate::route::RouteIdempotency::Direct,
8220 timeout: crate::route::RouteTimeout::Inherit,
8221 criticality: crate::admission::Criticality::Default,
8222 seo: crate::seo::SeoRouteDefaults::EMPTY,
8223 api_version: None,
8224 sunset_opt_out: false,
8225 };
8226
8227 let app = build_router(vec![route], &config, test_state());
8231
8232 let response = app
8234 .clone()
8235 .oneshot(
8236 Request::builder()
8237 .uri("/health")
8238 .body(Body::empty())
8239 .unwrap(),
8240 )
8241 .await
8242 .unwrap();
8243 assert_eq!(response.status(), StatusCode::OK);
8244 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
8245 .await
8246 .unwrap();
8247 assert_eq!(
8248 &body[..],
8249 b"user-health-handler",
8250 "user route must win at the health path"
8251 );
8252
8253 for path in ["/live", "/ready", "/startup"] {
8255 let resp = app
8256 .clone()
8257 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8258 .await
8259 .unwrap();
8260 assert_ne!(
8261 resp.status(),
8262 StatusCode::NOT_FOUND,
8263 "built-in probe {path} should still be mounted"
8264 );
8265 }
8266 }
8267
8268 #[tokio::test]
8274 async fn health_enabled_false_mounts_no_builtin_probes() {
8275 let mut config = AutumnConfig::default();
8276 config.health.enabled = false;
8277
8278 let app = build_router(vec![], &config, test_state());
8279
8280 for path in [
8281 config.health.path.as_str(),
8282 config.health.live_path.as_str(),
8283 config.health.ready_path.as_str(),
8284 config.health.startup_path.as_str(),
8285 ] {
8286 let response = app
8287 .clone()
8288 .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
8289 .await
8290 .unwrap();
8291 assert_eq!(
8292 response.status(),
8293 StatusCode::NOT_FOUND,
8294 "probe path {path} must not be auto-mounted when health.enabled = false"
8295 );
8296 }
8297 }
8298
8299 #[cfg(feature = "maud")]
8304 #[tokio::test]
8305 async fn widgets_css_route_serves_the_shared_stylesheet() {
8306 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8307
8308 let response = app
8309 .oneshot(
8310 Request::builder()
8311 .uri(crate::ui::WIDGETS_CSS_PATH)
8312 .body(Body::empty())
8313 .unwrap(),
8314 )
8315 .await
8316 .unwrap();
8317
8318 assert_eq!(response.status(), StatusCode::OK);
8319 let content_type = response
8320 .headers()
8321 .get(http::header::CONTENT_TYPE)
8322 .unwrap()
8323 .to_str()
8324 .unwrap();
8325 assert!(content_type.contains("text/css"), "{content_type}");
8326
8327 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
8328 .await
8329 .unwrap();
8330 let body = String::from_utf8(body.to_vec()).unwrap();
8331 assert!(body.contains(".autumn-field"), "{body}");
8332 assert!(body.contains(":root"), "{body}");
8333 }
8334
8335 #[cfg(feature = "maud")]
8341 #[tokio::test]
8342 async fn widgets_css_route_supports_conditional_get() {
8343 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8344
8345 let first = app
8346 .clone()
8347 .oneshot(
8348 Request::builder()
8349 .uri(crate::ui::WIDGETS_CSS_PATH)
8350 .body(Body::empty())
8351 .unwrap(),
8352 )
8353 .await
8354 .unwrap();
8355 assert_eq!(first.status(), StatusCode::OK);
8356 let etag = first
8357 .headers()
8358 .get(http::header::ETAG)
8359 .expect("widget stylesheet response should carry an ETag")
8360 .clone();
8361
8362 let revalidated = app
8363 .oneshot(
8364 Request::builder()
8365 .uri(crate::ui::WIDGETS_CSS_PATH)
8366 .header(http::header::IF_NONE_MATCH, etag)
8367 .body(Body::empty())
8368 .unwrap(),
8369 )
8370 .await
8371 .unwrap();
8372 assert_eq!(revalidated.status(), StatusCode::NOT_MODIFIED);
8373 let revalidated_body = axum::body::to_bytes(revalidated.into_body(), usize::MAX)
8374 .await
8375 .unwrap();
8376 assert!(revalidated_body.is_empty());
8377 }
8378
8379 #[cfg(feature = "maud")]
8384 #[tokio::test]
8385 async fn widgets_css_route_etag_is_weak_not_strong() {
8386 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8387
8388 let response = app
8389 .oneshot(
8390 Request::builder()
8391 .uri(crate::ui::WIDGETS_CSS_PATH)
8392 .body(Body::empty())
8393 .unwrap(),
8394 )
8395 .await
8396 .unwrap();
8397
8398 let etag = response
8399 .headers()
8400 .get(http::header::ETAG)
8401 .expect("widget stylesheet response should carry an ETag")
8402 .to_str()
8403 .unwrap()
8404 .to_owned();
8405 assert!(
8406 etag.starts_with("W/\""),
8407 "ETag must be weak since encoded variants aren't byte-identical: {etag}"
8408 );
8409 }
8410
8411 #[cfg(feature = "maud")]
8415 #[tokio::test]
8416 async fn widgets_css_route_serves_precompressed_brotli_when_accepted() {
8417 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8418
8419 let response = app
8420 .oneshot(
8421 Request::builder()
8422 .uri(crate::ui::WIDGETS_CSS_PATH)
8423 .header(http::header::ACCEPT_ENCODING, "br")
8424 .body(Body::empty())
8425 .unwrap(),
8426 )
8427 .await
8428 .unwrap();
8429
8430 assert_eq!(response.status(), StatusCode::OK);
8431 assert_eq!(
8432 response
8433 .headers()
8434 .get(http::header::CONTENT_ENCODING)
8435 .unwrap(),
8436 "br"
8437 );
8438 assert_eq!(
8439 response.headers().get(http::header::VARY).unwrap(),
8440 "Accept-Encoding"
8441 );
8442
8443 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
8444 .await
8445 .unwrap();
8446 let mut decoded = Vec::new();
8447 brotli::BrotliDecompress(&mut std::io::Cursor::new(body.as_ref()), &mut decoded)
8448 .expect("response body must be valid brotli");
8449 assert_eq!(String::from_utf8(decoded).unwrap(), crate::ui::WIDGETS_CSS);
8450 }
8451
8452 #[cfg(feature = "maud")]
8454 #[tokio::test]
8455 async fn widgets_css_route_serves_precompressed_gzip_when_accepted() {
8456 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8457
8458 let response = app
8459 .oneshot(
8460 Request::builder()
8461 .uri(crate::ui::WIDGETS_CSS_PATH)
8462 .header(http::header::ACCEPT_ENCODING, "gzip")
8463 .body(Body::empty())
8464 .unwrap(),
8465 )
8466 .await
8467 .unwrap();
8468
8469 assert_eq!(response.status(), StatusCode::OK);
8470 assert_eq!(
8471 response
8472 .headers()
8473 .get(http::header::CONTENT_ENCODING)
8474 .unwrap(),
8475 "gzip"
8476 );
8477
8478 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
8479 .await
8480 .unwrap();
8481 let mut gz = flate2::read::GzDecoder::new(body.as_ref());
8482 let mut output = String::new();
8483 std::io::Read::read_to_string(&mut gz, &mut output)
8484 .expect("response body must be valid gzip");
8485 assert_eq!(output, crate::ui::WIDGETS_CSS);
8486 }
8487
8488 #[cfg(feature = "maud")]
8492 #[tokio::test]
8493 async fn widgets_css_route_honors_q_zero_opt_out() {
8494 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8495
8496 let response = app
8497 .oneshot(
8498 Request::builder()
8499 .uri(crate::ui::WIDGETS_CSS_PATH)
8500 .header(http::header::ACCEPT_ENCODING, "br;q=0, gzip")
8501 .body(Body::empty())
8502 .unwrap(),
8503 )
8504 .await
8505 .unwrap();
8506
8507 assert_eq!(response.status(), StatusCode::OK);
8508 assert_eq!(
8509 response
8510 .headers()
8511 .get(http::header::CONTENT_ENCODING)
8512 .unwrap(),
8513 "gzip"
8514 );
8515 }
8516
8517 #[cfg(feature = "maud")]
8521 #[tokio::test]
8522 async fn widgets_css_route_serves_identity_with_no_accept_encoding() {
8523 let app = build_router(Vec::new(), &AutumnConfig::default(), test_state());
8524
8525 let response = app
8526 .oneshot(
8527 Request::builder()
8528 .uri(crate::ui::WIDGETS_CSS_PATH)
8529 .body(Body::empty())
8530 .unwrap(),
8531 )
8532 .await
8533 .unwrap();
8534
8535 assert_eq!(response.status(), StatusCode::OK);
8536 assert!(
8537 !response
8538 .headers()
8539 .contains_key(http::header::CONTENT_ENCODING)
8540 );
8541 }
8542
8543 #[test]
8548 fn startup_barrier_503s_are_access_logged() {
8549 use tracing_subscriber::layer::SubscriberExt as _;
8550
8551 #[derive(Clone, Default)]
8552 struct Capture {
8553 events: Arc<std::sync::Mutex<Vec<std::collections::BTreeMap<String, String>>>>,
8554 }
8555 struct Visitor<'a>(&'a mut std::collections::BTreeMap<String, String>);
8556 impl tracing::field::Visit for Visitor<'_> {
8557 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
8558 self.0.insert(field.name().to_owned(), format!("{value:?}"));
8559 }
8560 fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
8561 self.0.insert(field.name().to_owned(), value.to_string());
8562 }
8563 }
8564 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Capture {
8565 fn on_event(
8566 &self,
8567 event: &tracing::Event<'_>,
8568 _ctx: tracing_subscriber::layer::Context<'_, S>,
8569 ) {
8570 if event.metadata().target() != crate::middleware::ACCESS_LOG_TARGET {
8571 return;
8572 }
8573 let mut fields = std::collections::BTreeMap::new();
8574 event.record(&mut Visitor(&mut fields));
8575 self.events.lock().unwrap().push(fields);
8576 }
8577 }
8578
8579 let capture = Capture::default();
8580 let events = Arc::clone(&capture.events);
8581 let subscriber = tracing_subscriber::registry().with(capture);
8582
8583 tracing::subscriber::with_default(subscriber, || {
8584 let state = AppState::for_test()
8587 .with_profile("test")
8588 .with_startup_complete(false);
8589 let app = build_router(Vec::new(), &AutumnConfig::default(), state);
8590 let rt = tokio::runtime::Builder::new_current_thread()
8591 .enable_all()
8592 .build()
8593 .unwrap();
8594
8595 let mut response = None;
8604 for attempt in 1..=5 {
8605 tracing::callsite::rebuild_interest_cache();
8606 let resp = rt.block_on(async {
8607 app.clone()
8608 .oneshot(
8609 Request::builder()
8610 .uri("/not-a-probe")
8611 .body(Body::empty())
8612 .unwrap(),
8613 )
8614 .await
8615 .unwrap()
8616 });
8617 let captured = !events.lock().unwrap().is_empty();
8618 response = Some(resp);
8619 if captured {
8620 break;
8621 }
8622 assert!(
8623 attempt < 5,
8624 "access-log event was not captured after {attempt} attempts \
8625 (tracing interest-cache race with a concurrent test)"
8626 );
8627 }
8628 assert_eq!(response.unwrap().status(), StatusCode::SERVICE_UNAVAILABLE);
8629 });
8630
8631 let events = events.lock().unwrap().clone();
8632 assert_eq!(
8633 events.len(),
8634 1,
8635 "a barrier-rejected request should emit one access event: {events:?}"
8636 );
8637 assert_eq!(events[0].get("status").map(String::as_str), Some("503"));
8638 assert!(
8639 !events[0].contains_key("request_id"),
8640 "barrier short-circuits before RequestIdLayer, so no request id"
8641 );
8642 }
8643
8644 #[tokio::test]
8650 async fn startup_barrier_503s_carry_server_timing_header() {
8651 let state = AppState::for_test()
8654 .with_profile("test")
8655 .with_startup_complete(false);
8656 let mut config = AutumnConfig::default();
8657 config.observability.server_timing = Some(true);
8658
8659 let app = build_router(Vec::new(), &config, state);
8660 let response = app
8661 .oneshot(
8662 Request::builder()
8663 .uri("/not-a-probe")
8664 .body(Body::empty())
8665 .unwrap(),
8666 )
8667 .await
8668 .unwrap();
8669
8670 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
8671 let header = response
8672 .headers()
8673 .get("server-timing")
8674 .expect("startup 503 short-circuit should still carry Server-Timing via the fallback")
8675 .to_str()
8676 .expect("server-timing header should be valid ASCII");
8677 assert!(
8678 header.starts_with("total;dur="),
8679 "fallback should emit a `total` metric, got {header:?}"
8680 );
8681 assert_eq!(
8684 header.matches("total;dur=").count(),
8685 1,
8686 "short-circuit response must carry a single total metric: {header:?}"
8687 );
8688 }
8689
8690 #[test]
8691 fn try_build_router_rejects_invalid_session_backend_config() {
8692 let mut config = AutumnConfig::default();
8693 config.session.backend = crate::session::SessionBackend::Redis;
8694
8695 let error = try_build_router(Vec::new(), &config, test_state())
8696 .expect_err("missing redis config should fail checked router build");
8697
8698 assert!(matches!(
8699 error,
8700 RouterBuildError::InvalidSessionBackend(
8701 crate::session::SessionBackendConfigError::MissingRedisUrl
8702 )
8703 ));
8704 }
8705
8706 #[test]
8707 fn try_build_router_with_static_rejects_invalid_session_backend_config() {
8708 let mut config = AutumnConfig::default();
8709 config.session.backend = crate::session::SessionBackend::Redis;
8710
8711 let error = try_build_router_with_static(Vec::new(), &config, test_state(), None)
8712 .expect_err("missing redis config should fail checked static router build");
8713
8714 assert!(matches!(
8715 error,
8716 RouterBuildError::InvalidSessionBackend(
8717 crate::session::SessionBackendConfigError::MissingRedisUrl
8718 )
8719 ));
8720 }
8721
8722 #[test]
8723 fn try_build_router_returns_error_for_probe_actuator_path_overlap() {
8724 let mut config = AutumnConfig::default();
8725 config.actuator.prefix = "/".to_owned();
8726
8727 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8728 try_build_router(Vec::new(), &config, test_state())
8729 }));
8730
8731 assert!(result.is_ok(), "try_build_router panicked on route overlap");
8732 assert!(
8733 result.unwrap().is_err(),
8734 "route overlap should be reported as a checked router build error"
8735 );
8736 }
8737
8738 #[test]
8746 fn probe_actuator_overlap_detected_when_user_route_owns_probe_path() {
8747 async fn user_health() -> &'static str {
8748 "user-health-handler"
8749 }
8750
8751 let mut config = AutumnConfig::default();
8752 config.actuator.prefix = "/".to_owned();
8753 assert_eq!(config.health.path, "/health");
8756
8757 let route = Route {
8758 method: http::Method::GET,
8759 path: "/health",
8760 handler: axum::routing::get(user_health),
8761 name: "user_health",
8762 api_doc: crate::openapi::ApiDoc {
8763 method: "GET",
8764 path: "/health",
8765 operation_id: "user_health",
8766 success_status: 200,
8767 ..Default::default()
8768 },
8769 repository: None,
8770 idempotency: crate::route::RouteIdempotency::Direct,
8771 timeout: crate::route::RouteTimeout::Inherit,
8772 criticality: crate::admission::Criticality::Default,
8773 seo: crate::seo::SeoRouteDefaults::EMPTY,
8774 api_version: None,
8775 sunset_opt_out: false,
8776 };
8777
8778 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
8779 try_build_router(vec![route], &config, test_state())
8780 }));
8781
8782 assert!(
8783 result.is_ok(),
8784 "try_build_router panicked instead of returning a checked overlap error"
8785 );
8786 let build = result.unwrap();
8787 assert!(
8788 matches!(
8789 &build,
8790 Err(RouterBuildError::FrameworkRouteOverlap {
8791 path,
8792 incoming: "actuator endpoint",
8793 ..
8794 }) if path == "/health"
8795 ),
8796 "root-prefix actuator over a user-owned probe path must yield a checked \
8797 FrameworkRouteOverlap for /health, got: {:?}",
8798 build.as_ref().map(|_| "Ok(router)"),
8799 );
8800 }
8801
8802 #[tokio::test]
8803 async fn apply_cors_middleware_skipped_when_no_origins() {
8804 let config = AutumnConfig::default();
8805 assert!(config.cors.allowed_origins.is_empty());
8806
8807 let base: axum::Router<AppState> =
8808 axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
8809 let router = apply_cors_middleware(base, &config).with_state(test_state());
8810
8811 let response = router
8812 .oneshot(
8813 Request::builder()
8814 .uri("/test")
8815 .header("Origin", "https://example.com")
8816 .body(Body::empty())
8817 .unwrap(),
8818 )
8819 .await
8820 .unwrap();
8821
8822 assert_eq!(response.status(), StatusCode::OK);
8823 assert!(
8824 response
8825 .headers()
8826 .get("access-control-allow-origin")
8827 .is_none(),
8828 "CORS header must be absent when no origins are configured"
8829 );
8830 }
8831
8832 #[tokio::test]
8833 async fn apply_cors_middleware_present_when_origins_configured() {
8834 let mut config = AutumnConfig::default();
8835 config.cors.allowed_origins = vec!["https://example.com".to_owned()];
8836
8837 let base: axum::Router<AppState> =
8838 axum::Router::new().route("/test", axum::routing::get(|| async { "ok" }));
8839 let router = apply_cors_middleware(base, &config).with_state(test_state());
8840
8841 let response = router
8842 .oneshot(
8843 Request::builder()
8844 .uri("/test")
8845 .header("Origin", "https://example.com")
8846 .body(Body::empty())
8847 .unwrap(),
8848 )
8849 .await
8850 .unwrap();
8851
8852 assert_eq!(response.status(), StatusCode::OK);
8853 assert!(
8854 response
8855 .headers()
8856 .get("access-control-allow-origin")
8857 .is_some(),
8858 "CORS header must be present when origins are configured"
8859 );
8860 }
8861
8862 #[tokio::test]
8863 async fn apply_cors_middleware_handles_preflight_request() {
8864 let mut config = AutumnConfig::default();
8865 config.cors.allowed_origins = vec!["https://example.com".to_owned()];
8866
8867 let base: axum::Router<AppState> =
8868 axum::Router::new().route("/api/widgets", axum::routing::post(|| async { "ok" }));
8869 let router = apply_cors_middleware(base, &config).with_state(test_state());
8870
8871 let response = router
8872 .oneshot(
8873 Request::builder()
8874 .method("OPTIONS")
8875 .uri("/api/widgets")
8876 .header("Origin", "https://example.com")
8877 .header("Access-Control-Request-Method", "POST")
8878 .header("Access-Control-Request-Headers", "Content-Type")
8879 .body(Body::empty())
8880 .unwrap(),
8881 )
8882 .await
8883 .unwrap();
8884
8885 let headers = response.headers();
8886 assert_eq!(
8887 headers
8888 .get("access-control-allow-origin")
8889 .and_then(|v| v.to_str().ok()),
8890 Some("https://example.com"),
8891 "preflight must echo the allowed origin"
8892 );
8893 assert!(
8894 headers.get("access-control-allow-methods").is_some(),
8895 "preflight must advertise allowed methods"
8896 );
8897 assert!(
8898 headers.get("access-control-allow-headers").is_some(),
8899 "preflight must advertise allowed headers"
8900 );
8901 assert!(
8902 headers.get("access-control-max-age").is_some(),
8903 "preflight must advertise max-age so browsers can cache it"
8904 );
8905 }
8906
8907 #[tokio::test]
8908 async fn apply_csrf_middleware_skipped_when_disabled() {
8909 let config = AutumnConfig::default();
8910 assert!(!config.security.csrf.enabled);
8911
8912 let base: axum::Router<AppState> =
8913 axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
8914 let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
8915
8916 let response = router
8918 .oneshot(
8919 Request::builder()
8920 .method("POST")
8921 .uri("/form")
8922 .body(Body::empty())
8923 .unwrap(),
8924 )
8925 .await
8926 .unwrap();
8927
8928 assert_eq!(response.status(), StatusCode::OK);
8929 }
8930
8931 #[tokio::test]
8932 async fn apply_rate_limit_middleware_skipped_when_disabled() {
8933 let config = AutumnConfig::default();
8934 assert!(!config.security.rate_limit.enabled);
8935
8936 let base: axum::Router<AppState> =
8937 axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
8938 let state = test_state();
8939 let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
8940
8941 for _ in 0..5 {
8943 let response = router
8944 .clone()
8945 .oneshot(Request::builder().uri("/ping").body(Body::empty()).unwrap())
8946 .await
8947 .unwrap();
8948 assert_eq!(response.status(), StatusCode::OK);
8949 }
8950 }
8951
8952 #[tokio::test]
8953 async fn apply_rate_limit_middleware_returns_429_when_exhausted() {
8954 let mut config = AutumnConfig::default();
8955 config.security.rate_limit.enabled = true;
8956 config.security.rate_limit.requests_per_second = 0.1;
8957 config.security.rate_limit.burst = 1;
8958 config.security.rate_limit.trust_forwarded_headers = true;
8959
8960 let base: axum::Router<AppState> =
8961 axum::Router::new().route("/ping", axum::routing::get(|| async { "pong" }));
8962 let state = test_state();
8963 let router = apply_rate_limit_middleware(base, &config, &state).with_state(state.clone());
8964
8965 let ok = router
8966 .clone()
8967 .oneshot(
8968 Request::builder()
8969 .uri("/ping")
8970 .header("X-Forwarded-For", "203.0.113.9")
8971 .body(Body::empty())
8972 .unwrap(),
8973 )
8974 .await
8975 .unwrap();
8976 assert_eq!(ok.status(), StatusCode::OK);
8977
8978 let blocked = router
8979 .oneshot(
8980 Request::builder()
8981 .uri("/ping")
8982 .header("X-Forwarded-For", "203.0.113.9")
8983 .body(Body::empty())
8984 .unwrap(),
8985 )
8986 .await
8987 .unwrap();
8988 assert_eq!(blocked.status(), StatusCode::TOO_MANY_REQUESTS);
8989 assert!(blocked.headers().get("retry-after").is_some());
8990 }
8991
8992 #[cfg(feature = "mcp")]
8993 #[tokio::test]
8994 async fn mcp_envelope_is_gated_during_maintenance() {
8995 use crate::maintenance::{MaintenanceConfig, MaintenanceState};
8996
8997 let mut config = AutumnConfig::default();
9000 config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
9001
9002 let wiring = crate::mcp::McpWiring {
9003 cors: crate::config::CorsConfig::default(),
9004 trusted_hosts: TrustedHostPolicy::from_config(&config),
9005 tenant_header: None,
9006 csrf_header: "x-csrf-token".to_owned(),
9007 envelope_rate_limited: false,
9008 envelope_load_shed: false,
9009 state: test_state(),
9010 session_cookie: "autumn.sid".to_owned(),
9011 tasks: crate::mcp::McpTaskConfig::default(),
9012 instructions: None,
9013 catalog: crate::mcp::McpCatalog::Full,
9014 };
9015 let mcp_router =
9016 crate::mcp::build_mcp_router("/mcp", Vec::new(), axum::Router::new(), wiring, None);
9017
9018 let initialize = || {
9019 Request::builder()
9020 .method("POST")
9021 .uri("/mcp")
9022 .header("host", "app.example")
9023 .header("content-type", "application/json")
9024 .body(Body::from(
9025 serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize"}).to_string(),
9026 ))
9027 .unwrap()
9028 };
9029
9030 let state = test_state();
9033 let maintenance = MaintenanceState::new();
9034 maintenance.enable(MaintenanceConfig::default());
9035 state.insert_extension(maintenance);
9036 let gated = mcp_router
9037 .clone()
9038 .layer(build_maintenance_layer(&config, &state))
9039 .with_state(state);
9040 let resp = gated.oneshot(initialize()).await.unwrap();
9041 assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
9042
9043 let state = test_state();
9046 let open = mcp_router
9047 .layer(build_maintenance_layer(&config, &state))
9048 .with_state(state);
9049 let resp = open.oneshot(initialize()).await.unwrap();
9050 assert_eq!(resp.status(), StatusCode::OK);
9051 }
9052
9053 #[cfg(feature = "mail")]
9054 fn dev_mail_preview_config(dir: &std::path::Path) -> AutumnConfig {
9055 let mut config = AutumnConfig {
9056 profile: Some("dev".to_owned()),
9057 mail: crate::mail::MailConfig {
9058 transport: crate::mail::Transport::File,
9059 file_dir: dir.to_path_buf(),
9060 ..Default::default()
9061 },
9062 ..Default::default()
9063 };
9064 config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
9065 config
9066 }
9067
9068 #[cfg(any(feature = "mail", feature = "maud"))]
9069 async fn response_text(response: axum::response::Response) -> String {
9070 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
9071 .await
9072 .expect("body should collect");
9073 String::from_utf8(body.to_vec()).expect("body should be utf8")
9074 }
9075
9076 #[cfg(feature = "mail")]
9077 #[tokio::test]
9078 async fn build_router_mounts_dev_mail_preview_empty_state_for_file_transport() {
9079 let dir = tempfile::tempdir().expect("tempdir");
9080 let config = dev_mail_preview_config(dir.path());
9081 let router = build_router(Vec::new(), &config, test_state());
9082
9083 let response = router
9084 .oneshot(
9085 Request::builder()
9086 .uri("/_autumn/mail")
9087 .header("host", "example.com")
9088 .body(Body::empty())
9089 .unwrap(),
9090 )
9091 .await
9092 .unwrap();
9093
9094 assert_eq!(response.status(), StatusCode::OK);
9095 let body = response_text(response).await;
9096 assert!(
9097 body.contains("No captured emails"),
9098 "missing empty state: {body}"
9099 );
9100 assert!(
9101 body.contains("mail.transport = "file""),
9102 "empty state should explain capture setup: {body}"
9103 );
9104 }
9105
9106 #[cfg(feature = "mail")]
9107 #[tokio::test]
9108 async fn build_router_lists_captured_mail_newest_first() {
9109 let dir = tempfile::tempdir().expect("tempdir");
9110 let older = dir.path().join("older.eml");
9111 let newer = dir.path().join("newer.eml");
9112 std::fs::write(
9113 &older,
9114 "To: first@example.com\nSubject: First\nDate: Tue, 05 May 2026 10:00:00 +0000\nMessage-Id: <first@example.com>\n\nfirst body\n",
9115 )
9116 .expect("write older eml");
9117 std::fs::write(
9118 &newer,
9119 "To: second@example.com\nSubject: Second\nDate: Tue, 05 May 2026 10:01:00 +0000\nMessage-Id: <second@example.com>\n\nsecond body\n",
9120 )
9121 .expect("write newer eml");
9122 filetime::set_file_mtime(&older, filetime::FileTime::from_unix_time(100, 0))
9123 .expect("set older mtime");
9124 filetime::set_file_mtime(&newer, filetime::FileTime::from_unix_time(200, 0))
9125 .expect("set newer mtime");
9126
9127 let config = dev_mail_preview_config(dir.path());
9128 let router = build_router(Vec::new(), &config, test_state());
9129 let response = router
9130 .oneshot(
9131 Request::builder()
9132 .uri("/_autumn/mail")
9133 .header("host", "example.com")
9134 .body(Body::empty())
9135 .unwrap(),
9136 )
9137 .await
9138 .unwrap();
9139
9140 assert_eq!(response.status(), StatusCode::OK);
9141 let body = response_text(response).await;
9142 let second = body.find("Second").expect("newer subject should render");
9143 let first = body.find("First").expect("older subject should render");
9144 assert!(second < first, "newest message should render first: {body}");
9145 assert!(
9146 body.contains("second@example.com"),
9147 "missing To column: {body}"
9148 );
9149 assert!(
9150 body.contains("Timestamp"),
9151 "missing timestamp column: {body}"
9152 );
9153 }
9154
9155 #[cfg(feature = "mail")]
9156 #[tokio::test]
9157 async fn build_router_mail_preview_detail_renders_html_in_sandboxed_iframe() {
9158 let dir = tempfile::tempdir().expect("tempdir");
9159 std::fs::write(
9160 dir.path().join("detail.eml"),
9161 "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",
9162 )
9163 .expect("write detail eml");
9164
9165 let config = dev_mail_preview_config(dir.path());
9166 let router = build_router(Vec::new(), &config, test_state());
9167 let response = router
9168 .oneshot(
9169 Request::builder()
9170 .uri("/_autumn/mail/messages/detail.eml")
9171 .header("host", "example.com")
9172 .body(Body::empty())
9173 .unwrap(),
9174 )
9175 .await
9176 .unwrap();
9177
9178 assert_eq!(response.status(), StatusCode::OK);
9179 let body = response_text(response).await;
9180 assert!(body.contains("<iframe"), "missing iframe: {body}");
9181 assert!(body.contains("sandbox"), "iframe must be sandboxed: {body}");
9182 assert!(body.contains("Hello iframe"), "missing html body: {body}");
9183 assert!(body.contains("Plain text"), "missing text toggle: {body}");
9184 assert!(body.contains("Headers"), "missing headers toggle: {body}");
9185 assert!(
9186 body.contains("Raw .eml"),
9187 "missing raw source toggle: {body}"
9188 );
9189 assert!(
9190 body.contains("Message-Id"),
9191 "missing message id header: {body}"
9192 );
9193 }
9194
9195 #[cfg(feature = "mail")]
9196 #[tokio::test]
9197 async fn build_router_does_not_mount_mail_preview_outside_dev() {
9198 let dir = tempfile::tempdir().expect("tempdir");
9199 let mut config = dev_mail_preview_config(dir.path());
9200 config.profile = Some("prod".to_owned());
9201 let router = build_router(Vec::new(), &config, test_state());
9202
9203 let response = router
9204 .oneshot(
9205 Request::builder()
9206 .uri("/_autumn/mail")
9207 .header("host", "example.com")
9208 .body(Body::empty())
9209 .unwrap(),
9210 )
9211 .await
9212 .unwrap();
9213
9214 assert_eq!(response.status(), StatusCode::NOT_FOUND);
9215 }
9216
9217 #[cfg(feature = "maud")]
9225 fn story_gallery_config() -> AutumnConfig {
9226 let mut config = AutumnConfig::default();
9227 config.stories.enabled = true;
9228 config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
9229 config
9230 }
9231
9232 #[cfg(feature = "maud")]
9233 fn stories_state_with_builtin() -> AppState {
9234 let state = test_state();
9235 state.insert_extension(crate::stories::builtin());
9236 state
9237 }
9238
9239 #[cfg(feature = "maud")]
9240 async fn get_with_host(router: axum::Router, uri: &str) -> axum::response::Response {
9241 router
9242 .oneshot(
9243 Request::builder()
9244 .uri(uri)
9245 .header("host", "example.com")
9246 .body(Body::empty())
9247 .unwrap(),
9248 )
9249 .await
9250 .unwrap()
9251 }
9252
9253 #[cfg(feature = "maud")]
9257 #[tokio::test]
9258 async fn build_router_mounts_story_gallery_when_enabled() {
9259 let router = build_router(
9260 Vec::new(),
9261 &story_gallery_config(),
9262 stories_state_with_builtin(),
9263 );
9264
9265 let response = get_with_host(router, crate::stories::STORIES_PATH).await;
9266 assert_eq!(response.status(), StatusCode::OK);
9267 let body = response_text(response).await;
9268 assert!(
9269 body.contains("Data table"),
9270 "index should list builtin story names: {body}"
9271 );
9272 assert!(
9273 body.contains("autumn-widgets.css"),
9274 "index should link the framework widget stylesheet: {body}"
9275 );
9276 }
9277
9278 #[cfg(feature = "maud")]
9281 #[tokio::test]
9282 async fn story_detail_route_serves_render_source_and_html() {
9283 let router = build_router(
9284 Vec::new(),
9285 &story_gallery_config(),
9286 stories_state_with_builtin(),
9287 );
9288
9289 let response = get_with_host(router, "/_stories/data-table").await;
9290 assert_eq!(response.status(), StatusCode::OK);
9291 let body = response_text(response).await;
9292 assert!(
9293 body.contains("<table"),
9294 "detail page must contain the live data_table render: {body}"
9295 );
9296 assert!(
9297 body.contains("data_table("),
9298 "detail page must show the source snippet that produced the render: {body}"
9299 );
9300 assert!(
9301 body.contains("Rendered HTML"),
9302 "detail page must offer the rendered-HTML tab: {body}"
9303 );
9304 assert!(
9305 body.contains("Source"),
9306 "detail page must offer the source tab: {body}"
9307 );
9308 }
9309
9310 #[cfg(all(feature = "maud", feature = "htmx"))]
9313 #[tokio::test]
9314 async fn story_detail_route_loads_htmx_for_hx_stories() {
9315 let router = build_router(
9316 Vec::new(),
9317 &story_gallery_config(),
9318 stories_state_with_builtin(),
9319 );
9320
9321 let response = get_with_host(router, "/_stories/active-search").await;
9322 assert_eq!(response.status(), StatusCode::OK);
9323 let body = response_text(response).await;
9324 assert!(body.contains("hx-get"), "story must use htmx: {body}");
9325 let htmx_at = body
9326 .find(crate::htmx::HTMX_JS_PATH)
9327 .unwrap_or_else(|| panic!("page must load htmx.min.js: {body}"));
9328 let widgets_at = body
9329 .find(crate::htmx::AUTUMN_WIDGETS_JS_PATH)
9330 .unwrap_or_else(|| panic!("page must load autumn-widgets.js: {body}"));
9331 assert!(htmx_at < widgets_at, "htmx must load first: {body}");
9332 }
9333
9334 #[cfg(feature = "maud")]
9336 #[tokio::test]
9337 async fn story_detail_unknown_slug_is_404() {
9338 let router = build_router(
9339 Vec::new(),
9340 &story_gallery_config(),
9341 stories_state_with_builtin(),
9342 );
9343
9344 let missing = get_with_host(router.clone(), "/_stories/nope").await;
9345 assert_eq!(missing.status(), StatusCode::NOT_FOUND);
9346
9347 let index = get_with_host(router, "/_stories").await;
9348 assert_eq!(
9349 index.status(),
9350 StatusCode::OK,
9351 "index route must exist even when a slug misses"
9352 );
9353 }
9354
9355 #[cfg(feature = "maud")]
9358 #[tokio::test]
9359 async fn build_router_omits_story_gallery_by_default() {
9360 let mut config = AutumnConfig::default();
9361 assert!(
9362 !config.stories.enabled,
9363 "stories gallery must be off by default"
9364 );
9365 config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
9366
9367 let router = build_router(Vec::new(), &config, stories_state_with_builtin());
9368 let response = get_with_host(router, "/_stories").await;
9369 assert_eq!(response.status(), StatusCode::NOT_FOUND);
9370 }
9371
9372 #[cfg(feature = "i18n")]
9375 mod locale_prefix_routing {
9376 use super::*;
9377 use crate::i18n::{Bundle, I18nConfig, Locale};
9378
9379 async fn locale_probe(locale: Locale) -> String {
9380 locale.tag().to_owned()
9381 }
9382
9383 async fn plain_ok() -> &'static str {
9384 "ok"
9385 }
9386
9387 fn simple_route(path: &'static str, name: &'static str, probe: bool) -> Route {
9388 Route {
9389 method: http::Method::GET,
9390 path,
9391 handler: if probe {
9392 axum::routing::get(locale_probe)
9393 } else {
9394 axum::routing::get(plain_ok)
9395 },
9396 name,
9397 api_doc: crate::openapi::ApiDoc {
9398 method: "GET",
9399 path,
9400 operation_id: name,
9401 success_status: 200,
9402 ..Default::default()
9403 },
9404 repository: None,
9405 idempotency: crate::route::RouteIdempotency::Direct,
9406 timeout: crate::route::RouteTimeout::Inherit,
9407 criticality: crate::admission::Criticality::Default,
9408 seo: crate::seo::SeoRouteDefaults::EMPTY,
9409 api_version: None,
9410 sunset_opt_out: false,
9411 }
9412 }
9413
9414 fn config(supported: &[&str], exclude: &[&str]) -> AutumnConfig {
9415 let mut config = AutumnConfig::default();
9416 config.i18n.locale_prefix_enabled = true;
9417 config.i18n.default_locale = supported.first().copied().unwrap_or("en").to_owned();
9418 config.i18n.supported_locales = supported.iter().map(|s| (*s).to_owned()).collect();
9419 config.i18n.locale_prefix_exclude = exclude.iter().map(|s| (*s).to_owned()).collect();
9420 config
9421 }
9422
9423 fn bundle(supported: &[&str]) -> Arc<Bundle> {
9424 let cfg = I18nConfig {
9425 default_locale: supported.first().copied().unwrap_or("en").to_owned(),
9426 supported_locales: supported.iter().map(|s| (*s).to_owned()).collect(),
9427 fallback_chain: vec![],
9428 dir: "i18n".to_owned(),
9429 locale_prefix_enabled: false,
9430 locale_prefix_exclude: vec![],
9431 locale_prefix_exclude_exact: vec![],
9432 };
9433 Arc::new(Bundle::from_messages(
9434 std::collections::HashMap::new(),
9435 &cfg,
9436 ))
9437 }
9438
9439 async fn request(
9440 router: &axum::Router,
9441 uri: &str,
9442 headers: &[(&str, &str)],
9443 ) -> axum::response::Response {
9444 request_method(router, http::Method::GET, uri, headers).await
9445 }
9446
9447 async fn request_method(
9448 router: &axum::Router,
9449 method: http::Method,
9450 uri: &str,
9451 headers: &[(&str, &str)],
9452 ) -> axum::response::Response {
9453 let mut req = Request::builder().method(method).uri(uri);
9454 for (k, v) in headers {
9455 req = req.header(*k, *v);
9456 }
9457 router
9458 .clone()
9459 .oneshot(req.body(Body::empty()).unwrap())
9460 .await
9461 .unwrap()
9462 }
9463
9464 async fn body_string(resp: axum::response::Response) -> String {
9465 let bytes = axum::body::to_bytes(resp.into_body(), 1024).await.unwrap();
9466 String::from_utf8(bytes.to_vec()).unwrap()
9467 }
9468
9469 async fn uri_probe(uri: axum::http::Uri) -> String {
9470 uri.path().to_owned()
9471 }
9472
9473 async fn slug_probe(
9474 locale: Locale,
9475 axum::extract::Path(slug): axum::extract::Path<String>,
9476 ) -> String {
9477 format!("{}:{slug}", locale.tag())
9478 }
9479
9480 fn uri_probe_route(path: &'static str, name: &'static str) -> Route {
9481 Route {
9482 method: http::Method::GET,
9483 path,
9484 handler: axum::routing::get(uri_probe),
9485 name,
9486 api_doc: crate::openapi::ApiDoc {
9487 method: "GET",
9488 path,
9489 operation_id: name,
9490 success_status: 200,
9491 ..Default::default()
9492 },
9493 repository: None,
9494 idempotency: crate::route::RouteIdempotency::Direct,
9495 timeout: crate::route::RouteTimeout::Inherit,
9496 criticality: crate::admission::Criticality::Default,
9497 seo: crate::seo::SeoRouteDefaults::EMPTY,
9498 api_version: None,
9499 sunset_opt_out: false,
9500 }
9501 }
9502
9503 fn slug_route(path: &'static str, name: &'static str) -> Route {
9504 Route {
9505 method: http::Method::GET,
9506 path,
9507 handler: axum::routing::get(slug_probe),
9508 name,
9509 api_doc: crate::openapi::ApiDoc {
9510 method: "GET",
9511 path,
9512 operation_id: name,
9513 success_status: 200,
9514 ..Default::default()
9515 },
9516 repository: None,
9517 idempotency: crate::route::RouteIdempotency::Direct,
9518 timeout: crate::route::RouteTimeout::Inherit,
9519 criticality: crate::admission::Criticality::Default,
9520 seo: crate::seo::SeoRouteDefaults::EMPTY,
9521 api_version: None,
9522 sunset_opt_out: false,
9523 }
9524 }
9525
9526 fn make_post_route(path: &'static str, name: &'static str) -> Route {
9527 Route {
9528 method: http::Method::POST,
9529 path,
9530 handler: axum::routing::post(plain_ok),
9531 name,
9532 api_doc: crate::openapi::ApiDoc {
9533 method: "POST",
9534 path,
9535 operation_id: name,
9536 success_status: 200,
9537 ..Default::default()
9538 },
9539 repository: None,
9540 idempotency: crate::route::RouteIdempotency::Direct,
9541 timeout: crate::route::RouteTimeout::Inherit,
9542 criticality: crate::admission::Criticality::Default,
9543 seo: crate::seo::SeoRouteDefaults::EMPTY,
9544 api_version: None,
9545 sunset_opt_out: false,
9546 }
9547 }
9548
9549 #[tokio::test]
9555 async fn locale_prefix_redirect_works_without_an_i18n_bundle() {
9556 let config = config(&["fr"], &[]);
9557 let route = simple_route("/posts", "posts", true);
9558 let app = build_router(vec![route], &config, test_state());
9561
9562 let redirected = request(&app, "/posts", &[]).await;
9563 assert_eq!(redirected.status(), StatusCode::PERMANENT_REDIRECT);
9564 assert_eq!(
9565 redirected
9566 .headers()
9567 .get(axum::http::header::LOCATION)
9568 .and_then(|v| v.to_str().ok()),
9569 Some("/fr/posts"),
9570 "must redirect to the configured locale, not a hard-coded \"en\""
9571 );
9572
9573 let target = request(&app, "/fr/posts", &[]).await;
9574 assert_eq!(
9575 target.status(),
9576 StatusCode::OK,
9577 "the redirect target must actually resolve"
9578 );
9579 assert_eq!(body_string(target).await, "fr");
9580 }
9581
9582 #[tokio::test]
9585 async fn locale_prefix_routing_off_by_default() {
9586 let mut config = AutumnConfig::default();
9587 assert!(!config.i18n.locale_prefix_enabled);
9588 config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
9589
9590 let route = simple_route("/posts", "posts", false);
9591 let app = build_router(vec![route], &config, test_state())
9592 .layer(axum::Extension(bundle(&["en", "es"])));
9593
9594 let bare = request(&app, "/posts", &[]).await;
9595 assert_eq!(bare.status(), StatusCode::OK);
9596
9597 let prefixed = request(&app, "/en/posts", &[]).await;
9598 assert_eq!(
9599 prefixed.status(),
9600 StatusCode::NOT_FOUND,
9601 "no locale nest should exist when the flag is off"
9602 );
9603 }
9604
9605 #[tokio::test]
9611 async fn root_path_redirects_to_bare_locale_prefix_without_trailing_slash() {
9612 let config = config(&["en", "es"], &[]);
9613 let route = simple_route("/", "root", true);
9614 let app = build_router(vec![route], &config, test_state())
9615 .layer(axum::Extension(bundle(&["en", "es"])));
9616
9617 let bare = request(&app, "/", &[]).await;
9618 assert_eq!(bare.status(), StatusCode::PERMANENT_REDIRECT);
9619 assert_eq!(
9620 bare.headers()
9621 .get(axum::http::header::LOCATION)
9622 .and_then(|v| v.to_str().ok()),
9623 Some("/en"),
9624 "root redirect target must have no trailing slash"
9625 );
9626
9627 let nested = request(&app, "/en", &[]).await;
9628 assert_eq!(nested.status(), StatusCode::OK);
9629 assert_eq!(body_string(nested).await, "en");
9630
9631 let nested_with_slash = request(&app, "/en/", &[]).await;
9632 assert_eq!(
9633 nested_with_slash.status(),
9634 StatusCode::NOT_FOUND,
9635 "axum's nest-plus-root case does not also match the trailing-slash form"
9636 );
9637 }
9638
9639 #[tokio::test]
9642 async fn root_path_redirect_preserves_query_without_trailing_slash() {
9643 let config = config(&["en", "es"], &[]);
9644 let route = simple_route("/", "root", true);
9645 let app = build_router(vec![route], &config, test_state())
9646 .layer(axum::Extension(bundle(&["en", "es"])));
9647
9648 let response = request(&app, "/?ref=newsletter", &[]).await;
9649 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
9650 assert_eq!(
9651 response
9652 .headers()
9653 .get(axum::http::header::LOCATION)
9654 .and_then(|v| v.to_str().ok()),
9655 Some("/en?ref=newsletter")
9656 );
9657 }
9658
9659 #[tokio::test]
9663 async fn route_reachable_under_every_supported_locale() {
9664 let config = config(&["en", "es"], &[]);
9665 let route = simple_route("/posts", "posts", true);
9666 let app = build_router(vec![route], &config, test_state())
9667 .layer(axum::Extension(bundle(&["en", "es"])));
9668
9669 let en = request(&app, "/en/posts", &[]).await;
9670 assert_eq!(en.status(), StatusCode::OK);
9671 assert_eq!(body_string(en).await, "en");
9672
9673 let es = request(&app, "/es/posts", &[]).await;
9674 assert_eq!(es.status(), StatusCode::OK);
9675 assert_eq!(body_string(es).await, "es");
9676 }
9677
9678 #[tokio::test]
9683 async fn duplicate_supported_locale_does_not_panic_and_still_routes() {
9684 let config = config(&["en", "es", "en"], &[]);
9685 let route = simple_route("/posts", "posts", true);
9686 let app = build_router(vec![route], &config, test_state())
9687 .layer(axum::Extension(bundle(&["en", "es"])));
9688
9689 let en = request(&app, "/en/posts", &[]).await;
9690 assert_eq!(en.status(), StatusCode::OK);
9691 assert_eq!(body_string(en).await, "en");
9692
9693 let es = request(&app, "/es/posts", &[]).await;
9694 assert_eq!(es.status(), StatusCode::OK);
9695 assert_eq!(body_string(es).await, "es");
9696 }
9697
9698 #[tokio::test]
9704 async fn malformed_locale_segment_does_not_panic_and_valid_locale_still_routes() {
9705 let config = config(&["en", "", "es"], &[]);
9706 let route = simple_route("/posts", "posts", true);
9707 let app = build_router(vec![route], &config, test_state())
9708 .layer(axum::Extension(bundle(&["en", "es"])));
9709
9710 let en = request(&app, "/en/posts", &[]).await;
9711 assert_eq!(en.status(), StatusCode::OK);
9712 assert_eq!(body_string(en).await, "en");
9713
9714 let es = request(&app, "/es/posts", &[]).await;
9715 assert_eq!(es.status(), StatusCode::OK);
9716 assert_eq!(body_string(es).await, "es");
9717 }
9718
9719 #[tokio::test]
9727 async fn generated_locale_path_collision_is_a_build_error_not_a_panic() {
9728 let config = config(&["en", "es"], &[]);
9729 let foo = simple_route("/foo", "foo", false);
9730 let en_foo = simple_route("/en/foo", "en_foo", false);
9731 let err = try_build_router(vec![foo, en_foo], &config, test_state())
9732 .expect_err("a generated-path collision must be a build error, not a panic");
9733 match err {
9734 RouterBuildError::LocalePrefixPathCollision {
9735 ref locale,
9736 ref path,
9737 ref generated,
9738 } => {
9739 assert_eq!(locale, "en");
9740 assert_eq!(path, "/foo");
9741 assert_eq!(generated, "/en/foo");
9742 }
9743 other => panic!("expected LocalePrefixPathCollision, got {other:?}"),
9744 }
9745 }
9746
9747 #[tokio::test]
9754 async fn generated_locale_path_collision_via_capture_name_mismatch_is_a_build_error() {
9755 let config = config(&["en"], &[]);
9756 let users_id = slug_route("/users/{id}", "users_id");
9757 let en_users_slug = slug_route("/en/users/{slug}", "en_users_slug");
9758 let err = try_build_router(vec![users_id, en_users_slug], &config, test_state())
9759 .expect_err("a capture-name-mismatched shape collision must be a build error");
9760 assert!(
9761 matches!(err, RouterBuildError::LocalePrefixPathCollision { .. }),
9762 "expected LocalePrefixPathCollision, got {err:?}"
9763 );
9764 }
9765
9766 #[tokio::test]
9772 async fn exact_generated_path_with_different_method_is_not_a_collision() {
9773 let config = config(&["en"], &[]);
9774 let get_foo = simple_route("/foo", "foo_get", false);
9775 let post_en_foo = duplicate_test_route(http::Method::POST, "/en/foo", "post_en_foo");
9776
9777 let app = build_router(vec![get_foo, post_en_foo], &config, test_state());
9779
9780 let get_resp = request(&app, "/en/foo", &[]).await;
9781 assert_eq!(
9782 get_resp.status(),
9783 StatusCode::OK,
9784 "GET /en/foo must resolve to the nested /foo content"
9785 );
9786 }
9787
9788 #[tokio::test]
9794 async fn generated_locale_path_colliding_with_a_scoped_route_is_a_build_error() {
9795 let config = config(&["en"], &[]);
9796 let foo = simple_route("/foo", "foo", false);
9797 let scoped_route = duplicate_test_route(http::Method::GET, "/foo", "scoped_foo");
9798 let group = crate::app::ScopedGroup {
9799 prefix: "/en".to_owned(),
9800 routes: vec![scoped_route],
9801 source: crate::route_listing::RouteSource::User,
9802 layer_guarded: false,
9803 apply_layer: Box::new(|r| r),
9804 };
9805 let mut ctx = duplicate_test_ctx();
9806 ctx.scoped_groups.push(group);
9807
9808 let err = super::try_build_router_inner(vec![foo], &config, test_state(), ctx)
9809 .expect_err(
9810 "a scoped route colliding with a generated locale path must be a build error",
9811 );
9812 assert!(
9813 matches!(err, RouterBuildError::LocalePrefixPathCollision { .. }),
9814 "expected LocalePrefixPathCollision, got {err:?}"
9815 );
9816 }
9817
9818 #[tokio::test]
9824 async fn generated_locale_path_colliding_with_a_health_probe_is_a_build_error() {
9825 let mut config = config(&["en"], &[]);
9826 config.health.path = "/en/foo".to_owned();
9827 let foo = simple_route("/foo", "foo", false);
9828
9829 let err = try_build_router(vec![foo], &config, test_state()).expect_err(
9830 "a health probe colliding with a generated locale path must be a build error",
9831 );
9832 assert!(
9833 matches!(err, RouterBuildError::LocalePrefixPathCollision { .. }),
9834 "expected LocalePrefixPathCollision, got {err:?}"
9835 );
9836 }
9837
9838 #[tokio::test]
9843 async fn colon_prefixed_locale_segment_does_not_panic() {
9844 let config = config(&["en", ":en"], &[]);
9845 let route = simple_route("/posts", "posts", true);
9846 let app = build_router(vec![route], &config, test_state())
9847 .layer(axum::Extension(bundle(&["en"])));
9848
9849 let en = request(&app, "/en/posts", &[]).await;
9850 assert_eq!(en.status(), StatusCode::OK);
9851 assert_eq!(body_string(en).await, "en");
9852 }
9853
9854 #[tokio::test]
9862 async fn locale_segment_with_uri_delimiter_does_not_panic_and_valid_locale_still_routes() {
9863 let config = config(&["en", "en?x", "en#x", "en y"], &[]);
9864 let route = simple_route("/posts", "posts", true);
9865 let app = build_router(vec![route], &config, test_state())
9866 .layer(axum::Extension(bundle(&["en"])));
9867
9868 let en = request(&app, "/en/posts", &[]).await;
9869 assert_eq!(en.status(), StatusCode::OK);
9870 assert_eq!(body_string(en).await, "en");
9871 }
9872
9873 #[tokio::test]
9881 async fn bare_path_redirect_claims_only_registered_methods_not_every_method() {
9882 async fn user_health_post() -> &'static str {
9883 "posted"
9884 }
9885
9886 let config = config(&["en"], &[]);
9887 let route = Route {
9888 method: http::Method::POST,
9889 path: "/health",
9890 handler: axum::routing::post(user_health_post),
9891 name: "user_health_post",
9892 api_doc: crate::openapi::ApiDoc {
9893 method: "POST",
9894 path: "/health",
9895 operation_id: "user_health_post",
9896 success_status: 200,
9897 ..Default::default()
9898 },
9899 repository: None,
9900 idempotency: crate::route::RouteIdempotency::Direct,
9901 timeout: crate::route::RouteTimeout::Inherit,
9902 criticality: crate::admission::Criticality::Default,
9903 seo: crate::seo::SeoRouteDefaults::EMPTY,
9904 api_version: None,
9905 sunset_opt_out: false,
9906 };
9907
9908 let app = build_router(vec![route], &config, test_state());
9912
9913 let health = request(&app, "/health", &[]).await;
9914 assert_eq!(
9915 health.status(),
9916 StatusCode::OK,
9917 "the framework's own health probe must still respond at GET /health"
9918 );
9919
9920 let redirected = request_method(&app, http::Method::POST, "/health", &[]).await;
9921 assert_eq!(redirected.status(), StatusCode::PERMANENT_REDIRECT);
9922 assert_eq!(
9923 redirected
9924 .headers()
9925 .get(axum::http::header::LOCATION)
9926 .and_then(|v| v.to_str().ok()),
9927 Some("/en/health")
9928 );
9929 }
9930
9931 #[tokio::test]
9938 async fn skipped_invalid_locale_is_never_selected_by_negotiation() {
9939 let mut config = config(&["", "en"], &[]);
9940 config.i18n.default_locale = String::new();
9941 let route = simple_route("/posts", "posts", true);
9942 let app = build_router(vec![route], &config, test_state());
9943
9944 let response = request(&app, "/posts", &[]).await;
9948 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
9949 assert_eq!(
9950 response
9951 .headers()
9952 .get(axum::http::header::LOCATION)
9953 .and_then(|v| v.to_str().ok()),
9954 Some("/en/posts"),
9955 "must redirect to the validly-nested locale, not the skipped empty default"
9956 );
9957 }
9958
9959 #[tokio::test]
9961 async fn unknown_locale_prefix_is_404_not_panic() {
9962 let config = config(&["en", "es"], &[]);
9963 let route = simple_route("/posts", "posts", true);
9964 let app = build_router(vec![route], &config, test_state())
9965 .layer(axum::Extension(bundle(&["en", "es"])));
9966
9967 let response = request(&app, "/zz/posts", &[]).await;
9968 assert_eq!(response.status(), StatusCode::NOT_FOUND);
9969 }
9970
9971 #[tokio::test]
9974 async fn bare_path_redirects_preserving_query() {
9975 let config = config(&["en", "es"], &[]);
9976 let route = simple_route("/posts", "posts", true);
9977 let app = build_router(vec![route], &config, test_state())
9978 .layer(axum::Extension(bundle(&["en", "es"])));
9979
9980 let response = request(&app, "/posts?sort=asc", &[]).await;
9981 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
9982 assert_eq!(
9983 response
9984 .headers()
9985 .get(axum::http::header::LOCATION)
9986 .and_then(|v| v.to_str().ok()),
9987 Some("/en/posts?sort=asc")
9988 );
9989
9990 let response = request(
9991 &app,
9992 "/posts?sort=asc",
9993 &[(axum::http::header::ACCEPT_LANGUAGE.as_str(), "es")],
9994 )
9995 .await;
9996 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
9997 assert_eq!(
9998 response
9999 .headers()
10000 .get(axum::http::header::LOCATION)
10001 .and_then(|v| v.to_str().ok()),
10002 Some("/es/posts?sort=asc")
10003 );
10004 }
10005
10006 #[tokio::test]
10010 async fn url_prefix_locale_wins_over_cookie_and_accept_language() {
10011 let config = config(&["en", "es"], &[]);
10012 let route = simple_route("/posts", "posts", true);
10013 let app = build_router(vec![route], &config, test_state())
10014 .layer(axum::Extension(bundle(&["en", "es"])));
10015
10016 let response = request(
10017 &app,
10018 "/es/posts",
10019 &[
10020 (axum::http::header::COOKIE.as_str(), "autumn_locale=en"),
10021 (axum::http::header::ACCEPT_LANGUAGE.as_str(), "en"),
10022 ],
10023 )
10024 .await;
10025 assert_eq!(response.status(), StatusCode::OK);
10026 assert_eq!(body_string(response).await, "es");
10027 }
10028
10029 #[tokio::test]
10032 async fn excluded_prefix_stays_unprefixed_and_unredirected() {
10033 let config = config(&["en", "es"], &["/api"]);
10034 let route = simple_route("/api/status", "api_status", false);
10035 let app = build_router(vec![route], &config, test_state())
10036 .layer(axum::Extension(bundle(&["en", "es"])));
10037
10038 let direct = request(&app, "/api/status", &[]).await;
10039 assert_eq!(
10040 direct.status(),
10041 StatusCode::OK,
10042 "excluded route must serve directly, not redirect"
10043 );
10044
10045 let nested = request(&app, "/en/api/status", &[]).await;
10046 assert_eq!(
10047 nested.status(),
10048 StatusCode::NOT_FOUND,
10049 "excluded route must not be nested under a locale prefix"
10050 );
10051 }
10052
10053 #[tokio::test]
10056 async fn exclude_prefix_accepts_trailing_glob_suffix() {
10057 let config = config(&["en", "es"], &["/api/*"]);
10058 let route = simple_route("/api/status", "api_status", false);
10059 let app = build_router(vec![route], &config, test_state())
10060 .layer(axum::Extension(bundle(&["en", "es"])));
10061
10062 let direct = request(&app, "/api/status", &[]).await;
10063 assert_eq!(direct.status(), StatusCode::OK);
10064
10065 let nested = request(&app, "/en/api/status", &[]).await;
10066 assert_eq!(nested.status(), StatusCode::NOT_FOUND);
10067 }
10068
10069 #[tokio::test]
10074 async fn root_path_exclude_prefix_excludes_exactly_the_root() {
10075 let config = config(&["en", "es"], &["/"]);
10076 let root = simple_route("/", "root", false);
10077 let posts = simple_route("/posts", "posts", true);
10078 let app = build_router(vec![root, posts], &config, test_state())
10079 .layer(axum::Extension(bundle(&["en", "es"])));
10080
10081 let bare_root = request(&app, "/", &[]).await;
10082 assert_eq!(
10083 bare_root.status(),
10084 StatusCode::OK,
10085 "excluded root route must serve directly, not redirect"
10086 );
10087
10088 let nested_root = request(&app, "/en", &[]).await;
10089 assert_eq!(
10090 nested_root.status(),
10091 StatusCode::NOT_FOUND,
10092 "excluded root route must not be nested under a locale prefix"
10093 );
10094
10095 let bare_posts = request(&app, "/posts", &[]).await;
10098 assert_eq!(
10099 bare_posts.status(),
10100 StatusCode::PERMANENT_REDIRECT,
10101 "\"/\" in the exclude list must not exclude unrelated routes"
10102 );
10103 }
10104
10105 #[tokio::test]
10111 async fn exclude_prefix_does_not_match_unrelated_path_sharing_a_string_prefix() {
10112 let config = config(&["en", "es"], &["/api"]);
10113 let route = simple_route("/apikeys", "apikeys", true);
10114 let app = build_router(vec![route], &config, test_state())
10115 .layer(axum::Extension(bundle(&["en", "es"])));
10116
10117 let bare = request(&app, "/apikeys", &[]).await;
10118 assert_eq!(
10119 bare.status(),
10120 StatusCode::PERMANENT_REDIRECT,
10121 "/apikeys must be treated as included (locale-prefixed), not excluded"
10122 );
10123
10124 let nested = request(&app, "/en/apikeys", &[]).await;
10125 assert_eq!(
10126 nested.status(),
10127 StatusCode::OK,
10128 "/apikeys must be nested under the locale prefix like any other included route"
10129 );
10130 }
10131
10132 #[tokio::test]
10136 async fn parameterized_route_reachable_under_locale_prefix() {
10137 let config = config(&["en", "es"], &[]);
10138 let route = slug_route("/posts/{slug}", "posts_show");
10139 let app = build_router(vec![route], &config, test_state())
10140 .layer(axum::Extension(bundle(&["en", "es"])));
10141
10142 let response = request(&app, "/es/posts/hello-world", &[]).await;
10143 assert_eq!(response.status(), StatusCode::OK);
10144 assert_eq!(body_string(response).await, "es:hello-world");
10145 }
10146
10147 #[tokio::test]
10154 async fn exact_exclude_does_not_swallow_a_dynamic_sibling_route() {
10155 let mut config = config(&["en", "es"], &[]);
10156 config.i18n.locale_prefix_exclude_exact = vec!["/posts".to_owned()];
10157 let static_index = simple_route("/posts", "posts_index", false);
10158 let dynamic_child = slug_route("/posts/{slug}", "posts_show");
10159 let app = build_router(vec![static_index, dynamic_child], &config, test_state())
10160 .layer(axum::Extension(bundle(&["en", "es"])));
10161
10162 let excluded = request(&app, "/posts", &[]).await;
10163 assert_eq!(
10164 excluded.status(),
10165 StatusCode::OK,
10166 "the exactly-excluded static route must stay unprefixed and unredirected"
10167 );
10168
10169 let excluded_prefixed = request(&app, "/en/posts", &[]).await;
10170 assert_eq!(
10171 excluded_prefixed.status(),
10172 StatusCode::NOT_FOUND,
10173 "the exactly-excluded static route must not be nested under a locale prefix"
10174 );
10175
10176 let sibling_prefixed = request(&app, "/en/posts/hello-world", &[]).await;
10177 assert_eq!(
10178 sibling_prefixed.status(),
10179 StatusCode::OK,
10180 "a dynamic sibling sharing the excluded path as a string prefix must \
10181 still be locale-prefixed normally — an exact exclusion must not act \
10182 like a prefix exclusion"
10183 );
10184 assert_eq!(body_string(sibling_prefixed).await, "en:hello-world");
10185
10186 let sibling_bare = request(&app, "/posts/hello-world", &[]).await;
10187 assert_eq!(
10188 sibling_bare.status(),
10189 StatusCode::PERMANENT_REDIRECT,
10190 "the dynamic sibling's bare path must still redirect, since only the \
10191 exact literal `/posts` is excluded"
10192 );
10193 }
10194
10195 #[tokio::test]
10202 async fn uri_extractor_inside_locale_nest_sees_locale_stripped_path() {
10203 let config = config(&["en", "es"], &[]);
10204 let route = uri_probe_route("/posts", "posts_uri_probe");
10205 let app = build_router(vec![route], &config, test_state())
10206 .layer(axum::Extension(bundle(&["en", "es"])));
10207
10208 let response = request(&app, "/es/posts", &[]).await;
10209 assert_eq!(response.status(), StatusCode::OK);
10210 assert_eq!(
10211 body_string(response).await,
10212 "/posts",
10213 "Uri extractor inside a locale nest must see the prefix-stripped path"
10214 );
10215 }
10216
10217 #[tokio::test]
10220 async fn bare_path_redirect_applies_to_non_get_methods() {
10221 let config = config(&["en", "es"], &[]);
10222 let route = make_post_route("/posts", "posts_create");
10223 let app = build_router(vec![route], &config, test_state())
10224 .layer(axum::Extension(bundle(&["en", "es"])));
10225
10226 let response = request_method(&app, http::Method::POST, "/posts", &[]).await;
10227 assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
10228 assert_eq!(
10229 response
10230 .headers()
10231 .get(axum::http::header::LOCATION)
10232 .and_then(|v| v.to_str().ok()),
10233 Some("/en/posts")
10234 );
10235 }
10236
10237 #[tokio::test]
10243 async fn same_path_different_methods_dedups_to_one_redirect_route() {
10244 let config = config(&["en", "es"], &[]);
10245 let get_route = simple_route("/posts", "posts_list", true);
10246 let post_route = make_post_route("/posts", "posts_create");
10247 let app = build_router(vec![get_route, post_route], &config, test_state())
10248 .layer(axum::Extension(bundle(&["en", "es"])));
10249
10250 let get_redirect = request(&app, "/posts", &[]).await;
10251 assert_eq!(get_redirect.status(), StatusCode::PERMANENT_REDIRECT);
10252 let post_redirect = request_method(&app, http::Method::POST, "/posts", &[]).await;
10253 assert_eq!(post_redirect.status(), StatusCode::PERMANENT_REDIRECT);
10254
10255 let en_get = request(&app, "/en/posts", &[]).await;
10256 assert_eq!(en_get.status(), StatusCode::OK);
10257 let en_post = request_method(&app, http::Method::POST, "/en/posts", &[]).await;
10258 assert_eq!(en_post.status(), StatusCode::OK);
10259 }
10260
10261 #[tokio::test]
10266 async fn region_subtag_locale_nests_and_prefix_is_case_sensitive() {
10267 let config = config(&["en", "es-MX"], &[]);
10268 let route = simple_route("/posts", "posts", true);
10269 let app = build_router(vec![route], &config, test_state())
10270 .layer(axum::Extension(bundle(&["en", "es-MX"])));
10271
10272 let response = request(&app, "/es-MX/posts", &[]).await;
10273 assert_eq!(response.status(), StatusCode::OK);
10274 assert_eq!(body_string(response).await, "es-MX");
10275
10276 let wrong_case = request(&app, "/ES-MX/posts", &[]).await;
10277 assert_eq!(
10278 wrong_case.status(),
10279 StatusCode::NOT_FOUND,
10280 "the locale nest prefix is a literal path segment, not case-negotiated"
10281 );
10282 }
10283
10284 #[tokio::test]
10290 async fn empty_supported_locales_serves_clean_404_not_redirect_to_nowhere() {
10291 let mut config = AutumnConfig::default();
10292 config.i18n.locale_prefix_enabled = true;
10293 config.i18n.supported_locales = vec![];
10294
10295 let route = simple_route("/posts", "posts", false);
10296 let app = build_router(vec![route], &config, test_state());
10297
10298 let response = request(&app, "/posts", &[]).await;
10299 assert_eq!(response.status(), StatusCode::NOT_FOUND);
10300 }
10301
10302 #[tokio::test]
10309 async fn misconfigured_default_locale_falls_back_to_a_mounted_locale() {
10310 let mut config = config(&["en"], &[]);
10311 config.i18n.default_locale = "fr".to_owned();
10312
10313 let route = simple_route("/posts", "posts", true);
10314 let app = build_router(vec![route], &config, test_state());
10317
10318 let redirected = request(&app, "/posts", &[]).await;
10319 assert_eq!(redirected.status(), StatusCode::PERMANENT_REDIRECT);
10320 assert_eq!(
10321 redirected
10322 .headers()
10323 .get(axum::http::header::LOCATION)
10324 .and_then(|v| v.to_str().ok()),
10325 Some("/en/posts"),
10326 "must redirect to a locale that's actually mounted, not the \
10327 misconfigured default_locale"
10328 );
10329
10330 let target = request(&app, "/en/posts", &[]).await;
10331 assert_eq!(target.status(), StatusCode::OK);
10332 }
10333
10334 #[test]
10338 fn a_localized_static_page_resolves_inside_its_nest() {
10339 let config = config(&["en", "fr"], &[]);
10340 let routes = vec![
10341 simple_route("/files/public", "public", false),
10342 simple_route("/{*path}", "any", false),
10343 ];
10344 let pages = ["/en/files/public".to_owned(), "/fr/other".to_owned()];
10345 let map = super::super::static_page_routes(pages.iter(), &routes, &[], &config);
10346 assert_eq!(map["/en/files/public"].as_deref(), Some("/en/files/public"));
10347 assert_eq!(map["/fr/other"].as_deref(), Some("/fr/{*path}"));
10348 }
10349
10350 #[test]
10354 fn an_excluded_route_is_matched_where_it_is_mounted() {
10355 let config = config(&["en", "fr"], &["/api", "/en"]);
10356 let routes = vec![
10357 simple_route("/api/{id}", "api", false),
10358 simple_route("/en/{*path}", "english", false),
10359 simple_route("/posts", "posts", false),
10360 ];
10361 let pages = [
10362 "/en/api/42".to_owned(),
10363 "/api/42".to_owned(),
10364 "/fr/posts".to_owned(),
10365 "/fr/api/42".to_owned(),
10366 ];
10367 let map = super::super::static_page_routes(pages.iter(), &routes, &[], &config);
10368 assert_eq!(map["/en/api/42"].as_deref(), Some("/en/{*path}"));
10369 assert_eq!(map["/api/42"].as_deref(), Some("/api/{id}"));
10370 assert_eq!(map["/fr/posts"].as_deref(), Some("/fr/posts"));
10371 assert_eq!(map["/fr/api/42"], None);
10373 }
10374
10375 #[test]
10379 fn a_merged_router_home_stays_unprefixed() {
10380 let config = config(&["en", "fr"], &[]);
10381 let site = super::build_aeo_site::<(), ()>(
10382 &config,
10383 &test_state(),
10384 &[simple_route("/posts", "posts", false)],
10385 &[],
10386 true,
10387 None,
10388 None,
10389 );
10390 assert_eq!(site.facts.home_paths, vec!["/".to_owned()]);
10391 assert!(!site.facts.no_home);
10392
10393 let routed = super::build_aeo_site::<(), ()>(
10394 &config,
10395 &test_state(),
10396 &[simple_route("/", "home", false)],
10397 &[],
10398 true,
10399 None,
10400 None,
10401 );
10402 assert_eq!(
10403 routed.facts.home_paths,
10404 vec!["/en".to_owned(), "/fr".to_owned()]
10405 );
10406
10407 let scoped = crate::app::ScopedGroup {
10410 prefix: "/".to_owned(),
10411 routes: vec![simple_route("/", "scoped_home", false)],
10412 source: crate::route_listing::RouteSource::User,
10413 layer_guarded: false,
10414 apply_layer: Box::new(|r| r),
10415 };
10416 let both = super::build_aeo_site::<(), ()>(
10417 &config,
10418 &test_state(),
10419 &[simple_route("/", "home", false)],
10420 &[scoped],
10421 false,
10422 None,
10423 None,
10424 );
10425 assert_eq!(
10426 both.facts.home_paths,
10427 vec!["/en".to_owned(), "/fr".to_owned(), "/".to_owned()]
10428 );
10429 assert!(both.facts.noindex_homes.is_empty());
10430
10431 let mut scoped_home = simple_route("/", "scoped_home", false);
10434 scoped_home.seo = crate::seo::SeoRouteDefaults {
10435 robots: Some("noindex"),
10436 ..crate::seo::SeoRouteDefaults::EMPTY
10437 };
10438 let scoped = crate::app::ScopedGroup {
10439 prefix: "/".to_owned(),
10440 routes: vec![scoped_home],
10441 source: crate::route_listing::RouteSource::User,
10442 layer_guarded: false,
10443 apply_layer: Box::new(|r| r),
10444 };
10445 let mixed = super::build_aeo_site::<(), ()>(
10446 &config,
10447 &test_state(),
10448 &[simple_route("/", "home", false)],
10449 &[scoped],
10450 false,
10451 None,
10452 None,
10453 );
10454 assert_eq!(mixed.facts.noindex_homes, vec!["/".to_owned()]);
10455 }
10456
10457 #[tokio::test]
10465 async fn scoped_group_routes_are_not_locale_prefixed() {
10466 let config = config(&["en", "es"], &[]);
10467 let scoped_route = duplicate_test_route(http::Method::GET, "/status", "scoped_status");
10468 let group = crate::app::ScopedGroup {
10469 prefix: "/admin".to_owned(),
10470 routes: vec![scoped_route],
10471 source: crate::route_listing::RouteSource::User,
10472 layer_guarded: false,
10473 apply_layer: Box::new(|r| r),
10474 };
10475 let mut ctx = duplicate_test_ctx();
10476 ctx.scoped_groups.push(group);
10477
10478 let router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
10479 .expect("router with a scoped group under locale-prefix routing should build")
10480 .layer(axum::Extension(bundle(&["en", "es"])));
10481
10482 let direct = request(&router, "/admin/status", &[]).await;
10483 assert_eq!(
10484 direct.status(),
10485 StatusCode::OK,
10486 "scoped route must serve directly, unprefixed"
10487 );
10488
10489 let nested = request(&router, "/en/admin/status", &[]).await;
10490 assert_eq!(
10491 nested.status(),
10492 StatusCode::NOT_FOUND,
10493 "scoped routes are mounted after locale-prefix nesting, so no nest exists for them"
10494 );
10495 }
10496 }
10497
10498 #[cfg(feature = "maud")]
10501 async fn stories_status_for_layered_profile(toml: &str, profile: &str) -> StatusCode {
10502 let dir = tempfile::tempdir().expect("tempdir");
10503 std::fs::write(dir.path().join("autumn.toml"), toml).expect("write autumn.toml");
10504 let env = crate::config::MockEnv::new()
10505 .with("AUTUMN_MANIFEST_DIR", dir.path().to_str().unwrap())
10506 .with("AUTUMN_ENV", profile);
10507 let mut config = AutumnConfig::load_with_env(&env).expect("layered config should load");
10508 config.security.trusted_hosts.hosts = vec!["example.com".to_owned()];
10509
10510 let router = build_router(Vec::new(), &config, stories_state_with_builtin());
10511 get_with_host(router, "/_stories").await.status()
10512 }
10513
10514 #[cfg(feature = "maud")]
10517 #[tokio::test]
10518 async fn story_routes_mount_iff_resolved_profile_flag() {
10519 let dev_only = r"
10521[stories]
10522enabled = false
10523
10524[profile.dev.stories]
10525enabled = true
10526";
10527 assert_eq!(
10528 stories_status_for_layered_profile(dev_only, "dev").await,
10529 StatusCode::OK,
10530 "dev profile override must mount the gallery"
10531 );
10532 assert_eq!(
10533 stories_status_for_layered_profile(dev_only, "prod").await,
10534 StatusCode::NOT_FOUND,
10535 "prod must not mount the gallery when only dev enables it"
10536 );
10537
10538 let public_showcase = r"
10540[stories]
10541enabled = false
10542
10543[profile.prod.stories]
10544enabled = true
10545";
10546 assert_eq!(
10547 stories_status_for_layered_profile(public_showcase, "prod").await,
10548 StatusCode::OK,
10549 "prod profile override must mount the gallery for a public showcase"
10550 );
10551 assert_eq!(
10552 stories_status_for_layered_profile(public_showcase, "dev").await,
10553 StatusCode::NOT_FOUND,
10554 "dev must not mount the gallery when only prod enables it"
10555 );
10556 }
10557
10558 #[cfg(feature = "maud")]
10561 #[tokio::test]
10562 async fn custom_story_served_alongside_builtins() {
10563 let custom = crate::stories::story! {
10564 "App",
10565 "Greeting",
10566 {
10567 maud::html! { span class="app-greeting" { "hi from the app" } }
10568 }
10569 };
10570 let state = test_state();
10571 state.insert_extension(
10572 crate::stories::StoryGallery::builtin()
10573 .extend([custom])
10574 .into_registry(),
10575 );
10576
10577 let router = build_router(Vec::new(), &story_gallery_config(), state);
10578
10579 let detail = get_with_host(router.clone(), "/_stories/greeting").await;
10580 assert_eq!(detail.status(), StatusCode::OK);
10581 let body = response_text(detail).await;
10582 assert!(
10583 body.contains("hi from the app"),
10584 "custom story must render at its slug: {body}"
10585 );
10586
10587 let index = get_with_host(router, "/_stories").await;
10588 let body = response_text(index).await;
10589 assert!(
10590 body.contains("Greeting"),
10591 "index must list the custom story: {body}"
10592 );
10593 assert!(
10594 body.contains("App"),
10595 "index must show the custom story's group: {body}"
10596 );
10597 assert!(
10598 body.contains("Data table"),
10599 "builtins must still be listed alongside the custom story: {body}"
10600 );
10601 }
10602
10603 #[cfg(feature = "maud")]
10609 #[tokio::test]
10610 async fn story_pages_inline_style_carries_csp_header_nonce() {
10611 let mut config = story_gallery_config();
10612 config.security.headers.csp_nonce.enabled = true;
10613
10614 let router = build_router(Vec::new(), &config, stories_state_with_builtin());
10615
10616 for uri in ["/_stories", "/_stories/data-table"] {
10617 let response = get_with_host(router.clone(), uri).await;
10618 assert_eq!(response.status(), StatusCode::OK);
10619
10620 let csp = response
10621 .headers()
10622 .get("content-security-policy")
10623 .expect("CSP header must be present")
10624 .to_str()
10625 .unwrap()
10626 .to_owned();
10627 let nonce = csp
10628 .split("'nonce-")
10629 .nth(1)
10630 .and_then(|rest| rest.split('\'').next())
10631 .unwrap_or_else(|| panic!("CSP header must advertise a nonce: {csp}"))
10632 .to_owned();
10633 assert!(!nonce.is_empty(), "advertised nonce must be non-empty");
10634
10635 let body = response_text(response).await;
10636 assert!(
10637 body.contains(&format!(r#"<style nonce="{nonce}">"#)),
10638 "{uri} inline style must carry the CSP header nonce {nonce}: {body}"
10639 );
10640 }
10641 }
10642
10643 #[cfg(feature = "maud")]
10646 #[tokio::test]
10647 async fn enabled_without_registry_shows_empty_state() {
10648 let router = build_router(Vec::new(), &story_gallery_config(), test_state());
10649
10650 let response = get_with_host(router, "/_stories").await;
10651 assert_eq!(response.status(), StatusCode::OK);
10652 let body = response_text(response).await;
10653 assert!(
10654 body.contains("with_story_gallery"),
10655 "empty state should point at AppBuilder::with_story_gallery: {body}"
10656 );
10657 }
10658
10659 #[tokio::test]
10660 async fn apply_csrf_middleware_blocks_without_token_when_enabled() {
10661 let mut config = AutumnConfig::default();
10662 config.security.csrf.enabled = true;
10663
10664 let base: axum::Router<AppState> =
10665 axum::Router::new().route("/form", axum::routing::post(|| async { "posted" }));
10666 let router = apply_csrf_middleware(base, &config, None).with_state(test_state());
10667
10668 let response = router
10670 .oneshot(
10671 Request::builder()
10672 .method("POST")
10673 .uri("/form")
10674 .body(Body::empty())
10675 .unwrap(),
10676 )
10677 .await
10678 .unwrap();
10679
10680 assert_ne!(
10681 response.status(),
10682 StatusCode::OK,
10683 "POST without CSRF token should be rejected when CSRF is enabled"
10684 );
10685 }
10686
10687 #[test]
10688 fn join_nested_path_normalizes_like_axum() {
10689 assert_eq!(super::join_nested_path("/api", "/"), "/api");
10694 assert_eq!(super::join_nested_path("/api/", "/"), "/api/");
10700 assert_eq!(super::join_nested_path("/api", "/users"), "/api/users");
10702 assert_eq!(super::join_nested_path("/api/", "/users"), "/api/users");
10705 assert_eq!(super::join_nested_path("", "/"), "/");
10707 assert_eq!(super::join_nested_path("", "/users"), "/users");
10708 }
10709
10710 #[tokio::test]
10716 async fn join_nested_path_matches_axum_matched_path() {
10717 use axum::routing::get;
10718 async fn matched(mp: Option<axum::extract::MatchedPath>) -> String {
10719 mp.map(|m| m.as_str().to_owned()).unwrap_or_default()
10720 }
10721 for (prefix, child, req) in [
10723 ("/api", "/", "/api"),
10724 ("/api/", "/", "/api/"),
10725 ("/api", "/users", "/api/users"),
10726 ("/api/", "/users", "/api/users"),
10727 ] {
10728 let sub = axum::Router::new().route(child, get(matched));
10729 let app: axum::Router = axum::Router::new().nest(prefix, sub);
10730 let resp = tower::ServiceExt::oneshot(
10731 app,
10732 axum::http::Request::builder()
10733 .uri(req)
10734 .body(axum::body::Body::empty())
10735 .unwrap(),
10736 )
10737 .await
10738 .unwrap();
10739 assert_eq!(resp.status(), http::StatusCode::OK, "{prefix} + {child}");
10740 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
10741 .await
10742 .unwrap();
10743 let axum_matched = String::from_utf8(body.to_vec()).unwrap();
10744 assert_eq!(
10745 super::join_nested_path(prefix, child),
10746 axum_matched,
10747 "join_nested_path must equal axum MatchedPath for nest({prefix:?}, {child:?})"
10748 );
10749 }
10750 }
10751
10752 #[cfg(feature = "openapi")]
10753 #[tokio::test]
10754 async fn try_build_router_detects_scoped_root_collision() {
10755 use crate::openapi::{ApiDoc, OpenApiConfig};
10759 async fn child() -> &'static str {
10760 "inner"
10761 }
10762 let group = crate::app::ScopedGroup {
10763 prefix: "/api".to_owned(),
10764 routes: vec![Route {
10765 method: http::Method::GET,
10766 path: "/",
10767 handler: axum::routing::get(child),
10768 name: "root",
10769 api_doc: ApiDoc {
10770 method: "GET",
10771 path: "/",
10772 operation_id: "root",
10773 success_status: 200,
10774 ..Default::default()
10775 },
10776 repository: None,
10777 idempotency: crate::route::RouteIdempotency::Direct,
10778 timeout: crate::route::RouteTimeout::Inherit,
10779 criticality: crate::admission::Criticality::Default,
10780 seo: crate::seo::SeoRouteDefaults::EMPTY,
10781 api_version: None,
10782 sunset_opt_out: false,
10783 }],
10784 source: crate::route_listing::RouteSource::User,
10785 layer_guarded: false,
10786 apply_layer: Box::new(|r| r),
10787 };
10788
10789 let openapi = OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api");
10790 let config = AutumnConfig::default();
10791 let ctx = RouterContext {
10792 exception_filters: Vec::new(),
10793 scoped_groups: vec![group],
10794 merge_routers: Vec::new(),
10795 nest_routers: Vec::new(),
10796 declared_routes: Vec::new(),
10797 custom_layers: Vec::new(),
10798 static_gate_layers: Vec::new(),
10799 #[cfg(feature = "maud")]
10800 error_page_renderer: None,
10801 session_store: None,
10802 openapi: Some(openapi),
10803 #[cfg(feature = "mcp")]
10804 mcp: None,
10805 #[cfg(feature = "webmcp")]
10806 webmcp: None,
10807 };
10808 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
10809 .expect_err("scope '/api' + child '/' should collide with openapi path '/api'");
10810 assert!(matches!(
10811 err,
10812 RouterBuildError::OpenApiPathCollision {
10813 field: "openapi_json_path",
10814 ..
10815 }
10816 ));
10817 }
10818
10819 #[cfg(all(feature = "openapi", feature = "maud"))]
10824 #[test]
10825 fn try_build_router_detects_widgets_css_path_collision() {
10826 use crate::openapi::OpenApiConfig;
10827
10828 let openapi =
10829 OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::ui::WIDGETS_CSS_PATH);
10830 let config = AutumnConfig::default();
10831 let ctx = RouterContext {
10832 exception_filters: Vec::new(),
10833 scoped_groups: Vec::new(),
10834 merge_routers: Vec::new(),
10835 nest_routers: Vec::new(),
10836 declared_routes: Vec::new(),
10837 custom_layers: Vec::new(),
10838 static_gate_layers: Vec::new(),
10839 #[cfg(feature = "maud")]
10840 error_page_renderer: None,
10841 session_store: None,
10842 openapi: Some(openapi),
10843 #[cfg(feature = "mcp")]
10844 mcp: None,
10845 #[cfg(feature = "webmcp")]
10846 webmcp: None,
10847 };
10848 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
10849 "openapi_json_path colliding with the widget stylesheet route should be rejected",
10850 );
10851 assert!(matches!(
10852 err,
10853 RouterBuildError::OpenApiPathCollision {
10854 field: "openapi_json_path",
10855 ..
10856 }
10857 ));
10858 }
10859
10860 #[cfg(all(feature = "openapi", feature = "flash"))]
10864 #[test]
10865 fn try_build_router_detects_flash_css_path_collision() {
10866 use crate::openapi::OpenApiConfig;
10867
10868 let openapi =
10869 OpenApiConfig::new("Demo", "1.0.0").openapi_json_path(crate::flash::FLASH_CSS_PATH);
10870 let config = AutumnConfig::default();
10871 let ctx = RouterContext {
10872 exception_filters: Vec::new(),
10873 scoped_groups: Vec::new(),
10874 merge_routers: Vec::new(),
10875 nest_routers: Vec::new(),
10876 declared_routes: Vec::new(),
10877 custom_layers: Vec::new(),
10878 static_gate_layers: Vec::new(),
10879 #[cfg(feature = "maud")]
10880 error_page_renderer: None,
10881 session_store: None,
10882 openapi: Some(openapi),
10883 #[cfg(feature = "mcp")]
10884 mcp: None,
10885 #[cfg(feature = "webmcp")]
10886 webmcp: None,
10887 };
10888 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
10889 "openapi_json_path colliding with the flash stylesheet route should be rejected",
10890 );
10891 assert!(matches!(
10892 err,
10893 RouterBuildError::OpenApiPathCollision {
10894 field: "openapi_json_path",
10895 ..
10896 }
10897 ));
10898 }
10899
10900 #[cfg(feature = "openapi")]
10901 #[test]
10902 fn extract_path_params_matches_macro_behavior() {
10903 assert_eq!(
10905 super::extract_path_params("/orgs/{org_id}/users/{id}"),
10906 vec!["org_id".to_owned(), "id".to_owned()]
10907 );
10908 assert_eq!(
10909 super::extract_path_params("/users/{id}/posts/{slug}"),
10910 vec!["id".to_owned(), "slug".to_owned()]
10911 );
10912 assert!(super::extract_path_params("/static").is_empty());
10913
10914 assert_eq!(
10916 super::extract_path_params("/users/{id:[0-9]+}"),
10917 vec!["id".to_owned()]
10918 );
10919 assert_eq!(
10921 super::extract_path_params("{id:[0-9]{1,3}}"),
10922 vec!["id".to_owned()]
10923 );
10924
10925 assert!(super::extract_path_params("{{hello}}").is_empty());
10928 assert_eq!(
10929 super::extract_path_params("{{literal}}/{id}"),
10930 vec!["id".to_owned()]
10931 );
10932
10933 assert!(super::extract_path_params("{{}").is_empty());
10935 assert!(super::extract_path_params("{a{b}").is_empty());
10936 assert!(super::extract_path_params("{").is_empty());
10937 assert!(super::extract_path_params("}").is_empty());
10938 assert!(super::extract_path_params("{}").is_empty());
10939 }
10940
10941 #[cfg(feature = "openapi")]
10942 #[test]
10943 fn extract_path_params_handles_unbalanced_braces() {
10944 for path in ["{{}", "{", "}", "{a{b}"] {
10950 for name in super::extract_path_params(path) {
10951 assert!(
10952 !name.contains('{') && !name.contains('}'),
10953 "param name should be brace-free for {path:?}: {name:?}"
10954 );
10955 assert!(
10956 !name.is_empty(),
10957 "param name should be non-empty for {path:?}"
10958 );
10959 }
10960 }
10961 assert!(super::extract_path_params("{{}").is_empty());
10964 assert!(super::extract_path_params("{a{b}").is_empty());
10967 }
10968
10969 #[cfg(feature = "openapi")]
10970 #[tokio::test]
10971 async fn openapi_merges_scoped_prefix_path_params() {
10972 use crate::openapi::{ApiDoc, OpenApiConfig};
10973
10974 async fn handler() -> &'static str {
10979 "ok"
10980 }
10981 let child = Route {
10982 method: http::Method::GET,
10983 path: "/users/{id}",
10984 handler: axum::routing::get(handler),
10985 name: "child",
10986 api_doc: ApiDoc {
10987 method: "GET",
10988 path: "/users/{id}",
10989 operation_id: "child",
10990 path_params: &["id"],
10991 success_status: 200,
10992 ..Default::default()
10993 },
10994 repository: None,
10995 idempotency: crate::route::RouteIdempotency::Direct,
10996 timeout: crate::route::RouteTimeout::Inherit,
10997 criticality: crate::admission::Criticality::Default,
10998 seo: crate::seo::SeoRouteDefaults::EMPTY,
10999 api_version: None,
11000 sunset_opt_out: false,
11001 };
11002 let group = crate::app::ScopedGroup {
11003 prefix: "/orgs/{org_id}".to_owned(),
11004 routes: vec![child],
11005 source: crate::route_listing::RouteSource::User,
11006 layer_guarded: false,
11007 apply_layer: Box::new(|r| r),
11008 };
11009
11010 let config = OpenApiConfig::new("Demo", "1.0.0");
11011 let router = super::build_openapi_router(&[], &[group], Some(&config), "autumn.sid", &[])
11012 .expect("openapi sub-router builds")
11013 .expect("openapi sub-router present when config is Some");
11014 let state = test_state();
11015 let router = router.with_state(state);
11016
11017 let response = router
11018 .oneshot(
11019 Request::builder()
11020 .uri("/openapi.json")
11021 .body(Body::empty())
11022 .unwrap(),
11023 )
11024 .await
11025 .unwrap();
11026 assert_eq!(response.status(), StatusCode::OK);
11027 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11028 .await
11029 .unwrap();
11030 let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
11031 let params = &spec["paths"]["/orgs/{org_id}/users/{id}"]["get"]["parameters"];
11032 let names: Vec<&str> = params
11033 .as_array()
11034 .unwrap()
11035 .iter()
11036 .map(|p| p["name"].as_str().unwrap())
11037 .collect();
11038 assert!(names.contains(&"org_id"), "missing org_id: {names:?}");
11039 assert!(names.contains(&"id"), "missing id: {names:?}");
11040 }
11041
11042 #[cfg(feature = "openapi")]
11043 #[tokio::test]
11044 async fn openapi_documents_configured_session_cookie_name() {
11045 use crate::openapi::{ApiDoc, OpenApiConfig};
11046
11047 async fn handler() -> &'static str {
11048 "ok"
11049 }
11050
11051 let route = Route {
11052 method: http::Method::GET,
11053 path: "/protected",
11054 handler: axum::routing::get(handler),
11055 name: "protected",
11056 api_doc: ApiDoc {
11057 method: "GET",
11058 path: "/protected",
11059 operation_id: "protected",
11060 success_status: 200,
11061 secured: true,
11062 ..Default::default()
11063 },
11064 repository: None,
11065 idempotency: crate::route::RouteIdempotency::Direct,
11066 timeout: crate::route::RouteTimeout::Inherit,
11067 criticality: crate::admission::Criticality::Default,
11068 seo: crate::seo::SeoRouteDefaults::EMPTY,
11069 api_version: None,
11070 sunset_opt_out: false,
11071 };
11072
11073 let protected_routes = vec![route];
11074 let config = OpenApiConfig::new("Demo", "1.0.0");
11075 let docs_router =
11076 super::build_openapi_router(&protected_routes, &[], Some(&config), "demo.sid", &[])
11077 .expect("openapi sub-router builds")
11078 .expect("openapi sub-router present when config is Some");
11079 let docs_router = docs_router.with_state(test_state());
11080
11081 let response = docs_router
11082 .oneshot(
11083 Request::builder()
11084 .uri("/openapi.json")
11085 .body(Body::empty())
11086 .unwrap(),
11087 )
11088 .await
11089 .unwrap();
11090 assert_eq!(response.status(), StatusCode::OK);
11091 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
11092 .await
11093 .unwrap();
11094 let spec: serde_json::Value = serde_json::from_slice(&body).unwrap();
11095 let schemes = &spec["components"]["securitySchemes"];
11096
11097 assert_eq!(schemes["SessionAuth"]["type"], "apiKey");
11098 assert_eq!(schemes["SessionAuth"]["in"], "cookie");
11099 assert_eq!(schemes["SessionAuth"]["name"], "demo.sid");
11100 assert!(
11101 schemes.get("BearerAuth").is_none(),
11102 "secured routes must not be documented as bearer JWT routes"
11103 );
11104 }
11105
11106 #[cfg(feature = "openapi")]
11107 #[test]
11108 fn openapi_rejects_json_path_without_leading_slash() {
11109 let config =
11110 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("openapi.json");
11111 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11112 .expect_err("non-slash path should be rejected");
11113 assert!(matches!(
11114 err,
11115 RouterBuildError::InvalidOpenApiPath {
11116 field: "openapi_json_path",
11117 ..
11118 }
11119 ));
11120 }
11121
11122 #[cfg(feature = "openapi")]
11123 #[test]
11124 fn openapi_rejects_path_with_captures() {
11125 let config =
11128 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id}");
11129 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11130 .expect_err("captures should be rejected");
11131 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
11132 }
11133
11134 #[cfg(feature = "openapi")]
11135 #[test]
11136 fn openapi_rejects_path_with_unbalanced_brace() {
11137 let config =
11138 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/{id");
11139 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11140 .expect_err("unbalanced brace should be rejected");
11141 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
11142 }
11143
11144 #[cfg(feature = "openapi")]
11145 #[test]
11146 fn openapi_rejects_path_with_wildcard() {
11147 let config =
11148 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/docs/*rest");
11149 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11150 .expect_err("wildcard should be rejected");
11151 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
11152 }
11153
11154 #[cfg(feature = "openapi")]
11155 #[test]
11156 fn openapi_rejects_path_with_double_slash() {
11157 let config =
11158 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("//docs");
11159 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11160 .expect_err("double-slash should be rejected");
11161 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
11162 }
11163
11164 #[cfg(feature = "openapi")]
11165 #[test]
11166 fn openapi_rejects_swagger_ui_path_without_leading_slash() {
11167 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11168 .swagger_ui_path(Some("docs".to_owned()));
11169 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11170 .expect_err("non-slash path should be rejected");
11171 assert!(matches!(
11172 err,
11173 RouterBuildError::InvalidOpenApiPath {
11174 field: "swagger_ui_path",
11175 ..
11176 }
11177 ));
11178 }
11179
11180 #[cfg(feature = "openapi")]
11181 #[test]
11182 fn openapi_rejects_empty_json_path() {
11183 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("");
11184 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11185 .expect_err("empty path should be rejected");
11186 assert!(matches!(err, RouterBuildError::InvalidOpenApiPath { .. }));
11187 }
11188
11189 #[cfg(feature = "openapi")]
11190 #[test]
11191 fn openapi_accepts_valid_paths() {
11192 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11193 .openapi_json_path("/api-docs")
11194 .swagger_ui_path(Some("/ui".to_owned()));
11195 let out = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11196 .expect("valid paths must not error");
11197 assert!(out.is_some());
11198 }
11199
11200 #[cfg(feature = "openapi")]
11201 #[test]
11202 fn openapi_rejects_duplicate_json_and_swagger_paths() {
11203 let config = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11204 .openapi_json_path("/docs")
11205 .swagger_ui_path(Some("/docs".to_owned()));
11206 let err = super::build_openapi_router(&[], &[], Some(&config), "autumn.sid", &[])
11207 .expect_err("colliding paths should be rejected before axum panics");
11208 assert!(matches!(
11209 err,
11210 RouterBuildError::DuplicateOpenApiPath { ref path } if path == "/docs"
11211 ));
11212 }
11213
11214 #[cfg(feature = "openapi")]
11215 async fn collision_test_handler() -> &'static str {
11216 "user"
11217 }
11218
11219 #[cfg(feature = "openapi")]
11220 #[tokio::test]
11221 async fn try_build_router_rejects_openapi_path_colliding_with_user_route() {
11222 let mut config = AutumnConfig::default();
11223 config.actuator.prefix = "/ops".to_owned();
11224 let openapi =
11225 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/my-api-docs");
11226
11227 let user_route = Route {
11228 method: http::Method::GET,
11229 path: "/my-api-docs",
11230 handler: axum::routing::get(collision_test_handler),
11231 name: "collides",
11232 api_doc: crate::openapi::ApiDoc {
11233 method: "GET",
11234 path: "/my-api-docs",
11235 operation_id: "collides",
11236 success_status: 200,
11237 ..Default::default()
11238 },
11239 repository: None,
11240 idempotency: crate::route::RouteIdempotency::Direct,
11241 timeout: crate::route::RouteTimeout::Inherit,
11242 criticality: crate::admission::Criticality::Default,
11243 seo: crate::seo::SeoRouteDefaults::EMPTY,
11244 api_version: None,
11245 sunset_opt_out: false,
11246 };
11247
11248 let ctx = RouterContext {
11249 exception_filters: Vec::new(),
11250 scoped_groups: Vec::new(),
11251 merge_routers: Vec::new(),
11252 nest_routers: Vec::new(),
11253 declared_routes: Vec::new(),
11254 custom_layers: Vec::new(),
11255 static_gate_layers: Vec::new(),
11256 #[cfg(feature = "maud")]
11257 error_page_renderer: None,
11258 session_store: None,
11259 openapi: Some(openapi),
11260 #[cfg(feature = "mcp")]
11261 mcp: None,
11262 #[cfg(feature = "webmcp")]
11263 webmcp: None,
11264 };
11265 let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
11266 .expect_err("user-owned path should prevent OpenAPI mount");
11267 assert!(matches!(
11268 err,
11269 RouterBuildError::OpenApiPathCollision { field: "openapi_json_path", ref path } if path == "/my-api-docs"
11270 ));
11271 }
11272
11273 #[cfg(feature = "openapi")]
11274 #[tokio::test]
11275 async fn try_build_router_rejects_openapi_path_colliding_with_framework_route() {
11276 let config = AutumnConfig::default(); let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11278 .openapi_json_path("/actuator/health");
11279 let ctx = RouterContext {
11280 exception_filters: Vec::new(),
11281 scoped_groups: Vec::new(),
11282 merge_routers: Vec::new(),
11283 nest_routers: Vec::new(),
11284 declared_routes: Vec::new(),
11285 custom_layers: Vec::new(),
11286 static_gate_layers: Vec::new(),
11287 #[cfg(feature = "maud")]
11288 error_page_renderer: None,
11289 session_store: None,
11290 openapi: Some(openapi),
11291 #[cfg(feature = "mcp")]
11292 mcp: None,
11293 #[cfg(feature = "webmcp")]
11294 webmcp: None,
11295 };
11296 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11297 .expect_err("framework-owned path should prevent OpenAPI mount");
11298 assert!(matches!(
11299 err,
11300 RouterBuildError::OpenApiPathCollision {
11301 field: "openapi_json_path",
11302 ..
11303 }
11304 ));
11305 }
11306
11307 #[cfg(feature = "openapi")]
11308 #[tokio::test]
11309 async fn try_build_router_rejects_swagger_ui_asset_path_colliding_with_user_route() {
11310 let config = AutumnConfig::default();
11311 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0");
11312
11313 let user_route = Route {
11314 method: http::Method::GET,
11315 path: "/swagger-ui/swagger-ui.css",
11316 handler: axum::routing::get(collision_test_handler),
11317 name: "swagger-ui-asset-collides",
11318 api_doc: crate::openapi::ApiDoc {
11319 method: "GET",
11320 path: "/swagger-ui/swagger-ui.css",
11321 operation_id: "swagger_ui_asset_collides",
11322 success_status: 200,
11323 ..Default::default()
11324 },
11325 repository: None,
11326 idempotency: crate::route::RouteIdempotency::Direct,
11327 timeout: crate::route::RouteTimeout::Inherit,
11328 criticality: crate::admission::Criticality::Default,
11329 seo: crate::seo::SeoRouteDefaults::EMPTY,
11330 api_version: None,
11331 sunset_opt_out: false,
11332 };
11333
11334 let ctx = RouterContext {
11335 exception_filters: Vec::new(),
11336 scoped_groups: Vec::new(),
11337 merge_routers: Vec::new(),
11338 nest_routers: Vec::new(),
11339 declared_routes: Vec::new(),
11340 custom_layers: Vec::new(),
11341 static_gate_layers: Vec::new(),
11342 #[cfg(feature = "maud")]
11343 error_page_renderer: None,
11344 session_store: None,
11345 openapi: Some(openapi),
11346 #[cfg(feature = "mcp")]
11347 mcp: None,
11348 #[cfg(feature = "webmcp")]
11349 webmcp: None,
11350 };
11351 let err = super::try_build_router_inner(vec![user_route], &config, test_state(), ctx)
11352 .expect_err("swagger ui asset path should be reserved");
11353 assert!(matches!(
11354 err,
11355 RouterBuildError::OpenApiPathCollision {
11356 field: "swagger_ui_path",
11357 ref path,
11358 } if path == "/swagger-ui/swagger-ui.css"
11359 ));
11360 }
11361
11362 #[cfg(all(feature = "openapi", feature = "htmx"))]
11363 #[tokio::test]
11364 async fn try_build_router_rejects_openapi_path_colliding_with_htmx_csrf_route() {
11365 let config = AutumnConfig::default();
11366 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11367 .openapi_json_path(crate::htmx::HTMX_CSRF_JS_PATH);
11368 let ctx = RouterContext {
11369 exception_filters: Vec::new(),
11370 scoped_groups: Vec::new(),
11371 merge_routers: Vec::new(),
11372 nest_routers: Vec::new(),
11373 declared_routes: Vec::new(),
11374 custom_layers: Vec::new(),
11375 static_gate_layers: Vec::new(),
11376 #[cfg(feature = "maud")]
11377 error_page_renderer: None,
11378 session_store: None,
11379 openapi: Some(openapi),
11380 #[cfg(feature = "mcp")]
11381 mcp: None,
11382 #[cfg(feature = "webmcp")]
11383 webmcp: None,
11384 };
11385 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11386 .expect_err("htmx csrf helper path should be reserved");
11387 assert!(matches!(
11388 err,
11389 RouterBuildError::OpenApiPathCollision {
11390 field: "openapi_json_path",
11391 ref path,
11392 } if path == crate::htmx::HTMX_CSRF_JS_PATH
11393 ));
11394 }
11395
11396 #[cfg(feature = "openapi")]
11397 #[tokio::test]
11398 async fn try_build_router_rejects_openapi_path_under_nest_prefix() {
11399 let config = AutumnConfig::default();
11404 let openapi =
11405 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/api/docs");
11406 let nested = axum::Router::<AppState>::new()
11407 .route("/inner", axum::routing::get(|| async { "inner" }));
11408 let ctx = RouterContext {
11409 exception_filters: Vec::new(),
11410 scoped_groups: Vec::new(),
11411 merge_routers: Vec::new(),
11412 nest_routers: vec![("/api".to_owned(), nested)],
11413 declared_routes: Vec::new(),
11414 custom_layers: Vec::new(),
11415 static_gate_layers: Vec::new(),
11416 #[cfg(feature = "maud")]
11417 error_page_renderer: None,
11418 session_store: None,
11419 openapi: Some(openapi),
11420 #[cfg(feature = "mcp")]
11421 mcp: None,
11422 #[cfg(feature = "webmcp")]
11423 webmcp: None,
11424 };
11425 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11426 .expect_err("OpenAPI path under a nest prefix should collide");
11427 assert!(matches!(
11428 err,
11429 RouterBuildError::OpenApiPathCollision {
11430 field: "openapi_json_path",
11431 ref path,
11432 } if path == "/api/docs"
11433 ));
11434 }
11435
11436 #[cfg(all(feature = "openapi", feature = "mail"))]
11437 #[tokio::test]
11438 async fn try_build_router_rejects_openapi_path_on_unsubscribe_endpoint() {
11439 let mut config = AutumnConfig::default();
11444 config.mail.mount_unsubscribe_endpoint = true;
11445 config.mail.unsubscribe_base_url = Some("https://app.example.com".to_owned());
11446 assert!(config.mail.should_mount_unsubscribe_endpoint());
11447 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11448 .openapi_json_path(crate::mail::UNSUBSCRIBE_PATH);
11449 let ctx = RouterContext {
11450 exception_filters: Vec::new(),
11451 scoped_groups: Vec::new(),
11452 merge_routers: Vec::new(),
11453 nest_routers: Vec::new(),
11454 declared_routes: Vec::new(),
11455 custom_layers: Vec::new(),
11456 static_gate_layers: Vec::new(),
11457 #[cfg(feature = "maud")]
11458 error_page_renderer: None,
11459 session_store: None,
11460 openapi: Some(openapi),
11461 #[cfg(feature = "mcp")]
11462 mcp: None,
11463 #[cfg(feature = "webmcp")]
11464 webmcp: None,
11465 };
11466 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11467 .expect_err("unsubscribe endpoint path should be reserved");
11468 assert!(matches!(
11469 err,
11470 RouterBuildError::OpenApiPathCollision {
11471 field: "openapi_json_path",
11472 ref path,
11473 } if path == crate::mail::UNSUBSCRIBE_PATH
11474 ));
11475 }
11476
11477 #[cfg(feature = "openapi")]
11478 #[tokio::test]
11479 async fn try_build_router_rejects_openapi_path_on_job_status_endpoint() {
11480 let config = AutumnConfig::default();
11484 assert!(config.jobs.tracking.route_enabled);
11485 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11486 .openapi_json_path(crate::job_tracking::JOB_STATUS_ROUTE_PATH);
11487 let ctx = RouterContext {
11488 exception_filters: Vec::new(),
11489 scoped_groups: Vec::new(),
11490 merge_routers: Vec::new(),
11491 nest_routers: Vec::new(),
11492 declared_routes: Vec::new(),
11493 custom_layers: Vec::new(),
11494 static_gate_layers: Vec::new(),
11495 #[cfg(feature = "maud")]
11496 error_page_renderer: None,
11497 session_store: None,
11498 openapi: Some(openapi),
11499 #[cfg(feature = "mcp")]
11500 mcp: None,
11501 #[cfg(feature = "webmcp")]
11502 webmcp: None,
11503 };
11504 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11505 .expect_err("job status endpoint path should be reserved");
11506 assert!(matches!(
11507 err,
11508 RouterBuildError::OpenApiPathCollision {
11509 field: "openapi_json_path",
11510 ref path,
11511 } if path == crate::job_tracking::JOB_STATUS_ROUTE_PATH
11512 ));
11513 }
11514
11515 #[cfg(all(feature = "openapi", feature = "maud"))]
11516 #[tokio::test]
11517 async fn try_build_router_rejects_openapi_path_on_story_gallery() {
11518 let mut config = AutumnConfig::default();
11524 config.stories.enabled = true;
11525 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11526 .openapi_json_path(crate::stories::STORIES_PATH);
11527 let ctx = RouterContext {
11528 exception_filters: Vec::new(),
11529 scoped_groups: Vec::new(),
11530 merge_routers: Vec::new(),
11531 nest_routers: Vec::new(),
11532 declared_routes: Vec::new(),
11533 custom_layers: Vec::new(),
11534 static_gate_layers: Vec::new(),
11535 error_page_renderer: None,
11536 session_store: None,
11537 openapi: Some(openapi),
11538 #[cfg(feature = "mcp")]
11539 mcp: None,
11540 #[cfg(feature = "webmcp")]
11541 webmcp: None,
11542 };
11543 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11544 .expect_err("story gallery path should be reserved while stories are enabled");
11545 assert!(matches!(
11546 err,
11547 RouterBuildError::OpenApiPathCollision {
11548 field: "openapi_json_path",
11549 ref path,
11550 } if path == crate::stories::STORIES_PATH
11551 ));
11552 }
11553
11554 #[cfg(all(feature = "openapi", feature = "maud"))]
11555 #[tokio::test]
11556 async fn try_build_router_rejects_openapi_path_on_story_gallery_demo_route() {
11557 let mut config = AutumnConfig::default();
11563 config.stories.enabled = true;
11564 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11565 .openapi_json_path("/_stories/demo/search");
11566 let ctx = RouterContext {
11567 exception_filters: Vec::new(),
11568 scoped_groups: Vec::new(),
11569 merge_routers: Vec::new(),
11570 nest_routers: Vec::new(),
11571 declared_routes: Vec::new(),
11572 custom_layers: Vec::new(),
11573 static_gate_layers: Vec::new(),
11574 error_page_renderer: None,
11575 session_store: None,
11576 openapi: Some(openapi),
11577 #[cfg(feature = "mcp")]
11578 mcp: None,
11579 #[cfg(feature = "webmcp")]
11580 webmcp: None,
11581 };
11582 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).expect_err(
11583 "story gallery demo search path should be reserved while stories are enabled",
11584 );
11585 assert!(matches!(
11586 err,
11587 RouterBuildError::OpenApiPathCollision {
11588 field: "openapi_json_path",
11589 ref path,
11590 } if path == "/_stories/demo/search"
11591 ));
11592 }
11593
11594 #[cfg(feature = "openapi")]
11595 #[test]
11596 fn try_build_router_rejects_openapi_path_on_dev_live_reload() {
11597 temp_env::with_vars(
11598 [
11599 ("AUTUMN_DEV_RELOAD", Some("1")),
11600 ("AUTUMN_DEV_RELOAD_STATE", Some("/tmp/autumn-reload-test")),
11601 ],
11602 || {
11603 let config = AutumnConfig::default();
11604 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11605 .openapi_json_path("/__autumn/live-reload");
11606 let ctx = RouterContext {
11607 exception_filters: Vec::new(),
11608 scoped_groups: Vec::new(),
11609 merge_routers: Vec::new(),
11610 nest_routers: Vec::new(),
11611 declared_routes: Vec::new(),
11612 custom_layers: Vec::new(),
11613 static_gate_layers: Vec::new(),
11614 error_page_renderer: None,
11615 session_store: None,
11616 openapi: Some(openapi),
11617 #[cfg(feature = "mcp")]
11618 mcp: None,
11619 #[cfg(feature = "webmcp")]
11620 webmcp: None,
11621 };
11622 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11623 .expect_err("dev reload path should be reserved");
11624 assert!(matches!(
11625 err,
11626 RouterBuildError::OpenApiPathCollision {
11627 field: "openapi_json_path",
11628 ..
11629 }
11630 ));
11631 },
11632 );
11633 }
11634
11635 async fn duplicate_route_handler() -> &'static str {
11638 "ok"
11639 }
11640
11641 fn duplicate_test_route(method: http::Method, path: &'static str, name: &'static str) -> Route {
11647 let handler = match method {
11648 http::Method::POST => axum::routing::post(duplicate_route_handler),
11649 http::Method::PUT => axum::routing::put(duplicate_route_handler),
11650 http::Method::PATCH => axum::routing::patch(duplicate_route_handler),
11651 http::Method::DELETE => axum::routing::delete(duplicate_route_handler),
11652 _ => axum::routing::get(duplicate_route_handler),
11653 };
11654 let method_str = if method == http::Method::POST {
11655 "POST"
11656 } else if method == http::Method::PUT {
11657 "PUT"
11658 } else if method == http::Method::PATCH {
11659 "PATCH"
11660 } else if method == http::Method::DELETE {
11661 "DELETE"
11662 } else {
11663 "GET"
11664 };
11665 Route {
11666 method,
11667 path,
11668 handler,
11669 name,
11670 api_doc: crate::openapi::ApiDoc {
11671 method: method_str,
11672 path,
11673 operation_id: name,
11674 success_status: 200,
11675 ..Default::default()
11676 },
11677 repository: None,
11678 idempotency: crate::route::RouteIdempotency::Direct,
11679 timeout: crate::route::RouteTimeout::Inherit,
11680 criticality: crate::admission::Criticality::Default,
11681 seo: crate::seo::SeoRouteDefaults::EMPTY,
11682 api_version: None,
11683 sunset_opt_out: false,
11684 }
11685 }
11686
11687 fn duplicate_test_ctx() -> RouterContext {
11688 RouterContext {
11689 exception_filters: Vec::new(),
11690 scoped_groups: Vec::new(),
11691 merge_routers: Vec::new(),
11692 nest_routers: Vec::new(),
11693 declared_routes: Vec::new(),
11694 custom_layers: Vec::new(),
11695 static_gate_layers: Vec::new(),
11696 #[cfg(feature = "maud")]
11697 error_page_renderer: None,
11698 session_store: None,
11699 #[cfg(feature = "openapi")]
11700 openapi: None,
11701 #[cfg(feature = "mcp")]
11702 mcp: None,
11703 #[cfg(feature = "webmcp")]
11704 webmcp: None,
11705 }
11706 }
11707
11708 #[tokio::test]
11713 async fn try_build_router_rejects_duplicate_user_route_paths() {
11714 let config = AutumnConfig::default();
11715 let a = duplicate_test_route(http::Method::GET, "/", "root_a");
11716 let b = duplicate_test_route(http::Method::GET, "/", "root_b");
11717 let err =
11718 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
11719 .expect_err("two GET / routes should be rejected before mount");
11720 let display = err.to_string();
11721 match err {
11722 RouterBuildError::DuplicateUserRoute {
11723 ref method,
11724 ref path,
11725 ref existing,
11726 ref incoming,
11727 } => {
11728 assert_eq!(method, "GET");
11729 assert_eq!(path, "/");
11730 assert_eq!(existing, "root_a");
11731 assert_eq!(incoming, "root_b");
11732 }
11733 other => panic!("expected DuplicateUserRoute, got {other:?}"),
11734 }
11735 assert!(
11736 display.contains("root_a"),
11737 "error message must name first handler; got: {display}"
11738 );
11739 assert!(
11740 display.contains("root_b"),
11741 "error message must name second handler; got: {display}"
11742 );
11743 assert!(
11744 display.contains("GET"),
11745 "error message must name the HTTP method; got: {display}"
11746 );
11747 assert!(
11748 display.contains('/'),
11749 "error message must contain the path; got: {display}"
11750 );
11751 }
11752
11753 fn declared_route(method: &str, path: &str, plugin: &str) -> crate::route_listing::RouteInfo {
11757 crate::route_listing::RouteInfo {
11758 method: method.to_owned(),
11759 path: path.to_owned(),
11760 handler: format!("sandbox:{plugin}"),
11761 source: crate::route_listing::RouteSource::Plugin(plugin.to_owned()),
11762 ..crate::route_listing::RouteInfo::default()
11763 }
11764 }
11765
11766 #[tokio::test]
11776 async fn try_build_router_rejects_a_declared_plugin_route_the_app_already_serves() {
11777 let config = AutumnConfig::default();
11778 let app_route = duplicate_test_route(http::Method::GET, "/hello/greet", "app_greet");
11779 let mut ctx = duplicate_test_ctx();
11780 ctx.declared_routes = vec![declared_route("GET", "/hello/greet", "evil-plugin")];
11781 let err = super::try_build_router_inner(vec![app_route], &config, test_state(), ctx)
11782 .expect_err("a plugin declaring a path the app serves must be refused");
11783 match err {
11784 RouterBuildError::DuplicateUserRoute {
11785 ref method,
11786 ref path,
11787 ref existing,
11788 ref incoming,
11789 } => {
11790 assert_eq!(method, "GET");
11791 assert_eq!(path, "/hello/greet");
11792 assert_eq!(existing, "app_greet");
11796 assert_eq!(incoming, "sandbox:evil-plugin");
11797 }
11798 other => panic!("expected DuplicateUserRoute, got {other:?}"),
11799 }
11800 }
11801
11802 #[tokio::test]
11813 async fn try_build_router_rejects_a_declared_plugin_route_on_a_framework_path() {
11814 let config = AutumnConfig::default();
11815 let mut ctx = duplicate_test_ctx();
11816 ctx.declared_routes = vec![declared_route("GET", &config.health.path, "evil-plugin")];
11817 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11818 .expect_err("a plugin declaring a framework probe path must be refused");
11819 match err {
11820 RouterBuildError::DuplicateUserRoute {
11821 ref path,
11822 ref existing,
11823 ref incoming,
11824 ..
11825 } => {
11826 assert_eq!(path, &config.health.path);
11827 assert!(
11828 existing.starts_with("autumn framework route")
11829 && existing.contains(&config.health.path),
11830 "the refusal must name the framework route it clashed with; got: {existing}"
11831 );
11832 assert_eq!(incoming, "sandbox:evil-plugin");
11833 }
11834 other => panic!("expected the framework-path refusal, got {other:?}"),
11835 }
11836 }
11837
11838 #[cfg(feature = "openapi")]
11842 #[tokio::test]
11843 async fn try_build_router_rejects_a_declared_route_on_the_openapi_paths() {
11844 let config = AutumnConfig::default();
11845 let openapi = crate::openapi::OpenApiConfig::new("Demo", "1.0.0")
11846 .openapi_json_path("/spec.json")
11847 .swagger_ui_path(Some("/docs".to_owned()));
11848 let asset = crate::openapi::swagger_ui_asset_paths("/docs")
11849 .into_iter()
11850 .next()
11851 .expect("the UI serves at least one asset");
11852 for path in ["/spec.json", "/docs", asset.as_str()] {
11853 let mut ctx = duplicate_test_ctx();
11854 ctx.openapi = Some(openapi.clone());
11855 ctx.declared_routes = vec![declared_route("GET", path, "evil-plugin")];
11856 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11857 .expect_err("a declared route on an OpenAPI path must be refused");
11858 match err {
11859 RouterBuildError::DuplicateUserRoute {
11860 path: ref found,
11861 ref existing,
11862 ref incoming,
11863 ..
11864 } => {
11865 assert_eq!(found, path);
11866 assert!(existing.starts_with("autumn framework route"), "{existing}");
11867 assert_eq!(incoming, "sandbox:evil-plugin");
11868 }
11869 other => panic!("expected a typed refusal for {path}, got {other:?}"),
11870 }
11871 }
11872 }
11873
11874 #[cfg(feature = "openapi")]
11878 #[tokio::test]
11879 async fn try_build_router_accepts_a_declared_post_on_an_openapi_path() {
11880 let config = AutumnConfig::default();
11881 let mut ctx = duplicate_test_ctx();
11882 ctx.openapi = Some(
11883 crate::openapi::OpenApiConfig::new("Demo", "1.0.0").openapi_json_path("/spec.json"),
11884 );
11885 ctx.declared_routes = vec![declared_route("POST", "/spec.json", "honest-plugin")];
11886 let _built = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11887 .expect("a declared POST does not clash with the OpenAPI GET");
11888 }
11889
11890 #[cfg(feature = "mcp")]
11892 #[tokio::test]
11893 async fn try_build_router_rejects_a_declared_route_on_the_mcp_mount_path() {
11894 let config = AutumnConfig::default();
11895 for method in ["GET", "POST", "OPTIONS"] {
11897 let mut ctx = duplicate_test_ctx();
11898 ctx.mcp = Some(crate::mcp::McpRuntime::new("/agent-tools"));
11899 ctx.declared_routes = vec![declared_route(method, "/agent-tools", "evil-plugin")];
11900 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11901 .expect_err("a declared route on the MCP path must be refused");
11902 assert!(
11903 matches!(
11904 err,
11905 RouterBuildError::DuplicateUserRoute { ref existing, .. }
11906 if existing.starts_with("autumn framework route")
11907 ),
11908 "expected a typed refusal for {method}, got {err:?}"
11909 );
11910 }
11911 }
11912
11913 #[cfg(feature = "mcp")]
11915 #[tokio::test]
11916 async fn try_build_router_accepts_a_declared_delete_on_the_mcp_mount_path() {
11917 let config = AutumnConfig::default();
11918 let mut ctx = duplicate_test_ctx();
11919 ctx.mcp = Some(crate::mcp::McpRuntime::new("/agent-tools"));
11920 ctx.declared_routes = vec![declared_route("DELETE", "/agent-tools", "honest-plugin")];
11921 let _built = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11922 .expect("a declared DELETE does not clash with the MCP verbs");
11923 }
11924
11925 #[tokio::test]
11936 async fn try_build_router_rejects_a_non_get_declared_route_that_shape_clashes_with_a_framework_path()
11937 {
11938 let mut config = AutumnConfig::default();
11939 config.health.path = "/probe/{id}".to_owned();
11940 let mut ctx = duplicate_test_ctx();
11941 ctx.declared_routes = vec![declared_route("POST", "/probe/{slug}", "evil-plugin")];
11942 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11943 .expect_err("a shape clash must be refused whatever the method");
11944 match err {
11945 RouterBuildError::DuplicateUserRoute {
11946 ref method,
11947 ref existing,
11948 ..
11949 } => {
11950 assert_eq!(method, "POST", "the refusal must name the declared method");
11951 assert!(
11952 existing.contains("/probe/{id}"),
11953 "the refusal must name the framework template; got: {existing}"
11954 );
11955 }
11956 other => panic!("expected the framework shape refusal, got {other:?}"),
11957 }
11958 }
11959
11960 #[tokio::test]
11963 async fn try_build_router_allows_a_non_get_declared_route_at_a_framework_get_path() {
11964 let config = AutumnConfig::default();
11965 let mut ctx = duplicate_test_ctx();
11966 ctx.declared_routes = vec![declared_route("POST", &config.health.path, "honest-plugin")];
11967 assert!(
11968 super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).is_ok(),
11969 "a POST at a framework GET path merges into one MethodRouter and must be allowed"
11970 );
11971 }
11972
11973 #[tokio::test]
11980 async fn try_build_router_rejects_a_declared_plugin_route_on_a_framework_mutating_path() {
11981 let mut config = AutumnConfig::default();
11982 config.actuator.sensitive = true;
11983 let loggers =
11984 crate::actuator::actuator_route_path(&config.actuator.prefix, "/loggers/{name}");
11985 let mut ctx = duplicate_test_ctx();
11986 ctx.declared_routes = vec![declared_route("PUT", &loggers, "evil-plugin")];
11987 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
11988 .expect_err("a declared PUT at the actuator's own PUT must be refused");
11989 match err {
11990 RouterBuildError::DuplicateUserRoute {
11991 ref method,
11992 ref existing,
11993 ..
11994 } => {
11995 assert_eq!(method, "PUT", "the refusal must name the real method");
11996 assert!(
11997 existing.contains(&loggers),
11998 "the refusal must name the framework route; got: {existing}"
11999 );
12000 }
12001 other => panic!("expected the framework mutating-route refusal, got {other:?}"),
12002 }
12003 }
12004
12005 #[cfg(feature = "http-client")]
12014 #[tokio::test]
12015 async fn try_build_router_allows_a_declared_get_at_a_post_only_framework_path() {
12016 let mut config = AutumnConfig::default();
12017 config.actuator.sensitive = true;
12018 let replay =
12019 crate::actuator::actuator_route_path(&config.actuator.prefix, "/webhooks/replay");
12020 let mut ctx = duplicate_test_ctx();
12021 ctx.declared_routes = vec![declared_route("GET", &replay, "honest-plugin")];
12022 assert!(
12023 super::try_build_router_inner(Vec::new(), &config, test_state(), ctx).is_ok(),
12024 "a GET at a POST-only framework path merges cleanly and must be allowed"
12025 );
12026 }
12027
12028 #[tokio::test]
12035 async fn try_build_router_rejects_a_declared_plugin_route_that_shape_clashes_with_a_framework_path()
12036 {
12037 let mut config = AutumnConfig::default();
12038 config.health.path = "/probe/{id}".to_owned();
12039 let mut ctx = duplicate_test_ctx();
12040 ctx.declared_routes = vec![declared_route("GET", "/probe/{slug}", "evil-plugin")];
12041 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12042 .expect_err("a shape clash with a framework path must be refused");
12043 match err {
12044 RouterBuildError::DuplicateUserRoute { ref existing, .. } => assert!(
12045 existing.contains("/probe/{id}"),
12046 "the refusal must name the framework template it clashed with; got: {existing}"
12047 ),
12048 other => panic!("expected the framework shape refusal, got {other:?}"),
12049 }
12050 }
12051
12052 #[tokio::test]
12059 async fn try_build_router_rejects_a_declared_plugin_route_on_the_inspector_detail_path() {
12060 let mut config = AutumnConfig {
12061 profile: Some("dev".to_owned()),
12062 ..AutumnConfig::default()
12063 };
12064 config.dev.inspector_path = "/debug".to_owned();
12065 let mut ctx = duplicate_test_ctx();
12066 ctx.declared_routes = vec![declared_route(
12067 "GET",
12068 "/debug/requests/{slug}",
12069 "evil-plugin",
12070 )];
12071 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12072 .expect_err("the inspector detail route must be claimed too");
12073 match err {
12074 RouterBuildError::DuplicateUserRoute { ref existing, .. } => assert!(
12075 existing.contains("/debug/requests/{id}"),
12076 "the refusal must name the inspector detail template; got: {existing}"
12077 ),
12078 other => panic!("expected the inspector detail refusal, got {other:?}"),
12079 }
12080 }
12081
12082 #[tokio::test]
12087 async fn try_build_router_allows_a_declared_plugin_post_on_a_framework_path() {
12088 let config = AutumnConfig::default();
12089 let mut ctx = duplicate_test_ctx();
12090 ctx.declared_routes = vec![
12091 declared_route("POST", &config.health.path, "good-plugin"),
12092 declared_route("HEAD", &config.health.path, "good-plugin"),
12093 ];
12094 let _router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12095 .expect("non-GET declarations at a framework path must mount");
12096 }
12097
12098 #[tokio::test]
12105 async fn try_build_router_rejects_declared_plugin_routes_inside_framework_namespaces() {
12106 for (method, path) in [
12107 ("GET", "/static/app.js"),
12108 ("GET", "/static"),
12109 ("GET", "/static/_plugins/not-installed/evil.js"),
12113 ("POST", "/_autumn/unsubscribe"),
12114 ("GET", "/_autumn/jobs/abc"),
12115 ] {
12116 let config = AutumnConfig::default();
12117 let mut ctx = duplicate_test_ctx();
12118 ctx.declared_routes = vec![declared_route(method, path, "evil-plugin")];
12119 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12120 .expect_err("a plugin route inside a framework namespace must be refused");
12121 match err {
12122 RouterBuildError::DuplicateUserRoute {
12123 path: ref got,
12124 ref existing,
12125 ..
12126 } => {
12127 assert_eq!(got, path);
12128 assert!(
12129 existing.contains("framework namespace"),
12130 "the refusal must name the namespace; got: {existing}"
12131 );
12132 }
12133 other => {
12134 panic!("expected the namespace refusal for {method} {path}, got {other:?}")
12135 }
12136 }
12137 }
12138 }
12139
12140 #[tokio::test]
12143 async fn try_build_router_allows_a_plugin_path_that_merely_starts_like_a_namespace() {
12144 let config = AutumnConfig::default();
12145 let mut ctx = duplicate_test_ctx();
12146 ctx.declared_routes = vec![declared_route("GET", "/staticky/thing", "good-plugin")];
12147 let _router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12148 .expect("/staticky must not be read as /static");
12149 }
12150
12151 #[tokio::test]
12155 async fn try_build_router_allows_a_declared_probe_path_when_probes_are_disabled() {
12156 let mut config = AutumnConfig::default();
12157 config.health.enabled = false;
12158 let mut ctx = duplicate_test_ctx();
12159 ctx.declared_routes = vec![declared_route("GET", &config.health.path, "good-plugin")];
12160 let _router = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12161 .expect("a disabled probe path must not be claimed");
12162 }
12163
12164 #[tokio::test]
12169 async fn try_build_router_rejects_a_declared_plugin_route_that_shape_clashes() {
12170 let config = AutumnConfig::default();
12171 let app_route = duplicate_test_route(http::Method::GET, "/hello/{id}", "app_show");
12172 let mut ctx = duplicate_test_ctx();
12173 ctx.declared_routes = vec![declared_route("POST", "/hello/{slug}", "evil-plugin")];
12174 let err = super::try_build_router_inner(vec![app_route], &config, test_state(), ctx)
12175 .expect_err("a shape clash between app and plugin routes must be refused");
12176 assert!(
12177 matches!(err, RouterBuildError::ConflictingRouteShape { .. }),
12178 "expected ConflictingRouteShape, got {err:?}"
12179 );
12180 }
12181
12182 #[tokio::test]
12187 async fn try_build_router_allows_declared_plugin_routes_that_do_not_collide() {
12188 let config = AutumnConfig::default();
12189 let routes = vec![
12190 duplicate_test_route(http::Method::GET, "/hello/other", "app_other"),
12191 duplicate_test_route(http::Method::GET, "/hello", "app_index"),
12192 ];
12193 let mut ctx = duplicate_test_ctx();
12194 ctx.declared_routes = vec![
12195 declared_route("GET", "/hello/greet", "good-plugin"),
12196 declared_route("HEAD", "/hello/greet", "good-plugin"),
12199 ];
12200 let _router = super::try_build_router_inner(routes, &config, test_state(), ctx)
12201 .expect("non-colliding plugin routes must mount");
12202 }
12203
12204 #[tokio::test]
12208 async fn try_build_router_allows_distinct_methods_on_same_path() {
12209 let config = AutumnConfig::default();
12210 let get = duplicate_test_route(http::Method::GET, "/admin", "admin_index");
12211 let post = duplicate_test_route(http::Method::POST, "/admin", "admin_create");
12212 let _router = super::try_build_router_inner(
12213 vec![get, post],
12214 &config,
12215 test_state(),
12216 duplicate_test_ctx(),
12217 )
12218 .expect("GET + POST on the same path should build cleanly");
12219 }
12220
12221 #[tokio::test]
12225 async fn try_build_router_rejects_duplicate_across_scoped_group() {
12226 let config = AutumnConfig::default();
12227 let top = duplicate_test_route(http::Method::GET, "/api/posts", "top_posts");
12228 let scoped_child = duplicate_test_route(http::Method::GET, "/posts", "scoped_posts");
12229 let group = crate::app::ScopedGroup {
12230 prefix: "/api".to_owned(),
12231 routes: vec![scoped_child],
12232 source: crate::route_listing::RouteSource::User,
12233 layer_guarded: false,
12234 apply_layer: Box::new(|r| r),
12235 };
12236 let mut ctx = duplicate_test_ctx();
12237 ctx.scoped_groups.push(group);
12238 let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
12239 .expect_err("top-level + scoped resolving to same path should be rejected");
12240 match err {
12241 RouterBuildError::DuplicateUserRoute {
12242 ref method,
12243 ref path,
12244 ..
12245 } => {
12246 assert_eq!(method, "GET");
12247 assert_eq!(path, "/api/posts");
12248 }
12249 other => panic!("expected DuplicateUserRoute, got {other:?}"),
12250 }
12251 }
12252
12253 #[tokio::test]
12256 async fn try_build_router_rejects_duplicate_within_scoped_groups() {
12257 let config = AutumnConfig::default();
12258 let a = crate::app::ScopedGroup {
12259 prefix: "/api".to_owned(),
12260 routes: vec![duplicate_test_route(
12261 http::Method::GET,
12262 "/posts",
12263 "user_posts",
12264 )],
12265 source: crate::route_listing::RouteSource::User,
12266 layer_guarded: false,
12267 apply_layer: Box::new(|r| r),
12268 };
12269 let b = crate::app::ScopedGroup {
12270 prefix: "/api".to_owned(),
12271 routes: vec![duplicate_test_route(
12272 http::Method::GET,
12273 "/posts",
12274 "plugin_posts",
12275 )],
12276 source: crate::route_listing::RouteSource::Plugin("blog".to_owned()),
12277 layer_guarded: false,
12278 apply_layer: Box::new(|r| r),
12279 };
12280 let mut ctx = duplicate_test_ctx();
12281 ctx.scoped_groups.push(a);
12282 ctx.scoped_groups.push(b);
12283 let err = super::try_build_router_inner(Vec::new(), &config, test_state(), ctx)
12284 .expect_err("two scoped groups colliding on /api/posts should be rejected");
12285 assert!(matches!(
12286 err,
12287 RouterBuildError::DuplicateUserRoute { ref existing, ref incoming, .. }
12288 if existing == "user_posts" && incoming == "plugin_posts"
12289 ));
12290 }
12291
12292 #[tokio::test]
12297 async fn try_build_router_skips_duplicate_check_for_opaque_merge_router() {
12298 let config = AutumnConfig::default();
12299 let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
12300 let raw = axum::Router::<AppState>::new()
12301 .route("/raw", axum::routing::get(duplicate_route_handler));
12302 let mut ctx = duplicate_test_ctx();
12303 ctx.merge_routers.push(raw);
12304 let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
12305 .expect("opaque merge routers must not fail the duplicate preflight");
12306 }
12307
12308 #[tokio::test]
12310 async fn try_build_router_skips_duplicate_check_for_opaque_nest_router() {
12311 let config = AutumnConfig::default();
12312 let ok_route = duplicate_test_route(http::Method::GET, "/hello", "hello");
12313 let nested = axum::Router::<AppState>::new()
12314 .route("/child", axum::routing::get(duplicate_route_handler));
12315 let mut ctx = duplicate_test_ctx();
12316 ctx.nest_routers.push(("/plugin".to_owned(), nested));
12317 let _router = super::try_build_router_inner(vec![ok_route], &config, test_state(), ctx)
12318 .expect("opaque nest routers must not fail the duplicate preflight");
12319 }
12320
12321 #[tokio::test]
12329 async fn try_build_router_rejects_duplicate_capture_name_paths() {
12330 let config = AutumnConfig::default();
12331 let a = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
12332 let b = duplicate_test_route(http::Method::GET, "/users/{slug}", "by_slug");
12333 let err =
12334 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12335 .expect_err("capture-name-only difference must be rejected before mount");
12336 match err {
12337 RouterBuildError::ConflictingRouteShape {
12338 ref existing,
12339 ref existing_path,
12340 ref incoming,
12341 ref incoming_path,
12342 } => {
12343 assert_eq!(existing, "by_id");
12344 assert_eq!(existing_path, "/users/{id}");
12345 assert_eq!(incoming, "by_slug");
12346 assert_eq!(incoming_path, "/users/{slug}");
12347 }
12348 other => panic!("expected ConflictingRouteShape, got {other:?}"),
12349 }
12350 let display = err.to_string();
12352 assert!(
12353 display.contains("/users/{id}") && display.contains("/users/{slug}"),
12354 "error must show both original path templates; got: {display}"
12355 );
12356 }
12357
12358 #[tokio::test]
12362 async fn try_build_router_rejects_duplicate_capture_name_across_scoped_group() {
12363 let config = AutumnConfig::default();
12364 let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
12365 let scoped_child =
12366 duplicate_test_route(http::Method::GET, "/users/{slug}", "scoped_by_slug");
12367 let group = crate::app::ScopedGroup {
12368 prefix: "/api".to_owned(),
12369 routes: vec![scoped_child],
12370 source: crate::route_listing::RouteSource::User,
12371 layer_guarded: false,
12372 apply_layer: Box::new(|r| r),
12373 };
12374 let mut ctx = duplicate_test_ctx();
12375 ctx.scoped_groups.push(group);
12376 let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
12377 .expect_err("scoped capture-name collision must be rejected before mount");
12378 assert!(
12379 matches!(
12380 err,
12381 RouterBuildError::ConflictingRouteShape {
12382 ref existing, ref incoming, ref existing_path, ref incoming_path
12383 }
12384 if existing == "top_by_id" && incoming == "scoped_by_slug"
12385 && existing_path == "/api/users/{id}"
12386 && incoming_path == "/api/users/{slug}"
12387 ),
12388 "expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
12389 );
12390 }
12391
12392 #[tokio::test]
12396 async fn try_build_router_allows_distinct_route_shapes() {
12397 let config = AutumnConfig::default();
12398 let a = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
12399 let b = duplicate_test_route(http::Method::GET, "/users/{id}/posts", "posts");
12400 let _router =
12401 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12402 .expect("distinct route shapes must not be flagged as duplicates");
12403 }
12404
12405 #[tokio::test]
12416 async fn try_build_router_rejects_ws_get_collision() {
12417 let config = AutumnConfig::default();
12418 let get = duplicate_test_route(http::Method::GET, "/live", "live_poll");
12419 let ws = duplicate_test_route(
12420 http::Method::from_bytes(b"WS").unwrap(),
12421 "/live",
12422 "live_socket",
12423 );
12424 let err = super::try_build_router_inner(
12425 vec![get, ws],
12426 &config,
12427 test_state(),
12428 duplicate_test_ctx(),
12429 )
12430 .expect_err("GET + WS on the same path must be rejected before mount");
12431 match err {
12432 RouterBuildError::DuplicateUserRoute {
12433 ref method,
12434 ref path,
12435 ref existing,
12436 ref incoming,
12437 } => {
12438 assert_eq!(method, "GET", "WS must be normalized to its effective GET");
12439 assert_eq!(path, "/live");
12440 assert_eq!(existing, "live_poll");
12441 assert_eq!(incoming, "live_socket");
12442 }
12443 other => panic!("expected DuplicateUserRoute, got {other:?}"),
12444 }
12445 }
12446
12447 #[tokio::test]
12456 async fn try_build_router_rejects_cross_method_shape_conflict() {
12457 let config = AutumnConfig::default();
12458 let get = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
12459 let post = duplicate_test_route(http::Method::POST, "/users/{slug}", "by_slug");
12460 let err = super::try_build_router_inner(
12461 vec![get, post],
12462 &config,
12463 test_state(),
12464 duplicate_test_ctx(),
12465 )
12466 .expect_err("cross-method capture-name-only conflict must be rejected before mount");
12467 match err {
12468 RouterBuildError::ConflictingRouteShape {
12469 ref existing,
12470 ref existing_path,
12471 ref incoming,
12472 ref incoming_path,
12473 } => {
12474 assert_eq!(existing, "by_id");
12475 assert_eq!(existing_path, "/users/{id}");
12476 assert_eq!(incoming, "by_slug");
12477 assert_eq!(incoming_path, "/users/{slug}");
12478 }
12479 other => panic!("expected ConflictingRouteShape, got {other:?}"),
12480 }
12481 let display = err.to_string();
12482 assert!(
12483 display.contains("by_id") && display.contains("by_slug"),
12484 "error must name both handlers; got: {display}"
12485 );
12486 assert!(
12487 display.contains("/users/{id}") && display.contains("/users/{slug}"),
12488 "error must name both original templates; got: {display}"
12489 );
12490 }
12491
12492 #[tokio::test]
12496 async fn try_build_router_rejects_cross_method_shape_conflict_across_scoped_group() {
12497 let config = AutumnConfig::default();
12498 let top = duplicate_test_route(http::Method::GET, "/api/users/{id}", "top_by_id");
12499 let scoped_child =
12500 duplicate_test_route(http::Method::POST, "/users/{slug}", "scoped_by_slug");
12501 let group = crate::app::ScopedGroup {
12502 prefix: "/api".to_owned(),
12503 routes: vec![scoped_child],
12504 source: crate::route_listing::RouteSource::User,
12505 layer_guarded: false,
12506 apply_layer: Box::new(|r| r),
12507 };
12508 let mut ctx = duplicate_test_ctx();
12509 ctx.scoped_groups.push(group);
12510 let err = super::try_build_router_inner(vec![top], &config, test_state(), ctx)
12511 .expect_err("scoped cross-method shape conflict must be rejected before mount");
12512 assert!(
12513 matches!(
12514 err,
12515 RouterBuildError::ConflictingRouteShape {
12516 ref existing, ref incoming, ref existing_path, ref incoming_path
12517 }
12518 if existing == "top_by_id" && incoming == "scoped_by_slug"
12519 && existing_path == "/api/users/{id}"
12520 && incoming_path == "/api/users/{slug}"
12521 ),
12522 "expected ConflictingRouteShape naming both handlers + both paths, got {err:?}"
12523 );
12524 }
12525
12526 #[tokio::test]
12532 async fn try_build_router_allows_same_capture_template_distinct_methods() {
12533 let config = AutumnConfig::default();
12534 let get = duplicate_test_route(http::Method::GET, "/users/{id}", "show");
12535 let post = duplicate_test_route(http::Method::POST, "/users/{id}", "update");
12536 let _router = super::try_build_router_inner(
12537 vec![get, post],
12538 &config,
12539 test_state(),
12540 duplicate_test_ctx(),
12541 )
12542 .expect("same capture template on GET + POST must build cleanly");
12543 }
12544
12545 #[tokio::test]
12551 async fn try_build_router_allows_escaped_brace_literals() {
12552 let config = AutumnConfig::default();
12553 let a = duplicate_test_route(http::Method::GET, "/{{foo}}", "lit_foo");
12554 let b = duplicate_test_route(http::Method::GET, "/{{bar}}", "lit_bar");
12555 let _router =
12556 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12557 .expect("distinct escaped-literal paths must not be flagged as duplicates");
12558 }
12559
12560 #[tokio::test]
12564 async fn try_build_router_allows_escaped_literal_prefix_with_capture() {
12565 let config = AutumnConfig::default();
12566 let a = duplicate_test_route(http::Method::GET, "/{{x}}/{id}", "x_show");
12567 let b = duplicate_test_route(http::Method::GET, "/{{y}}/{id}", "y_show");
12568 let _router =
12569 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12570 .expect("distinct escaped-literal prefixes with a shared capture must build");
12571 }
12572
12573 #[tokio::test]
12578 async fn try_build_router_rejects_mixed_literal_capture_shape_conflict() {
12579 let config = AutumnConfig::default();
12580 let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
12581 let b = duplicate_test_route(http::Method::GET, "/file.{kind}", "by_kind");
12582 let err =
12583 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12584 .expect_err("mixed literal+capture shape conflict must be rejected before mount");
12585 assert!(
12586 matches!(
12587 err,
12588 RouterBuildError::ConflictingRouteShape {
12589 ref existing_path, ref incoming_path, ..
12590 }
12591 if existing_path == "/file.{ext}" && incoming_path == "/file.{kind}"
12592 ),
12593 "expected ConflictingRouteShape naming both templates, got {err:?}"
12594 );
12595 }
12596
12597 #[tokio::test]
12602 async fn try_build_router_allows_mixed_capture_vs_static_segment() {
12603 let config = AutumnConfig::default();
12604 let a = duplicate_test_route(http::Method::GET, "/file.{ext}", "by_ext");
12605 let b = duplicate_test_route(http::Method::GET, "/file.json", "static_json");
12606 let _router =
12607 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12608 .expect("a capture segment and a static segment must not be flagged as duplicates");
12609 }
12610
12611 #[tokio::test]
12616 async fn try_build_router_rejects_catch_all_vs_normal_capture() {
12617 let config = AutumnConfig::default();
12618 let a = duplicate_test_route(http::Method::GET, "/u/{id}", "one");
12619 let b = duplicate_test_route(http::Method::GET, "/u/{*rest}", "rest");
12620 let err =
12621 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12622 .expect_err("catch-all vs normal capture must be rejected before mount");
12623 assert!(
12624 matches!(
12625 err,
12626 RouterBuildError::ConflictingRouteShape {
12627 ref existing_path, ref incoming_path, ..
12628 }
12629 if existing_path == "/u/{id}" && incoming_path == "/u/{*rest}"
12630 ),
12631 "expected ConflictingRouteShape naming both templates, got {err:?}"
12632 );
12633 }
12634
12635 #[tokio::test]
12644 async fn try_build_router_rejects_catch_all_vs_dynamic_descendant() {
12645 let config = AutumnConfig::default();
12646 let a = duplicate_test_route(http::Method::GET, "/cmd/{tool}/{sub}", "cmd_sub");
12647 let b = duplicate_test_route(http::Method::POST, "/cmd/{*path}", "cmd_all");
12648 let err =
12649 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12650 .expect_err("catch-all vs dynamic descendant must be rejected before mount");
12651 match err {
12652 RouterBuildError::ConflictingRouteShape {
12653 ref existing,
12654 ref existing_path,
12655 ref incoming,
12656 ref incoming_path,
12657 } => {
12658 assert_eq!(existing, "cmd_sub");
12659 assert_eq!(existing_path, "/cmd/{tool}/{sub}");
12660 assert_eq!(incoming, "cmd_all");
12661 assert_eq!(incoming_path, "/cmd/{*path}");
12662 }
12663 other => panic!("expected ConflictingRouteShape, got {other:?}"),
12664 }
12665 let display = err.to_string();
12666 assert!(
12667 display.contains("/cmd/{tool}/{sub}") && display.contains("/cmd/{*path}"),
12668 "error must name both original templates; got: {display}"
12669 );
12670 }
12671
12672 #[tokio::test]
12678 async fn try_build_router_allows_static_vs_dynamic_segment() {
12679 let config = AutumnConfig::default();
12680 let a = duplicate_test_route(http::Method::GET, "/users/me", "me");
12681 let b = duplicate_test_route(http::Method::GET, "/users/{id}", "by_id");
12682 let _router =
12683 super::try_build_router_inner(vec![a, b], &config, test_state(), duplicate_test_ctx())
12684 .expect("a static segment and a dynamic capture must not be flagged as a conflict");
12685 }
12686
12687 #[test]
12695 fn matchit_agrees_with_axum_route_conflicts() {
12696 let matrix: &[(&str, &str, bool)] = &[
12698 ("/users/{id}", "/users/{slug}", true),
12699 ("/users/{id}", "/users/{id}/posts", false),
12700 ("/cmd/{tool}/{sub}", "/cmd/{*path}", true),
12701 ("/users/me", "/users/{id}", false),
12702 ("/{{foo}}", "/{{bar}}", false),
12703 ("/file.{ext}", "/file.{kind}", true),
12704 ("/file.{ext}", "/file.json", false),
12705 ("/u/{id}", "/u/{*rest}", true),
12706 ];
12707
12708 let prev_hook = std::panic::take_hook();
12711 std::panic::set_hook(Box::new(|_| {}));
12712
12713 let mut rows = Vec::new();
12714 let mut mismatches = Vec::new();
12715 for &(a, b, expect_conflict) in matrix {
12716 let axum_panics = std::panic::catch_unwind(|| {
12718 let _ = axum::Router::<()>::new()
12719 .route(a, axum::routing::get(|| async { "a" }))
12720 .route(b, axum::routing::get(|| async { "b" }));
12721 })
12722 .is_err();
12723
12724 let mut r: matchit::Router<()> = matchit::Router::new();
12726 r.insert(a, ()).expect("first template must insert cleanly");
12727 let matchit_conflicts =
12728 matches!(r.insert(b, ()), Err(matchit::InsertError::Conflict { .. }));
12729
12730 rows.push(format!(
12731 "{a:<20} vs {b:<20} axum={} matchit={} expected={}",
12732 if axum_panics { "PANIC" } else { "ok" },
12733 if matchit_conflicts { "Err" } else { "Ok" },
12734 if expect_conflict { "conflict" } else { "ok" },
12735 ));
12736
12737 if axum_panics != matchit_conflicts || axum_panics != expect_conflict {
12738 mismatches.push(rows.last().unwrap().clone());
12739 }
12740 }
12741
12742 std::panic::set_hook(prev_hook);
12743
12744 assert!(
12745 mismatches.is_empty(),
12746 "matchit must agree with axum 0.8.9 AND the expected outcome on every \
12747 case (oracle divergence => false positives/negatives at mount).\n\
12748 full matrix:\n{}\nmismatches:\n{}",
12749 rows.join("\n"),
12750 mismatches.join("\n"),
12751 );
12752 }
12753
12754 fn create_ssg_dist(entries: &[(&str, &str, &[u8])]) -> tempfile::TempDir {
12769 let with_types: Vec<_> = entries
12770 .iter()
12771 .map(|(route, file, bytes)| (*route, *file, *bytes, None))
12772 .collect();
12773 create_ssg_dist_with_types(&with_types)
12774 }
12775
12776 fn create_ssg_dist_with_types(
12780 entries: &[(&str, &str, &[u8], Option<&str>)],
12781 ) -> tempfile::TempDir {
12782 let dir = tempfile::tempdir().expect("tempdir");
12783 let dist = dir.path().join("dist");
12784 let mut routes = std::collections::HashMap::new();
12785 for (route, file, bytes, content_type) in entries {
12786 let path = dist.join(file);
12787 if let Some(parent) = path.parent() {
12788 std::fs::create_dir_all(parent).expect("mkdir");
12789 }
12790 std::fs::write(&path, bytes).expect("write file");
12791 routes.insert(
12792 (*route).to_owned(),
12793 crate::static_gen::ManifestEntry::new(*file)
12794 .with_content_type(content_type.map(str::to_owned)),
12795 );
12796 }
12797 let manifest = crate::static_gen::StaticManifest {
12798 generated_at: "2026-07-12T00:00:00Z".to_owned(),
12799 autumn_version: "0.6.0".to_owned(),
12800 routes,
12801 };
12802 std::fs::write(
12803 dist.join("manifest.json"),
12804 serde_json::to_string(&manifest).unwrap(),
12805 )
12806 .unwrap();
12807 dir
12808 }
12809
12810 fn compression_enabled_config() -> AutumnConfig {
12811 let mut config = AutumnConfig::default();
12812 config.compression.enabled = true;
12813 config
12814 }
12815
12816 #[cfg(feature = "tls")]
12826 #[tokio::test]
12827 async fn a_cached_ssg_page_under_a_required_path_still_demands_a_certificate() {
12828 let tmp = create_ssg_dist(&[("/internal/report", "report.html", b"<h1>secret</h1>")]);
12829 let dist = tmp.path().join("dist");
12830
12831 let mut config = AutumnConfig::default();
12832 config.server.tls = Some(crate::config::TlsConfig {
12833 cert_path: Some("cert.pem".into()),
12834 key_path: Some("key.pem".into()),
12835 reload_interval_secs: 60,
12836 handshake_timeout_secs: 10,
12837 acme: None,
12838 client_auth: Some(crate::config::ClientAuthConfig {
12839 mode: crate::config::ClientAuthMode::Optional,
12840 ca_bundle_path: Some("client-ca.pem".into()),
12841 crl_path: None,
12842 required_paths: vec!["/internal/".to_owned()],
12843 reload_interval_secs: 60,
12844 }),
12845 });
12846
12847 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
12848 .expect("router builds");
12849
12850 let response = router
12853 .clone()
12854 .oneshot(
12855 Request::builder()
12856 .uri("/internal/report")
12857 .body(Body::empty())
12858 .unwrap(),
12859 )
12860 .await
12861 .unwrap();
12862 assert_eq!(
12863 response.status(),
12864 StatusCode::FORBIDDEN,
12865 "a cached page under a required_paths prefix must still demand a certificate"
12866 );
12867
12868 let identity = std::sync::Arc::new(crate::tls::client_auth::ClientIdentity::new_for_test(
12871 "svc-reports",
12872 Vec::new(),
12873 ));
12874 let mut request = Request::builder()
12875 .uri("/internal/report")
12876 .body(Body::empty())
12877 .unwrap();
12878 request.extensions_mut().insert(identity);
12879 let response = router.oneshot(request).await.unwrap();
12880 assert_eq!(response.status(), StatusCode::OK);
12881 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
12882 .await
12883 .unwrap();
12884 assert_eq!(&body[..], b"<h1>secret</h1>");
12885 }
12886
12887 #[test]
12890 fn a_scoped_noindex_home_is_not_listed() {
12891 let mut home = duplicate_test_route(http::Method::GET, "/", "home");
12892 home.seo = crate::seo::SeoRouteDefaults {
12893 robots: Some("noindex"),
12894 ..crate::seo::SeoRouteDefaults::EMPTY
12895 };
12896 let group = crate::app::ScopedGroup {
12897 prefix: "/".to_owned(),
12898 routes: vec![home],
12899 source: crate::route_listing::RouteSource::User,
12900 layer_guarded: false,
12901 apply_layer: Box::new(|r| r),
12902 };
12903 let site = super::build_aeo_site::<(), ()>(
12904 &AutumnConfig::default(),
12905 &AppState::for_test(),
12906 &[],
12907 &[group],
12908 false,
12909 None,
12910 None,
12911 );
12912 assert!(!site.facts.no_home);
12913 assert_eq!(site.facts.noindex_homes, vec!["/".to_owned()]);
12914 }
12915
12916 #[cfg(feature = "http-client")]
12920 #[tokio::test]
12921 async fn a_static_build_leaves_a_dynamic_miss_to_the_matched_route() {
12922 let tmp = create_ssg_dist(&[("/about", "about.html", b"<h1>About</h1>")]);
12923 let dist = tmp.path().join("dist");
12924 let mut config = AutumnConfig::default();
12925 config.aeo.x402 = toml::from_str(
12926 "facilitator_url = \"https://facilitator.example\"\npay_to = \"0xabc\"\n\
12927 network = \"eip155:84532\"\nasset = \"0xusdc\"",
12928 )
12929 .unwrap();
12930 config.aeo.paid_routes = vec![crate::aeo::commerce::PaidRoute::new(
12931 "GET",
12932 "/files/{*path}",
12933 "1",
12934 )];
12935 let router = try_build_router_with_static(
12936 vec![duplicate_test_route(
12937 http::Method::GET,
12938 "/files/public",
12939 "public",
12940 )],
12941 &config,
12942 test_state(),
12943 Some(&dist),
12944 )
12945 .expect("router builds");
12946 let get = |uri: &'static str| {
12947 router
12948 .clone()
12949 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
12950 };
12951 assert_eq!(get("/files/public").await.unwrap().status(), StatusCode::OK);
12954 assert_eq!(
12956 get("/files/report.pdf").await.unwrap().status(),
12957 StatusCode::PAYMENT_REQUIRED
12958 );
12959 }
12960
12961 #[cfg(feature = "http-client")]
12965 #[tokio::test]
12966 async fn a_static_page_is_priced_by_the_route_that_rendered_it() {
12967 let tmp = create_ssg_dist(&[
12968 ("/files/public", "files/public.html", b"<h1>Public</h1>"),
12969 ("/files/report.pdf", "files/report.html", b"<h1>Report</h1>"),
12970 ]);
12971 let dist = tmp.path().join("dist");
12972 let mut config = AutumnConfig::default();
12973 config.aeo.x402 = toml::from_str(
12974 "facilitator_url = \"https://facilitator.example\"\npay_to = \"0xabc\"\n\
12975 network = \"eip155:84532\"\nasset = \"0xusdc\"",
12976 )
12977 .unwrap();
12978 config.aeo.paid_routes = vec![crate::aeo::commerce::PaidRoute::new(
12979 "GET",
12980 "/files/{*path}",
12981 "1",
12982 )];
12983 let router = try_build_router_with_static(
12984 vec![
12985 duplicate_test_route(http::Method::GET, "/files/public", "public"),
12986 duplicate_test_route(http::Method::GET, "/files/{*path}", "files"),
12987 ],
12988 &config,
12989 test_state(),
12990 Some(&dist),
12991 )
12992 .expect("router builds");
12993 let get = |uri: &'static str| {
12994 router
12995 .clone()
12996 .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap())
12997 };
12998 let public = get("/files/public").await.unwrap();
12999 assert_eq!(public.status(), StatusCode::OK);
13000 let body = axum::body::to_bytes(public.into_body(), usize::MAX)
13001 .await
13002 .unwrap();
13003 assert_eq!(&body[..], b"<h1>Public</h1>");
13004 assert_eq!(
13005 get("/files/report.pdf").await.unwrap().status(),
13006 StatusCode::PAYMENT_REQUIRED
13007 );
13008 }
13009
13010 #[cfg(feature = "http-client")]
13015 #[tokio::test]
13016 async fn a_priced_static_page_passes_the_ingress_stack() {
13017 let tmp = create_ssg_dist(&[
13018 ("/report", "report.html", b"<h1>Report</h1>"),
13019 ("/about", "about.html", b"<h1>About</h1>"),
13020 ]);
13021 let dist = tmp.path().join("dist");
13022 let mut config = AutumnConfig::default();
13023 config.aeo.x402 = toml::from_str(
13024 "facilitator_url = \"https://facilitator.example\"\npay_to = \"0xabc\"\n\
13025 network = \"eip155:84532\"\nasset = \"0xusdc\"",
13026 )
13027 .unwrap();
13028 config.aeo.paid_routes = vec![crate::aeo::commerce::PaidRoute::new("GET", "/report", "1")];
13029 config.cors.allowed_origins = vec!["https://app.example".to_owned()];
13030 let router = try_build_router_with_static(
13031 vec![
13032 duplicate_test_route(http::Method::GET, "/report", "report"),
13033 duplicate_test_route(http::Method::GET, "/about", "about"),
13034 ],
13035 &config,
13036 test_state(),
13037 Some(&dist),
13038 )
13039 .expect("router builds");
13040 let get = |uri: &'static str| {
13041 router.clone().oneshot(
13042 Request::builder()
13043 .uri(uri)
13044 .header(http::header::ORIGIN, "https://app.example")
13045 .body(Body::empty())
13046 .unwrap(),
13047 )
13048 };
13049 let report = get("/report").await.unwrap();
13050 assert_eq!(report.status(), StatusCode::PAYMENT_REQUIRED);
13051 assert_eq!(
13052 report.headers()[http::header::ACCESS_CONTROL_ALLOW_ORIGIN],
13053 "https://app.example",
13054 "the CORS layer ran"
13055 );
13056 let about = get("/about").await.unwrap();
13057 assert_eq!(about.status(), StatusCode::OK);
13058 let body = axum::body::to_bytes(about.into_body(), usize::MAX)
13059 .await
13060 .unwrap();
13061 assert_eq!(&body[..], b"<h1>About</h1>", "served from the manifest");
13062 }
13063
13064 #[test]
13065 fn static_pages_resolve_to_the_route_axum_would_match() {
13066 let routes = vec![
13067 duplicate_test_route(http::Method::GET, "/files/public", "public"),
13068 duplicate_test_route(http::Method::GET, "/files/{*path}", "files"),
13069 ];
13070 let pages = [
13071 "/files/public".to_owned(),
13072 "/files/a.pdf".to_owned(),
13073 "/hand-written".to_owned(),
13074 ];
13075 let map = super::static_page_routes(pages.iter(), &routes, &[], &AutumnConfig::default());
13076 assert_eq!(map["/files/public"].as_deref(), Some("/files/public"));
13077 assert_eq!(map["/files/a.pdf"].as_deref(), Some("/files/{*path}"));
13078 assert_eq!(map["/hand-written"], None);
13079 }
13080
13081 #[tokio::test]
13084 async fn ssg_head_mirrors_the_markdown_get() {
13085 let html = b"<html><body><main><h1>About</h1></main></body></html>";
13086 let tmp = create_ssg_dist(&[("/about", "about.html", html)]);
13087 let dist = tmp.path().join("dist");
13088 let router = try_build_router_with_static(
13089 Vec::new(),
13090 &AutumnConfig::default(),
13091 test_state(),
13092 Some(&dist),
13093 )
13094 .expect("router builds");
13095 let send = |method: http::Method| {
13096 router.clone().oneshot(
13097 Request::builder()
13098 .method(method)
13099 .uri("/about")
13100 .header(http::header::ACCEPT, "text/markdown")
13101 .body(Body::empty())
13102 .unwrap(),
13103 )
13104 };
13105 let head = send(http::Method::HEAD).await.unwrap();
13106 let get = send(http::Method::GET).await.unwrap();
13107 assert_eq!(head.status(), StatusCode::OK);
13108 assert_eq!(
13109 head.headers().get(http::header::CONTENT_TYPE),
13110 get.headers().get(http::header::CONTENT_TYPE),
13111 );
13112 assert!(
13113 head.headers()[http::header::CONTENT_TYPE]
13114 .to_str()
13115 .unwrap()
13116 .starts_with("text/markdown"),
13117 "{:?}",
13118 head.headers()
13119 );
13120 let get_len = get.headers()[http::header::CONTENT_LENGTH].clone();
13122 assert_eq!(head.headers()[http::header::CONTENT_LENGTH], get_len);
13123 let body = axum::body::to_bytes(head.into_body(), usize::MAX)
13124 .await
13125 .unwrap();
13126 assert!(body.is_empty(), "{body:?}");
13127 }
13128
13129 #[tokio::test]
13133 async fn ssg_html_hit_is_gzip_compressed() {
13134 let html = format!(
13135 "<html><body>{}</body></html>",
13136 "Lorem ipsum dolor sit amet. ".repeat(64)
13137 );
13138 let tmp = create_ssg_dist(&[("/", "index.html", html.as_bytes())]);
13139 let dist = tmp.path().join("dist");
13140
13141 let router = try_build_router_with_static(
13142 Vec::new(),
13143 &compression_enabled_config(),
13144 test_state(),
13145 Some(&dist),
13146 )
13147 .expect("router builds");
13148 let response = router
13149 .oneshot(
13150 Request::builder()
13151 .uri("/")
13152 .header("accept-encoding", "gzip")
13153 .body(Body::empty())
13154 .unwrap(),
13155 )
13156 .await
13157 .unwrap();
13158
13159 assert_eq!(response.status(), StatusCode::OK);
13160 assert_eq!(
13161 response
13162 .headers()
13163 .get(http::header::CONTENT_ENCODING)
13164 .and_then(|v| v.to_str().ok()),
13165 Some("gzip"),
13166 "manifest-backed SSG HTML page must be gzip-compressed"
13167 );
13168 let vary = response
13170 .headers()
13171 .get_all(http::header::VARY)
13172 .iter()
13173 .filter_map(|v| v.to_str().ok())
13174 .collect::<Vec<_>>()
13175 .join(", ");
13176 assert!(
13177 vary.to_lowercase().contains("accept-encoding"),
13178 "Vary must advertise Accept-Encoding, got {vary:?}"
13179 );
13180 assert_eq!(
13181 response
13182 .headers()
13183 .get(http::header::CONTENT_TYPE)
13184 .and_then(|v| v.to_str().ok()),
13185 Some("text/html; charset=utf-8"),
13186 "HTML page keeps its text/html content type"
13187 );
13188 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
13190 .await
13191 .unwrap();
13192 assert_ne!(
13193 body.as_ref(),
13194 html.as_bytes(),
13195 "compressed body must differ from the raw HTML"
13196 );
13197 }
13198
13199 #[tokio::test]
13203 async fn ssg_binary_asset_is_not_compressed_and_keeps_mime() {
13204 let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
13207 bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
13208 let tmp = create_ssg_dist(&[("/logo", "logo.png", &bytes)]);
13209 let dist = tmp.path().join("dist");
13210
13211 let router = try_build_router_with_static(
13212 Vec::new(),
13213 &compression_enabled_config(),
13214 test_state(),
13215 Some(&dist),
13216 )
13217 .expect("router builds");
13218 let response = router
13219 .oneshot(
13220 Request::builder()
13221 .uri("/logo")
13222 .header("accept-encoding", "gzip")
13223 .body(Body::empty())
13224 .unwrap(),
13225 )
13226 .await
13227 .unwrap();
13228
13229 assert_eq!(response.status(), StatusCode::OK);
13230 assert_eq!(
13231 response
13232 .headers()
13233 .get(http::header::CONTENT_TYPE)
13234 .and_then(|v| v.to_str().ok()),
13235 Some("image/png"),
13236 "binary manifest asset must keep its real MIME type, not text/html"
13237 );
13238 assert_eq!(
13239 response.headers().get(http::header::CONTENT_ENCODING),
13240 None,
13241 "binary asset must not be blindly compressed"
13242 );
13243 }
13244
13245 #[tokio::test]
13250 async fn ssg_woff2_font_is_not_compressed_and_keeps_mime() {
13251 let mut bytes = b"wOF2".to_vec();
13254 bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
13255 let tmp = create_ssg_dist(&[("/inter", "fonts/inter.woff2", &bytes)]);
13256 let dist = tmp.path().join("dist");
13257
13258 let router = try_build_router_with_static(
13259 Vec::new(),
13260 &compression_enabled_config(),
13261 test_state(),
13262 Some(&dist),
13263 )
13264 .expect("router builds");
13265 let response = router
13266 .oneshot(
13267 Request::builder()
13268 .uri("/inter")
13269 .header("accept-encoding", "gzip")
13270 .body(Body::empty())
13271 .unwrap(),
13272 )
13273 .await
13274 .unwrap();
13275
13276 assert_eq!(response.status(), StatusCode::OK);
13277 assert_eq!(
13278 response
13279 .headers()
13280 .get(http::header::CONTENT_TYPE)
13281 .and_then(|v| v.to_str().ok()),
13282 Some("font/woff2"),
13283 "woff2 manifest asset must keep its font/woff2 MIME type"
13284 );
13285 assert_eq!(
13286 response.headers().get(http::header::CONTENT_ENCODING),
13287 None,
13288 "pre-compressed woff2 font must not be re-compressed"
13289 );
13290 }
13291
13292 #[tokio::test]
13297 async fn ssg_woff_font_is_not_compressed_and_keeps_mime() {
13298 let mut bytes = b"wOFF".to_vec();
13299 bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
13300
13301 for (label, recorded) in [("derived", None), ("recorded", Some("font/woff"))] {
13304 let tmp =
13305 create_ssg_dist_with_types(&[("/inter", "fonts/inter.woff", &bytes, recorded)]);
13306 let dist = tmp.path().join("dist");
13307 let router = try_build_router_with_static(
13308 Vec::new(),
13309 &compression_enabled_config(),
13310 test_state(),
13311 Some(&dist),
13312 )
13313 .expect("router builds");
13314 let response = router
13315 .oneshot(
13316 Request::builder()
13317 .uri("/inter")
13318 .header("accept-encoding", "gzip")
13319 .body(Body::empty())
13320 .unwrap(),
13321 )
13322 .await
13323 .unwrap();
13324
13325 assert_eq!(response.status(), StatusCode::OK, "{label}");
13326 assert_eq!(
13327 response
13328 .headers()
13329 .get(http::header::CONTENT_TYPE)
13330 .and_then(|v| v.to_str().ok()),
13331 Some("font/woff"),
13332 "{label}: woff asset must keep its font/woff MIME type"
13333 );
13334 assert_eq!(
13335 response.headers().get(http::header::CONTENT_ENCODING),
13336 None,
13337 "{label}: pre-compressed woff font must not be re-compressed"
13338 );
13339 }
13340 }
13341
13342 #[tokio::test]
13348 async fn ssg_raw_sfnt_fonts_stay_compressible() {
13349 let mut bytes = vec![0u8, 1, 0, 0];
13351 bytes.extend(std::iter::repeat_n(b'A', 4096));
13352
13353 for (route, file, expected) in [
13354 ("/inter-ttf", "fonts/inter.ttf", "font/ttf"),
13355 ("/inter-otf", "fonts/inter.otf", "font/otf"),
13356 ] {
13357 let tmp = create_ssg_dist(&[(route, file, &bytes)]);
13358 let dist = tmp.path().join("dist");
13359 let router = try_build_router_with_static(
13360 Vec::new(),
13361 &compression_enabled_config(),
13362 test_state(),
13363 Some(&dist),
13364 )
13365 .expect("router builds");
13366 let response = router
13367 .oneshot(
13368 Request::builder()
13369 .uri(route)
13370 .header("accept-encoding", "gzip")
13371 .body(Body::empty())
13372 .unwrap(),
13373 )
13374 .await
13375 .unwrap();
13376
13377 assert_eq!(response.status(), StatusCode::OK, "{route}");
13378 assert_eq!(
13379 response
13380 .headers()
13381 .get(http::header::CONTENT_TYPE)
13382 .and_then(|v| v.to_str().ok()),
13383 Some(expected),
13384 "{route} must keep its raw-font MIME type"
13385 );
13386 assert_eq!(
13387 response
13388 .headers()
13389 .get(http::header::CONTENT_ENCODING)
13390 .and_then(|v| v.to_str().ok()),
13391 Some("gzip"),
13392 "{route}: raw SFNT fonts are uncompressed data and must still be \
13393 gzipped — only WOFF/WOFF2 embed their own compression"
13394 );
13395 }
13396 }
13397
13398 fn predicate_compresses(content_type: &str) -> bool {
13400 use tower_http::compression::predicate::Predicate;
13401 let response = http::Response::builder()
13402 .header(http::header::CONTENT_TYPE, content_type)
13403 .body(Body::from(vec![b'A'; 4096]))
13404 .unwrap();
13405 compression_predicate().should_compress(&response)
13406 }
13407
13408 #[test]
13411 fn compression_carve_outs_ignore_case() {
13412 for ct in [
13413 "Audio/MPEG",
13414 "VIDEO/mp4",
13415 "Application/Zip",
13416 "Application/Octet-Stream",
13417 "Font/WOFF2",
13418 "font/WOFF",
13419 "Font/Woff2; foo=bar",
13420 "audio/mpeg",
13421 "application/zip",
13422 "font/woff2",
13423 ] {
13424 assert!(!predicate_compresses(ct), "{ct} must stay uncompressed");
13425 }
13426 }
13427
13428 #[test]
13432 fn compression_still_applies_to_other_types_in_any_case() {
13433 for ct in [
13434 "font/ttf",
13435 "Font/TTF",
13436 "font/otf",
13437 "text/html",
13438 "Text/HTML",
13439 "Application/JSON; charset=utf-8",
13440 "application/zi",
13441 "Font/WOF",
13442 "au",
13443 ] {
13444 assert!(predicate_compresses(ct), "{ct} must be compressed");
13445 }
13446 }
13447
13448 #[test]
13450 fn compression_exclusion_is_safe_on_short_and_non_ascii_input() {
13451 for ct in ["", "a", "au", "é", "audiö/x", "fönt/woff2"] {
13452 assert!(!is_compression_excluded(ct), "{ct:?} must not match");
13453 }
13454 }
13455
13456 #[tokio::test]
13462 async fn ssg_nested_multidot_asset_resolves_js_mime() {
13463 let js = format!("console.log({:?});", "x".repeat(256));
13464 let tmp = create_ssg_dist(&[("/app.js", "assets/js/app.min.js", js.as_bytes())]);
13465 let dist = tmp.path().join("dist");
13466
13467 let router = try_build_router_with_static(
13468 Vec::new(),
13469 &compression_enabled_config(),
13470 test_state(),
13471 Some(&dist),
13472 )
13473 .expect("router builds");
13474 let response = router
13475 .oneshot(
13476 Request::builder()
13477 .uri("/app.js")
13478 .header("accept-encoding", "gzip")
13479 .body(Body::empty())
13480 .unwrap(),
13481 )
13482 .await
13483 .unwrap();
13484
13485 assert_eq!(response.status(), StatusCode::OK);
13486 assert_eq!(
13487 response
13488 .headers()
13489 .get(http::header::CONTENT_TYPE)
13490 .and_then(|v| v.to_str().ok()),
13491 Some("text/javascript; charset=utf-8"),
13492 "nested multi-dot JS asset must resolve to the JavaScript MIME type"
13493 );
13494 assert_eq!(
13496 response
13497 .headers()
13498 .get(http::header::CONTENT_ENCODING)
13499 .and_then(|v| v.to_str().ok()),
13500 Some("gzip"),
13501 "compressible JS asset must be gzip-compressed"
13502 );
13503 }
13504
13505 #[tokio::test]
13511 async fn ssg_generated_html_page_keeps_text_html_and_is_compressed() {
13512 let html = format!("<html><body>{}</body></html>", "About us. ".repeat(128));
13513 let tmp = create_ssg_dist(&[("/about", "about/index.html", html.as_bytes())]);
13514 let dist = tmp.path().join("dist");
13515
13516 let router = try_build_router_with_static(
13517 Vec::new(),
13518 &compression_enabled_config(),
13519 test_state(),
13520 Some(&dist),
13521 )
13522 .expect("router builds");
13523 let response = router
13524 .oneshot(
13525 Request::builder()
13526 .uri("/about")
13527 .header("accept-encoding", "gzip")
13528 .body(Body::empty())
13529 .unwrap(),
13530 )
13531 .await
13532 .unwrap();
13533
13534 assert_eq!(response.status(), StatusCode::OK);
13535 assert_eq!(
13536 response
13537 .headers()
13538 .get(http::header::CONTENT_TYPE)
13539 .and_then(|v| v.to_str().ok()),
13540 Some("text/html; charset=utf-8"),
13541 "extensionless generated page must stay text/html, not octet-stream"
13542 );
13543 assert_eq!(
13544 response
13545 .headers()
13546 .get(http::header::CONTENT_ENCODING)
13547 .and_then(|v| v.to_str().ok()),
13548 Some("gzip"),
13549 "generated HTML page must be gzip-compressed"
13550 );
13551 }
13552
13553 #[tokio::test]
13559 async fn ssg_generated_txt_route_is_text_plain_and_compressed() {
13560 let body_text = format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(128));
13561 let tmp =
13562 create_ssg_dist(&[("/robots.txt", "robots.txt/index.html", body_text.as_bytes())]);
13563 let dist = tmp.path().join("dist");
13564
13565 let router = try_build_router_with_static(
13566 Vec::new(),
13567 &compression_enabled_config(),
13568 test_state(),
13569 Some(&dist),
13570 )
13571 .expect("router builds");
13572 let response = router
13573 .oneshot(
13574 Request::builder()
13575 .uri("/robots.txt")
13576 .header("accept-encoding", "gzip")
13577 .body(Body::empty())
13578 .unwrap(),
13579 )
13580 .await
13581 .unwrap();
13582
13583 assert_eq!(response.status(), StatusCode::OK);
13584 assert_eq!(
13585 response
13586 .headers()
13587 .get(http::header::CONTENT_TYPE)
13588 .and_then(|v| v.to_str().ok()),
13589 Some("text/plain; charset=utf-8"),
13590 "generated .txt route must be text/plain, derived from the route extension"
13591 );
13592 assert_eq!(
13593 response
13594 .headers()
13595 .get(http::header::CONTENT_ENCODING)
13596 .and_then(|v| v.to_str().ok()),
13597 Some("gzip"),
13598 "compressible text/plain route must be gzip-compressed"
13599 );
13600 }
13601
13602 #[tokio::test]
13606 async fn ssg_generated_xml_route_is_xml_mime() {
13607 let xml = format!(
13608 "<?xml version=\"1.0\"?><urlset>{}</urlset>",
13609 "<url><loc>https://example.com/</loc></url>".repeat(64)
13610 );
13611 let tmp = create_ssg_dist(&[("/sitemap.xml", "sitemap.xml/index.html", xml.as_bytes())]);
13612 let dist = tmp.path().join("dist");
13613
13614 let router = try_build_router_with_static(
13615 Vec::new(),
13616 &compression_enabled_config(),
13617 test_state(),
13618 Some(&dist),
13619 )
13620 .expect("router builds");
13621 let response = router
13622 .oneshot(
13623 Request::builder()
13624 .uri("/sitemap.xml")
13625 .header("accept-encoding", "gzip")
13626 .body(Body::empty())
13627 .unwrap(),
13628 )
13629 .await
13630 .unwrap();
13631
13632 assert_eq!(response.status(), StatusCode::OK);
13633 assert_eq!(
13634 response
13635 .headers()
13636 .get(http::header::CONTENT_TYPE)
13637 .and_then(|v| v.to_str().ok()),
13638 Some("application/xml"),
13639 "generated .xml route must be application/xml, derived from the route extension"
13640 );
13641 }
13642
13643 #[tokio::test]
13650 async fn ssg_dotted_slug_generated_page_stays_html_and_compressed() {
13651 let html = format!(
13652 "<html><body>{}</body></html>",
13653 "Release notes. ".repeat(128)
13654 );
13655 let tmp = create_ssg_dist(&[(
13656 "/posts/release.v1",
13657 "release.v1/index.html",
13658 html.as_bytes(),
13659 )]);
13660 let dist = tmp.path().join("dist");
13661
13662 let router = try_build_router_with_static(
13663 Vec::new(),
13664 &compression_enabled_config(),
13665 test_state(),
13666 Some(&dist),
13667 )
13668 .expect("router builds");
13669 let response = router
13670 .oneshot(
13671 Request::builder()
13672 .uri("/posts/release.v1")
13673 .header("accept-encoding", "gzip")
13674 .body(Body::empty())
13675 .unwrap(),
13676 )
13677 .await
13678 .unwrap();
13679
13680 assert_eq!(response.status(), StatusCode::OK);
13681 assert_eq!(
13682 response
13683 .headers()
13684 .get(http::header::CONTENT_TYPE)
13685 .and_then(|v| v.to_str().ok()),
13686 Some("text/html; charset=utf-8"),
13687 "dotted-slug generated page must stay text/html, not octet-stream"
13688 );
13689 assert_eq!(
13690 response
13691 .headers()
13692 .get(http::header::CONTENT_ENCODING)
13693 .and_then(|v| v.to_str().ok()),
13694 Some("gzip"),
13695 "dotted-slug generated HTML page must be gzip-compressed"
13696 );
13697 }
13698
13699 #[tokio::test]
13704 async fn ssg_email_slug_generated_page_stays_html() {
13705 let html = format!("<html><body>{}</body></html>", "Profile. ".repeat(64));
13706 let tmp = create_ssg_dist(&[(
13707 "/users/alice@example.com",
13708 "alice@example.com/index.html",
13709 html.as_bytes(),
13710 )]);
13711 let dist = tmp.path().join("dist");
13712
13713 let router = try_build_router_with_static(
13714 Vec::new(),
13715 &compression_enabled_config(),
13716 test_state(),
13717 Some(&dist),
13718 )
13719 .expect("router builds");
13720 let response = router
13721 .oneshot(
13722 Request::builder()
13723 .uri("/users/alice@example.com")
13724 .header("accept-encoding", "gzip")
13725 .body(Body::empty())
13726 .unwrap(),
13727 )
13728 .await
13729 .unwrap();
13730
13731 assert_eq!(response.status(), StatusCode::OK);
13732 assert_eq!(
13733 response
13734 .headers()
13735 .get(http::header::CONTENT_TYPE)
13736 .and_then(|v| v.to_str().ok()),
13737 Some("text/html; charset=utf-8"),
13738 "email-like dotted-slug generated page must stay text/html"
13739 );
13740 }
13741
13742 #[tokio::test]
13753 async fn ssg_recorded_content_type_overrides_route_extension() {
13754 let rss = format!(
13755 "<?xml version=\"1.0\"?><rss>{}</rss>",
13756 "<item><title>Post</title></item>".repeat(64)
13757 );
13758 let tmp = create_ssg_dist_with_types(&[(
13759 "/feed.xml",
13760 "feed.xml/index.html",
13761 rss.as_bytes(),
13762 Some("application/rss+xml"),
13763 )]);
13764 let dist = tmp.path().join("dist");
13765
13766 let router = try_build_router_with_static(
13767 Vec::new(),
13768 &compression_enabled_config(),
13769 test_state(),
13770 Some(&dist),
13771 )
13772 .expect("router builds");
13773 let response = router
13774 .oneshot(
13775 Request::builder()
13776 .uri("/feed.xml")
13777 .header("accept-encoding", "gzip")
13778 .body(Body::empty())
13779 .unwrap(),
13780 )
13781 .await
13782 .unwrap();
13783
13784 assert_eq!(response.status(), StatusCode::OK);
13785 assert_eq!(
13786 response
13787 .headers()
13788 .get(http::header::CONTENT_TYPE)
13789 .and_then(|v| v.to_str().ok()),
13790 Some("application/rss+xml"),
13791 "the manifest's recorded Content-Type must win over route-extension derivation"
13792 );
13793 }
13794
13795 #[tokio::test]
13799 async fn ssg_recorded_content_type_serves_type_outside_the_asset_table() {
13800 let ics = format!(
13801 "BEGIN:VCALENDAR\r\n{}END:VCALENDAR\r\n",
13802 "BEGIN:VEVENT\r\nSUMMARY:Standup\r\nEND:VEVENT\r\n".repeat(64)
13803 );
13804 let tmp = create_ssg_dist_with_types(&[(
13805 "/calendar",
13806 "calendar/index.html",
13807 ics.as_bytes(),
13808 Some("text/calendar; charset=utf-8"),
13809 )]);
13810 let dist = tmp.path().join("dist");
13811
13812 let router = try_build_router_with_static(
13813 Vec::new(),
13814 &compression_enabled_config(),
13815 test_state(),
13816 Some(&dist),
13817 )
13818 .expect("router builds");
13819 let response = router
13820 .oneshot(
13821 Request::builder()
13822 .uri("/calendar")
13823 .header("accept-encoding", "gzip")
13824 .body(Body::empty())
13825 .unwrap(),
13826 )
13827 .await
13828 .unwrap();
13829
13830 assert_eq!(response.status(), StatusCode::OK);
13831 assert_eq!(
13832 response
13833 .headers()
13834 .get(http::header::CONTENT_TYPE)
13835 .and_then(|v| v.to_str().ok()),
13836 Some("text/calendar; charset=utf-8"),
13837 "a recorded type outside the asset table must still be served"
13838 );
13839 }
13840
13841 #[tokio::test]
13845 async fn ssg_recorded_binary_content_type_is_not_compressed() {
13846 let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec();
13847 bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
13848 let tmp = create_ssg_dist_with_types(&[(
13849 "/badge",
13850 "badge/index.html",
13851 &bytes,
13852 Some("image/png"),
13853 )]);
13854 let dist = tmp.path().join("dist");
13855
13856 let router = try_build_router_with_static(
13857 Vec::new(),
13858 &compression_enabled_config(),
13859 test_state(),
13860 Some(&dist),
13861 )
13862 .expect("router builds");
13863 let response = router
13864 .oneshot(
13865 Request::builder()
13866 .uri("/badge")
13867 .header("accept-encoding", "gzip")
13868 .body(Body::empty())
13869 .unwrap(),
13870 )
13871 .await
13872 .unwrap();
13873
13874 assert_eq!(response.status(), StatusCode::OK);
13875 assert_eq!(
13876 response
13877 .headers()
13878 .get(http::header::CONTENT_TYPE)
13879 .and_then(|v| v.to_str().ok()),
13880 Some("image/png"),
13881 "recorded binary type must be honoured on an extensionless route"
13882 );
13883 assert_eq!(
13884 response.headers().get(http::header::CONTENT_ENCODING),
13885 None,
13886 "a recorded binary type must suppress compression just like a derived one"
13887 );
13888 }
13889
13890 #[tokio::test]
13895 async fn ssg_header_illegal_recorded_content_type_falls_back_without_panicking() {
13896 let html = format!("<html><body>{}</body></html>", "About us. ".repeat(128));
13897 let tmp = create_ssg_dist_with_types(&[(
13898 "/about",
13899 "about/index.html",
13900 html.as_bytes(),
13901 Some("text/html\r\nX-Injected: yes"),
13902 )]);
13903 let dist = tmp.path().join("dist");
13904
13905 let router = try_build_router_with_static(
13906 Vec::new(),
13907 &compression_enabled_config(),
13908 test_state(),
13909 Some(&dist),
13910 )
13911 .expect("router builds");
13912 let response = router
13913 .oneshot(
13914 Request::builder()
13915 .uri("/about")
13916 .body(Body::empty())
13917 .unwrap(),
13918 )
13919 .await
13920 .unwrap();
13921
13922 assert_eq!(response.status(), StatusCode::OK);
13923 assert_eq!(
13924 response
13925 .headers()
13926 .get(http::header::CONTENT_TYPE)
13927 .and_then(|v| v.to_str().ok()),
13928 Some("text/html; charset=utf-8"),
13929 "a header-illegal recorded value must fall back to the derived type"
13930 );
13931 assert!(
13932 response.headers().get("x-injected").is_none(),
13933 "a CRLF in the manifest must never become a response header"
13934 );
13935 }
13936
13937 #[tokio::test]
13942 async fn ssg_recorded_woff2_type_suppresses_compression() {
13943 let mut bytes = b"wOF2".to_vec();
13944 bytes.extend((0u32..1024).map(|i| i.wrapping_mul(2_654_435_761).to_le_bytes()[0]));
13945 let tmp = create_ssg_dist_with_types(&[(
13946 "/inter",
13947 "inter/index.html",
13948 &bytes,
13949 Some("font/woff2"),
13950 )]);
13951 let dist = tmp.path().join("dist");
13952
13953 let router = try_build_router_with_static(
13954 Vec::new(),
13955 &compression_enabled_config(),
13956 test_state(),
13957 Some(&dist),
13958 )
13959 .expect("router builds");
13960 let response = router
13961 .oneshot(
13962 Request::builder()
13963 .uri("/inter")
13964 .header("accept-encoding", "gzip")
13965 .body(Body::empty())
13966 .unwrap(),
13967 )
13968 .await
13969 .unwrap();
13970
13971 assert_eq!(response.status(), StatusCode::OK);
13972 assert_eq!(
13973 response
13974 .headers()
13975 .get(http::header::CONTENT_TYPE)
13976 .and_then(|v| v.to_str().ok()),
13977 Some("font/woff2"),
13978 );
13979 assert_eq!(
13980 response.headers().get(http::header::CONTENT_ENCODING),
13981 None,
13982 "a recorded pre-compressed font type must suppress compression that the derived text/html would have allowed"
13983 );
13984 }
13985
13986 #[tokio::test]
13991 async fn ssg_recorded_text_type_enables_compression_derivation_would_refuse() {
13992 let text = "log line ".repeat(256);
13993 let tmp = create_ssg_dist_with_types(&[(
13994 "/data",
13995 "data.bin",
13996 text.as_bytes(),
13997 Some("text/plain; charset=utf-8"),
13998 )]);
13999 let dist = tmp.path().join("dist");
14000
14001 let router = try_build_router_with_static(
14002 Vec::new(),
14003 &compression_enabled_config(),
14004 test_state(),
14005 Some(&dist),
14006 )
14007 .expect("router builds");
14008 let response = router
14009 .oneshot(
14010 Request::builder()
14011 .uri("/data")
14012 .header("accept-encoding", "gzip")
14013 .body(Body::empty())
14014 .unwrap(),
14015 )
14016 .await
14017 .unwrap();
14018
14019 assert_eq!(response.status(), StatusCode::OK);
14020 assert_eq!(
14021 response
14022 .headers()
14023 .get(http::header::CONTENT_TYPE)
14024 .and_then(|v| v.to_str().ok()),
14025 Some("text/plain; charset=utf-8"),
14026 );
14027 assert_eq!(
14028 response
14029 .headers()
14030 .get(http::header::CONTENT_ENCODING)
14031 .and_then(|v| v.to_str().ok()),
14032 Some("gzip"),
14033 "the recorded text type must enable compression the octet-stream derivation would have refused"
14034 );
14035 }
14036
14037 #[tokio::test]
14041 async fn ssg_manifest_route_with_missing_file_falls_through_to_dynamic_router() {
14042 async fn dynamic() -> impl axum::response::IntoResponse {
14043 (
14044 [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
14045 "<h1>dynamic</h1>".to_owned(),
14046 )
14047 }
14048 let route = Route {
14049 method: http::Method::GET,
14050 path: "/feed",
14051 handler: axum::routing::get(dynamic),
14052 name: "feed",
14053 api_doc: crate::openapi::ApiDoc {
14054 method: "GET",
14055 path: "/feed",
14056 operation_id: "feed",
14057 success_status: 200,
14058 ..Default::default()
14059 },
14060 api_version: None,
14061 sunset_opt_out: false,
14062 repository: None,
14063 idempotency: crate::route::RouteIdempotency::default(),
14064 timeout: crate::route::RouteTimeout::default(),
14065 criticality: crate::admission::Criticality::Default,
14066 seo: crate::seo::SeoRouteDefaults::EMPTY,
14067 };
14068
14069 let tmp = create_ssg_dist_with_types(&[(
14070 "/feed",
14071 "feed/index.html",
14072 b"<rss/>",
14073 Some("application/rss+xml"),
14074 )]);
14075 let dist = tmp.path().join("dist");
14076 std::fs::remove_file(dist.join("feed/index.html")).expect("remove generated file");
14078
14079 let router = try_build_router_with_static(
14080 vec![route],
14081 &compression_enabled_config(),
14082 test_state(),
14083 Some(&dist),
14084 )
14085 .expect("router builds");
14086 let response = router
14087 .oneshot(Request::builder().uri("/feed").body(Body::empty()).unwrap())
14088 .await
14089 .unwrap();
14090
14091 assert_eq!(response.status(), StatusCode::OK);
14092 assert_eq!(
14093 response
14094 .headers()
14095 .get(http::header::CONTENT_TYPE)
14096 .and_then(|v| v.to_str().ok()),
14097 Some("text/html; charset=utf-8"),
14098 "a manifest miss on disk must serve the dynamic handler's own type, not the recorded one"
14099 );
14100 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
14101 .await
14102 .unwrap();
14103 assert_eq!(body.as_ref(), b"<h1>dynamic</h1>");
14104 }
14105
14106 #[tokio::test]
14112 async fn ssg_trailing_slash_request_serves_recorded_content_type() {
14113 let body_text = format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(64));
14114 let tmp = create_ssg_dist_with_types(&[(
14115 "/robots.txt",
14116 "robots.txt/index.html",
14117 body_text.as_bytes(),
14118 Some("text/plain; charset=utf-8"),
14119 )]);
14120 let dist = tmp.path().join("dist");
14121
14122 let router = try_build_router_with_static(
14123 Vec::new(),
14124 &compression_enabled_config(),
14125 test_state(),
14126 Some(&dist),
14127 )
14128 .expect("router builds");
14129 let response = router
14130 .oneshot(
14131 Request::builder()
14132 .uri("/robots.txt/")
14133 .body(Body::empty())
14134 .unwrap(),
14135 )
14136 .await
14137 .unwrap();
14138
14139 assert_eq!(response.status(), StatusCode::OK);
14140 assert_eq!(
14141 response
14142 .headers()
14143 .get(http::header::CONTENT_TYPE)
14144 .and_then(|v| v.to_str().ok()),
14145 Some("text/plain; charset=utf-8"),
14146 "the normalized path drives both the manifest lookup and the header"
14147 );
14148 }
14149
14150 #[tokio::test]
14153 async fn ssg_head_request_carries_recorded_content_type() {
14154 let tmp = create_ssg_dist_with_types(&[(
14155 "/feed",
14156 "feed/index.html",
14157 b"<rss/>",
14158 Some("application/rss+xml"),
14159 )]);
14160 let dist = tmp.path().join("dist");
14161
14162 let router = try_build_router_with_static(
14163 Vec::new(),
14164 &compression_enabled_config(),
14165 test_state(),
14166 Some(&dist),
14167 )
14168 .expect("router builds");
14169 let response = router
14170 .oneshot(
14171 Request::builder()
14172 .method(http::Method::HEAD)
14173 .uri("/feed")
14174 .body(Body::empty())
14175 .unwrap(),
14176 )
14177 .await
14178 .unwrap();
14179
14180 assert_eq!(response.status(), StatusCode::OK);
14181 assert_eq!(
14182 response
14183 .headers()
14184 .get(http::header::CONTENT_TYPE)
14185 .and_then(|v| v.to_str().ok()),
14186 Some("application/rss+xml")
14187 );
14188 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
14189 .await
14190 .unwrap();
14191 assert!(body.is_empty(), "HEAD response must have an empty body");
14192 }
14193
14194 #[tokio::test]
14208 #[allow(clippy::too_many_lines)] async fn ssg_generated_manifest_round_trips_content_types_end_to_end() {
14210 async fn robots() -> impl axum::response::IntoResponse {
14211 (
14212 [(http::header::CONTENT_TYPE, "text/plain; charset=utf-8")],
14213 format!("User-agent: *\nDisallow:\n{}", "# note\n".repeat(128)),
14214 )
14215 }
14216 async fn sitemap() -> impl axum::response::IntoResponse {
14220 (
14221 [(http::header::CONTENT_TYPE, "application/xml")],
14222 format!(
14223 "<?xml version=\"1.0\"?><urlset>{}</urlset>",
14224 "<url><loc>https://example.com/</loc></url>".repeat(16)
14225 ),
14226 )
14227 }
14228 async fn release_notes() -> impl axum::response::IntoResponse {
14229 axum::response::Html(format!(
14230 "<html><body>{}</body></html>",
14231 "Release notes. ".repeat(128)
14232 ))
14233 }
14234 async fn feed() -> impl axum::response::IntoResponse {
14235 (
14236 [(http::header::CONTENT_TYPE, "application/rss+xml")],
14237 format!("<rss><channel>{}</channel></rss>", "<item/>".repeat(64)),
14238 )
14239 }
14240 async fn notes() -> impl axum::response::IntoResponse {
14243 (
14244 [(http::header::CONTENT_TYPE, "application/json")],
14245 format!(
14246 r#"{{"notes":[{}]}}"#,
14247 r#""note","#.repeat(32).trim_end_matches(',')
14248 ),
14249 )
14250 }
14251 async fn profile() -> impl axum::response::IntoResponse {
14254 axum::response::Html(format!(
14255 "<html><body>{}</body></html>",
14256 "Alice. ".repeat(128)
14257 ))
14258 }
14259 async fn about() -> impl axum::response::IntoResponse {
14265 "About us. ".repeat(128)
14266 }
14267
14268 fn meta(path: &'static str, name: &'static str) -> crate::static_gen::StaticRouteMeta {
14269 crate::static_gen::StaticRouteMeta {
14270 path,
14271 name,
14272 revalidate: None,
14273 params_fn: None,
14274 seo: crate::seo::SeoRouteDefaults::EMPTY,
14275 content_type: None,
14276 }
14277 }
14278
14279 let build_router = axum::Router::new()
14280 .route("/robots.txt", axum::routing::get(robots))
14281 .route("/sitemap.xml", axum::routing::get(sitemap))
14282 .route("/posts/release.v1", axum::routing::get(release_notes))
14283 .route("/feed", axum::routing::get(feed))
14284 .route("/notes.txt", axum::routing::get(notes))
14285 .route("/users/alice@example.com", axum::routing::get(profile))
14286 .route("/about", axum::routing::get(about));
14287
14288 let tmp = tempfile::tempdir().expect("tempdir");
14289 let dist = tmp.path().join("dist");
14290 crate::static_gen::render_static_routes(
14291 build_router,
14292 &[
14293 meta("/robots.txt", "robots"),
14294 meta("/sitemap.xml", "sitemap"),
14295 meta("/posts/release.v1", "release_notes"),
14296 meta("/feed", "feed"),
14297 meta("/notes.txt", "notes"),
14298 meta("/users/alice@example.com", "profile"),
14299 meta("/about", "about"),
14300 ],
14301 &dist,
14302 )
14303 .await
14304 .expect("static build succeeds");
14305
14306 for file in [
14309 "robots.txt/index.html",
14310 "sitemap.xml/index.html",
14311 "posts/release.v1/index.html",
14312 "feed/index.html",
14313 "notes.txt/index.html",
14314 "users/alice@example.com/index.html",
14315 "about/index.html",
14316 ] {
14317 assert!(dist.join(file).is_file(), "{file} must have been generated");
14318 }
14319
14320 let expected = [
14323 ("/robots.txt", "text/plain; charset=utf-8", true),
14324 ("/sitemap.xml", "application/xml", true),
14325 ("/posts/release.v1", "text/html; charset=utf-8", true),
14326 ("/feed", "application/rss+xml", true),
14327 ("/notes.txt", "application/json", true),
14328 ("/users/alice@example.com", "text/html; charset=utf-8", true),
14329 ("/about", "text/plain; charset=utf-8", true),
14330 ];
14331
14332 let manifest =
14337 crate::static_gen::StaticManifest::load(&dist.join("manifest.json")).expect("manifest");
14338 for (route, content_type, _) in expected {
14339 assert_eq!(
14340 manifest.routes[route].content_type.as_deref(),
14341 Some(content_type),
14342 "{route} must carry its declared type in the manifest"
14343 );
14344 }
14345
14346 for (route, content_type, compressible) in expected {
14347 let router = try_build_router_with_static(
14348 Vec::new(),
14349 &compression_enabled_config(),
14350 test_state(),
14351 Some(&dist),
14352 )
14353 .expect("router builds");
14354 let response = router
14355 .oneshot(
14356 Request::builder()
14357 .uri(route)
14358 .header("accept-encoding", "gzip")
14359 .body(Body::empty())
14360 .unwrap(),
14361 )
14362 .await
14363 .unwrap();
14364
14365 assert_eq!(response.status(), StatusCode::OK, "{route}");
14366 assert_eq!(
14367 response
14368 .headers()
14369 .get(http::header::CONTENT_TYPE)
14370 .and_then(|v| v.to_str().ok()),
14371 Some(content_type),
14372 "{route} must be served as the type its handler declared at build time"
14373 );
14374 assert_eq!(
14375 response
14376 .headers()
14377 .get(http::header::CONTENT_ENCODING)
14378 .and_then(|v| v.to_str().ok())
14379 == Some("gzip"),
14380 compressible,
14381 "{route}: the recorded type must drive compression negotiation"
14382 );
14383 }
14384 }
14385
14386 #[tokio::test]
14390 async fn ssg_dynamic_fallback_route_is_gzip_compressed() {
14391 async fn dynamic() -> impl axum::response::IntoResponse {
14392 (
14393 [(http::header::CONTENT_TYPE, "text/html; charset=utf-8")],
14394 format!(
14395 "<html><body>{}</body></html>",
14396 "dynamic content ".repeat(64)
14397 ),
14398 )
14399 }
14400 let route = Route {
14401 method: http::Method::GET,
14402 path: "/dynamic",
14403 handler: axum::routing::get(dynamic),
14404 name: "dynamic",
14405 api_doc: crate::openapi::ApiDoc {
14406 method: "GET",
14407 path: "/dynamic",
14408 operation_id: "dynamic",
14409 success_status: 200,
14410 ..Default::default()
14411 },
14412 api_version: None,
14413 sunset_opt_out: false,
14414 repository: None,
14415 idempotency: crate::route::RouteIdempotency::default(),
14416 timeout: crate::route::RouteTimeout::default(),
14417 criticality: crate::admission::Criticality::Default,
14418 seo: crate::seo::SeoRouteDefaults::EMPTY,
14419 };
14420
14421 let tmp = create_ssg_dist(&[("/", "index.html", b"<h1>home</h1>")]);
14424 let dist = tmp.path().join("dist");
14425
14426 let router = try_build_router_with_static(
14427 vec![route],
14428 &compression_enabled_config(),
14429 test_state(),
14430 Some(&dist),
14431 )
14432 .expect("router builds");
14433 let response = router
14434 .oneshot(
14435 Request::builder()
14436 .uri("/dynamic")
14437 .header("accept-encoding", "gzip")
14438 .body(Body::empty())
14439 .unwrap(),
14440 )
14441 .await
14442 .unwrap();
14443
14444 assert_eq!(response.status(), StatusCode::OK);
14445 assert_eq!(
14446 response
14447 .headers()
14448 .get(http::header::CONTENT_ENCODING)
14449 .and_then(|v| v.to_str().ok()),
14450 Some("gzip"),
14451 "dynamic fallback route must be compressed just like SSG pages"
14452 );
14453 }
14454
14455 fn create_static_dist(revalidate: Option<u64>) -> tempfile::TempDir {
14456 let dir = tempfile::tempdir().expect("tempdir");
14457 let dist = dir.path().join("dist");
14458 std::fs::create_dir_all(dist.join("about")).expect("mkdir about");
14459 std::fs::write(dist.join("index.html"), b"<h1>Home</h1>").expect("write index");
14460 std::fs::write(dist.join("about/index.html"), b"<h1>About</h1>").expect("write about");
14461
14462 let mut routes = std::collections::HashMap::new();
14463 routes.insert(
14464 "/".to_owned(),
14465 crate::static_gen::ManifestEntry::new("index.html".to_owned()),
14466 );
14467 routes.insert(
14468 "/about".to_owned(),
14469 crate::static_gen::ManifestEntry::new("about/index.html".to_owned())
14470 .with_revalidate(revalidate),
14471 );
14472
14473 let manifest = crate::static_gen::StaticManifest {
14474 generated_at: "2026-05-18T00:00:00Z".to_owned(),
14475 autumn_version: "0.5.0".to_owned(),
14476 routes,
14477 };
14478 let json = serde_json::to_string(&manifest).expect("serialize manifest");
14479 std::fs::write(dist.join("manifest.json"), json).expect("write manifest");
14480 dir
14481 }
14482
14483 #[tokio::test]
14484 async fn static_serving_serves_get_request_inside_user_layers() {
14485 let tmp = create_static_dist(None);
14486 let dist = tmp.path().join("dist");
14487 let config = AutumnConfig::default();
14488
14489 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14490 .expect("router builds");
14491
14492 let response = router
14493 .oneshot(
14494 Request::builder()
14495 .uri("/about")
14496 .body(Body::empty())
14497 .unwrap(),
14498 )
14499 .await
14500 .unwrap();
14501
14502 assert_eq!(response.status(), StatusCode::OK);
14503 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
14504 .await
14505 .unwrap();
14506 assert_eq!(body.as_ref(), b"<h1>About</h1>");
14507 }
14508
14509 #[tokio::test]
14510 async fn static_serving_serves_head_request() {
14511 let tmp = create_static_dist(None);
14512 let dist = tmp.path().join("dist");
14513 let config = AutumnConfig::default();
14514
14515 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14516 .expect("router builds");
14517
14518 let response = router
14519 .oneshot(
14520 Request::builder()
14521 .method("HEAD")
14522 .uri("/about")
14523 .body(Body::empty())
14524 .unwrap(),
14525 )
14526 .await
14527 .unwrap();
14528
14529 assert_eq!(response.status(), StatusCode::OK);
14530 let body = axum::body::to_bytes(response.into_body(), usize::MAX)
14531 .await
14532 .unwrap();
14533 assert!(body.is_empty(), "HEAD response body should be empty");
14534 }
14535
14536 #[tokio::test]
14537 async fn a_markdown_head_of_a_static_page_mirrors_the_get() {
14538 let tmp = create_static_dist(None);
14539 let dist = tmp.path().join("dist");
14540 let config = AutumnConfig::default();
14541 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14542 .expect("router builds");
14543 let send = |method: &str| {
14544 router.clone().oneshot(
14545 Request::builder()
14546 .method(method)
14547 .uri("/about")
14548 .header("accept", "text/markdown")
14549 .body(Body::empty())
14550 .unwrap(),
14551 )
14552 };
14553 let get = send("GET").await.unwrap();
14554 let head = send("HEAD").await.unwrap();
14555 let header = |res: &axum::response::Response, name: &str| {
14556 res.headers()
14557 .get(name)
14558 .map(|v| v.to_str().unwrap().to_owned())
14559 };
14560 assert!(
14561 header(&get, "content-type")
14562 .unwrap()
14563 .starts_with("text/markdown")
14564 );
14565 assert_eq!(header(&head, "content-type"), header(&get, "content-type"));
14566 assert_eq!(
14567 header(&head, "x-markdown-tokens"),
14568 header(&get, "x-markdown-tokens")
14569 );
14570 assert!(header(&head, "x-markdown-tokens").is_some());
14571 let head_length = header(&head, "content-length");
14572 let get_body = axum::body::to_bytes(get.into_body(), usize::MAX)
14573 .await
14574 .unwrap();
14575 assert_eq!(head_length, Some(get_body.len().to_string()));
14577 }
14578
14579 #[tokio::test]
14580 async fn static_serving_normalizes_trailing_slash() {
14581 let tmp = create_static_dist(None);
14582 let dist = tmp.path().join("dist");
14583 let config = AutumnConfig::default();
14584
14585 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14586 .expect("router builds");
14587
14588 let response = router
14589 .oneshot(
14590 Request::builder()
14591 .uri("/about/")
14592 .body(Body::empty())
14593 .unwrap(),
14594 )
14595 .await
14596 .unwrap();
14597
14598 assert_eq!(response.status(), StatusCode::OK);
14599 }
14600
14601 #[tokio::test]
14602 async fn static_serving_falls_through_for_unknown_route() {
14603 let tmp = create_static_dist(None);
14604 let dist = tmp.path().join("dist");
14605 let config = AutumnConfig::default();
14606
14607 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14608 .expect("router builds");
14609
14610 let response = router
14611 .oneshot(
14612 Request::builder()
14613 .uri("/not-in-manifest")
14614 .body(Body::empty())
14615 .unwrap(),
14616 )
14617 .await
14618 .unwrap();
14619
14620 assert_eq!(response.status(), StatusCode::NOT_FOUND);
14621 }
14622
14623 #[tokio::test]
14624 async fn static_serving_skipped_when_no_manifest() {
14625 let tmp = tempfile::tempdir().expect("tempdir");
14626 let dist = tmp.path().join("dist");
14627 std::fs::create_dir_all(&dist).expect("mkdir dist");
14628 let config = AutumnConfig::default();
14629
14630 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14631 .expect("router builds even without manifest");
14632
14633 let response = router
14634 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
14635 .await
14636 .unwrap();
14637
14638 assert_eq!(response.status(), StatusCode::NOT_FOUND);
14639 }
14640
14641 #[tokio::test]
14642 async fn static_serving_with_isr_manifest_builds_successfully() {
14643 let tmp = create_static_dist(Some(3600));
14644 let dist = tmp.path().join("dist");
14645 let config = AutumnConfig::default();
14646
14647 let router = try_build_router_with_static(Vec::new(), &config, test_state(), Some(&dist))
14648 .expect("router with ISR manifest should build");
14649
14650 let response = router
14651 .oneshot(
14652 Request::builder()
14653 .uri("/about")
14654 .body(Body::empty())
14655 .unwrap(),
14656 )
14657 .await
14658 .unwrap();
14659
14660 assert_eq!(response.status(), StatusCode::OK);
14661 }
14662}
14663
14664#[cfg(test)]
14665mod trusted_host_tests {
14666 use super::*;
14667 use axum::body::Body;
14668 use http::Request;
14669 use tower::util::ServiceExt;
14670
14671 #[tokio::test]
14672 async fn trusted_host_allows_matching_and_blocks_nonmatching() {
14673 let mut cfg = AutumnConfig::default();
14674 cfg.security.trusted_hosts.hosts = vec!["example.com".into(), ".example.com".into()];
14675 let state = crate::state::AppState::for_test();
14676 let router = build_router(vec![], &cfg, state);
14677
14678 let ok = router
14679 .clone()
14680 .oneshot(
14681 Request::builder()
14682 .uri("/nope")
14683 .header("host", "api.example.com")
14684 .body(Body::empty())
14685 .unwrap(),
14686 )
14687 .await
14688 .unwrap();
14689 assert_eq!(ok.status(), StatusCode::NOT_FOUND);
14690
14691 let blocked = router
14692 .oneshot(
14693 Request::builder()
14694 .uri("/nope")
14695 .header("host", "evil.com")
14696 .body(Body::empty())
14697 .unwrap(),
14698 )
14699 .await
14700 .unwrap();
14701 assert_eq!(blocked.status(), StatusCode::BAD_REQUEST);
14702 }
14703
14704 #[tokio::test]
14705 async fn trusted_host_wildcard_allows_any_host() {
14706 let mut cfg = AutumnConfig::default();
14707 cfg.security.trusted_hosts.hosts = vec!["*".into()];
14708 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14709 let response = router
14710 .oneshot(
14711 Request::builder()
14712 .uri("/nope")
14713 .header("host", "anything.example")
14714 .body(Body::empty())
14715 .expect("request should build"),
14716 )
14717 .await
14718 .expect("request should complete");
14719 assert_eq!(response.status(), StatusCode::NOT_FOUND);
14720 }
14721
14722 #[tokio::test]
14723 async fn trusted_host_bypasses_probe_paths() {
14724 let mut cfg = AutumnConfig::default();
14725 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14726 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14727 let response = router
14728 .oneshot(
14729 Request::builder()
14730 .uri("/actuator/health")
14731 .header("host", "evil.com")
14732 .body(Body::empty())
14733 .expect("request should build"),
14734 )
14735 .await
14736 .expect("request should complete");
14737 assert_eq!(response.status(), StatusCode::OK);
14738 }
14739
14740 #[tokio::test]
14741 async fn trusted_host_bypasses_actuator_health_path() {
14742 let mut cfg = AutumnConfig::default();
14743 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14744 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14745 let response = router
14746 .oneshot(
14747 Request::builder()
14748 .uri("/actuator/health")
14749 .header("host", "evil.com")
14750 .body(Body::empty())
14751 .expect("request should build"),
14752 )
14753 .await
14754 .expect("request should complete");
14755 assert_eq!(response.status(), StatusCode::OK);
14756 }
14757
14758 #[test]
14765 fn probe_bypass_paths_is_the_single_source_for_trusted_host_and_startup_barrier() {
14766 let mut cfg = AutumnConfig::default();
14767 cfg.health.path = "/custom-health-check".into();
14768 let expected = probe_bypass_paths(&cfg);
14769 assert!(expected.contains(&"/custom-health-check".to_string()));
14770
14771 let trusted_host = TrustedHostPolicy::from_config(&cfg);
14772 for path in &expected {
14773 assert!(
14774 trusted_host.probe_bypass_paths.contains(path),
14775 "TrustedHostPolicy must derive its bypass set from probe_bypass_paths(): missing {path}"
14776 );
14777 }
14778
14779 let state = crate::state::AppState::for_test();
14780 let barrier = StartupBarrierState::from_config(&cfg, &state);
14781 for path in &expected {
14782 assert!(
14783 barrier.allows_path(path),
14784 "StartupBarrierState must derive its bypass set from probe_bypass_paths(): missing {path}"
14785 );
14786 }
14787 }
14788
14789 #[cfg(feature = "http-client")]
14796 #[test]
14797 fn startup_barrier_allows_webhook_replay_post_path() {
14798 let mut cfg = AutumnConfig::default();
14799 cfg.actuator.sensitive = true;
14800 let state = crate::state::AppState::for_test();
14801 let barrier = StartupBarrierState::from_config(&cfg, &state);
14802 let replay_path =
14803 crate::actuator::actuator_route_path(&cfg.actuator.prefix, "/webhooks/replay");
14804 assert!(
14805 barrier.allows_path(&replay_path),
14806 "startup barrier must allow {replay_path} to bypass admission"
14807 );
14808 }
14809
14810 #[tokio::test]
14823 async fn ready_probe_stays_green_while_maintenance_mode_is_active() {
14824 let cfg = AutumnConfig::default();
14825 let state = crate::state::AppState::for_test();
14826 state.probes().mark_startup_complete();
14829 let maintenance = crate::maintenance::MaintenanceState::new();
14830 maintenance.enable(crate::maintenance::MaintenanceConfig::default());
14831 state.insert_extension(maintenance);
14832 let router = build_router(vec![], &cfg, state);
14833
14834 let ready = router
14835 .clone()
14836 .oneshot(
14837 Request::builder()
14838 .uri(&cfg.health.ready_path)
14839 .body(Body::empty())
14840 .unwrap(),
14841 )
14842 .await
14843 .unwrap();
14844 assert_eq!(
14845 ready.status(),
14846 StatusCode::OK,
14847 "/ready must BYPASS maintenance mode: it is the load balancer's health \
14848 signal, and gating it would eject every host from rotation the moment \
14849 maintenance is enabled (#1621)"
14850 );
14851
14852 let app_route = router
14854 .oneshot(
14855 Request::builder()
14856 .uri("/some-app-route")
14857 .body(Body::empty())
14858 .unwrap(),
14859 )
14860 .await
14861 .unwrap();
14862 assert_eq!(
14863 app_route.status(),
14864 StatusCode::SERVICE_UNAVAILABLE,
14865 "maintenance mode must still gate non-probe traffic"
14866 );
14867 }
14868
14869 #[tokio::test]
14870 async fn trusted_host_release_rejects_loopback_unless_listed() {
14871 let mut cfg = AutumnConfig {
14872 profile: Some("prod".into()),
14873 ..AutumnConfig::default()
14874 };
14875 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14876 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14877 let response = router
14878 .oneshot(
14879 Request::builder()
14880 .uri("/nope")
14881 .header("host", "localhost")
14882 .body(Body::empty())
14883 .expect("request should build"),
14884 )
14885 .await
14886 .expect("request should complete");
14887 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
14888 }
14889
14890 #[tokio::test]
14891 async fn trusted_host_uses_uri_authority_when_host_header_missing() {
14892 let mut cfg = AutumnConfig::default();
14893 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14894 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14895 let response = router
14896 .oneshot(
14897 Request::builder()
14898 .uri("http://EXAMPLE.COM/nope")
14899 .body(Body::empty())
14900 .expect("request should build"),
14901 )
14902 .await
14903 .expect("request should complete");
14904 assert_eq!(response.status(), StatusCode::NOT_FOUND);
14905 }
14906
14907 #[tokio::test]
14908 async fn trusted_host_accepts_bracketed_ipv6_loopback_in_dev() {
14909 let cfg = AutumnConfig::default();
14910 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14911 let response = router
14912 .oneshot(
14913 Request::builder()
14914 .uri("/nope")
14915 .header("host", "[::1]:3000")
14916 .body(Body::empty())
14917 .expect("request should build"),
14918 )
14919 .await
14920 .expect("request should complete");
14921 assert_eq!(response.status(), StatusCode::NOT_FOUND);
14922 }
14923
14924 #[tokio::test]
14925 async fn trusted_host_matching_is_case_insensitive() {
14926 let mut cfg = AutumnConfig::default();
14927 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14928 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14929 let response = router
14930 .oneshot(
14931 Request::builder()
14932 .uri("/nope")
14933 .header("host", "EXAMPLE.COM")
14934 .body(Body::empty())
14935 .expect("request should build"),
14936 )
14937 .await
14938 .expect("request should complete");
14939 assert_eq!(response.status(), StatusCode::NOT_FOUND);
14940 }
14941
14942 #[tokio::test]
14943 async fn trusted_host_rejects_malformed_port() {
14944 let mut cfg = AutumnConfig::default();
14945 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14946 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14947 let response = router
14948 .oneshot(
14949 Request::builder()
14950 .uri("/nope")
14951 .header("host", "example.com:abc")
14952 .body(Body::empty())
14953 .expect("request should build"),
14954 )
14955 .await
14956 .expect("request should complete");
14957 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
14958 }
14959
14960 #[tokio::test]
14961 async fn trusted_host_rejects_empty_port_suffix() {
14962 let mut cfg = AutumnConfig::default();
14963 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14964 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14965 let response = router
14966 .oneshot(
14967 Request::builder()
14968 .uri("/nope")
14969 .header("host", "example.com:")
14970 .body(Body::empty())
14971 .expect("request should build"),
14972 )
14973 .await
14974 .expect("request should complete");
14975 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
14976 }
14977
14978 #[tokio::test]
14979 async fn trusted_host_rejects_bracketed_reg_name() {
14980 let mut cfg = AutumnConfig::default();
14981 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
14982 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
14983 let response = router
14984 .oneshot(
14985 Request::builder()
14986 .uri("/nope")
14987 .header("host", "[example.com]")
14988 .body(Body::empty())
14989 .expect("request should build"),
14990 )
14991 .await
14992 .expect("request should complete");
14993 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
14994 }
14995 #[tokio::test]
14996 async fn trusted_host_configured_trailing_dot_matches_normalized_host() {
14997 let mut cfg = AutumnConfig::default();
14998 cfg.security.trusted_hosts.hosts = vec!["example.com.".into()];
14999 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15000 let response = router
15001 .oneshot(
15002 Request::builder()
15003 .uri("/nope")
15004 .header("host", "example.com")
15005 .body(Body::empty())
15006 .expect("request should build"),
15007 )
15008 .await
15009 .expect("request should complete");
15010 assert_eq!(response.status(), StatusCode::NOT_FOUND);
15011 }
15012
15013 #[tokio::test]
15014 async fn trusted_host_accepts_trailing_dot_fqdn() {
15015 let mut cfg = AutumnConfig::default();
15016 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
15017 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15018 let response = router
15019 .oneshot(
15020 Request::builder()
15021 .uri("/nope")
15022 .header("host", "example.com.")
15023 .body(Body::empty())
15024 .expect("request should build"),
15025 )
15026 .await
15027 .expect("request should complete");
15028 assert_eq!(response.status(), StatusCode::NOT_FOUND);
15029 }
15030
15031 #[test]
15035 fn normalize_trusted_host_borrows_an_already_normal_host() {
15036 use std::borrow::Cow;
15037
15038 assert!(matches!(
15039 normalize_trusted_host("example.com"),
15040 Some(Cow::Borrowed("example.com"))
15041 ));
15042 assert!(matches!(
15043 normalize_trusted_host("example.com."),
15044 Some(Cow::Borrowed("example.com"))
15045 ));
15046 assert!(matches!(
15047 normalize_trusted_host("::1"),
15048 Some(Cow::Borrowed("::1"))
15049 ));
15050 assert!(matches!(
15051 normalize_trusted_host("API.Example.COM"),
15052 Some(Cow::Owned(_))
15053 ));
15054 assert_eq!(
15055 normalize_trusted_host("API.Example.COM..").as_deref(),
15056 Some("api.example.com")
15057 );
15058 assert!(normalize_trusted_host("...").is_none());
15059 assert!(normalize_trusted_host("").is_none());
15060 }
15061
15062 #[tokio::test]
15063 async fn trusted_host_mixed_case_trailing_dot_matches_subdomain_rule() {
15064 let mut cfg = AutumnConfig::default();
15065 cfg.security.trusted_hosts.hosts = vec![".example.com".into()];
15066 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15067 let response = router
15068 .oneshot(
15069 Request::builder()
15070 .uri("/nope")
15071 .header("host", "API.Example.COM.")
15072 .body(Body::empty())
15073 .expect("request should build"),
15074 )
15075 .await
15076 .expect("request should complete");
15077 assert_eq!(response.status(), StatusCode::NOT_FOUND);
15078 }
15079
15080 #[tokio::test]
15084 async fn trusted_host_wildcard_still_rejects_dots_only_host() {
15085 let mut cfg = AutumnConfig::default();
15086 cfg.security.trusted_hosts.hosts = vec!["*".into()];
15087 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15088 let response = router
15089 .oneshot(
15090 Request::builder()
15091 .uri("/nope")
15092 .header("host", "...")
15093 .body(Body::empty())
15094 .expect("request should build"),
15095 )
15096 .await
15097 .expect("request should complete");
15098 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
15099 }
15100
15101 #[tokio::test]
15102 async fn trusted_host_bypasses_custom_probe_path_only() {
15103 let mut cfg = AutumnConfig::default();
15104 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
15105 cfg.health.path = "/healthz".into();
15106 cfg.health.startup_path = "/startupz".into();
15107 cfg.health.ready_path = "/readyz".into();
15108 cfg.health.live_path = "/livez".into();
15109 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15110
15111 let bypassed = router
15112 .clone()
15113 .oneshot(
15114 Request::builder()
15115 .uri("/healthz")
15116 .header("host", "evil.com")
15117 .body(Body::empty())
15118 .expect("request should build"),
15119 )
15120 .await
15121 .expect("request should complete");
15122 assert_eq!(bypassed.status(), StatusCode::OK);
15123
15124 let not_bypassed = router
15125 .oneshot(
15126 Request::builder()
15127 .uri("/health")
15128 .header("host", "evil.com")
15129 .body(Body::empty())
15130 .expect("request should build"),
15131 )
15132 .await
15133 .expect("request should complete");
15134 assert_eq!(not_bypassed.status(), StatusCode::BAD_REQUEST);
15135 }
15136
15137 #[tokio::test]
15138 async fn trusted_host_does_not_bypass_non_get_probe_path_requests() {
15139 let mut cfg = AutumnConfig::default();
15140 cfg.security.trusted_hosts.hosts = vec!["example.com".into()];
15141 let router = build_router(vec![], &cfg, crate::state::AppState::for_test());
15142 let response = router
15143 .oneshot(
15144 Request::builder()
15145 .method("POST")
15146 .uri("/health")
15147 .header("host", "evil.com")
15148 .body(Body::empty())
15149 .expect("request should build"),
15150 )
15151 .await
15152 .expect("request should complete");
15153 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
15154 }
15155
15156 #[tokio::test]
15159 async fn apply_upload_middleware_rejects_oversized_json_body() {
15160 let mut config = AutumnConfig::default();
15161 config.security.upload.max_request_size_bytes = 100; let base: axum::Router<AppState> = axum::Router::new().route(
15164 "/data",
15165 axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
15166 );
15167 let router =
15168 apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
15169
15170 let big_body = "x".repeat(200);
15172 let response = router
15173 .oneshot(
15174 Request::builder()
15175 .method("POST")
15176 .uri("/data")
15177 .header("content-type", "application/json")
15178 .body(Body::from(big_body))
15179 .unwrap(),
15180 )
15181 .await
15182 .unwrap();
15183
15184 assert_eq!(
15185 response.status(),
15186 StatusCode::PAYLOAD_TOO_LARGE,
15187 "oversized body must be rejected with 413 regardless of content type"
15188 );
15189 }
15190
15191 #[tokio::test]
15192 async fn apply_upload_middleware_accepts_body_within_limit() {
15193 let mut config = AutumnConfig::default();
15194 config.security.upload.max_request_size_bytes = 1024;
15195
15196 let base: axum::Router<AppState> = axum::Router::new().route(
15197 "/data",
15198 axum::routing::post(|_: axum::body::Bytes| async { "ok" }),
15199 );
15200 let router =
15201 apply_upload_middleware(base, &config).with_state(crate::state::AppState::for_test());
15202
15203 let response = router
15204 .oneshot(
15205 Request::builder()
15206 .method("POST")
15207 .uri("/data")
15208 .header("content-type", "application/json")
15209 .body(Body::from("hello"))
15210 .unwrap(),
15211 )
15212 .await
15213 .unwrap();
15214
15215 assert_eq!(response.status(), StatusCode::OK);
15216 }
15217
15218 fn no_route_timeouts() -> RouteTimeoutTable {
15222 std::sync::Arc::new(std::collections::HashMap::new())
15223 }
15224
15225 fn get_route_timeouts(path: &str, timeout: crate::route::RouteTimeout) -> RouteTimeoutTable {
15228 let mut by_method = std::collections::HashMap::new();
15229 by_method.insert(http::Method::GET, timeout);
15230 let mut table = std::collections::HashMap::new();
15231 table.insert(path.to_owned(), by_method);
15232 std::sync::Arc::new(table)
15233 }
15234
15235 #[tokio::test(start_paused = true)]
15236 async fn request_timeout_returns_503_when_exceeded() {
15237 let mut config = AutumnConfig::default();
15238 config.server.timeouts.request_timeout_ms = Some(100);
15239
15240 let state = crate::state::AppState::for_test();
15241 let router: axum::Router<AppState> = axum::Router::new().route(
15242 "/slow",
15243 axum::routing::get(|| async {
15244 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15246 "ok"
15247 }),
15248 );
15249
15250 let router =
15252 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15253 .layer(RequestIdLayer::default())
15254 .with_state(state);
15255
15256 let response = router
15257 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
15258 .await
15259 .unwrap();
15260
15261 assert_eq!(
15262 response.status(),
15263 StatusCode::SERVICE_UNAVAILABLE,
15264 "a slow handler must trigger 503"
15265 );
15266 assert_eq!(
15267 response
15268 .headers()
15269 .get("content-type")
15270 .and_then(|v| v.to_str().ok()),
15271 Some("application/problem+json"),
15272 "timeout response must use Problem Details content type"
15273 );
15274 }
15275
15276 #[derive(Clone)]
15279 struct LogBuf(Arc<std::sync::Mutex<Vec<u8>>>);
15280 impl std::io::Write for LogBuf {
15281 fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
15282 if let Ok(mut held) = self.0.lock() {
15283 held.extend_from_slice(bytes);
15284 }
15285 Ok(bytes.len())
15286 }
15287 fn flush(&mut self) -> std::io::Result<()> {
15288 Ok(())
15289 }
15290 }
15291 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogBuf {
15292 type Writer = Self;
15293 fn make_writer(&'a self) -> Self::Writer {
15294 self.clone()
15295 }
15296 }
15297 impl LogBuf {
15298 fn install() -> (Self, tracing::subscriber::DefaultGuard) {
15300 let buf = Self(Arc::default());
15301 let subscriber = tracing_subscriber::fmt()
15302 .with_writer(buf.clone())
15303 .with_ansi(false)
15304 .finish();
15305 (buf, tracing::subscriber::set_default(subscriber))
15306 }
15307
15308 fn text(&self) -> String {
15309 String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
15310 }
15311 }
15312
15313 #[tokio::test(start_paused = true)]
15317 async fn request_timeout_warn_names_the_route_template() {
15318 let (logs, _guard) = LogBuf::install();
15319 let mut config = AutumnConfig::default();
15320 config.server.timeouts.request_timeout_ms = Some(100);
15321
15322 let state = crate::state::AppState::for_test();
15323 let router: axum::Router<AppState> = axum::Router::new().route(
15324 "/slow/{id}",
15325 axum::routing::get(|| async {
15326 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15327 "ok"
15328 }),
15329 );
15330 let router =
15331 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15332 .layer(RequestIdLayer::default())
15333 .with_state(state);
15334
15335 let response = router
15336 .oneshot(
15337 Request::builder()
15338 .uri("/slow/7")
15339 .body(Body::empty())
15340 .unwrap(),
15341 )
15342 .await
15343 .unwrap();
15344 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15345 let request_id = response.headers()["x-request-id"].to_str().unwrap();
15346
15347 let out = logs.text();
15348 assert!(out.contains("inbound request exceeded deadline"), "{out}");
15349 assert!(out.contains(r#"route="/slow/{id}""#), "{out}");
15350 assert!(
15351 out.contains(&format!(r#"request_id="{request_id}""#)),
15352 "{out}"
15353 );
15354 assert!(!out.contains("/slow/7"), "{out}");
15355 }
15356
15357 #[tokio::test(start_paused = true)]
15360 async fn zero_caller_deadline_warn_names_the_route_template() {
15361 let (logs, _guard) = LogBuf::install();
15362 let mut config = AutumnConfig::default();
15363 config.server.timeouts.request_timeout_ms = Some(100);
15364 config.server.timeouts.accept_deadline_header = true;
15365
15366 let state = crate::state::AppState::for_test();
15367 let router: axum::Router<AppState> =
15368 axum::Router::new().route("/slow/{id}", axum::routing::get(|| async { "ok" }));
15369 let router =
15370 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15371 .with_state(state);
15372
15373 let response = router
15374 .oneshot(
15375 Request::builder()
15376 .uri("/slow/7")
15377 .header(crate::deadline::DEADLINE_HEADER, "0")
15378 .body(Body::empty())
15379 .unwrap(),
15380 )
15381 .await
15382 .unwrap();
15383 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15384
15385 let out = logs.text();
15386 assert!(out.contains(r#"route="/slow/{id}""#), "{out}");
15387 assert!(!out.contains("/slow/7"), "{out}");
15388 }
15389
15390 #[tokio::test(start_paused = true)]
15393 async fn request_timeout_takes_the_time_left_in_a_fault_scope() {
15394 let mut config = AutumnConfig::default();
15395 config.server.timeouts.request_timeout_ms = Some(1000);
15396
15397 let state = crate::state::AppState::for_test();
15398 let router: axum::Router<AppState> = axum::Router::new().route(
15399 "/slow",
15400 axum::routing::get(|| async {
15401 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
15402 "ok"
15403 }),
15404 );
15405 let router =
15406 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15407 .with_state(state);
15408
15409 let request = Request::builder().uri("/slow").body(Body::empty()).unwrap();
15410 let response = crate::fault_injection::with_request_budget(
15411 std::time::Duration::from_millis(200),
15412 router.oneshot(request),
15413 )
15414 .await
15415 .unwrap();
15416
15417 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15418 }
15419
15420 #[tokio::test(start_paused = true)]
15421 async fn request_timeout_increments_metric() {
15422 let mut config = AutumnConfig::default();
15423 config.server.timeouts.request_timeout_ms = Some(100);
15424
15425 let state = crate::state::AppState::for_test();
15426 let router: axum::Router<AppState> = axum::Router::new().route(
15427 "/slow",
15428 axum::routing::get(|| async {
15429 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15430 "ok"
15431 }),
15432 );
15433
15434 let router =
15435 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15436 .layer(RequestIdLayer::default())
15437 .with_state(state.clone());
15438
15439 router
15440 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
15441 .await
15442 .unwrap();
15443
15444 let snap = state.metrics.snapshot();
15445 assert_eq!(
15446 snap.http.request_timeouts_total, 1,
15447 "autumn_request_timeouts_total must be incremented on timeout"
15448 );
15449 }
15450
15451 #[tokio::test(start_paused = true)]
15452 async fn render_deadline_exempt_marker_skips_timeout() {
15453 let mut config = AutumnConfig::default();
15454 config.server.timeouts.request_timeout_ms = Some(100);
15455
15456 let state = crate::state::AppState::for_test();
15457 let router: axum::Router<AppState> = axum::Router::new().route(
15458 "/slow",
15459 axum::routing::get(|| async {
15460 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15463 "ok"
15464 }),
15465 );
15466
15467 let router =
15468 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15469 .layer(RequestIdLayer::default())
15470 .with_state(state);
15471
15472 let live = router
15474 .clone()
15475 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
15476 .await
15477 .unwrap();
15478 assert_eq!(
15479 live.status(),
15480 StatusCode::SERVICE_UNAVAILABLE,
15481 "a live request to a slow handler must still time out"
15482 );
15483
15484 let exempt = router
15487 .oneshot(
15488 Request::builder()
15489 .uri("/slow")
15490 .extension(crate::static_gen::RenderDeadlineExempt)
15491 .body(Body::empty())
15492 .unwrap(),
15493 )
15494 .await
15495 .unwrap();
15496 assert_eq!(
15497 exempt.status(),
15498 StatusCode::OK,
15499 "the build/ISR render marker must exempt the request from the deadline"
15500 );
15501 }
15502
15503 #[tokio::test(start_paused = true)]
15504 async fn request_timeout_503_mirrors_cors_headers() {
15505 let mut config = AutumnConfig::default();
15506 config.server.timeouts.request_timeout_ms = Some(100);
15507 config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
15509 config.cors.allow_credentials = true;
15510
15511 let state = crate::state::AppState::for_test();
15512 let router: axum::Router<AppState> = axum::Router::new().route(
15513 "/slow",
15514 axum::routing::get(|| async {
15515 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15516 "ok"
15517 }),
15518 );
15519
15520 let router =
15523 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), true)
15524 .layer(RequestIdLayer::default())
15525 .with_state(state);
15526
15527 let response = router
15528 .oneshot(
15529 Request::builder()
15530 .uri("/slow")
15531 .header("origin", "https://app.example.com")
15532 .body(Body::empty())
15533 .unwrap(),
15534 )
15535 .await
15536 .unwrap();
15537
15538 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15539 assert_eq!(
15540 response
15541 .headers()
15542 .get("access-control-allow-origin")
15543 .and_then(|v| v.to_str().ok()),
15544 Some("https://app.example.com"),
15545 "an allowed origin must be reflected on the timeout 503 so browsers can read it"
15546 );
15547 assert_eq!(
15548 response
15549 .headers()
15550 .get("access-control-allow-credentials")
15551 .and_then(|v| v.to_str().ok()),
15552 Some("true"),
15553 "credentials flag must be mirrored when configured"
15554 );
15555 assert!(
15556 response
15557 .headers()
15558 .get_all("vary")
15559 .iter()
15560 .any(|v| v.to_str().is_ok_and(|s| s.eq_ignore_ascii_case("origin"))),
15561 "a reflected origin must carry Vary: origin"
15562 );
15563 }
15564
15565 #[tokio::test(start_paused = true)]
15566 async fn request_timeout_503_omits_cors_for_disallowed_origin() {
15567 let mut config = AutumnConfig::default();
15568 config.server.timeouts.request_timeout_ms = Some(100);
15569 config.cors.allowed_origins = vec!["https://app.example.com".to_owned()];
15570
15571 let state = crate::state::AppState::for_test();
15572 let router: axum::Router<AppState> = axum::Router::new().route(
15573 "/slow",
15574 axum::routing::get(|| async {
15575 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15576 "ok"
15577 }),
15578 );
15579
15580 let router =
15581 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), true)
15582 .layer(RequestIdLayer::default())
15583 .with_state(state);
15584
15585 let response = router
15586 .oneshot(
15587 Request::builder()
15588 .uri("/slow")
15589 .header("origin", "https://evil.example.com")
15590 .body(Body::empty())
15591 .unwrap(),
15592 )
15593 .await
15594 .unwrap();
15595
15596 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15597 assert!(
15598 response
15599 .headers()
15600 .get("access-control-allow-origin")
15601 .is_none(),
15602 "a disallowed origin must not be reflected, mirroring CorsLayer"
15603 );
15604 }
15605
15606 #[tokio::test(start_paused = true)]
15607 async fn request_timeout_response_includes_request_id_header() {
15608 let mut config = AutumnConfig::default();
15609 config.server.timeouts.request_timeout_ms = Some(100);
15610
15611 let state = crate::state::AppState::for_test();
15612 let router: axum::Router<AppState> = axum::Router::new().route(
15613 "/slow",
15614 axum::routing::get(|| async {
15615 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15616 "ok"
15617 }),
15618 );
15619
15620 let router =
15621 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15622 .layer(RequestIdLayer::default())
15623 .with_state(state);
15624
15625 let response = router
15626 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
15627 .await
15628 .unwrap();
15629
15630 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15631 assert!(
15633 response.headers().contains_key("x-request-id"),
15634 "503 response must carry the X-Request-Id header"
15635 );
15636
15637 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
15639 .await
15640 .unwrap();
15641 let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap();
15642 assert_eq!(body["status"], 503);
15643 }
15644
15645 #[tokio::test]
15646 async fn request_timeout_disabled_when_none() {
15647 let config = AutumnConfig::default(); let state = crate::state::AppState::for_test();
15650 let router: axum::Router<AppState> =
15651 axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
15652
15653 let router =
15654 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15655 .with_state(state);
15656
15657 let response = router
15658 .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
15659 .await
15660 .unwrap();
15661
15662 assert_eq!(response.status(), StatusCode::OK);
15663 }
15664
15665 #[tokio::test]
15666 async fn request_timeout_zero_treated_as_disabled() {
15667 let mut config = AutumnConfig::default();
15668 config.server.timeouts.request_timeout_ms = Some(0); let state = crate::state::AppState::for_test();
15671 let router: axum::Router<AppState> =
15672 axum::Router::new().route("/fast", axum::routing::get(|| async { "pong" }));
15673
15674 let router =
15675 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15676 .with_state(state);
15677
15678 let response = router
15679 .oneshot(Request::builder().uri("/fast").body(Body::empty()).unwrap())
15680 .await
15681 .unwrap();
15682
15683 assert_eq!(response.status(), StatusCode::OK);
15684 }
15685
15686 #[tokio::test(start_paused = true)]
15689 async fn request_timeout_503_without_request_id_layer() {
15690 let mut config = AutumnConfig::default();
15691 config.server.timeouts.request_timeout_ms = Some(100);
15692
15693 let state = crate::state::AppState::for_test();
15694 let router: axum::Router<AppState> = axum::Router::new().route(
15695 "/slow",
15696 axum::routing::get(|| async {
15697 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15698 "ok"
15699 }),
15700 );
15701
15702 let router =
15705 apply_request_timeout_middleware(router, &config, &state, no_route_timeouts(), false)
15706 .with_state(state);
15707
15708 let response = router
15709 .oneshot(Request::builder().uri("/slow").body(Body::empty()).unwrap())
15710 .await
15711 .unwrap();
15712
15713 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
15714 }
15715
15716 #[tokio::test(start_paused = true)]
15719 async fn request_timeout_per_route_override_extends_deadline() {
15720 let mut config = AutumnConfig::default();
15721 config.server.timeouts.request_timeout_ms = Some(100); let state = crate::state::AppState::for_test();
15724 let router: axum::Router<AppState> = axum::Router::new().route(
15725 "/export",
15726 axum::routing::get(|| async {
15727 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
15729 "report"
15730 }),
15731 );
15732
15733 let table = get_route_timeouts(
15734 "/export",
15735 crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10)),
15736 );
15737 let router = apply_request_timeout_middleware(router, &config, &state, table, false)
15738 .with_state(state);
15739
15740 let response = router
15741 .oneshot(
15742 Request::builder()
15743 .uri("/export")
15744 .body(Body::empty())
15745 .unwrap(),
15746 )
15747 .await
15748 .unwrap();
15749
15750 assert_eq!(
15751 response.status(),
15752 StatusCode::OK,
15753 "the override must let the slow route complete past the global deadline"
15754 );
15755 }
15756
15757 #[tokio::test(start_paused = true)]
15759 async fn request_timeout_per_route_disabled_exempts_route() {
15760 let mut config = AutumnConfig::default();
15761 config.server.timeouts.request_timeout_ms = Some(100);
15762
15763 let state = crate::state::AppState::for_test();
15764 let router: axum::Router<AppState> = axum::Router::new().route(
15765 "/stream",
15766 axum::routing::get(|| async {
15767 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
15768 "done"
15769 }),
15770 );
15771
15772 let table = get_route_timeouts("/stream", crate::route::RouteTimeout::Disabled);
15773 let router = apply_request_timeout_middleware(router, &config, &state, table, false)
15774 .with_state(state.clone());
15775
15776 let response = router
15777 .oneshot(
15778 Request::builder()
15779 .uri("/stream")
15780 .body(Body::empty())
15781 .unwrap(),
15782 )
15783 .await
15784 .unwrap();
15785
15786 assert_eq!(response.status(), StatusCode::OK);
15787 assert_eq!(
15788 state.metrics.snapshot().http.request_timeouts_total,
15789 0,
15790 "an exempt route must not record a timeout"
15791 );
15792 }
15793
15794 #[tokio::test(start_paused = true)]
15796 async fn request_timeout_override_active_when_global_disabled() {
15797 let config = AutumnConfig::default(); let state = crate::state::AppState::for_test();
15800 let router: axum::Router<AppState> = axum::Router::new().route(
15801 "/export",
15802 axum::routing::get(|| async {
15803 tokio::time::sleep(std::time::Duration::from_secs(60)).await;
15804 "report"
15805 }),
15806 );
15807
15808 let table = get_route_timeouts(
15809 "/export",
15810 crate::route::RouteTimeout::Override(std::time::Duration::from_millis(100)),
15811 );
15812 let router = apply_request_timeout_middleware(router, &config, &state, table, false)
15813 .with_state(state);
15814
15815 let response = router
15816 .oneshot(
15817 Request::builder()
15818 .uri("/export")
15819 .body(Body::empty())
15820 .unwrap(),
15821 )
15822 .await
15823 .unwrap();
15824
15825 assert_eq!(
15826 response.status(),
15827 StatusCode::SERVICE_UNAVAILABLE,
15828 "a per-route override must be enforced even with the global timeout off"
15829 );
15830 }
15831
15832 #[test]
15833 fn build_route_timeout_table_is_empty_without_routes() {
15834 let table = build_route_timeout_table(&[], &[], &AutumnConfig::default());
15838 assert!(table.is_empty(), "no routes ⇒ empty override table");
15839 }
15840
15841 fn timeout_route(
15844 method: http::Method,
15845 path: &'static str,
15846 timeout: crate::route::RouteTimeout,
15847 ) -> Route {
15848 async fn noop() -> &'static str {
15849 "ok"
15850 }
15851 Route {
15852 method,
15853 path,
15854 handler: axum::routing::get(noop),
15855 name: "noop",
15856 api_doc: crate::openapi::ApiDoc::default(),
15857 repository: None,
15858 idempotency: crate::route::RouteIdempotency::Direct,
15859 timeout,
15860 criticality: crate::admission::Criticality::Default,
15861 api_version: None,
15862 sunset_opt_out: false,
15863 seo: crate::seo::SeoRouteDefaults::EMPTY,
15864 }
15865 }
15866
15867 #[test]
15868 fn build_route_timeout_table_normalizes_method_aliases() {
15869 let override_10s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(10));
15870 let routes = vec![
15871 timeout_route(http::Method::GET, "/export", override_10s),
15873 timeout_route(
15876 http::Method::from_bytes(b"WS").unwrap(),
15877 "/live",
15878 crate::route::RouteTimeout::Disabled,
15879 ),
15880 timeout_route(http::Method::POST, "/submit", override_10s),
15882 ];
15883
15884 let table = build_route_timeout_table(&routes, &[], &AutumnConfig::default());
15885
15886 let export = table.get("/export").expect("/export keyed");
15888 assert_eq!(export.get(&http::Method::GET), Some(&override_10s));
15889 assert_eq!(
15890 export.get(&http::Method::HEAD),
15891 Some(&override_10s),
15892 "a GET override must also cover the HEAD alias axum serves"
15893 );
15894
15895 let live = table.get("/live").expect("/live keyed");
15898 assert_eq!(
15899 live.get(&http::Method::GET),
15900 Some(&crate::route::RouteTimeout::Disabled),
15901 "a WS override must be keyed under the GET the upgrade arrives as"
15902 );
15903 assert!(
15904 live.get(&http::Method::from_bytes(b"WS").unwrap())
15905 .is_none(),
15906 "the synthetic WS method is never seen at lookup time"
15907 );
15908
15909 let submit = table.get("/submit").expect("/submit keyed");
15911 assert_eq!(submit.get(&http::Method::POST), Some(&override_10s));
15912 assert!(submit.get(&http::Method::HEAD).is_none());
15913 }
15914
15915 #[test]
15916 fn build_route_timeout_table_keys_scoped_root_by_axum_matched_path() {
15917 let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
15923 let make_group = |prefix: &str| crate::app::ScopedGroup {
15924 prefix: prefix.to_owned(),
15925 routes: vec![timeout_route(http::Method::GET, "/", override_5s)],
15926 source: crate::route_listing::RouteSource::User,
15927 layer_guarded: false,
15928 apply_layer: Box::new(|r| r),
15929 };
15930
15931 let table =
15932 build_route_timeout_table(&[], &[make_group("/api/")], &AutumnConfig::default());
15933 assert_eq!(
15934 table.get("/api/").and_then(|m| m.get(&http::Method::GET)),
15935 Some(&override_5s),
15936 "trailing-slash scoped root must key the override at /api/"
15937 );
15938 assert!(
15939 table.get("/api").is_none(),
15940 "the stripped /api key would never match the runtime lookup"
15941 );
15942
15943 let table = build_route_timeout_table(&[], &[make_group("/api")], &AutumnConfig::default());
15945 assert_eq!(
15946 table.get("/api").and_then(|m| m.get(&http::Method::GET)),
15947 Some(&override_5s),
15948 );
15949 }
15950
15951 #[test]
15952 fn build_route_criticality_table_keys_scoped_routes_and_head() {
15953 let mut shed = timeout_route(
15954 http::Method::GET,
15955 "/export",
15956 crate::route::RouteTimeout::Inherit,
15957 );
15958 shed.criticality = crate::admission::Criticality::Sheddable;
15959 let plain = timeout_route(
15960 http::Method::POST,
15961 "/export",
15962 crate::route::RouteTimeout::Inherit,
15963 );
15964 let group = crate::app::ScopedGroup {
15965 prefix: "/admin".to_owned(),
15966 routes: vec![{
15967 let mut r = timeout_route(
15968 http::Method::POST,
15969 "/pay",
15970 crate::route::RouteTimeout::Inherit,
15971 );
15972 r.criticality = crate::admission::Criticality::Critical;
15973 r
15974 }],
15975 source: crate::route_listing::RouteSource::User,
15976 layer_guarded: false,
15977 apply_layer: Box::new(|r| r),
15978 };
15979 let table =
15980 build_route_criticality_table(&[shed, plain], &[group], &AutumnConfig::default());
15981 let get = |p: &str, m: http::Method| table.get(p).and_then(|t| t.get(&m)).copied();
15982 assert_eq!(
15983 get("/export", http::Method::GET),
15984 Some(crate::admission::Criticality::Sheddable)
15985 );
15986 assert_eq!(
15987 get("/export", http::Method::HEAD),
15988 Some(crate::admission::Criticality::Sheddable),
15989 "a GET route is also served for HEAD"
15990 );
15991 assert_eq!(
15992 get("/export", http::Method::POST),
15993 None,
15994 "default gets no entry"
15995 );
15996 assert_eq!(
15997 get("/admin/pay", http::Method::POST),
15998 Some(crate::admission::Criticality::Critical)
15999 );
16000 assert!(
16001 build_route_criticality_table(&[], &[], &AutumnConfig::default()).is_empty(),
16002 "no criticality routes: empty table, so a passive layer"
16003 );
16004 }
16005
16006 #[test]
16011 fn timeout_off_only_routes_install_the_service_for_adaptive_admission() {
16012 let mut by_method = std::collections::HashMap::new();
16013 by_method.insert(http::Method::GET, crate::route::RouteTimeout::Disabled);
16014 let mut table = std::collections::HashMap::new();
16015 table.insert("/poll".to_owned(), by_method);
16016 let table: RouteTimeoutTable = Arc::new(table);
16017 let state = AppState::for_test();
16018 let build = |config: &AutumnConfig| {
16019 build_request_timeout_settings(config, &state, table.clone(), false).is_some()
16020 };
16021
16022 let mut config = AutumnConfig::default();
16023 config.server.timeouts.request_timeout_ms = None;
16024 assert!(!build(&config), "static admission: nothing to do");
16025
16026 config.server.admission.mode = crate::config::AdmissionMode::Adaptive;
16027 assert!(build(&config), "adaptive admission needs the Off marker");
16028 }
16029
16030 #[cfg(feature = "i18n")]
16036 #[test]
16037 fn build_route_timeout_table_expands_entries_under_each_locale_prefix() {
16038 let disabled = crate::route::RouteTimeout::Disabled;
16039 let routes = vec![timeout_route(http::Method::GET, "/events", disabled)];
16040 let mut config = AutumnConfig::default();
16041 config.i18n.locale_prefix_enabled = true;
16042 config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
16043
16044 let table = build_route_timeout_table(&routes, &[], &config);
16045
16046 for path in ["/events", "/en/events", "/es/events"] {
16047 assert_eq!(
16048 table.get(path).and_then(|m| m.get(&http::Method::GET)),
16049 Some(&disabled),
16050 "expected a timeout override at {path}"
16051 );
16052 }
16053 }
16054
16055 #[cfg(feature = "i18n")]
16058 #[test]
16059 fn build_route_timeout_table_does_not_expand_excluded_routes() {
16060 let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
16061 let routes = vec![timeout_route(http::Method::GET, "/api/keys", override_5s)];
16062 let mut config = AutumnConfig::default();
16063 config.i18n.locale_prefix_enabled = true;
16064 config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
16065 config.i18n.locale_prefix_exclude = vec!["/api".to_owned()];
16066
16067 let table = build_route_timeout_table(&routes, &[], &config);
16068
16069 assert_eq!(
16070 table
16071 .get("/api/keys")
16072 .and_then(|m| m.get(&http::Method::GET)),
16073 Some(&override_5s)
16074 );
16075 assert!(
16076 table.get("/en/api/keys").is_none(),
16077 "an excluded route must not gain a locale-prefixed timeout entry"
16078 );
16079 }
16080
16081 #[cfg(feature = "i18n")]
16084 #[test]
16085 fn build_route_timeout_table_does_not_expand_when_locale_prefix_disabled() {
16086 let override_5s = crate::route::RouteTimeout::Override(std::time::Duration::from_secs(5));
16087 let routes = vec![timeout_route(http::Method::GET, "/events", override_5s)];
16088 let mut config = AutumnConfig::default();
16089 assert!(!config.i18n.locale_prefix_enabled);
16090 config.i18n.supported_locales = vec!["en".to_owned(), "es".to_owned()];
16091
16092 let table = build_route_timeout_table(&routes, &[], &config);
16093
16094 assert_eq!(table.len(), 1, "only the bare-path entry should exist");
16095 assert!(table.get("/en/events").is_none());
16096 }
16097
16098 fn redirect_gate_registration() -> crate::app::CustomLayerRegistration {
16105 let gate = axum::middleware::from_fn(
16106 |req: axum::extract::Request, next: axum::middleware::Next| async move {
16107 if req.headers().contains_key("x-authed") {
16108 next.run(req).await
16109 } else {
16110 http::Response::builder()
16111 .status(StatusCode::FOUND)
16112 .header(http::header::LOCATION, "/login")
16113 .body(Body::empty())
16114 .unwrap()
16115 }
16116 },
16117 );
16118 crate::app::CustomLayerRegistration {
16119 type_id: std::any::TypeId::of::<()>(),
16120 type_name: "redirect_gate",
16121 layer: tower::util::BoxCloneSyncServiceLayer::new(gate),
16122 }
16123 }
16124
16125 #[test]
16134 fn static_render_partition_drains_user_layers() {
16135 let (kept, drained) =
16136 partition_custom_layers_for_static_render(vec![redirect_gate_registration()]);
16137 assert!(
16138 kept.is_empty(),
16139 "user layers must not survive the static-render drain"
16140 );
16141 assert_eq!(drained.len(), 1);
16142 assert_eq!(drained[0].type_name, "redirect_gate");
16143 }
16144
16145 #[cfg(feature = "i18n")]
16154 #[test]
16155 fn static_render_partition_keeps_the_ambient_locale_layer() {
16156 let ambient = crate::app::CustomLayerRegistration {
16157 type_id: std::any::TypeId::of::<crate::i18n::AmbientLocaleLayer>(),
16158 type_name: "ambient_locale",
16159 layer: tower::util::BoxCloneSyncServiceLayer::new(axum::middleware::from_fn(
16160 |req: axum::extract::Request, next: axum::middleware::Next| async move {
16161 next.run(req).await
16162 },
16163 )),
16164 };
16165 let bundle_ext = crate::app::CustomLayerRegistration {
16166 type_id: std::any::TypeId::of::<axum::Extension<Arc<crate::i18n::Bundle>>>(),
16167 type_name: "i18n_bundle_extension",
16168 layer: tower::util::BoxCloneSyncServiceLayer::new(axum::middleware::from_fn(
16169 |req: axum::extract::Request, next: axum::middleware::Next| async move {
16170 next.run(req).await
16171 },
16172 )),
16173 };
16174 let (kept, drained) = partition_custom_layers_for_static_render(vec![
16175 redirect_gate_registration(),
16176 bundle_ext,
16177 ambient,
16178 ]);
16179 assert_eq!(
16180 kept.len(),
16181 2,
16182 "the ambient-locale layer and the i18n bundle extension must stay"
16183 );
16184 assert_eq!(kept[0].type_name, "i18n_bundle_extension");
16185 assert_eq!(kept[1].type_name, "ambient_locale");
16186 assert_eq!(drained.len(), 1, "the user layer must drain");
16187 assert_eq!(drained[0].type_name, "redirect_gate");
16188 }
16189
16190 fn build_cached_dist(marker: &str) -> (tempfile::TempDir, std::path::PathBuf) {
16194 let tmp = tempfile::tempdir().expect("tempdir");
16195 let dist = tmp.path().join("dist");
16196 std::fs::create_dir_all(&dist).expect("create dist");
16197 std::fs::write(dist.join("index.html"), marker).expect("write index.html");
16198 let mut routes = std::collections::HashMap::new();
16199 routes.insert(
16200 "/".to_owned(),
16201 crate::static_gen::ManifestEntry::new("index.html".to_owned()),
16202 );
16203 let manifest = crate::static_gen::StaticManifest {
16204 generated_at: "2026-06-14T00:00:00Z".to_owned(),
16205 autumn_version: "0.3.0".to_owned(),
16206 routes,
16207 };
16208 std::fs::write(
16209 dist.join("manifest.json"),
16210 serde_json::to_string(&manifest).expect("serialize manifest"),
16211 )
16212 .expect("write manifest");
16213 (tmp, dist)
16214 }
16215
16216 fn ctx_with_static_gate(gate: crate::app::CustomLayerRegistration) -> RouterContext {
16217 RouterContext {
16218 exception_filters: Vec::new(),
16219 scoped_groups: Vec::new(),
16220 merge_routers: Vec::new(),
16221 nest_routers: Vec::new(),
16222 declared_routes: Vec::new(),
16223 custom_layers: Vec::new(),
16224 static_gate_layers: vec![gate],
16225 #[cfg(feature = "maud")]
16226 error_page_renderer: None,
16227 session_store: None,
16228 #[cfg(feature = "openapi")]
16229 openapi: None,
16230 #[cfg(feature = "mcp")]
16231 mcp: None,
16232 #[cfg(feature = "webmcp")]
16233 webmcp: None,
16234 }
16235 }
16236
16237 #[tokio::test]
16240 async fn route_fault_reaches_a_cached_static_page() {
16241 let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
16242 let mut config = AutumnConfig {
16243 profile: Some("staging".to_owned()),
16244 ..AutumnConfig::default()
16245 };
16246 config.fault_injection.enabled = true;
16247 config.fault_injection.faults = vec![crate::fault_injection::FaultRule::new(
16248 crate::fault_injection::FaultTarget::Route,
16249 crate::fault_injection::FaultKind::Error,
16250 1.0,
16251 )];
16252 let state = crate::state::AppState::for_test();
16253 let app = super::try_build_router_with_static_inner(
16254 Vec::new(),
16255 &config,
16256 state.clone(),
16257 Some(dist.as_path()),
16258 ctx_with_static_gate(redirect_gate_registration()),
16259 )
16260 .expect("router builds");
16261
16262 let response = app
16263 .oneshot(
16264 Request::builder()
16265 .uri("/")
16266 .header("x-authed", "1")
16267 .body(Body::empty())
16268 .unwrap(),
16269 )
16270 .await
16271 .unwrap();
16272 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
16273 let faults = state
16274 .extension::<crate::fault_injection::FaultInjection>()
16275 .expect("the handle is installed");
16276 assert_eq!(faults.snapshot().injected, 1, "one fault, not two");
16277 }
16278
16279 #[tokio::test]
16280 async fn static_gate_runs_before_cached_static_page() {
16281 let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
16285 let config = AutumnConfig::default();
16286 let ctx = ctx_with_static_gate(redirect_gate_registration());
16287
16288 let app = super::try_build_router_with_static_inner(
16289 Vec::new(),
16290 &config,
16291 crate::state::AppState::for_test(),
16292 Some(dist.as_path()),
16293 ctx,
16294 )
16295 .expect("router builds");
16296
16297 let unauthed = app
16299 .clone()
16300 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
16301 .await
16302 .unwrap();
16303 assert_eq!(
16304 unauthed.status(),
16305 StatusCode::FOUND,
16306 "static_gate must redirect before the cached page is served"
16307 );
16308 assert_eq!(
16309 unauthed.headers().get(http::header::LOCATION).unwrap(),
16310 "/login"
16311 );
16312
16313 let authed = app
16315 .oneshot(
16316 Request::builder()
16317 .uri("/")
16318 .header("x-authed", "1")
16319 .body(Body::empty())
16320 .unwrap(),
16321 )
16322 .await
16323 .unwrap();
16324 assert_eq!(authed.status(), StatusCode::OK);
16325 let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
16326 .await
16327 .unwrap();
16328 assert!(
16329 String::from_utf8_lossy(&body).contains("cached"),
16330 "authenticated request should receive the cached page"
16331 );
16332 }
16333
16334 #[tokio::test]
16335 async fn static_gate_runs_in_dynamic_mode() {
16336 async fn dynamic_handler() -> &'static str {
16340 "dynamic"
16341 }
16342 let route = Route {
16343 method: http::Method::GET,
16344 path: "/",
16345 handler: axum::routing::get(dynamic_handler),
16346 name: "root",
16347 api_doc: crate::openapi::ApiDoc {
16348 method: "GET",
16349 path: "/",
16350 operation_id: "root",
16351 success_status: 200,
16352 ..Default::default()
16353 },
16354 repository: None,
16355 idempotency: crate::route::RouteIdempotency::Direct,
16356 timeout: crate::route::RouteTimeout::Inherit,
16357 criticality: crate::admission::Criticality::Default,
16358 seo: crate::seo::SeoRouteDefaults::EMPTY,
16359 api_version: None,
16360 sunset_opt_out: false,
16361 };
16362 let config = AutumnConfig::default();
16363 let ctx = ctx_with_static_gate(redirect_gate_registration());
16364
16365 let app = super::try_build_router_with_static_inner(
16366 vec![route],
16367 &config,
16368 crate::state::AppState::for_test(),
16369 None,
16370 ctx,
16371 )
16372 .expect("router builds");
16373
16374 let unauthed = app
16375 .clone()
16376 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
16377 .await
16378 .unwrap();
16379 assert_eq!(unauthed.status(), StatusCode::FOUND);
16380
16381 let authed = app
16382 .oneshot(
16383 Request::builder()
16384 .uri("/")
16385 .header("x-authed", "1")
16386 .body(Body::empty())
16387 .unwrap(),
16388 )
16389 .await
16390 .unwrap();
16391 assert_eq!(authed.status(), StatusCode::OK);
16392 let body = axum::body::to_bytes(authed.into_body(), usize::MAX)
16393 .await
16394 .unwrap();
16395 assert_eq!(String::from_utf8_lossy(&body), "dynamic");
16396 }
16397
16398 #[cfg(feature = "mcp")]
16408 fn deny_unless_api_key_for_secret_path_registration() -> crate::app::CustomLayerRegistration {
16409 let gate = axum::middleware::from_fn(
16410 |req: axum::extract::Request, next: axum::middleware::Next| async move {
16411 let protected = req.uri().path() == "/secret";
16412 let has_credential = req.headers().get("x-api-key").and_then(|v| v.to_str().ok())
16413 == Some("secret123");
16414 if !protected || has_credential {
16415 next.run(req).await
16416 } else {
16417 http::Response::builder()
16418 .status(StatusCode::UNAUTHORIZED)
16419 .body(Body::empty())
16420 .unwrap()
16421 }
16422 },
16423 );
16424 crate::app::CustomLayerRegistration {
16425 type_id: std::any::TypeId::of::<()>(),
16426 type_name: "deny_unless_api_key_for_secret_path",
16427 layer: tower::util::BoxCloneSyncServiceLayer::new(gate),
16428 }
16429 }
16430
16431 #[cfg(feature = "mcp")]
16435 fn build_empty_dist() -> (tempfile::TempDir, std::path::PathBuf) {
16436 let tmp = tempfile::tempdir().expect("tempdir");
16437 let dist = tmp.path().join("dist");
16438 std::fs::create_dir_all(&dist).expect("create dist");
16439 let manifest = crate::static_gen::StaticManifest {
16440 generated_at: "2026-09-07T00:00:00Z".to_owned(),
16441 autumn_version: "0.3.0".to_owned(),
16442 routes: std::collections::HashMap::new(),
16443 };
16444 std::fs::write(
16445 dist.join("manifest.json"),
16446 serde_json::to_string(&manifest).expect("serialize manifest"),
16447 )
16448 .expect("write manifest");
16449 (tmp, dist)
16450 }
16451
16452 #[cfg(feature = "mcp")]
16491 #[tokio::test]
16492 #[allow(clippy::too_many_lines)]
16493 async fn custom_layer_protects_mcp_dispatch_in_static_mode() {
16494 async fn secret_handler() -> StatusCode {
16495 StatusCode::NO_CONTENT
16496 }
16497 let route = Route {
16498 method: http::Method::GET,
16499 path: "/secret",
16500 handler: axum::routing::get(secret_handler),
16501 name: "secret_tool",
16502 api_doc: crate::openapi::ApiDoc {
16503 method: "GET",
16504 path: "/secret",
16505 operation_id: "secret_tool",
16506 success_status: 204,
16507 ..Default::default()
16508 },
16509 repository: None,
16510 idempotency: crate::route::RouteIdempotency::Direct,
16511 timeout: crate::route::RouteTimeout::Inherit,
16512 criticality: crate::admission::Criticality::Default,
16513 seo: crate::seo::SeoRouteDefaults::EMPTY,
16514 api_version: None,
16515 sunset_opt_out: false,
16516 };
16517
16518 let mut config = AutumnConfig::default();
16519 config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
16520
16521 let ctx = RouterContext {
16522 exception_filters: Vec::new(),
16523 scoped_groups: Vec::new(),
16524 merge_routers: Vec::new(),
16525 nest_routers: Vec::new(),
16526 declared_routes: Vec::new(),
16527 custom_layers: vec![deny_unless_api_key_for_secret_path_registration()],
16528 static_gate_layers: Vec::new(),
16529 #[cfg(feature = "maud")]
16530 error_page_renderer: None,
16531 session_store: None,
16532 #[cfg(feature = "openapi")]
16533 openapi: None,
16534 #[cfg(feature = "mcp")]
16535 mcp: Some(crate::mcp::McpRuntime {
16536 expose_all: true,
16537 ..crate::mcp::McpRuntime::new("/mcp")
16538 }),
16539 #[cfg(feature = "webmcp")]
16540 webmcp: None,
16541 };
16542
16543 let (_tmp, dist) = build_empty_dist();
16544
16545 let app = super::try_build_router_with_static_inner(
16546 vec![route],
16547 &config,
16548 crate::state::AppState::for_test(),
16549 Some(dist.as_path()),
16550 ctx,
16551 )
16552 .expect("router builds");
16553
16554 let direct = app
16557 .clone()
16558 .oneshot(
16559 Request::builder()
16560 .uri("/secret")
16561 .header("host", "app.example")
16562 .body(Body::empty())
16563 .unwrap(),
16564 )
16565 .await
16566 .unwrap();
16567 assert_eq!(
16568 direct.status(),
16569 StatusCode::UNAUTHORIZED,
16570 "a direct request without the credential must be rejected by the custom layer"
16571 );
16572
16573 let mcp_resp = app
16580 .oneshot(
16581 Request::builder()
16582 .method("POST")
16583 .uri("/mcp")
16584 .header("host", "app.example")
16585 .header("content-type", "application/json")
16586 .body(Body::from(
16587 serde_json::json!({
16588 "jsonrpc": "2.0",
16589 "id": 1,
16590 "method": "tools/call",
16591 "params": {"name": "secret_tool", "arguments": {}}
16592 })
16593 .to_string(),
16594 ))
16595 .unwrap(),
16596 )
16597 .await
16598 .unwrap();
16599 assert_eq!(mcp_resp.status(), StatusCode::OK);
16600 let body = axum::body::to_bytes(mcp_resp.into_body(), usize::MAX)
16601 .await
16602 .unwrap();
16603 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
16604 assert_eq!(
16605 json["result"]["isError"], true,
16606 "the custom AppBuilder::layer() gate that protects the direct route \
16607 must also protect the MCP tools/call replay in static/ISR mode — an \
16608 unauthenticated tool call must not reach the handler: {json}"
16609 );
16610 let text = json["result"]["content"][0]["text"].as_str().unwrap_or("");
16611 assert!(
16612 text.contains("401") || text.to_ascii_lowercase().contains("unauthorized"),
16613 "the tool error should surface the layer's 401 rejection: {json}"
16614 );
16615 }
16616
16617 #[cfg(feature = "mcp")]
16635 #[tokio::test]
16636 async fn custom_layer_runs_exactly_once_per_tools_call_in_static_mode() {
16637 async fn secret_handler() -> StatusCode {
16638 StatusCode::NO_CONTENT
16639 }
16640 use std::sync::atomic::{AtomicUsize, Ordering};
16641 let counter = std::sync::Arc::new(AtomicUsize::new(0));
16642 let counter2 = counter.clone();
16643 let gate = axum::middleware::from_fn(
16644 move |req: axum::extract::Request, next: axum::middleware::Next| {
16645 let counter = counter2.clone();
16646 async move {
16647 counter.fetch_add(1, Ordering::SeqCst);
16648 next.run(req).await
16649 }
16650 },
16651 );
16652 let registration = crate::app::CustomLayerRegistration {
16653 type_id: std::any::TypeId::of::<()>(),
16654 type_name: "counter",
16655 layer: tower::util::BoxCloneSyncServiceLayer::new(gate),
16656 };
16657 let route = Route {
16658 method: http::Method::GET,
16659 path: "/secret",
16660 handler: axum::routing::get(secret_handler),
16661 name: "secret_tool",
16662 api_doc: crate::openapi::ApiDoc {
16663 method: "GET",
16664 path: "/secret",
16665 operation_id: "secret_tool",
16666 success_status: 204,
16667 ..Default::default()
16668 },
16669 repository: None,
16670 idempotency: crate::route::RouteIdempotency::Direct,
16671 timeout: crate::route::RouteTimeout::Inherit,
16672 criticality: crate::admission::Criticality::Default,
16673 seo: crate::seo::SeoRouteDefaults::EMPTY,
16674 api_version: None,
16675 sunset_opt_out: false,
16676 };
16677 let mut config = AutumnConfig::default();
16678 config.security.trusted_hosts.hosts = vec!["app.example".to_owned()];
16679 let ctx = RouterContext {
16680 exception_filters: Vec::new(),
16681 scoped_groups: Vec::new(),
16682 merge_routers: Vec::new(),
16683 nest_routers: Vec::new(),
16684 declared_routes: Vec::new(),
16685 custom_layers: vec![registration],
16686 static_gate_layers: Vec::new(),
16687 #[cfg(feature = "maud")]
16688 error_page_renderer: None,
16689 session_store: None,
16690 #[cfg(feature = "openapi")]
16691 openapi: None,
16692 #[cfg(feature = "mcp")]
16693 mcp: Some(crate::mcp::McpRuntime {
16694 expose_all: true,
16695 ..crate::mcp::McpRuntime::new("/mcp")
16696 }),
16697 #[cfg(feature = "webmcp")]
16698 webmcp: None,
16699 };
16700 let (_tmp, dist) = build_empty_dist();
16701 let app = super::try_build_router_with_static_inner(
16702 vec![route],
16703 &config,
16704 crate::state::AppState::for_test(),
16705 Some(dist.as_path()),
16706 ctx,
16707 )
16708 .expect("router builds");
16709
16710 let _resp = app
16711 .oneshot(
16712 Request::builder()
16713 .method("POST")
16714 .uri("/mcp")
16715 .header("host", "app.example")
16716 .header("content-type", "application/json")
16717 .body(Body::from(
16718 serde_json::json!({
16719 "jsonrpc": "2.0",
16720 "id": 1,
16721 "method": "tools/call",
16722 "params": {"name": "secret_tool", "arguments": {}}
16723 })
16724 .to_string(),
16725 ))
16726 .unwrap(),
16727 )
16728 .await
16729 .unwrap();
16730
16731 assert_eq!(
16732 counter.load(Ordering::SeqCst),
16733 1,
16734 "a custom layer protecting a route must run exactly once per \
16735 tools/call, matching a direct HTTP request — not once for the \
16736 live /mcp envelope and once again for the dispatch replay"
16737 );
16738 }
16739
16740 #[tokio::test]
16741 async fn static_gate_redirect_carries_security_headers_ssg() {
16742 let (_tmp, dist) = build_cached_dist("<h1>cached</h1>");
16745 let config = AutumnConfig::default();
16746 let ctx = ctx_with_static_gate(redirect_gate_registration());
16747
16748 let app = super::try_build_router_with_static_inner(
16749 Vec::new(),
16750 &config,
16751 crate::state::AppState::for_test(),
16752 Some(dist.as_path()),
16753 ctx,
16754 )
16755 .expect("router builds");
16756
16757 let unauthed = app
16758 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
16759 .await
16760 .unwrap();
16761 assert_eq!(unauthed.status(), StatusCode::FOUND);
16762 assert_eq!(
16765 unauthed
16766 .headers()
16767 .get("x-content-type-options")
16768 .expect("gate redirect must carry security headers"),
16769 "nosniff"
16770 );
16771 }
16772
16773 #[tokio::test]
16774 async fn static_gate_redirect_carries_security_headers_dynamic() {
16775 async fn dynamic_handler() -> &'static str {
16779 "dynamic"
16780 }
16781 let route = Route {
16782 method: http::Method::GET,
16783 path: "/",
16784 handler: axum::routing::get(dynamic_handler),
16785 name: "root",
16786 api_doc: crate::openapi::ApiDoc {
16787 method: "GET",
16788 path: "/",
16789 operation_id: "root",
16790 success_status: 200,
16791 ..Default::default()
16792 },
16793 repository: None,
16794 idempotency: crate::route::RouteIdempotency::Direct,
16795 timeout: crate::route::RouteTimeout::Inherit,
16796 criticality: crate::admission::Criticality::Default,
16797 seo: crate::seo::SeoRouteDefaults::EMPTY,
16798 api_version: None,
16799 sunset_opt_out: false,
16800 };
16801 let config = AutumnConfig::default();
16802 let ctx = ctx_with_static_gate(redirect_gate_registration());
16803
16804 let app = super::try_build_router_with_static_inner(
16805 vec![route],
16806 &config,
16807 crate::state::AppState::for_test(),
16808 None,
16809 ctx,
16810 )
16811 .expect("router builds");
16812
16813 let unauthed = app
16814 .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
16815 .await
16816 .unwrap();
16817 assert_eq!(unauthed.status(), StatusCode::FOUND);
16818 assert_eq!(
16819 unauthed
16820 .headers()
16821 .get("x-content-type-options")
16822 .expect("dynamic gate redirect must carry security headers"),
16823 "nosniff"
16824 );
16825 }
16826
16827 #[test]
16828 fn static_gate_layer_requires_fail_closed_idempotency() {
16829 let gate = vec![redirect_gate_registration()];
16833 assert!(super::custom_layers_require_fail_closed_idempotency(&gate));
16834 assert!(!super::custom_layers_require_fail_closed_idempotency(&[]));
16836 }
16837
16838 #[test]
16867 fn converted_ingress_middleware_futures_are_never_boxed() {
16868 type Inner = tower::util::ServiceFn<
16872 fn(
16873 axum::extract::Request,
16874 )
16875 -> std::future::Ready<Result<axum::response::Response, std::convert::Infallible>>,
16876 >;
16877 type Fut<Svc> = <Svc as tower::Service<axum::extract::Request>>::Future;
16878
16879 fn assert_unboxed<Svc: tower::Service<axum::extract::Request>>(what: &str) {
16880 let name = std::any::type_name::<Fut<Svc>>();
16881 assert!(
16882 !name.contains("Box"),
16883 "{what} must return a named, unboxed future — an \
16884 `axum::middleware::from_fn`-shaped `Box::pin` here is one heap \
16885 allocation on every request (issue #2214). Got: {name}"
16886 );
16887 }
16888
16889 assert_unboxed::<super::TrustedHostService<Inner>>("TrustedHostService");
16890 assert_unboxed::<super::StartupBarrierService<Inner>>("StartupBarrierService");
16891 assert_unboxed::<super::RequestTimeoutService<Inner>>("RequestTimeoutService");
16892 assert_unboxed::<crate::assets::AssetCacheControlService<Inner>>(
16893 "AssetCacheControlService",
16894 );
16895 assert_unboxed::<crate::events::EventAppContextService<Inner>>("EventAppContextService");
16896 assert_unboxed::<crate::read_your_writes::ReadYourWritesService<Inner>>(
16897 "ReadYourWritesService",
16898 );
16899 assert_unboxed::<crate::middleware::method_override::MethodOverrideRejectionService<Inner>>(
16900 "MethodOverrideRejectionService",
16901 );
16902 assert_unboxed::<crate::aeo::negotiate::NegotiateService<Inner>>("NegotiateService");
16903 #[cfg(feature = "oauth2")]
16904 assert_unboxed::<super::HttpInterceptorService<Inner>>("HttpInterceptorService");
16905 }
16906
16907 #[test]
16923 fn webhook_replay_cleanup_boxes_only_its_rare_release_branch() {
16924 use std::mem::size_of;
16925
16926 type Ready = std::future::Ready<Result<axum::response::Response, std::convert::Infallible>>;
16927 type Scoped = tokio::task::futures::TaskLocalFuture<crate::webhook::ReplayStoreCell, Ready>;
16928
16929 let serving_inline = size_of::<Scoped>()
16930 + size_of::<crate::webhook::ReplayStoreCell>()
16931 + size_of::<crate::webhook::ReplayKeyGuard>();
16932 let whole = size_of::<crate::webhook::WebhookReplayCleanupFuture<Ready>>();
16933 assert!(
16934 whole >= serving_inline,
16935 "WebhookReplayCleanupFuture must hold the scoped inner future, the \
16936 replay cell and the drop guard inline in its `Serving` variant \
16937 ({serving_inline} bytes together), but the whole future is only \
16938 {whole} bytes — the inner future has been boxed, which costs an \
16939 allocation on every request rather than only on a failed webhook \
16940 delivery (issue #2214)"
16941 );
16942 }
16943
16944 #[tokio::test]
16952 async fn startup_barrier_refuses_requests_until_startup_completes() {
16953 use tower::{Layer, ServiceExt};
16954
16955 fn ok_service() -> impl tower::Service<
16956 axum::extract::Request,
16957 Response = axum::response::Response,
16958 Error = std::convert::Infallible,
16959 > + Clone {
16960 tower::service_fn(|_req: axum::extract::Request| async move {
16961 Ok::<_, std::convert::Infallible>("handler ran".into_response())
16962 })
16963 }
16964
16965 #[allow(clippy::future_not_send)]
16969 async fn status_for(startup_complete: bool, path: &str) -> (StatusCode, String) {
16970 let state = AppState::for_test().with_startup_complete(startup_complete);
16971 let config = AutumnConfig::default();
16972 let layer = super::StartupBarrierLayer::new(super::StartupBarrierState::from_config(
16973 &config, &state,
16974 ));
16975 let response = layer
16976 .layer(ok_service())
16977 .oneshot(
16978 axum::extract::Request::builder()
16979 .uri(path)
16980 .body(Body::empty())
16981 .expect("request builds"),
16982 )
16983 .await
16984 .expect("infallible");
16985 let status = response.status();
16986 let body = axum::body::to_bytes(response.into_body(), 64 * 1024)
16987 .await
16988 .expect("body collects");
16989 (status, String::from_utf8_lossy(&body).into_owned())
16990 }
16991
16992 let (status, body) = status_for(false, "/anything").await;
16993 assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
16994 assert_eq!(body, "Service is still starting up");
16995
16996 let (status, body) = status_for(true, "/anything").await;
16999 assert_eq!(status, StatusCode::OK);
17000 assert_eq!(body, "handler ran");
17001
17002 let (status, _) = status_for(false, "/actuator/health").await;
17005 assert_ne!(
17006 status,
17007 StatusCode::SERVICE_UNAVAILABLE,
17008 "probe paths must bypass the startup barrier"
17009 );
17010 }
17011}
17012#[derive(Clone, Debug)]
17013pub struct TrustedHostPolicy {
17014 rules: Arc<Vec<String>>,
17015 allow_any: bool,
17016 allow_missing_host: bool,
17017 probe_bypass_paths: Arc<std::collections::HashSet<String>>,
17018 custom_domains: Option<crate::state::LateExtensions>,
17030}
17031
17032impl TrustedHostPolicy {
17033 pub fn from_config(config: &AutumnConfig) -> Self {
17034 let mut rules: Vec<String> = config
17035 .security
17036 .trusted_hosts
17037 .hosts
17038 .iter()
17039 .map(|h| h.trim().to_ascii_lowercase())
17040 .map(|h| h.trim_end_matches('.').to_owned())
17041 .filter(|h| !h.is_empty())
17042 .collect();
17043 let is_production = matches!(config.profile.as_deref(), Some("prod" | "production"));
17044 if !is_production {
17045 rules.extend(
17046 ["localhost", "127.0.0.1", "::1"]
17047 .into_iter()
17048 .map(std::borrow::ToOwned::to_owned),
17049 );
17050 }
17051 let allow_any = rules.iter().any(|h| h == "*");
17052 let probe_bypass_paths = probe_bypass_paths(config).into_iter().collect();
17053 Self {
17054 rules: Arc::new(rules),
17055 allow_any,
17056 allow_missing_host: !is_production,
17057 probe_bypass_paths: Arc::new(probe_bypass_paths),
17058 custom_domains: None,
17059 }
17060 }
17061
17062 pub(crate) fn from_config_with_state(config: &AutumnConfig, state: &AppState) -> Self {
17069 Self {
17070 custom_domains: Some(state.late_extensions()),
17071 ..Self::from_config(config)
17072 }
17073 }
17074
17075 #[cfg(feature = "mcp")]
17082 pub const fn allows_missing_host(&self) -> bool {
17083 self.allow_missing_host
17084 }
17085
17086 pub fn allows_host(&self, host: &str) -> bool {
17087 if self.allow_any {
17088 return true;
17089 }
17090 let matches_rule = self.rules.iter().any(|rule| {
17091 rule.strip_prefix('.').map_or_else(
17092 || host == rule,
17093 |suffix| {
17094 host == suffix
17095 || host
17096 .strip_suffix(suffix)
17097 .is_some_and(|prefix| prefix.ends_with('.'))
17098 },
17099 )
17100 });
17101 matches_rule || self.is_connected_domain(host)
17102 }
17103
17104 fn is_connected_domain(&self, host: &str) -> bool {
17111 self.custom_domains.as_ref().is_some_and(|extensions| {
17112 extensions
17113 .get::<Arc<crate::custom_domain::CustomDomainRegistry>>()
17114 .is_some_and(|registry| registry.is_servable(host))
17115 })
17116 }
17117}
17118
17119#[derive(Clone, Debug)]
17121pub struct RouteVersionMetadata {
17122 pub version: String,
17123 pub sunset_opt_out: bool,
17124 pub secured: bool,
17125 pub required_roles: &'static [&'static str],
17126 pub has_policy: bool,
17127}
17128
17129async fn api_versioning_middleware(
17131 state: axum::extract::State<AppState>,
17132 route_version: Option<axum::extract::Extension<RouteVersionMetadata>>,
17133 request: axum::http::Request<axum::body::Body>,
17134 next: axum::middleware::Next,
17135) -> axum::response::Response {
17136 let Some(axum::extract::Extension(meta)) = route_version else {
17137 return next.run(request).await;
17138 };
17139
17140 let clock = state.clock();
17141 let now = clock.now();
17142
17143 let versions = state.extension::<crate::app::RegisteredApiVersions>();
17144 let matching_version = versions
17145 .as_ref()
17146 .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
17147
17148 let Some(version) = matching_version else {
17149 return next.run(request).await;
17150 };
17151
17152 let is_deprecated = version.deprecated_at.is_some_and(|d| now >= d);
17153 let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
17154
17155 if is_sunset && !meta.sunset_opt_out {
17156 if meta.has_policy {
17157 return next.run(request).await;
17158 }
17159 if meta.secured {
17160 let session = request.extensions().get::<crate::session::Session>();
17161 let mut auth_failed = false;
17162 let mut auth_error = None;
17163 if let Some(session) = session {
17164 if let Err(err) = crate::auth::__check_secured_with_key(
17165 session,
17166 state.auth_session_key(),
17167 meta.required_roles,
17168 )
17169 .await
17170 {
17171 auth_failed = true;
17172 auth_error = Some(err);
17173 }
17174 } else {
17175 auth_failed = true;
17176 auth_error = Some(crate::error::AutumnError::unauthorized_msg(
17177 "authentication required",
17178 ));
17179 }
17180 if auth_failed {
17181 return auth_error.unwrap().into_response();
17182 }
17183 }
17184
17185 let err = crate::error::AutumnError::gone_msg(format!(
17186 "API version '{}' has been sunsetted.",
17187 meta.version
17188 ));
17189 let mut response = err.into_response();
17190 if let Some(sunset) = version.sunset_at {
17191 let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
17192 if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
17193 response.headers_mut().insert("Sunset", val);
17194 }
17195 }
17196 let deprecation_date = match (version.deprecated_at, version.sunset_at) {
17197 (Some(d), Some(s)) => Some(d.min(s)),
17198 (d, s) => d.or(s),
17199 };
17200 if let Some(date) = deprecation_date {
17201 let timestamp = date.timestamp();
17202 if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
17203 response.headers_mut().insert("Deprecation", val);
17204 }
17205 }
17206 return response;
17207 }
17208
17209 let mut response = next.run(request).await;
17210
17211 if is_deprecated || is_sunset {
17212 let deprecation_date = match (version.deprecated_at, version.sunset_at) {
17213 (Some(d), Some(s)) => Some(d.min(s)),
17214 (d, s) => d.or(s),
17215 };
17216 if let Some(date) = deprecation_date {
17217 let timestamp = date.timestamp();
17218 if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
17219 response.headers_mut().insert("Deprecation", val);
17220 }
17221 }
17222 }
17223 if let Some(sunset) = version.sunset_at.filter(|_| is_deprecated || is_sunset) {
17224 let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
17225 if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
17226 response.headers_mut().insert("Sunset", val);
17227 }
17228 }
17229
17230 response
17231}
17232
17233#[must_use]
17236pub fn check_sunset(
17237 state: &crate::state::AppState,
17238 meta: &RouteVersionMetadata,
17239) -> Option<axum::response::Response> {
17240 let clock = state.clock();
17241 let now = clock.now();
17242
17243 let versions = state.extension::<crate::app::RegisteredApiVersions>();
17244 let matching_version = versions
17245 .as_ref()
17246 .and_then(|v| v.0.iter().find(|av| av.version == meta.version));
17247
17248 let version = matching_version?;
17249 let is_sunset = version.sunset_at.is_some_and(|s| now >= s);
17250
17251 if is_sunset && !meta.sunset_opt_out {
17252 let err = crate::error::AutumnError::gone_msg(format!(
17253 "API version '{}' has been sunsetted.",
17254 meta.version
17255 ));
17256 let mut response = axum::response::IntoResponse::into_response(err);
17257 if let Some(sunset) = version.sunset_at {
17258 let http_date = sunset.format("%a, %d %b %Y %H:%M:%S GMT").to_string();
17259 if let Ok(val) = axum::http::HeaderValue::from_str(&http_date) {
17260 response.headers_mut().insert("Sunset", val);
17261 }
17262 }
17263 let deprecation_date = match (version.deprecated_at, version.sunset_at) {
17264 (Some(d), Some(s)) => Some(d.min(s)),
17265 (d, s) => d.or(s),
17266 };
17267 if let Some(date) = deprecation_date {
17268 let timestamp = date.timestamp();
17269 if let Ok(val) = axum::http::HeaderValue::from_str(&format!("@{timestamp}")) {
17270 response.headers_mut().insert("Deprecation", val);
17271 }
17272 }
17273 return Some(response);
17274 }
17275
17276 None
17277}
17278
17279#[cfg(all(test, feature = "htmx"))]
17280mod framework_scripts_tests {
17281 use super::*;
17282 use http::StatusCode;
17283 use http_body_util::BodyExt;
17284 use tower::ServiceExt as _;
17285
17286 async fn get(path: &str, if_none_match: Option<&str>) -> axum::response::Response {
17287 let mut request = http::Request::builder().uri(path);
17288 if let Some(tag) = if_none_match {
17289 request = request.header(http::header::IF_NONE_MATCH, tag);
17290 }
17291 framework_scripts_router()
17292 .with_state(AppState::for_test())
17293 .oneshot(request.body(axum::body::Body::empty()).unwrap())
17294 .await
17295 .unwrap()
17296 }
17297
17298 fn header(response: &axum::response::Response, name: http::HeaderName) -> &str {
17299 response
17300 .headers()
17301 .get(name)
17302 .and_then(|v| v.to_str().ok())
17303 .unwrap_or("")
17304 }
17305
17306 #[tokio::test]
17311 async fn plain_paths_revalidate_with_a_weak_etag() {
17312 for (path, bytes) in [
17313 (crate::htmx::HTMX_JS_PATH, crate::htmx::HTMX_JS),
17314 (crate::htmx::HTMX_SSE_JS_PATH, crate::htmx::HTMX_SSE_JS),
17315 (crate::htmx::IDIOMORPH_JS_PATH, crate::htmx::IDIOMORPH_JS),
17316 (
17317 crate::htmx::AUTUMN_WIDGETS_JS_PATH,
17318 crate::htmx::AUTUMN_WIDGETS_JS,
17319 ),
17320 (
17321 crate::htmx::HTMX_CSRF_JS_PATH,
17322 crate::htmx::HTMX_CSRF_JS.as_bytes(),
17323 ),
17324 ] {
17325 let response = get(path, None).await;
17326 assert_eq!(response.status(), StatusCode::OK, "{path}");
17327 assert_eq!(
17328 header(&response, http::header::CONTENT_TYPE),
17329 "text/javascript; charset=utf-8",
17330 "{path}"
17331 );
17332 let cc = header(&response, http::header::CACHE_CONTROL);
17333 assert!(cc.contains("must-revalidate"), "{path}: {cc}");
17334 assert!(!cc.contains("immutable"), "{path}: {cc}");
17335 let etag = header(&response, http::header::ETAG);
17336 assert!(etag.starts_with("W/\""), "{path}: {etag}");
17337 let body = response.into_body().collect().await.unwrap().to_bytes();
17338 assert_eq!(&body[..], bytes, "{path}");
17339 }
17340 }
17341
17342 #[tokio::test]
17345 async fn fingerprinted_paths_are_immutable_and_resolved_by_asset_url() {
17346 let mut seen = 0;
17347 for asset in crate::htmx::framework_scripts() {
17348 seen += 1;
17349 assert_eq!(
17350 crate::assets::asset_url(asset.logical_path()),
17351 asset.url(),
17352 "asset_url must return the hashed URL"
17353 );
17354 assert_ne!(asset.url(), asset.plain_url());
17355 let response = get(asset.url(), None).await;
17356 assert_eq!(response.status(), StatusCode::OK, "{}", asset.url());
17357 assert_eq!(
17358 header(&response, http::header::CACHE_CONTROL),
17359 "public, max-age=31536000, immutable"
17360 );
17361 let body = response.into_body().collect().await.unwrap().to_bytes();
17362 assert_eq!(&body[..], asset.bytes());
17363 }
17364 let expected = 5 + usize::from(cfg!(feature = "webmcp"));
17366 assert_eq!(
17367 seen, expected,
17368 "htmx, sse, idiomorph, csrf helper, widgets (+ webmcp)"
17369 );
17370 }
17371
17372 #[tokio::test]
17373 async fn revalidation_with_the_current_etag_is_not_modified() {
17374 let first = get(crate::htmx::HTMX_JS_PATH, None).await;
17375 let etag = header(&first, http::header::ETAG).to_owned();
17376 let second = get(crate::htmx::HTMX_JS_PATH, Some(&etag)).await;
17377 assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
17378 let body = second.into_body().collect().await.unwrap().to_bytes();
17379 assert!(body.is_empty());
17380 }
17381}
17382
17383#[cfg(test)]
17384mod proptests {
17385 use super::*;
17390 use proptest::prelude::*;
17391
17392 proptest! {
17393 #![proptest_config(ProptestConfig::with_cases(256))]
17394
17395 #[test]
17399 fn join_nested_path_root_child_is_identity(prefix in "/?[a-z0-9/]{0,20}", root in prop::sample::select(vec!["/", ""])) {
17400 let once = join_nested_path(&prefix, root);
17401 let expected = if prefix.is_empty() { "/".to_owned() } else { prefix };
17402 prop_assert_eq!(&once, &expected);
17403 let twice = join_nested_path(&once, root);
17404 prop_assert_eq!(once, twice);
17405 }
17406
17407 #[test]
17410 fn join_nested_path_no_double_slash_at_seam(prefix in "/[a-z0-9]{1,8}/?", child in "/[a-z0-9]{1,8}") {
17411 let joined = join_nested_path(&prefix, &child);
17412 prop_assert!(!joined.contains("//"), "unexpected `//` in {joined:?}");
17413 }
17414
17415 #[test]
17420 fn extract_host_without_port_never_panics(header in ".*") {
17421 if let Some(host) = extract_host_without_port(&header) {
17422 prop_assert!(header.contains(host));
17423 }
17424 }
17425
17426 #[test]
17429 fn path_matches_route_prefix_reflexive(path in ".*") {
17430 prop_assert!(path_matches_route_prefix(&path, &path));
17431 }
17432
17433 #[test]
17437 fn path_matches_route_prefix_boundary(path in "/?[a-z0-9/]{0,24}", prefix in "/?[a-z0-9/]{0,24}") {
17438 if path_matches_route_prefix(&path, &prefix) {
17439 let boundary_ok = path == prefix
17440 || path.strip_prefix(&prefix).is_some_and(|rest| rest.starts_with('/'));
17441 prop_assert!(boundary_ok, "match without boundary: path={path:?} prefix={prefix:?}");
17442 }
17443 }
17444 }
17445
17446 #[cfg(feature = "openapi")]
17449 proptest! {
17450 #![proptest_config(ProptestConfig::with_cases(256))]
17451
17452 #[test]
17453 fn extract_path_params_never_panics(path in ".*") {
17454 for name in extract_path_params(&path) {
17455 prop_assert!(!name.is_empty());
17456 let has_brace = name.contains('{') || name.contains('}');
17457 prop_assert!(!has_brace, "param name should be brace-free: {name:?}");
17458 }
17459 }
17460
17461 #[test]
17470 fn extract_path_params_brace_inputs_are_brace_free(path in "[{}a-z:]{0,6}") {
17471 for name in extract_path_params(&path) {
17472 prop_assert!(!name.is_empty());
17473 let has_brace = name.contains('{') || name.contains('}');
17474 prop_assert!(!has_brace, "param name should be brace-free for {path:?}: {name:?}");
17475 }
17476 }
17477 }
17478}