diff --git a/CHANGELOG.md b/CHANGELOG.md index 518b032a0..ab6b32f96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +### Axum buffered JSON limits and body API + +Axum now defaults framework-managed JSON buffering to 2 MiB, exactly 2,097,152 bytes. +Set `EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES` for standalone hosting, or use the fallible +`with_json_body_limit_bytes` builder on `AxumRunOptions` or `AxumDevServer`. Explicit +application caps may tighten the ceiling. Overflow is typed HTTP 413; malformed JSON remains +400. Taken streams, forwarding, manual collection, and other providers' defaults are unchanged. + +Public `Body` adds an opaque `Managed` variant and is now `#[non_exhaustive]`. Both changes +break downstream exhaustive matches. Prefer logical-content accessors or match the exhaustive +`BodyContent` returned by `Body::into_content` for transport branching. Raw `Body` matches +outside core require a wildcard. Native policy and cache provenance survive context reconstruction +independently of mutable headers and extensions; transport handoff removes that metadata. + ### Outbound content encoding `ContentEncoding::Passthrough` is now `Passthrough(PassthroughReason)`, and the public decoder diff --git a/Cargo.lock b/Cargo.lock index fbb4e8fd3..54d394296 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -689,6 +689,7 @@ dependencies = [ "hyper", "hyper-util", "log", + "mime", "redb", "reqwest", "serde", diff --git a/Cargo.toml b/Cargo.toml index c7ae41443..659c493b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,7 @@ log = "0.4" miniz_oxide = { version = "=0.8.9", default-features = false, features = ["simd", "with-alloc"] } log-fastly = "=0.13.1" matchit = "0.9" +mime = "0.3" once_cell = "1" redb = "4.1.0" reqwest = { version = "=0.13.4", default-features = false, features = ["rustls", "blocking", "json"] } diff --git a/crates/edgezero-adapter-axum/Cargo.toml b/crates/edgezero-adapter-axum/Cargo.toml index 231b7a81c..d4fe4084f 100644 --- a/crates/edgezero-adapter-axum/Cargo.toml +++ b/crates/edgezero-adapter-axum/Cargo.toml @@ -21,6 +21,7 @@ axum = [ "dep:http-body", "dep:hyper", "dep:hyper-util", + "dep:mime", "dep:reqwest", "dep:redb", ] @@ -51,6 +52,7 @@ http-body = { workspace = true, optional = true } hyper = { workspace = true, optional = true } hyper-util = { workspace = true, optional = true } log = { workspace = true } +mime = { workspace = true, optional = true } redb = { workspace = true, optional = true } reqwest = { workspace = true, optional = true } serde = { workspace = true, optional = true } diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 1fb0abc04..4fabce192 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -7,6 +7,8 @@ reason = "compatibility entrypoints, normalization, native ownership and startup policy remain grouped by responsibility" )] +use std::env::vars_os; +use std::ffi::OsString; use std::fs; use std::future; use std::future::Future; @@ -19,7 +21,7 @@ use std::str::FromStr as _; use std::sync::Arc; pub use crate::run_options::AxumRunOptions; -use crate::run_options::{failure, shutdown_grace}; +use crate::run_options::{JsonBodyLimit, failure, shutdown_grace}; use anyhow::Context as _; use edgezero_core::probe::LifecyclePhase; use std::time::Duration; @@ -133,6 +135,7 @@ struct Stores { /// Blocking dev server runner used by the `EdgeZero` CLI. pub struct AxumDevServer { config: AxumDevServerConfig, + json_body_limit: JsonBodyLimit, router: RouterService, stores: Stores, } @@ -143,6 +146,7 @@ impl AxumDevServer { pub fn new(router: RouterService) -> Self { Self { config: AxumDevServerConfig::default(), + json_body_limit: JsonBodyLimit::DEFAULT, router, stores: Stores::default(), } @@ -156,13 +160,14 @@ impl AxumDevServer { router, config, stores, + json_body_limit, } = self; run_local( ListenerSource::Bind(config.addr), config.enable_ctrl_c, Duration::from_secs(10), future::pending::<()>(), - async move { prepare_transport(App::new(router), stores.into()) }, + async move { prepare_transport(App::new(router), stores.into(), json_body_limit) }, ) } @@ -172,8 +177,16 @@ impl AxumDevServer { router, config, stores, + json_body_limit, } = self; - serve_with_stores(App::new(router), listener, config.enable_ctrl_c, stores).await + serve_with_stores( + App::new(router), + listener, + config.enable_ctrl_c, + stores, + json_body_limit, + ) + .await } #[must_use] @@ -181,11 +194,22 @@ impl AxumDevServer { pub fn with_config(router: RouterService, config: AxumDevServerConfig) -> Self { Self { config, + json_body_limit: JsonBodyLimit::DEFAULT, router, stores: Stores::default(), } } + /// Selects the ceiling for framework-managed JSON buffering, in bytes. + /// + /// # Errors + /// Rejects zero and values above the collector's representable capacity. + #[inline] + pub fn with_json_body_limit_bytes(mut self, limit: usize) -> anyhow::Result { + self.json_body_limit = JsonBodyLimit::new(limit)?; + Ok(self) + } + #[must_use] #[inline] pub fn with_config_registry(mut self, registry: ConfigRegistry) -> Self { @@ -354,6 +378,7 @@ async fn serve_with_stores( listener: TokioTcpListener, enable_ctrl_c: bool, stores: Stores, + json_body_limit: JsonBodyLimit, ) -> anyhow::Result<()> { struct ShutdownOnDrop(Option>); @@ -376,6 +401,7 @@ async fn serve_with_stores( native_listener, enable_ctrl_c, stores, + json_body_limit, shutdown_receiver, ) }); @@ -390,6 +416,7 @@ fn serve_local( std_listener: StdTcpListener, enable_ctrl_c: bool, stores: Stores, + json_body_limit: JsonBodyLimit, shutdown_receiver: ShutdownReceiver<()>, ) -> anyhow::Result<()> { run_local( @@ -399,11 +426,11 @@ fn serve_local( async move { let _closed = shutdown_receiver.await; }, - async move { prepare_transport(app, stores.into()) }, + async move { prepare_transport(app, stores.into(), json_body_limit) }, ) } -type PreparedApp = (App, PreparedStores, Arc); +type PreparedApp = (App, PreparedStores, Arc, JsonBodyLimit); enum ListenerSource { Bind(SocketAddr), @@ -432,10 +459,14 @@ fn adopt_listener(source: ListenerSource) -> anyhow::Result { .map_err(|_error| failure("bind", "listener", "runtime adoption failed")) } -fn prepare_transport(app: App, stores: PreparedStores) -> anyhow::Result { +fn prepare_transport( + app: App, + stores: PreparedStores, + json_body_limit: JsonBodyLimit, +) -> anyhow::Result { let transport = AxumOutboundClient::try_transport() .map_err(|_error| failure("transport", "HTTP client", "initialization failed"))?; - Ok((app, stores, transport)) + Ok((app, stores, transport, json_body_limit)) } struct Signals { @@ -557,8 +588,9 @@ where } prepared = &mut prepare => prepared?, }; - let (app, stores, transport) = prepared; - let mut service = AxumServiceState::with_transport(app, transport).with_phase(reader.clone()); + let (app, stores, transport, json_body_limit) = prepared; + let mut service = AxumServiceState::with_transport(app, transport, json_body_limit) + .with_phase(reader.clone()); if let Some(registry) = stores.config { service = service.with_config_registry(registry); } @@ -677,7 +709,7 @@ fn build_app_for_dispatch() -> anyhow::Result { /// binding, or connection serving fails. #[inline] pub fn run_app() -> anyhow::Result<()> { - let env = EnvConfig::from_env(); + let (env, json_body_limit) = capture_dev_environment(vars_os())?; let grace = shutdown_grace(&env)?; let resolution = resolve_addr(&env); run_local( @@ -701,11 +733,40 @@ pub fn run_app() -> anyhow::Result<()> { config: build_config_registry(metadata.config, &env), secrets: build_secret_registry(metadata.secrets, &env), }; - prepare_transport(app, stores) + prepare_transport(app, stores, json_body_limit) }, ) } +fn capture_dev_environment(vars: I) -> anyhow::Result<(EnvConfig, JsonBodyLimit)> +where + I: IntoIterator, +{ + let mut pairs = Vec::new(); + for (os_key, os_value) in vars { + let Some(key) = os_key.to_str() else { + continue; + }; + let recognized = key + .strip_prefix("EDGEZERO__") + .is_some_and(|path| path.eq_ignore_ascii_case("ADAPTER__JSON_BODY_LIMIT_BYTES")); + match os_value.to_str() { + Some(value) => pairs.push((key.to_owned(), value.to_owned())), + None if recognized => { + return Err(failure( + "settings", + "JSON_BODY_LIMIT_BYTES", + "non-Unicode value", + )); + } + None => {} + } + } + let env = EnvConfig::from_vars(pairs); + let limit = JsonBodyLimit::from_config(&env)?; + Ok((env, limit)) +} + fn init_logging(level: LevelFilter) { if !A::owns_logging() { let _logger_init = SimpleLogger::new().with_level(level).init(); @@ -829,7 +890,7 @@ where ); failure("initializer", "application", &category) })?; - Ok((app, stores, transport)) + Ok((app, stores, transport, options.json_body_limit())) }, ) } @@ -1179,6 +1240,68 @@ mod tests { assert!(!config.enable_ctrl_c); } + #[test] + fn json_limit_development_capture_and_explicit_router_builder() { + let (env, selected) = capture_dev_environment([ + ( + OsString::from("EDGEZERO__ADAPTER__HOST"), + OsString::from("invalid-host"), + ), + ( + OsString::from("EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES"), + OsString::from("64"), + ), + ]) + .expect("permissive bind and strict JSON option"); + assert_eq!(selected.get().get(), 64); + assert_eq!(resolve_addr(&env).addr.ip(), addr::DEFAULT_HOST); + let router = RouterService::builder().build(); + let default = AxumDevServer::new(router.clone()); + assert_eq!(default.json_body_limit.get().get(), 0x0020_0000); + let explicit = AxumDevServer::with_config(router, AxumDevServerConfig::default()) + .with_json_body_limit_bytes(64) + .expect("builder"); + let prepared = prepare_transport( + App::new(explicit.router), + explicit.stores.into(), + explicit.json_body_limit, + ) + .expect("prepared transport"); + assert_eq!(prepared.3.get().get(), 64); + assert!( + AxumDevServer::new(RouterService::builder().build()) + .with_json_body_limit_bytes(0) + .is_err() + ); + } + + #[cfg(unix)] + #[test] + fn development_capture_skips_unrelated_binary_settings_but_rejects_binary_json_limit() { + use std::os::unix::ffi::OsStringExt as _; + let binary = OsString::from_vec(vec![0xff]); + let (env, selected) = capture_dev_environment([ + (binary.clone(), OsString::from("ignored")), + (OsString::from("UNRELATED"), binary.clone()), + (OsString::from("EDGEZERO__ADAPTER__HOST"), binary.clone()), + ( + OsString::from("EDGEZERO__STORES__KV__DEFAULT__NAME"), + binary.clone(), + ), + ]) + .expect("unrelated omissions"); + assert_eq!(selected.get().get(), 0x0020_0000); + assert!(env.adapter_host().is_none()); + assert_eq!(env.store_name("kv", "default"), "default"); + let error = capture_dev_environment([( + OsString::from("EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES"), + binary, + )]) + .expect_err("recognized binary setting"); + assert!(error.to_string().contains("JSON_BODY_LIMIT_BYTES")); + assert_eq!(error.chain().count(), 1); + } + #[test] fn dev_server_new_uses_default_config() { use edgezero_core::router::RouterService; @@ -1189,6 +1312,66 @@ mod tests { assert!(server.config.enable_ctrl_c); } + #[tokio::test(flavor = "multi_thread")] + async fn router_only_default_json_boundary_for_both_constructors() { + use edgezero_core::action; + use edgezero_core::extractor::Json; + use edgezero_core::http::StatusCode; + #[action] + async fn count_json(Json(value): Json) -> String { + value.len().to_string() + } + let limit = 0x0020_0000_usize; + for configured in [false, true] { + let listener = TokioTcpListener::bind("127.0.0.1:0") + .await + .expect("listener"); + let addr = listener.local_addr().expect("address"); + let router = RouterService::builder().post("/json", count_json).build(); + let server = if configured { + AxumDevServer::with_config( + router, + AxumDevServerConfig { + addr, + enable_ctrl_c: false, + }, + ) + } else { + AxumDevServer::new(router) + }; + let serving = tokio::spawn(async move { server.run_with_listener(listener).await }); + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .expect("client"); + for (bytes, status) in [ + (limit, StatusCode::OK), + (limit.saturating_add(1), StatusCode::PAYLOAD_TOO_LARGE), + ] { + let body = format!( + "\"{}\"", + "x".repeat(bytes.checked_sub(2).expect("delimiters")) + ); + let response = client + .post(format!("http://{addr}/json")) + .header("content-type", "application/json") + .body(body) + .send() + .await + .expect("prebound listener request"); + assert_eq!(response.status(), status); + let text = response.text().await.expect("usable response"); + if status == StatusCode::OK { + assert_eq!(text, limit.checked_sub(2).expect("length").to_string()); + } else { + assert!(text.contains("payload_too_large")); + } + } + serving.abort(); + assert!(serving.await.expect_err("cancel server").is_cancelled()); + } + } + #[test] fn dev_server_with_config_uses_custom_config() { use edgezero_core::router::RouterService; diff --git a/crates/edgezero-adapter-axum/src/outbound.rs b/crates/edgezero-adapter-axum/src/outbound.rs index e229e7fed..ba2eb1832 100644 --- a/crates/edgezero-adapter-axum/src/outbound.rs +++ b/crates/edgezero-adapter-axum/src/outbound.rs @@ -4,7 +4,7 @@ use bytes::Bytes; use core::num::NonZeroU64; use core::task::Poll; use core::time::Duration; -use edgezero_core::body::{Body, BodyStream}; +use edgezero_core::body::{Body, BodyContent, BodyStream}; use edgezero_core::compression::{ ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_deflate_stream, decode_gzip_stream, @@ -267,8 +267,8 @@ async fn collect_request_body( budget: DispatchBudget, clock: &MonotonicClock, ) -> Result { - match body { - Body::Once(bytes) => { + match body.into_content() { + BodyContent::Once(bytes) => { let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); if length > maximum { return Err(EdgeError::bad_request( @@ -278,7 +278,7 @@ async fn collect_request_body( budget_remaining(budget, clock)?; Ok(bytes) } - Body::Stream(mut source) => { + BodyContent::Stream(mut source) => { let mut collected = Vec::new(); let mut total = 0_u64; loop { diff --git a/crates/edgezero-adapter-axum/src/request.rs b/crates/edgezero-adapter-axum/src/request.rs index b7da384cb..d6bbc16f0 100644 --- a/crates/edgezero-adapter-axum/src/request.rs +++ b/crates/edgezero-adapter-axum/src/request.rs @@ -7,7 +7,7 @@ use axum::extract::connect_info::ConnectInfo; use axum::http::{Request, request::Parts}; use edgezero_core::body::Body; use edgezero_core::error::EdgeError; -use edgezero_core::http::Request as CoreRequest; +use edgezero_core::http::{Request as CoreRequest, header::CONTENT_TYPE}; use edgezero_core::outbound::HttpClient; use edgezero_core::time::{Deadline, MonotonicClock}; use futures_util::{StreamExt as _, stream}; @@ -15,9 +15,12 @@ use tokio::time::timeout; use crate::context::AxumRequestContext; use crate::outbound::AxumOutboundClient; +use crate::run_options::JsonBodyLimit; /// Convert an Axum/Hyper request into an `EdgeZero` core request while preserving streaming bodies /// and exposing connection metadata through `AxumRequestContext`. +/// Attaches a finite 2 MiB JSON buffering policy without reading ambient configuration. +/// Taken streams and manual body collection do not enforce this policy. /// /// # Errors /// Returns an error if the outbound client cannot be initialized. @@ -28,7 +31,7 @@ use crate::outbound::AxumOutboundClient; )] pub async fn into_core_request(request: Request) -> Result { let (parts, axum_body) = request.into_parts(); - into_core_request_parts(parts, axum_body, None, None) + into_core_request_parts(parts, axum_body, None, None, JsonBodyLimit::DEFAULT) } pub(crate) fn into_core_request_parts( @@ -36,7 +39,13 @@ pub(crate) fn into_core_request_parts( axum_body: AxumBody, read_lifetime: Option<(Deadline, MonotonicClock)>, outbound_transport: Option>, + json_body_limit: JsonBodyLimit, ) -> Result { + let ingress_is_json = parts + .headers + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(is_json_media_type); let outbound_clock = read_lifetime .as_ref() .map_or_else(MonotonicClock::default, |(_, clock)| clock.clone()); @@ -47,7 +56,8 @@ pub(crate) fn into_core_request_parts( None => Body::from_external_stream(axum_body.into_data_stream()), }; - let mut core_request = CoreRequest::from_parts(parts, body); + let managed_body = body.with_buffered_json_policy(json_body_limit.get(), ingress_is_json); + let mut core_request = CoreRequest::from_parts(parts, managed_body); if let Some(remote_addr) = core_request .extensions() @@ -77,6 +87,13 @@ pub(crate) fn into_core_request_parts( Ok(core_request) } +fn is_json_media_type(raw: &str) -> bool { + raw.parse::().is_ok_and(|media| { + media.type_() == mime::APPLICATION + && (media.subtype() == mime::JSON || media.suffix() == Some(mime::JSON)) + }) +} + fn deadline_body( body_stream: BodyDataStream, deadline: Deadline, @@ -130,7 +147,6 @@ fn deadline_body( mod tests { use super::*; use bytes::Bytes; - use edgezero_core::body::Body; use edgezero_core::http::{Method, StatusCode}; use edgezero_core::time::MonotonicInstant; use std::io; @@ -190,10 +206,10 @@ mod tests { assert_eq!(core_request.method(), &Method::POST); assert_eq!(core_request.uri().path(), "/demo"); assert_eq!(core_request.headers()["x-test"], "1"); - match core_request.body() { - Body::Stream(_) => {} // streaming bodies stay streaming - Body::Once(_) => panic!("body should remain streaming"), - } + assert!( + core_request.body().is_stream(), + "body should remain streaming" + ); let context = AxumRequestContext::get(&core_request).expect("context"); assert_eq!(context.remote_addr, Some("127.0.0.1:4000".parse().unwrap())); @@ -216,8 +232,14 @@ mod tests { .expect("request"); let (parts, body) = request.into_parts(); - let core_request = into_core_request_parts(parts, body, None, Some(Arc::clone(&transport))) - .expect("request conversion"); + let core_request = into_core_request_parts( + parts, + body, + None, + Some(Arc::clone(&transport)), + JsonBodyLimit::DEFAULT, + ) + .expect("request conversion"); assert_eq!(Arc::strong_count(&transport), 2); drop(core_request); @@ -238,6 +260,28 @@ mod tests { assert!(AxumRequestContext::get(&core_request).is_none()); } + #[test] + fn ingress_json_classifier_validates_media_type_syntax() { + for raw in [ + "application/json", + "APPLICATION/JSON; charset=utf-8", + "application/problem+json", + "application/vnd.fixture+json; profile=\"a;b\"", + ] { + assert!(is_json_media_type(raw), "{raw}"); + } + for raw in [ + "text/json", + "application/jsonp", + "application/json; broken", + "application/json extra", + "application/", + "", + ] { + assert!(!is_json_media_type(raw), "{raw}"); + } + } + #[tokio::test] async fn json_content_type_stays_streaming() { let json_payload = serde_json::json!({"name": "test"}).to_string(); @@ -253,7 +297,7 @@ mod tests { .expect("request conversion"); assert_eq!(core_request.method(), &Method::POST); - assert!(matches!(core_request.body(), Body::Stream(_))); + assert!(core_request.body().is_stream()); } #[tokio::test] @@ -269,6 +313,6 @@ mod tests { .await .expect("request conversion"); - assert!(matches!(core_request.body(), Body::Stream(_))); + assert!(core_request.body().is_stream()); } } diff --git a/crates/edgezero-adapter-axum/src/response.rs b/crates/edgezero-adapter-axum/src/response.rs index d5dabcab8..722d82c83 100644 --- a/crates/edgezero-adapter-axum/src/response.rs +++ b/crates/edgezero-adapter-axum/src/response.rs @@ -9,7 +9,7 @@ use std::task::{Context, Poll}; use axum::http::Response; use bytes::Bytes; -use edgezero_core::body::{Body, BodyStream}; +use edgezero_core::body::{Body, BodyContent, BodyStream}; use edgezero_core::http::{Method, Response as CoreResponse, StatusCode}; use edgezero_core::response_egress::{ RESPONSE_EGRESS_FALLBACK_SAFETY_BUDGET, ResponseEgressAttempt, ResponseEgressEnvelope, @@ -268,13 +268,13 @@ impl AxumEgressBody { return Err(Box::new(attempt)); } let (source, inferred_length) = if transmits_body { - match body { - Body::Once(bytes) if bytes.is_empty() => (ResponseSource::Done, Some(0)), - Body::Once(bytes) => { + match body.into_content() { + BodyContent::Once(bytes) if bytes.is_empty() => (ResponseSource::Done, Some(0)), + BodyContent::Once(bytes) => { let length = u64::try_from(bytes.len()).ok(); (ResponseSource::Once(Some(bytes)), length) } - Body::Stream(stream) => (ResponseSource::Stream(stream), None), + BodyContent::Stream(stream) => (ResponseSource::Stream(stream), None), } } else { drop(body); diff --git a/crates/edgezero-adapter-axum/src/run_options.rs b/crates/edgezero-adapter-axum/src/run_options.rs index eac43bad2..971043cf3 100644 --- a/crates/edgezero-adapter-axum/src/run_options.rs +++ b/crates/edgezero-adapter-axum/src/run_options.rs @@ -1,5 +1,6 @@ use std::env::vars_os; use std::net::SocketAddr; +use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; use std::str::FromStr as _; use std::time::{Duration, Instant}; @@ -9,6 +10,43 @@ use edgezero_core::app::StoresMetadata; use edgezero_core::env_config::EnvConfig; use log::LevelFilter; +/// Validated native JSON buffering ceiling. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct JsonBodyLimit(NonZeroUsize); + +impl JsonBodyLimit { + pub(crate) const DEFAULT: Self = + Self(NonZeroUsize::new(0x0020_0000).expect("positive default")); + + pub(crate) fn from_config(env: &EnvConfig) -> anyhow::Result { + let Some(raw) = env.get(&["adapter", "json_body_limit_bytes"]) else { + return Ok(Self::DEFAULT); + }; + if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(Self::invalid()); + } + Self::new(raw.parse::().map_err(|_error| Self::invalid())?) + } + + pub(crate) fn get(self) -> NonZeroUsize { + self.0 + } + + fn invalid() -> anyhow::Error { + failure( + "settings", + "JSON_BODY_LIMIT_BYTES", + "invalid positive representable integer bytes", + ) + } + pub(crate) fn new(limit: usize) -> anyhow::Result { + NonZeroUsize::new(limit) + .filter(|value| value.get() <= isize::MAX.unsigned_abs()) + .map(Self) + .ok_or_else(Self::invalid) + } +} + /// Validated explicit production hosting options. Construction never selects a /// policy from build profile, cwd or container detection. Explicit constructors /// do not merge environment values; only `from_env` reads hosting environment. @@ -19,6 +57,7 @@ pub struct AxumRunOptions { data_dir: Option, env: EnvConfig, grace: Duration, + json_body_limit: JsonBodyLimit, level: LevelFilter, non_unicode_overrides: Vec>, } @@ -103,6 +142,7 @@ impl AxumRunOptions { .map_err(|_error| failure("settings", "LOGGING__LEVEL", "invalid level"))?; } options.grace = shutdown_grace(&env)?; + options.json_body_limit = JsonBodyLimit::from_config(&env)?; if let Some(root) = env.get(&["adapter", "config_dir"]) { options = options.with_config_dir(root)?; } @@ -113,6 +153,10 @@ impl AxumRunOptions { Ok(options) } + pub(crate) fn json_body_limit(&self) -> JsonBodyLimit { + self.json_body_limit + } + pub(crate) fn logging_level(&self) -> LevelFilter { self.level } @@ -132,6 +176,7 @@ impl AxumRunOptions { data_dir: None, env: EnvConfig::default(), grace: Duration::from_secs(10), + json_body_limit: JsonBodyLimit::DEFAULT, level: LevelFilter::Info, non_unicode_overrides: Vec::new(), }) @@ -213,6 +258,16 @@ impl AxumRunOptions { Ok(self) } + /// Selects the ceiling for framework-managed JSON buffering, in bytes. + /// + /// # Errors + /// Rejects zero and values above the collector's representable capacity. + #[inline] + pub fn with_json_body_limit_bytes(mut self, limit: usize) -> anyhow::Result { + self.json_body_limit = JsonBodyLimit::new(limit)?; + Ok(self) + } + #[inline] #[must_use] pub fn with_logging_level(mut self, level: LevelFilter) -> Self { @@ -246,6 +301,7 @@ fn fixed_setting(path: &[String]) -> Option<&'static str> { ["adapter", "config_dir"] => Some("CONFIG_DIR"), ["adapter", "data_dir"] => Some("DATA_DIR"), ["adapter", "shutdown_grace_seconds"] => Some("SHUTDOWN_GRACE_SECONDS"), + ["adapter", "json_body_limit_bytes"] => Some("JSON_BODY_LIMIT_BYTES"), ["logging", "level"] => Some("LOGGING__LEVEL"), _ => None, } @@ -298,6 +354,53 @@ mod tests { use super::*; use edgezero_core::app::StoreMetadata; + #[test] + fn json_body_limit_is_positive_decimal_and_representable() { + let key = "EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES"; + let defaults = AxumRunOptions::from_vars(Vec::<(String, String)>::new()).expect("defaults"); + assert_eq!(defaults.json_body_limit.get().get(), 0x0020_0000); + for bytes in [ + 1, + 64, + 9 * 1024 * 1024, + usize::try_from(isize::MAX).expect("maximum"), + ] { + let options = AxumRunOptions::from_vars([(key, bytes.to_string())]).expect("valid"); + assert_eq!(options.json_body_limit.get().get(), bytes); + assert_eq!( + defaults + .clone() + .with_json_body_limit_bytes(bytes) + .expect("builder") + .json_body_limit + .get() + .get(), + bytes + ); + } + for raw in [ + "", + "0", + "-1", + "+1", + " 64", + "64 ", + "1.5", + "2MiB", + "sentinel-limit", + "9999999999999999999999999999999999", + ] { + let error = AxumRunOptions::from_vars([(key, raw)]) + .err() + .expect("invalid"); + assert!(error.to_string().contains("JSON_BODY_LIMIT_BYTES")); + assert!(!format!("{error:?}").contains("sentinel")); + assert_eq!(error.chain().count(), 1); + } + assert!(defaults.clone().with_json_body_limit_bytes(0).is_err()); + assert!(defaults.with_json_body_limit_bytes(usize::MAX).is_err()); + } + #[test] fn defaults_and_invalid_supplied_scalars_are_distinct() { let options = AxumRunOptions::from_vars(Vec::<(String, String)>::new()).expect("defaults"); diff --git a/crates/edgezero-adapter-axum/src/service.rs b/crates/edgezero-adapter-axum/src/service.rs index 76d4c6fb8..2edbcf3c4 100644 --- a/crates/edgezero-adapter-axum/src/service.rs +++ b/crates/edgezero-adapter-axum/src/service.rs @@ -37,6 +37,7 @@ use crate::connection::{ConnectionExit, serve_http1}; use crate::outbound::AxumOutboundClient; use crate::request::into_core_request_parts; use crate::response::{AxumEgressBody, EgressConnection, prepare_egress_response}; +use crate::run_options::JsonBodyLimit; /// Private Hyper service error used only to close/reset an admission-aborted connection. #[derive(Debug, thiserror::Error)] @@ -49,6 +50,7 @@ pub(crate) struct AxumServiceState { app: Arc, config_registry: Option, config_store_handle: Option, + json_body_limit: JsonBodyLimit, kv_handle: Option, kv_registry: Option, outbound_transport: Option>, @@ -70,14 +72,20 @@ impl AxumServiceState { Self::with_transport( app, AxumOutboundClient::try_transport().expect("test transport"), + JsonBodyLimit::DEFAULT, ) } - pub(crate) fn with_transport(app: App, transport: Arc) -> Self { + pub(crate) fn with_transport( + app: App, + transport: Arc, + json_body_limit: JsonBodyLimit, + ) -> Self { Self { app: Arc::new(app), config_registry: None, config_store_handle: None, + json_body_limit, kv_handle: None, kv_registry: None, outbound_transport: Some(transport), @@ -307,6 +315,7 @@ impl AxumServiceState { native_body, Some((read_deadline, monotonic_clock)), Some(outbound_transport), + self.json_body_limit, ) { Ok(converted) => converted, Err(error) => { @@ -410,6 +419,7 @@ mod tests { use super::*; use bytes::{Bytes, BytesMut}; + use edgezero_core::action; use edgezero_core::body::Body; use edgezero_core::config_store::{ BoundedStoreRead, ConfigStore, ConfigStoreError, ConfigStoreHandle, @@ -417,6 +427,7 @@ mod tests { }; use edgezero_core::context::RequestContext; use edgezero_core::error::EdgeError; + use edgezero_core::extractor::Json; use edgezero_core::http::{ HeaderMap, HeaderValue, Response as CoreResponse, StatusCode, response_builder, }; @@ -464,6 +475,65 @@ mod tests { } } + #[action] + async fn json_limit_probe(Json(value): Json) -> Result { + Ok(value.len().to_string()) + } + + #[tokio::test] + async fn managed_json_overflow_drops_source_once_stops_polling_and_keeps_error_egress() { + let polls = Arc::new(AtomicUsize::new(0)); + let drops = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&polls); + let guard = DropSignal(Arc::clone(&drops)); + let source = poll_fn(move |_| { + let _retained = &guard; + let index = observed.fetch_add(1, Ordering::SeqCst); + let chunk = match index { + 0 => Bytes::from_static(b"\"ab"), + 1 => Bytes::from_static(b"cdef\""), + _ => panic!("source polled after oversized chunk"), + }; + Poll::Ready(Some(Ok::<_, io::Error>(chunk))) + }); + let router = RouterService::builder() + .post("/json", json_limit_probe) + .build(); + let reports = Arc::new(Mutex::new(Vec::new())); + let mut app = App::new(router); + app.set_response_egress_observer(RecordingEgressObserver(Arc::clone(&reports))); + app.set_error_response_renderer(|error| { + assert_eq!(error.kind(), "payload_too_large"); + CoreResponse::new(Body::from("custom renderer")) + }); + let mut service = AxumServiceState::from_app(app); + service.json_body_limit = JsonBodyLimit::new(4).expect("cap"); + let request = Request::builder() + .method("POST") + .uri("/json") + .header("content-type", "application/json") + .body(AxumBody::from_stream(source)) + .expect("request"); + let response = service + .dispatch_request(request, &EgressConnection::default(), None) + .await + .expect("dispatch"); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(polls.load(Ordering::SeqCst), 2); + assert_eq!(drops.load(Ordering::SeqCst), 1); + assert_eq!( + to_bytes(response.into_body(), 1024).await.expect("egress"), + "custom renderer" + ); + let observed_reports = reports.lock().expect("reports"); + assert_eq!(observed_reports.len(), 1); + assert_eq!( + observed_reports[0].outcome, + ResponseEgressOutcome::HostHandoff + ); + assert_eq!(drops.load(Ordering::SeqCst), 1); + } + struct FixedConfigStore(String); struct CountingMiddleware(Arc); diff --git a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs index c13358088..0f0b72cd2 100644 --- a/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs +++ b/crates/edgezero-adapter-axum/tests/fixtures/production_host.rs @@ -19,8 +19,9 @@ use edgezero_adapter_axum::dev_server::{ }; use edgezero_core::app::{App, Hooks, StoreMetadata, StoresMetadata}; use edgezero_core::context::RequestContext; -use edgezero_core::extractor::{AppConfig, State}; +use edgezero_core::extractor::{AppConfig, Json, State}; use edgezero_core::key_value_store::KvHandle; +use edgezero_core::params::PathParams; use edgezero_core::probe::{self, LifecyclePhase, LifecycleReader}; use edgezero_core::router::RouterService; use edgezero_core::{Body, EdgeError, MonotonicClock, MonotonicInstant, action}; @@ -36,6 +37,7 @@ struct FixtureApp; )] impl Hooks for FixtureApp { fn configure(app: &mut App) -> Result<(), EdgeError> { + println!("HOOK_ENTERED"); if mode() == "hook-error" { return Err(EdgeError::internal(anyhow::anyhow!("HOOK_SENTINEL"))); } @@ -52,6 +54,11 @@ impl Hooks for FixtureApp { fn routes() -> RouterService { RouterService::builder() + .post("/json", buffered_json) + .post("/json-explicit", explicit_json) + .post("/json-small", small_json) + .post("/generic", generic_body) + .post("/taken", taken_body) .get("/ready", probe::readiness) .get("/live", probe::liveness) .get("/state", fixture::state) @@ -99,6 +106,53 @@ impl Hooks for FixtureApp { } } +#[action] +async fn buffered_json(Json(value): Json) -> Result { + println!("JSON_HANDLER_ENTERED"); + Ok(value.len().to_string()) +} + +#[action] +async fn explicit_json(ctx: RequestContext) -> Result { + let value: String = ctx.json_within(16 * 1024 * 1024).await?; + Ok(value.len().to_string()) +} + +#[action] +async fn small_json(ctx: RequestContext) -> Result { + let value: String = ctx.json_within(32).await?; + Ok(value.len().to_string()) +} + +#[action] +async fn generic_body(mut ctx: RequestContext) -> Result { + ctx.headers_mut().clear(); + ctx.extensions_mut().clear(); + let request = ctx.into_request()?; + let reconstructed = RequestContext::new(request, PathParams::default()); + Ok(reconstructed + .body_bytes(16 * 1024 * 1024) + .await? + .len() + .to_string()) +} + +#[action] +async fn taken_body(ctx: RequestContext) -> Result { + use futures::StreamExt as _; + let mut source = ctx + .take_body()? + .into_stream() + .ok_or_else(|| EdgeError::bad_request("expected stream"))?; + let mut bytes = 0_usize; + while let Some(chunk) = source.next().await { + bytes = bytes + .checked_add(chunk?.len()) + .ok_or_else(|| EdgeError::bad_request("fixture accounting overflow"))?; + } + Ok(bytes.to_string()) +} + struct WorkGuard(&'static str); impl Drop for WorkGuard { @@ -281,6 +335,9 @@ fn main() -> anyhow::Result<()> { if selected.starts_with("embedding") { let address: SocketAddr = env::var("FIXTURE_BIND")?.parse()?; let mut options = AxumRunOptions::new(address)?; + if let Ok(raw) = env::var("FIXTURE_JSON_LIMIT") { + options = options.with_json_body_limit_bytes(raw.parse()?)?; + } if selected == "embedding" { options = options .with_config_dir(env::var("FIXTURE_CONFIG_ROOT")?)? diff --git a/crates/edgezero-adapter-axum/tests/production_hosting.rs b/crates/edgezero-adapter-axum/tests/production_hosting.rs index 97e8db9a7..843b0da94 100644 --- a/crates/edgezero-adapter-axum/tests/production_hosting.rs +++ b/crates/edgezero-adapter-axum/tests/production_hosting.rs @@ -7,6 +7,7 @@ reason = "subprocess fixtures group setup, process ownership and acceptance scenarios" )] mod tests { + use std::ffi::OsString; use std::fs; use std::io::{self, BufRead as _, BufReader, Read, Write as _}; use std::net::{SocketAddr, TcpListener, TcpStream}; @@ -240,6 +241,224 @@ mod tests { log } + fn exact_json(bytes: usize) -> Vec { + assert!(bytes >= 2); + let mut body = vec![b'x'; bytes]; + body[0] = b'"'; + *body.last_mut().expect("JSON closing quote") = b'"'; + body + } + + fn upload( + server: &Server, + path: &str, + media: Option<&str>, + body: Vec, + chunked: bool, + ) -> String { + let mut socket = server.socket().expect("upload connection"); + let mut writer = socket.try_clone().expect("upload writer"); + let content_type = + media.map_or_else(String::new, |value| format!("Content-Type: {value}\r\n")); + let framing = if chunked { + "Transfer-Encoding: chunked\r\n".to_owned() + } else { + format!("Content-Length: {}\r\n", body.len()) + }; + let request_head = format!( + "POST {path} HTTP/1.1\r\nHost: fixture\r\n{content_type}{framing}Connection: close\r\n\r\n" + ); + let sending = thread::spawn(move || -> io::Result<()> { + writer.write_all(request_head.as_bytes())?; + for chunk in body.chunks(0x4000) { + if chunked { + write!(writer, "{:x}\r\n", chunk.len())?; + } + writer.write_all(chunk)?; + if chunked { + writer.write_all(b"\r\n")?; + } + } + if chunked { + writer.write_all(b"0\r\n\r\n")?; + } + Ok(()) + }); + let mut response = String::new(); + let reading = socket.read_to_string(&mut response); + let sent = sending.join().expect("bounded upload writer"); + let (head, payload) = response + .split_once("\r\n\r\n") + .expect("usable response head"); + let is_413 = head.starts_with("HTTP/1.1 413"); + let length = head + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("response length")) + }) + .expect("finite response framing"); + assert_eq!(payload.len(), length, "complete response: {response}"); + if let Err(error) = reading { + assert!( + is_413 && error.kind() == io::ErrorKind::ConnectionReset, + "response read: {error}" + ); + } + if let Err(error) = sent { + assert!(is_413, "upload failure without 413: {error}"); + } + response + } + + fn assert_upload_status(response: &str, status: u16) { + assert!( + response.starts_with(&format!("HTTP/1.1 {status}")), + "{response}" + ); + if status == 413 { + let (_, body) = response.split_once("\r\n\r\n").expect("response"); + let value: Value = serde_json::from_str(body).expect("usable error body"); + assert_eq!(value["error"]["kind"], "payload_too_large"); + assert_eq!(value["error"]["message"], "request body too large"); + } + } + + #[test] + fn json_body_limit_wire_content_length_and_chunked() { + let roots = Roots::new(); + let addr = address(); + let mut server = Server::spawn(command("bare", &roots, addr), addr); + server.ready(); + server.wait_line("HOOK_ENTERED"); + for chunked in [false, true] { + assert_upload_status( + &upload( + &server, + "/json", + Some("application/json"), + exact_json(0x0020_0000), + chunked, + ), + 200, + ); + server.wait_line("JSON_HANDLER_ENTERED"); + assert_upload_status( + &upload( + &server, + "/json", + Some("application/json"), + exact_json(0x0020_0001), + chunked, + ), + 413, + ); + } + server.signal("TERM"); + assert!(server.wait().success()); + assert_eq!(server.log().matches("JSON_HANDLER_ENTERED").count(), 2); + } + + #[test] + fn json_limit_overrides_helpers_generic_reads_and_taken_streams() { + for mode in ["bare", "bare-dev", "embedding-bare"] { + let roots = Roots::new(); + let addr = address(); + let mut launch = command(mode, &roots, addr); + launch.env("EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES", "64"); + if mode == "embedding-bare" { + launch.env("FIXTURE_JSON_LIMIT", "64").env( + "EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES", + "INVALID_SENTINEL", + ); + } + let mut server = Server::spawn(launch, addr); + server.ready(); + for media in [ + None, + Some("text/plain"), + Some("APPLICATION/JSON; charset=utf-8"), + Some("application/vnd.fixture+json"), + ] { + assert_upload_status(&upload(&server, "/json", media, exact_json(64), false), 200); + assert_upload_status(&upload(&server, "/json", media, exact_json(65), true), 413); + } + assert_upload_status( + &upload(&server, "/json-explicit", None, exact_json(65), false), + 413, + ); + assert_upload_status( + &upload(&server, "/json-small", None, exact_json(32), false), + 200, + ); + assert_upload_status( + &upload(&server, "/json-small", None, exact_json(33), false), + 413, + ); + assert_upload_status( + &upload(&server, "/json", None, b"invalid json".to_vec(), false), + 400, + ); + for media in [ + "application/json", + "Application/Problem+JSON; charset=utf-8", + ] { + assert_upload_status( + &upload(&server, "/generic", Some(media), exact_json(65), true), + 413, + ); + } + assert_upload_status( + &upload( + &server, + "/generic", + Some("text/plain"), + exact_json(65), + false, + ), + 200, + ); + for media in ["application/json", "application/octet-stream"] { + assert_upload_status( + &upload(&server, "/taken", Some(media), exact_json(4096), true), + 200, + ); + } + } + let roots = Roots::new(); + let addr = address(); + let cap = 9 * 1024 * 1024; + let mut launch = command("bare", &roots, addr); + launch.env("EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES", cap.to_string()); + let mut server = Server::spawn(launch, addr); + server.ready(); + assert_upload_status(&upload(&server, "/json", None, exact_json(cap), true), 200); + assert_upload_status( + &upload(&server, "/json", None, exact_json(cap + 1), false), + 413, + ); + } + + #[test] + fn invalid_json_limits_fail_before_application_hooks_in_dev_and_production() { + use std::os::unix::ffi::OsStringExt as _; + for mode in ["bare", "bare-dev"] { + for value in [ + OsString::from("0"), + OsString::from("INVALID_SENTINEL"), + OsString::from_vec(vec![0xff]), + ] { + let roots = Roots::new(); + let addr = address(); + let mut launch = command(mode, &roots, addr); + launch.env("EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES", value); + let log = failed(launch, addr, "JSON_BODY_LIMIT_BYTES"); + assert!(!log.contains("HOOK_ENTERED")); + } + } + } + #[test] fn both_signals_stop_idle_processes() { for signal in ["INT", "TERM"] { diff --git a/crates/edgezero-adapter-cloudflare/src/outbound.rs b/crates/edgezero-adapter-cloudflare/src/outbound.rs index b6f273555..7e68daa07 100644 --- a/crates/edgezero-adapter-cloudflare/src/outbound.rs +++ b/crates/edgezero-adapter-cloudflare/src/outbound.rs @@ -99,7 +99,7 @@ mod worker_impl { use async_stream::stream; use async_trait::async_trait; use bytes::Bytes; - use edgezero_core::body::{Body, BodyStream}; + use edgezero_core::body::{Body, BodyContent, BodyStream}; use edgezero_core::compression::{ ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_deflate_stream, decode_gzip_stream, @@ -721,8 +721,8 @@ mod worker_impl { budget: DispatchBudget, clock: MonotonicClock, ) -> Result, EdgeError> { - match body { - Body::Once(bytes) => { + match body.into_content() { + BodyContent::Once(bytes) => { let length = u64::try_from(bytes.len()).unwrap_or(u64::MAX); if length > maximum { return Err(EdgeError::bad_request( @@ -736,7 +736,7 @@ mod worker_impl { Ok(Some(Uint8Array::from(bytes.as_ref()).into())) } } - Body::Stream(source) => { + BodyContent::Stream(source) => { let mut bounded = upload_stream(source, maximum, budget, clock); let mut collected = Vec::new(); while let Some(item) = bounded.next().await { diff --git a/crates/edgezero-adapter-cloudflare/src/response.rs b/crates/edgezero-adapter-cloudflare/src/response.rs index 649d84c03..e00123ae4 100644 --- a/crates/edgezero-adapter-cloudflare/src/response.rs +++ b/crates/edgezero-adapter-cloudflare/src/response.rs @@ -10,7 +10,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::time::Duration; use bytes::Bytes; -use edgezero_core::body::{Body, BodyStream}; +use edgezero_core::body::{Body, BodyContent, BodyStream}; use edgezero_core::error::EdgeError; use edgezero_core::http::header::CONTENT_ENCODING; use edgezero_core::http::{HeaderMap, Method, Response, StatusCode}; @@ -248,9 +248,9 @@ where } fn body_source(body: Body) -> BodyStream { - match body { - Body::Once(bytes) => stream::once(async move { Ok(bytes) }).boxed_local(), - Body::Stream(source) => source, + match body.into_content() { + BodyContent::Once(bytes) => stream::once(async move { Ok(bytes) }).boxed_local(), + BodyContent::Stream(source) => source, } } diff --git a/crates/edgezero-adapter-fastly/src/outbound.rs b/crates/edgezero-adapter-fastly/src/outbound.rs index afa776b49..4c7e52496 100644 --- a/crates/edgezero-adapter-fastly/src/outbound.rs +++ b/crates/edgezero-adapter-fastly/src/outbound.rs @@ -49,6 +49,10 @@ enum SendFailure { } #[cfg(any(feature = "fastly", test))] +#[expect( + clippy::large_enum_variant, + reason = "keep batch observation allocation-free despite one-word Body carrier growth" +)] enum FastlyBatchObservation { Continue(OutboundBatchItem), Cutoff, @@ -186,7 +190,7 @@ mod fastly_impl { use async_stream::stream; use async_trait::async_trait; use bytes::Bytes; - use edgezero_core::body::{Body, BodyStream}; + use edgezero_core::body::{Body, BodyContent, BodyStream}; use edgezero_core::compression::{ ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_deflate_stream, decode_gzip_stream, @@ -442,8 +446,8 @@ mod fastly_impl { .. } = parts; let fastly_request = build_fastly_request(&method, &uri, &headers, cache_policy); - let response = match body { - Body::Once(bytes) => { + let response = match body.into_content() { + BodyContent::Once(bytes) => { validate_request_body_length(&bytes, max_request_body_bytes)?; let mut buffered_request = fastly_request; buffered_request.set_body(bytes.to_vec()); @@ -453,7 +457,7 @@ mod fastly_impl { .map_err(|error| map_send_error(&error, budget, &self.clock))?; wait_pending(pending, budget, &self.clock)? } - Body::Stream(source) => { + BodyContent::Stream(source) => { send_streamed( fastly_request, backend, @@ -511,7 +515,7 @@ mod fastly_impl { uri, .. } = parts; - let Body::Once(bytes) = body else { + let BodyContent::Once(bytes) = body.into_content() else { return Err(EdgeError::internal(anyhow::anyhow!( "Fastly batch preflight admitted a streamed upload" ))); diff --git a/crates/edgezero-adapter-fastly/src/response.rs b/crates/edgezero-adapter-fastly/src/response.rs index 0960ac2d3..be96028ef 100644 --- a/crates/edgezero-adapter-fastly/src/response.rs +++ b/crates/edgezero-adapter-fastly/src/response.rs @@ -10,7 +10,7 @@ #[path = "fastly_response_abi.rs"] mod fastly_abi; -use edgezero_core::body::Body; +use edgezero_core::body::{Body, BodyContent}; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderMap, Method, Response, StatusCode, Uri}; use edgezero_core::response_egress::{ @@ -474,9 +474,9 @@ fn pump_body( where Sink: EgressSink, { - match body { - Body::Once(bytes) => write_chunk(bytes.as_ref(), sink, deadline, attempt, clock), - Body::Stream(mut source) => loop { + match body.into_content() { + BodyContent::Once(bytes) => write_chunk(bytes.as_ref(), sink, deadline, attempt, clock), + BodyContent::Stream(mut source) => loop { ensure_deadline(deadline, clock)?; let next = executor::block_on(source.next()); ensure_deadline(deadline, clock)?; diff --git a/crates/edgezero-adapter-spin/src/outbound.rs b/crates/edgezero-adapter-spin/src/outbound.rs index ac5e6257f..2bc8261e6 100644 --- a/crates/edgezero-adapter-spin/src/outbound.rs +++ b/crates/edgezero-adapter-spin/src/outbound.rs @@ -243,7 +243,7 @@ mod spin_impl { use async_stream::stream; use async_trait::async_trait; use bytes::Bytes; - use edgezero_core::body::{Body, BodyStream}; + use edgezero_core::body::{Body, BodyContent, BodyStream}; use edgezero_core::compression::{ ContentEncoding, classify_content_encoding, decode_brotli_stream, decode_deflate_stream, decode_gzip_stream, @@ -603,9 +603,9 @@ mod spin_impl { .. } = writer; let mut total = 0_u64; - let mut source = match body { - Body::Once(bytes) => once(async move { Ok(bytes) }).boxed_local(), - Body::Stream(source) => source, + let mut source = match body.into_content() { + BodyContent::Once(bytes) => once(async move { Ok(bytes) }).boxed_local(), + BodyContent::Stream(source) => source, }; // `BodyWriter` configures `result_writer` to publish a typed host error on drop. diff --git a/crates/edgezero-adapter-spin/src/response.rs b/crates/edgezero-adapter-spin/src/response.rs index e23839fed..b81b6a284 100644 --- a/crates/edgezero-adapter-spin/src/response.rs +++ b/crates/edgezero-adapter-spin/src/response.rs @@ -11,7 +11,7 @@ use std::panic::{AssertUnwindSafe, catch_unwind}; use std::time::Duration; use bytes::Bytes; -use edgezero_core::body::{Body, BodyStream}; +use edgezero_core::body::{Body, BodyContent, BodyStream}; use edgezero_core::error::EdgeError; use edgezero_core::http::{HeaderMap, Method, Response, StatusCode}; use edgezero_core::response_egress::{ @@ -265,9 +265,9 @@ where } fn body_source(body: Body) -> BodyStream { - match body { - Body::Once(bytes) => stream::once(async move { Ok(bytes) }).boxed_local(), - Body::Stream(source) => source, + match body.into_content() { + BodyContent::Once(bytes) => stream::once(async move { Ok(bytes) }).boxed_local(), + BodyContent::Stream(source) => source, } } diff --git a/crates/edgezero-core/src/body.rs b/crates/edgezero-core/src/body.rs index 4eeee51f3..e79c0ee91 100644 --- a/crates/edgezero-core/src/body.rs +++ b/crates/edgezero-core/src/body.rs @@ -1,5 +1,6 @@ use std::fmt; use std::io; +use std::num::NonZeroUsize; use bytes::Bytes; use futures_util::stream::{LocalBoxStream, Stream, StreamExt}; @@ -13,21 +14,179 @@ pub type BodyStream = LocalBoxStream<'static, Result>; /// Lightweight HTTP body that can either contain a single `Bytes` buffer or a streaming source of /// chunks. The streaming variant is implemented with `LocalBoxStream` so it remains compatible with /// `wasm32` targets that lack thread support. +/// +/// Raw matches outside core require a wildcard, even when all current variants are named: +/// +/// ```compile_fail +/// use edgezero_core::Body; +/// fn kind(body: Body) -> usize { +/// match body { +/// Body::Once(_) => 0, +/// Body::Stream(_) => 1, +/// Body::Managed(_) => 2, +/// } +/// } +/// ``` +/// +/// ``` +/// use edgezero_core::Body; +/// fn kind(body: Body) -> usize { +/// match body { +/// Body::Once(_) => 0, +/// Body::Stream(_) => 1, +/// Body::Managed(_) => 2, +/// _ => 3, +/// } +/// } +/// ``` +#[non_exhaustive] pub enum Body { + Managed(ManagedBody), Once(Bytes), Stream(BodyStream), } +/// Logical body ownership without ingress policy or cache provenance. +/// This view is exhaustive, so transports need no wildcard: +/// +/// ``` +/// use edgezero_core::{Body, BodyContent}; +/// fn kind(body: Body) -> usize { +/// match body.into_content() { +/// BodyContent::Once(_) => 0, +/// BodyContent::Stream(_) => 1, +/// } +/// } +/// ``` +pub enum BodyContent { + Once(Bytes), + Stream(BodyStream), +} + +/// Opaque ingress policy and transfer provenance. Only core creates cached provenance. +pub struct ManagedBody { + inner: Box, +} + +struct ManagedBodyState { + policy: BufferedJsonPolicy, + transfer: BodyTransfer, +} + +#[derive(Clone, Copy)] +pub(crate) struct BufferedJsonPolicy { + ceiling: NonZeroUsize, + ingress_is_json: bool, +} + +impl BufferedJsonPolicy { + pub(crate) fn ceiling(self) -> NonZeroUsize { + self.ceiling + } + pub(crate) fn ingress_is_json(self) -> bool { + self.ingress_is_json + } +} + +pub(crate) enum BodyTransfer { + Cached(Bytes), + Initial(BodyContent), +} + +impl BodyTransfer { + fn into_content(self) -> BodyContent { + match self { + Self::Initial(content) => content, + Self::Cached(bytes) => BodyContent::Once(bytes), + } + } +} + +impl From for Body { + #[inline] + fn from(content: BodyContent) -> Self { + match content { + BodyContent::Once(bytes) => Self::Once(bytes), + BodyContent::Stream(stream) => Self::Stream(stream), + } + } +} + +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "policy transfer helpers precede the existing logical content operations" +)] impl Body { + /// Attaches a validated positive ceiling and original ingress classification. + /// Original policy/provenance win on reattachment. Never polls or copies content. + #[must_use] + #[inline] + pub fn with_buffered_json_policy(self, ceiling: NonZeroUsize, ingress_is_json: bool) -> Self { + match self { + Self::Managed(_) => self, + Self::Once(_) | Self::Stream(_) => Self::from_transfer( + Some(BufferedJsonPolicy { + ceiling, + ingress_is_json, + }), + BodyTransfer::Initial(self.into_content()), + ), + } + } + + /// Consumes raw content, discarding policy/provenance without polling. + /// For application-owned operations and transports, not context reconstruction. + #[must_use] + #[inline] + pub fn into_content(self) -> BodyContent { + let (_, transfer) = self.into_transfer(); + transfer.into_content() + } + + pub(crate) fn into_transfer(self) -> (Option, BodyTransfer) { + match self { + Self::Once(bytes) => (None, BodyTransfer::Initial(BodyContent::Once(bytes))), + Self::Stream(stream) => (None, BodyTransfer::Initial(BodyContent::Stream(stream))), + Self::Managed(managed) => { + let state = *managed.inner; + (Some(state.policy), state.transfer) + } + } + } + + pub(crate) fn from_transfer( + policy: Option, + transfer: BodyTransfer, + ) -> Self { + match policy { + Some(selected) => Self::Managed(ManagedBody { + inner: Box::new(ManagedBodyState { + policy: selected, + transfer, + }), + }), + None => transfer.into_content().into(), + } + } + + fn buffered_bytes(&self) -> Option<&Bytes> { + match self { + Self::Once(bytes) => Some(bytes), + Self::Stream(_) => None, + Self::Managed(managed) => match &managed.inner.transfer { + BodyTransfer::Initial(BodyContent::Once(bytes)) | BodyTransfer::Cached(bytes) => { + Some(bytes) + } + BodyTransfer::Initial(BodyContent::Stream(_)) => None, + }, + } + } /// Returns the in-memory bytes for a buffered body, or `None` if this is /// a streaming body. To consume a streaming body into bytes, use /// [`Body::into_bytes_bounded`]. #[inline] pub fn as_bytes(&self) -> Option<&[u8]> { - match self { - Body::Once(bytes) => Some(bytes.as_ref()), - Body::Stream(_) => None, - } + self.buffered_bytes().map(Bytes::as_ref) } #[must_use] @@ -72,9 +231,9 @@ impl Body { /// [`Body::into_bytes_bounded`]. #[inline] pub fn into_bytes(self) -> Option { - match self { - Body::Once(bytes) => Some(bytes), - Body::Stream(_) => None, + match self.into_content() { + BodyContent::Once(bytes) => Some(bytes), + BodyContent::Stream(_) => None, } } @@ -86,14 +245,14 @@ impl Body { /// Returns [`EdgeError::bad_request`] if the body exceeds `max_size` bytes; or [`EdgeError::internal`] if the upstream stream errors. #[inline] pub async fn into_bytes_bounded(self, max_size: usize) -> Result { - match self { - Body::Once(bytes) => { + match self.into_content() { + BodyContent::Once(bytes) => { if bytes.len() > max_size { return Err(EdgeError::bad_request("request body too large")); } Ok(bytes) } - Body::Stream(mut stream) => { + BodyContent::Stream(mut stream) => { let mut buf = Vec::new(); while let Some(result) = StreamExt::next(&mut stream).await { let chunk = result?; @@ -112,15 +271,15 @@ impl Body { #[inline] pub fn into_stream(self) -> Option { - match self { - Body::Once(_) => None, - Body::Stream(stream) => Some(stream), + match self.into_content() { + BodyContent::Once(_) => None, + BodyContent::Stream(stream) => Some(stream), } } #[inline] pub fn is_stream(&self) -> bool { - matches!(self, Body::Stream(_)) + self.buffered_bytes().is_none() } /// # Errors @@ -156,9 +315,9 @@ impl Body { where T: DeserializeOwned, { - match self { - Body::Once(bytes) => serde_json::from_slice(bytes.as_ref()), - Body::Stream(_) => Err(serde_json::Error::io(io::Error::other( + match self.buffered_bytes() { + Some(bytes) => serde_json::from_slice(bytes.as_ref()), + None => Err(serde_json::Error::io(io::Error::other( "streaming body cannot be materialised as JSON", ))), } @@ -175,12 +334,12 @@ impl Default for Body { impl fmt::Debug for Body { #[inline] fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Body::Once(bytes) => f + match self.buffered_bytes() { + Some(bytes) => f .debug_struct("Body::Once") .field("len", &bytes.len()) .finish(), - Body::Stream(_) => f.debug_tuple("Body::Stream").finish(), + None => f.debug_tuple("Body::Stream").finish(), } } } @@ -224,11 +383,65 @@ impl From for Body { mod tests { use super::*; use crate::error::ResponseLimitReason; + use crate::http::StatusCode; use futures::executor::block_on; use futures_util::stream; use std::cell::Cell; use std::io; use std::rc::Rc; + use std::task::Poll; + + #[test] + fn managed_content_accessors_and_manual_collection_ignore_ingress_ceiling() { + let cap = NonZeroUsize::new(1).expect("positive cap"); + let bytes = Bytes::from_static(b"\"value\""); + let buffered = Body::from_bytes(bytes.clone()).with_buffered_json_policy(cap, true); + assert_eq!(buffered.as_bytes(), Some(bytes.as_ref())); + assert!(!buffered.is_stream()); + assert_eq!(buffered.to_json::().expect("raw JSON"), "value"); + assert!(format!("{buffered:?}").contains("Body::Once")); + let moved = buffered.into_bytes().expect("bytes"); + assert_eq!(moved.as_ptr(), bytes.as_ptr()); + for make in [false, true] { + let raw = || { + if make { + Body::stream(stream::iter([bytes.clone()])) + } else { + Body::from_bytes(bytes.clone()) + } + }; + assert_eq!( + block_on( + raw() + .with_buffered_json_policy(cap, true) + .into_bytes_bounded(7) + ) + .expect("caller cap"), + bytes + ); + assert_eq!( + block_on( + raw() + .with_buffered_json_policy(cap, true) + .into_bytes_bounded(6) + ) + .expect_err("manual overflow") + .status(), + StatusCode::BAD_REQUEST + ); + } + let polls = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&polls); + let lazy = Body::from_stream(stream::poll_fn(move |_| { + observed.set(observed.get().saturating_add(1)); + Poll::Ready(None) + })) + .with_buffered_json_policy(cap, true); + assert!(lazy.is_stream()); + assert!(lazy.as_bytes().is_none()); + assert!(matches!(lazy.into_content(), BodyContent::Stream(_))); + assert_eq!(polls.get(), 0); + } #[test] fn as_bytes_returns_none_for_stream() { diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index d2570d134..4fa1c6cae 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -1,6 +1,6 @@ use std::{cell::RefCell, collections::BTreeMap, env, mem}; -use crate::body::Body; +use crate::body::{Body, BodyContent, BodyTransfer, BufferedJsonPolicy}; use crate::config_store::ConfigExtractionLimits; use crate::error::{ BadGatewayReason, BudgetSource, EdgeError, ResponseLimitReason, StoreExtractionReason, @@ -60,6 +60,42 @@ enum BodyState { Taken, } +impl BodyState { + fn from_transfer(transfer: BodyTransfer) -> Self { + match transfer { + BodyTransfer::Initial(content) => Self::Initial(content.into()), + BodyTransfer::Cached(bytes) => Self::Cached(bytes), + } + } +} + +#[derive(Clone, Copy, PartialEq)] +enum ReadKind { + Generic, + Json, +} + +#[derive(Clone, Copy)] +enum OverflowKind { + BadRequest, + PayloadTooLarge, +} + +#[derive(Clone, Copy)] +struct BufferPolicy { + cap: usize, + overflow: OverflowKind, +} + +impl BufferPolicy { + fn error(self, message: &str) -> EdgeError { + match self.overflow { + OverflowKind::BadRequest => EdgeError::bad_request(message), + OverflowKind::PayloadTooLarge => EdgeError::payload_too_large(message), + } + } +} + pub(crate) enum FallbackDrainOutcome { Complete, Exceeded, @@ -75,6 +111,7 @@ enum StoredError { MethodNotAllowed(Method, Vec), NotFound(String), NotImplemented(String), + PayloadTooLarge(String), RequestHeaderFieldsTooLarge(String), RequestTimeout(String), ResponseTooLarge(String, ResponseLimitReason), @@ -104,6 +141,7 @@ impl StoredError { } EdgeError::NotFound { path } => Self::NotFound(path), EdgeError::NotImplemented { message } => Self::NotImplemented(message), + EdgeError::PayloadTooLarge { message } => Self::PayloadTooLarge(message), EdgeError::RequestHeaderFieldsTooLarge { message } => { Self::RequestHeaderFieldsTooLarge(message) } @@ -141,6 +179,7 @@ impl StoredError { }, Self::NotFound(path) => EdgeError::not_found(path.clone()), Self::NotImplemented(message) => EdgeError::not_implemented(message.clone()), + Self::PayloadTooLarge(message) => EdgeError::payload_too_large(message.clone()), Self::RequestHeaderFieldsTooLarge(message) => { EdgeError::request_header_fields_too_large(message.clone()) } @@ -161,6 +200,7 @@ impl StoredError { /// Request context exposed to handlers and middleware. pub struct RequestContext { body: RefCell, + body_policy: Option, config_extraction_limits: ConfigExtractionLimits, ingress_grant: RefCell>, monotonic_clock: MonotonicClock, @@ -192,19 +232,47 @@ impl Drop for DrainGuard<'_> { } } +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "buffer resolution and guarded collection stay grouped with body_bytes" +)] impl RequestContext { /// Drains and caches the inbound body under the caller's byte cap. /// /// # Errors - /// Returns 400 on overflow, 408 on an admitted read deadline, and preserves a sticky + /// Returns 413 for managed JSON overflow, otherwise 400, and 408 on an admitted deadline. + /// Preserves a sticky /// source error for every later accessor. #[inline] pub async fn body_bytes(&self, max: usize) -> Result { + self.buffered_bytes(ReadKind::Generic, max).await + } + + pub(crate) fn json_body_default(&self) -> usize { + self.body_policy + .map_or(DEFAULT_INBOUND_JSON_BYTES, |policy| policy.ceiling().get()) + } + + fn buffer_policy(&self, kind: ReadKind, max: usize) -> BufferPolicy { + match self.body_policy { + Some(policy) if kind == ReadKind::Json || policy.ingress_is_json() => BufferPolicy { + cap: max.min(policy.ceiling().get()), + overflow: OverflowKind::PayloadTooLarge, + }, + Some(_) | None => BufferPolicy { + cap: max, + overflow: OverflowKind::BadRequest, + }, + } + } + + async fn buffered_bytes(&self, kind: ReadKind, max: usize) -> Result { + let policy = self.buffer_policy(kind, max); let body = { let mut state = self.body.borrow_mut(); match mem::replace(&mut *state, BodyState::Draining) { BodyState::Cached(bytes) => { - let result = check_cached_body(&bytes, max); + let result = check_cached_body(&bytes, policy); *state = BodyState::Cached(bytes); return result; } @@ -233,7 +301,7 @@ impl RequestContext { armed: true, body: &self.body, }; - let result = drain_body(body, max, self.read_deadline, &self.monotonic_clock).await; + let result = drain_body(body, policy, self.read_deadline, &self.monotonic_clock).await; match result { Ok(bytes) => { *self.body.borrow_mut() = BodyState::Cached(bytes.clone()); @@ -338,7 +406,8 @@ impl RequestContext { /// Buffers at most `max` bytes and deserializes form-urlencoded data. /// /// # Errors - /// Returns 400 when the body is oversized or malformed, and preserves body drain errors. + /// Oversized managed JSON ingress returns 413; other oversized bodies and malformed + /// form data return 400. Preserves body drain errors. #[inline] pub async fn form_within(&self, max: usize) -> Result where @@ -372,30 +441,34 @@ impl RequestContext { /// Returns the sticky body error or an internal error if a drain is in progress. #[inline] pub fn into_request(self) -> Result { - let body = match self.body.into_inner() { - BodyState::Cached(bytes) => Body::from(bytes), + let transfer = match self.body.into_inner() { + BodyState::Cached(bytes) => BodyTransfer::Cached(bytes), BodyState::Draining => { return Err(EdgeError::internal(anyhow::anyhow!( "body read in progress" ))); } - BodyState::Initial(body) => body, + BodyState::Initial(body) => BodyTransfer::Initial(body.into_content()), BodyState::Poisoned(error) => return Err(error.to_edge_error()), - BodyState::Taken => Body::empty(), + BodyState::Taken => return Ok(Request::from_parts(self.parts, Body::empty())), }; - Ok(Request::from_parts(self.parts, body)) + Ok(Request::from_parts( + self.parts, + Body::from_transfer(self.body_policy, transfer), + )) } /// Buffers at most `max` bytes and deserializes JSON. /// /// # Errors - /// Returns 400 when the body is oversized or malformed, and preserves body drain errors. + /// Managed JSON-policy overflow returns 413; unmanaged overflow and malformed JSON + /// return 400. Preserves body drain errors. #[inline] pub async fn json_within(&self, max: usize) -> Result where T: DeserializeOwned, { - let bytes = self.body_bytes(max).await?; + let bytes = self.buffered_bytes(ReadKind::Json, max).await?; serde_json::from_slice(bytes.as_ref()) .map_err(|err| EdgeError::bad_request(format!("invalid JSON payload: {err}"))) } @@ -436,8 +509,10 @@ impl RequestContext { #[inline] pub fn new(request: Request, params: PathParams) -> Self { let (parts, body) = request.into_parts(); + let (body_policy, transfer) = body.into_transfer(); Self { - body: RefCell::new(BodyState::Initial(body)), + body: RefCell::new(BodyState::from_transfer(transfer)), + body_policy, config_extraction_limits: ConfigExtractionLimits::default(), ingress_grant: RefCell::new(None), monotonic_clock: MonotonicClock::default(), @@ -458,8 +533,10 @@ impl RequestContext { let (request_start, read_deadline, grant, config_extraction_limits, monotonic_clock) = ingress.into_parts(); let (parts, body) = request.into_parts(); + let (body_policy, transfer) = body.into_transfer(); Self { - body: RefCell::new(BodyState::Initial(body)), + body: RefCell::new(BodyState::from_transfer(transfer)), + body_policy, config_extraction_limits, ingress_grant: RefCell::new(Some(grant)), monotonic_clock, @@ -611,9 +688,12 @@ impl RequestContext { } } -fn check_cached_body(bytes: &bytes::Bytes, max: usize) -> Result { - if bytes.len() > max { - return Err(EdgeError::bad_request("request body too large")); +fn check_cached_body( + bytes: &bytes::Bytes, + policy: BufferPolicy, +) -> Result { + if bytes.len() > policy.cap { + return Err(policy.error("request body too large")); } Ok(bytes.clone()) } @@ -630,19 +710,19 @@ fn check_read_deadline( Ok(()) } -pub(crate) async fn drain_body( +async fn drain_body( body: Body, - max: usize, + policy: BufferPolicy, deadline: Option, monotonic_clock: &MonotonicClock, ) -> Result { check_read_deadline(deadline, monotonic_clock)?; - match body { - Body::Once(bytes) => { + match body.into_content() { + BodyContent::Once(bytes) => { check_read_deadline(deadline, monotonic_clock)?; - check_cached_body(&bytes, max) + check_cached_body(&bytes, policy) } - Body::Stream(mut stream) => { + BodyContent::Stream(mut stream) => { let mut buffered = Vec::new(); loop { check_read_deadline(deadline, monotonic_clock)?; @@ -653,11 +733,12 @@ pub(crate) async fn drain_body( return Ok(bytes::Bytes::from(buffered)); }; let chunk = result?; - let next_len = buffered.len().checked_add(chunk.len()).ok_or_else(|| { - EdgeError::bad_request("request body size accounting overflow") - })?; - if next_len > max { - return Err(EdgeError::bad_request("request body too large")); + let next_len = buffered + .len() + .checked_add(chunk.len()) + .ok_or_else(|| policy.error("request body size accounting overflow"))?; + if next_len > policy.cap { + return Err(policy.error("request body too large")); } buffered.extend_from_slice(&chunk); } @@ -683,8 +764,8 @@ pub(crate) async fn drain_body_discard( if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { return Ok(outcome); } - match body { - Body::Once(bytes) => { + match body.into_content() { + BodyContent::Once(bytes) => { if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { return Ok(outcome); } @@ -693,7 +774,7 @@ pub(crate) async fn drain_body_discard( } Ok(FallbackDrainOutcome::Complete) } - Body::Stream(mut stream) => { + BodyContent::Stream(mut stream) => { let mut consumed = 0_usize; loop { if let Some(outcome) = fallback_deadline_outcome(deadline, monotonic_clock) { @@ -727,6 +808,10 @@ pub(crate) async fn drain_body_discard( } #[cfg(test)] +#[expect( + clippy::arbitrary_source_item_ordering, + reason = "managed policy regressions stay grouped in a colocated module after shared imports" +)] mod tests { use super::*; use crate::config_store::ready_config_store_bounded_read; @@ -750,6 +835,360 @@ mod tests { use std::task::{Context, Poll}; use std::time::Duration; + mod managed_policy_tests { + use super::*; + use std::num::NonZeroUsize; + + #[test] + fn managed_form_family_keeps_caller_caps_and_typed_json_overflow() { + use crate::extractor::{Form, FromRequest as _, ValidatedForm, ValidatedFormWithin}; + use validator::Validate; + #[derive(Debug, Deserialize, Validate)] + struct Fields { + name: String, + } + let make = || { + ctx( + "/form", + managed(Body::from("name=a"), 5, true), + PathParams::default(), + ) + }; + assert_eq!( + block_on(make().form_within::(100)) + .expect_err("helper cap") + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!( + block_on(Form::::from_request(&make())) + .err() + .expect("Form cap") + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!( + block_on(ValidatedForm::::from_request(&make())) + .err() + .expect("validated Form cap") + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!( + block_on(ValidatedFormWithin::::from_request(&make())) + .err() + .expect("within cap") + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + let non_json = ctx( + "/form", + managed(Body::from("name=a"), 1, false), + PathParams::default(), + ); + assert_eq!( + block_on(Form::::from_request(&non_json)) + .expect("non-JSON generic") + .0 + .name, + "a" + ); + } + + #[test] + fn managed_source_bad_request_is_not_translated_by_message() { + let source = Body::from_stream(stream::iter([Err(EdgeError::bad_request( + "request body too large", + ))])); + let context = ctx("/source", managed(source, 1, true), PathParams::default()); + assert_eq!( + block_on(context.body_bytes(100)) + .expect_err("source error") + .status(), + StatusCode::BAD_REQUEST + ); + } + + fn managed(body: Body, cap: usize, is_json: bool) -> Body { + body.with_buffered_json_policy(NonZeroUsize::new(cap).expect("positive cap"), is_json) + } + + #[test] + fn managed_partial_cancellation_drops_source_once_and_poisons() { + struct DropSignal(Rc>); + impl Drop for DropSignal { + fn drop(&mut self) { + self.0.set(self.0.get().saturating_add(1)); + } + } + let drops = Rc::new(Cell::new(0_usize)); + let signal = DropSignal(Rc::clone(&drops)); + let polls = Rc::new(Cell::new(0_usize)); + let seen = Rc::clone(&polls); + let source = Body::from_stream(stream::poll_fn(move |_| { + let _retained = &signal; + let step = seen.get(); + seen.set(step.saturating_add(1)); + if step == 0 { + Poll::Ready(Some(Ok(Bytes::from_static(b"ab")))) + } else { + Poll::Pending + } + })); + let context = ctx("/body", managed(source, 4, true), PathParams::default()); + let mut reading = Box::pin(context.body_bytes(100)); + let mut task = Context::from_waker(noop_waker_ref()); + assert!(matches!(reading.as_mut().poll(&mut task), Poll::Pending)); + assert_eq!(polls.get(), 2); + assert_eq!(context.body_kind(), BodyKind::Draining); + drop(reading); + assert_eq!(drops.get(), 1); + assert_eq!(context.body_kind(), BodyKind::Poisoned); + let error = block_on(context.body_bytes(100)).expect_err("cancel poison"); + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(error.message().contains("inbound body drain cancelled")); + assert_eq!( + context + .into_request() + .expect_err("sticky reconstruction") + .status(), + error.status() + ); + assert_eq!(polls.get(), 2); + assert_eq!(drops.get(), 1); + } + + #[test] + fn managed_deadline_tie_wins_over_overflow_without_repolling() { + let start = MonotonicInstant::now(); + let deadline = start.checked_add(Duration::from_secs(1)).expect("deadline"); + let now = Arc::new(Mutex::new(start)); + let poll_clock = Arc::clone(&now); + let observed_clock = Arc::clone(&now); + let polls = Rc::new(Cell::new(0_usize)); + let seen = Rc::clone(&polls); + let source = Body::from_stream(stream::poll_fn(move |_| { + seen.set(seen.get().saturating_add(1)); + *poll_clock.lock().expect("clock") = deadline; + Poll::Ready(Some(Ok(Bytes::from_static(b"oversized")))) + })); + let mut context = ctx("/body", managed(source, 1, true), PathParams::default()); + context.monotonic_clock = + MonotonicClock::new(move || *observed_clock.lock().expect("clock")); + context.read_deadline = Some(Deadline::at_instant(deadline)); + let error = block_on(context.body_bytes(100)).expect_err("deadline wins"); + assert!(matches!(error, EdgeError::RequestTimeout { .. })); + *now.lock().expect("clock") = start; + assert_eq!( + block_on(context.body_bytes(100)) + .expect_err("sticky deadline") + .status(), + StatusCode::REQUEST_TIMEOUT + ); + assert_eq!(context.body_kind(), BodyKind::Poisoned); + assert_eq!(polls.get(), 1); + } + + #[test] + fn managed_routed_reconstruction_restores_initial_and_cache_after_head_mutation() { + use crate::app::App; + use crate::ingress::{ + IngressBeginOutcome, IngressFraming, IngressHeadAccounting, IngressHeadParts, + }; + use crate::router::RouterService; + for cached in [false, true] { + let original = ctx( + "/body", + managed(Body::from("hello"), 5, true), + PathParams::default(), + ); + let retained = cached.then(|| block_on(original.body_bytes(5)).expect("cache")); + let mut request = original.into_request().expect("reconstruct"); + request + .headers_mut() + .insert("content-type", HeaderValue::from_static("text/plain")); + request.extensions_mut().clear(); + request.extensions_mut().insert(99_u32); + let app = App::new(RouterService::builder().build()); + let head = IngressHeadParts::from_request( + &request, + IngressHeadAccounting::HostManaged, + IngressFraming::HostManaged, + ); + let IngressBeginOutcome::Admitted(prepared) = app + .begin_ingress(head, app.monotonic_now()) + .expect("admission") + else { + panic!("admit"); + }; + let (_resolved, ingress, _completion) = prepared.into_parts(); + let metadata = RouteMetadata::new(Method::GET, "/body"); + let routed = RequestContext::new_routed( + request, + PathParams::default(), + metadata.clone(), + ingress, + ); + assert_eq!(routed.route_metadata(), Some(&metadata)); + assert_eq!(routed.extensions().get::(), Some(&99)); + assert_eq!( + block_on(routed.body_bytes(4)) + .expect_err("captured JSON cap") + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + if let Some(bytes) = retained { + assert_eq!(routed.body_kind(), BodyKind::Cached { len: 5 }); + assert_eq!( + block_on(routed.body_bytes(100)) + .expect("cache retry") + .as_ptr(), + bytes.as_ptr() + ); + } else { + assert_eq!(routed.body_kind(), BodyKind::Poisoned); + assert_eq!( + block_on(routed.body_bytes(100)) + .expect_err("initial sticky") + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + } + } + } + + #[test] + fn managed_initial_reconstruction_preserves_policy_without_polling() { + let polls = Rc::new(Cell::new(0_usize)); + let observed = Rc::clone(&polls); + let initial_source = Body::from_stream(stream::poll_fn(move |_| { + observed.set(observed.get() + 1); + Poll::Ready(Some(Ok(Bytes::from_static(b"oversized")))) + })); + let original = ctx( + "/body", + managed(initial_source, 3, true), + PathParams::default(), + ); + let mut request = original.into_request().expect("initial reconstruction"); + request + .headers_mut() + .insert("content-type", HeaderValue::from_static("text/plain")); + request.extensions_mut().clear(); + let (parts, body) = request.into_parts(); + let reconstructed = RequestContext::new( + Request::from_parts(parts, managed(body, 100, false)), + PathParams::default(), + ); + assert_eq!(polls.get(), 0); + let error = block_on(reconstructed.body_bytes(100)).expect_err("original ceiling"); + assert_eq!(error.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(error.kind(), "payload_too_large"); + assert_eq!(polls.get(), 1); + assert_eq!( + block_on(reconstructed.body_bytes(1000)) + .expect_err("sticky") + .status(), + error.status() + ); + assert_eq!( + reconstructed.take_body().expect_err("sticky take").status(), + error.status() + ); + assert_eq!( + reconstructed + .into_request() + .expect_err("sticky reconstruction") + .status(), + error.status() + ); + assert_eq!(polls.get(), 1); + } + + #[test] + fn managed_cached_reconstruction_reuses_allocation_and_stateless_rechecks() { + let original = ctx( + "/body", + managed(Body::from("hello"), 5, true), + PathParams::default(), + ); + let bytes = block_on(original.body_bytes(5)).expect("exact cap"); + let mut request = original.into_request().expect("cached reconstruction"); + request.headers_mut().clear(); + request.extensions_mut().clear(); + let (parts, body) = request.into_parts(); + let reconstructed = RequestContext::new( + Request::from_parts(parts, managed(body, 100, false)), + PathParams::default(), + ); + assert_eq!(reconstructed.body_kind(), BodyKind::Cached { len: 5 }); + assert_eq!( + block_on(reconstructed.body_bytes(4)) + .expect_err("stricter cap") + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + let retry = block_on(reconstructed.body_bytes(100)).expect("intact cache"); + assert_eq!(bytes.as_ptr(), retry.as_ptr()); + assert_eq!(reconstructed.body_kind(), BodyKind::Cached { len: 5 }); + } + + #[test] + fn managed_json_helpers_cap_non_json_ingress_but_generic_reads_do_not() { + let original = ctx( + "/body", + managed(Body::from("\"abcdef\""), 4, false), + PathParams::default(), + ); + let bytes = block_on(original.body_bytes(20)).expect("non-JSON generic read"); + let error = block_on(original.json_within::(20)).expect_err("JSON policy"); + assert_eq!(error.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(original.body_kind(), BodyKind::Cached { len: 8 }); + let request = original.into_request().expect("cached round trip"); + let reconstructed = RequestContext::new(request, PathParams::default()); + assert_eq!( + block_on(reconstructed.body_bytes(20)) + .expect("generic retry") + .as_ptr(), + bytes.as_ptr() + ); + assert_eq!( + block_on(reconstructed.json_within::(20)) + .expect_err("preserved ceiling") + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + } + + #[test] + fn managed_initial_once_overflow_is_sticky_and_taken_content_is_unmanaged() { + let initial = ctx( + "/body", + managed(Body::from("large"), 2, true), + PathParams::default(), + ); + assert_eq!( + block_on(initial.body_bytes(10)) + .expect_err("overflow") + .status(), + StatusCode::PAYLOAD_TOO_LARGE + ); + assert_eq!(initial.body_kind(), BodyKind::Poisoned); + let taken = ctx( + "/body", + managed(Body::from("large"), 2, true), + PathParams::default(), + ) + .take_body() + .expect("take"); + assert!(matches!(taken, Body::Once(_))); + assert_eq!( + block_on(taken.into_bytes_bounded(5)).expect("manual cap"), + "large" + ); + } + } + struct DummyOutboundClient; #[derive(Debug, PartialEq, Deserialize, Serialize)] diff --git a/crates/edgezero-core/src/error.rs b/crates/edgezero-core/src/error.rs index 66ead0991..d53c0cb3c 100644 --- a/crates/edgezero-core/src/error.rs +++ b/crates/edgezero-core/src/error.rs @@ -168,6 +168,9 @@ pub enum EdgeError { NotFound { path: String }, #[error("not implemented: {message}")] NotImplemented { message: String }, + /// Framework-managed request buffering exceeded its effective cap. HTTP 413. + #[error("{message}")] + PayloadTooLarge { message: String }, #[error("{message}")] RequestHeaderFieldsTooLarge { message: String }, #[error("{message}")] @@ -259,7 +262,8 @@ impl EdgeError { pub fn inner(&self) -> Option<&AnyError> { match self { EdgeError::Internal { source } => Some(source), - EdgeError::BadGateway { .. } + EdgeError::PayloadTooLarge { .. } + | EdgeError::BadGateway { .. } | EdgeError::BadRequest { .. } | EdgeError::ConfigOutOfDate { .. } | EdgeError::GatewayTimeout { .. } @@ -299,6 +303,7 @@ impl EdgeError { EdgeError::MethodNotAllowed { .. } => "method_not_allowed", EdgeError::NotFound { .. } => "not_found", EdgeError::NotImplemented { .. } => "not_implemented", + EdgeError::PayloadTooLarge { .. } => "payload_too_large", EdgeError::RequestHeaderFieldsTooLarge { .. } => "request_header_fields_too_large", EdgeError::RequestTimeout { .. } => "request_timeout", EdgeError::ResponseTooLarge { .. } => "response_too_large", @@ -314,6 +319,7 @@ impl EdgeError { pub fn message(&self) -> String { match self { EdgeError::BadGateway { message, .. } + | EdgeError::PayloadTooLarge { message } | EdgeError::BadRequest { message } | EdgeError::ConfigOutOfDate { message, .. } | EdgeError::GatewayTimeout { message, .. } @@ -360,6 +366,13 @@ impl EdgeError { } } + #[inline] + pub fn payload_too_large>(message: S) -> Self { + Self::PayloadTooLarge { + message: message.into(), + } + } + #[inline] pub fn request_header_fields_too_large>(message: S) -> Self { Self::RequestHeaderFieldsTooLarge { @@ -433,6 +446,7 @@ impl EdgeError { EdgeError::NotFound { .. } => StatusCode::NOT_FOUND, EdgeError::MethodNotAllowed { .. } => StatusCode::METHOD_NOT_ALLOWED, EdgeError::NotImplemented { .. } => StatusCode::NOT_IMPLEMENTED, + EdgeError::PayloadTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE, EdgeError::StoreExtraction { reason, .. } => reason.wire_status(), EdgeError::UriTooLong { .. } => StatusCode::URI_TOO_LONG, EdgeError::Internal { .. } => StatusCode::INTERNAL_SERVER_ERROR, @@ -478,7 +492,8 @@ impl EdgeError { pub fn store_extraction_reason(&self) -> Option { match self { Self::StoreExtraction { reason, .. } => Some(*reason), - Self::BadGateway { .. } + Self::PayloadTooLarge { .. } + | Self::BadGateway { .. } | Self::BadRequest { .. } | Self::ConfigOutOfDate { .. } | Self::GatewayTimeout { .. } @@ -513,6 +528,7 @@ impl EdgeError { match self { EdgeError::BadGateway { .. } => "bad gateway".to_owned(), EdgeError::GatewayTimeout { .. } => "gateway timeout".to_owned(), + EdgeError::PayloadTooLarge { .. } => "request body too large".to_owned(), EdgeError::Internal { .. } => "internal server error".to_owned(), EdgeError::ResponseTooLarge { .. } => { "upstream response exceeded configured limits".to_owned() @@ -569,7 +585,8 @@ impl IntoResponse for EdgeError { field_path: Some(field_path), .. } => Some(field_path.as_str()), - EdgeError::BadGateway { .. } + EdgeError::PayloadTooLarge { .. } + | EdgeError::BadGateway { .. } | EdgeError::BadRequest { .. } | EdgeError::ConfigOutOfDate { .. } | EdgeError::GatewayTimeout { .. } @@ -945,6 +962,7 @@ mod tests { | EdgeError::ServiceUnavailable { .. } | EdgeError::StoreExtraction { .. } | EdgeError::UriTooLong { .. } + | EdgeError::PayloadTooLarge { .. } | EdgeError::Validation { .. } => panic!("expected ConfigOutOfDate"), } } diff --git a/crates/edgezero-core/src/extractor.rs b/crates/edgezero-core/src/extractor.rs index 3971e1455..1c2bc2308 100644 --- a/crates/edgezero-core/src/extractor.rs +++ b/crates/edgezero-core/src/extractor.rs @@ -14,7 +14,7 @@ use validator::Validate; use crate::app_config::{AppConfigMeta, SecretField, SecretKind, SecretPathSegment}; use crate::blob_envelope::{BlobEnvelope, BlobEnvelopeError}; use crate::config_store::{ConfigExtractionLimits, ConfigStoreError, ConfigStoreHandle}; -use crate::context::{DEFAULT_INBOUND_FORM_BYTES, DEFAULT_INBOUND_JSON_BYTES, RequestContext}; +use crate::context::{DEFAULT_INBOUND_FORM_BYTES, RequestContext}; use crate::error::{EdgeError, StoreExtractionReason}; use crate::http::HeaderMap; use crate::secret_store::SecretError; @@ -39,7 +39,7 @@ where { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.json_within(DEFAULT_INBOUND_JSON_BYTES).await.map(Json) + ctx.json_within(ctx.json_body_default()).await.map(Json) } } diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 7584da69d..fb47bdd14 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -50,7 +50,7 @@ pub mod store_registry; pub mod test_env; pub mod time; -pub use body::{Body, BodyStream}; +pub use body::{Body, BodyContent, BodyStream}; pub use compression::{ BROTLI_DECODER_FIXED_CHARGE_BYTES, ContentEncoding, FLATE_DECODER_FIXED_CHARGE_BYTES, PassthroughReason, brotli_decoder_memory_charge, classify_content_encoding, diff --git a/crates/edgezero-core/src/outbound.rs b/crates/edgezero-core/src/outbound.rs index c5c1f0999..700b31ae9 100644 --- a/crates/edgezero-core/src/outbound.rs +++ b/crates/edgezero-core/src/outbound.rs @@ -14,7 +14,7 @@ use serde::Serialize; use serde::de::DeserializeOwned; use url::Url; -use crate::body::{Body, BodyStream}; +use crate::body::{Body, BodyContent, BodyStream}; use crate::compression::ContentEncoding; use crate::error::{BadGatewayDecodeReason, BadGatewayReason, EdgeError, ResponseLimitReason}; use crate::http::header::{ @@ -148,6 +148,10 @@ pub enum OutboundBatchTermination { /// `Finished(Completed)`; completion is not inferred from the number of items observed. #[derive(Debug)] #[non_exhaustive] +#[expect( + clippy::large_enum_variant, + reason = "the body carrier adds one word to terminal items; preserve the public unboxed batch contract" +)] pub enum OutboundBatchNext { /// The batch reached its normal or cutoff terminal state. Finished(OutboundBatchTermination), @@ -535,9 +539,9 @@ impl OutboundResponse { /// Returns a typed source error or `BufferedBody` response-limit error. #[inline] pub async fn into_bytes_bounded(self, max: u64) -> Result { - match self.body { - Body::Once(bytes) => validate_buffered_response_bytes(bytes, max), - Body::Stream(stream) => collect_response_stream(stream, max).await, + match self.body.into_content() { + BodyContent::Once(bytes) => validate_buffered_response_bytes(bytes, max), + BodyContent::Stream(stream) => collect_response_stream(stream, max).await, } } @@ -554,13 +558,13 @@ impl OutboundResponse { ) -> Result { let clock = self.monotonic_clock.clone(); ensure_deadline_live(deadline, &clock)?; - match self.body { - Body::Once(bytes) => { + match self.body.into_content() { + BodyContent::Once(bytes) => { let outcome = validate_buffered_response_bytes(bytes, max); ensure_deadline_live(deadline, &clock)?; outcome } - Body::Stream(stream) => { + BodyContent::Stream(stream) => { collect_response_stream_until_with_clock(stream, max, deadline, &clock).await } } @@ -609,7 +613,7 @@ impl OutboundResponse { where Value: DeserializeOwned, { - let Body::Once(bytes) = &self.body else { + let Some(bytes) = self.body.as_bytes() else { return Err(EdgeError::bad_gateway_with_reason( "response body not buffered; use json_bounded(max) or json_bounded_until(max, deadline)", BadGatewayReason::Protocol, @@ -667,7 +671,7 @@ impl OutboundResponse { monotonic_clock: MonotonicClock, ) -> Self { Self { - body, + body: body.into_content().into(), headers, monotonic_clock, request_method, @@ -696,7 +700,7 @@ impl OutboundRequest { where BodyValue: Into, { - self.body = body.into(); + self.body = body.into().into_content().into(); self } @@ -736,7 +740,7 @@ impl OutboundRequest { pub fn from_parts(parts: OutboundRequestParts) -> Result { let canonical_uri = canonicalize_typed_uri(&parts.uri)?; Ok(Self { - body: parts.body, + body: parts.body.into_content().into(), cache_policy: parts.cache_policy, deadline: parts.deadline, headers: parts.headers, @@ -775,7 +779,7 @@ impl OutboundRequest { pub fn from_request(request: Request, target: Uri) -> Result { let (parts, body) = request.into_parts(); let mut outbound = Self::new(parts.method, target)?; - outbound.body = body; + outbound.body = body.into_content().into(); outbound.headers = parts.headers; normalize_request_headers(&mut outbound.headers)?; Ok(outbound) @@ -1321,14 +1325,14 @@ pub fn validate_for_dispatch(request: &OutboundRequest) -> Result<(), EdgeError> } if matches!(request.method, Method::GET | Method::HEAD) { - match &request.body { - Body::Once(bytes) if bytes.is_empty() => {} - Body::Once(_) => { + match request.body.as_bytes() { + Some([]) => {} + Some(_) => { return Err(EdgeError::bad_request( "GET/HEAD request must not carry a body", )); } - Body::Stream(_) => { + None => { return Err(EdgeError::bad_request( "GET/HEAD request must not carry a streamed body; emptiness cannot be determined without consuming the stream", )); @@ -1795,6 +1799,44 @@ mod tests { ) } + #[test] + fn managed_outbound_inputs_strip_policy_and_preserve_bytes() { + use std::num::NonZeroUsize; + let bytes = Bytes::from_static(b"\"value\""); + let tagged = || { + Body::from_bytes(bytes.clone()) + .with_buffered_json_policy(NonZeroUsize::new(1).expect("cap"), true) + }; + let request = OutboundRequest::post("https://example.com/") + .expect("target") + .body(tagged()); + let parts = request.into_parts(); + assert!(matches!(&parts.body, Body::Once(_))); + let rebuilt = OutboundRequest::from_parts(parts).expect("parts"); + assert!(matches!(rebuilt.into_parts().body, Body::Once(_))); + let inbound = request_builder() + .method(Method::POST) + .body(tagged()) + .expect("inbound"); + let forwarded = + OutboundRequest::from_request(inbound, "https://example.com/".parse().expect("target")) + .expect("forward"); + assert!(matches!(forwarded.into_parts().body, Body::Once(_))); + let response = OutboundResponse::new( + Method::GET, + StatusCode::OK, + HeaderMap::new(), + tagged(), + MonotonicClock::default(), + ); + assert!(matches!(response.body(), Body::Once(_))); + assert_eq!( + response.body().as_bytes().expect("bytes").as_ptr(), + bytes.as_ptr() + ); + assert_eq!(response.json::().expect("JSON"), "value"); + } + #[test] fn finish_batch_item_preserves_an_on_time_terminal_outcome() { let started_at = MonotonicInstant::now(); diff --git a/crates/edgezero-core/src/response.rs b/crates/edgezero-core/src/response.rs index 8040035b5..4dcd4dff2 100644 --- a/crates/edgezero-core/src/response.rs +++ b/crates/edgezero-core/src/response.rs @@ -93,9 +93,10 @@ where pub fn response_with_body(status: StatusCode, body: Body) -> Result { use crate::http::response_builder; + let plain_body = Body::from(body.into_content()); let mut builder = response_builder().status(status); - if let Body::Once(bytes) = &body + if let Some(bytes) = plain_body.as_bytes() && !bytes.is_empty() { builder = builder @@ -106,13 +107,29 @@ pub fn response_with_body(status: StatusCode, body: Body) -> Result Result { + let content = mem::take(response.body_mut()).into_content(); + *response.body_mut() = content.into(); let mut declared_length = parse_content_length(response.headers())?; if response.status().is_informational() { return Err(ResponseEgressFramingError::UnsupportedStatus); @@ -118,22 +120,19 @@ pub fn prepare_response_egress( .map_err(|_error| ResponseEgressFramingError::InvalidContentLength)?; response.headers_mut().insert(CONTENT_LENGTH, canonical); if !suppress_body { - match response.body() { - Body::Once(bytes) => { + *response.body_mut() = match mem::take(response.body_mut()).into_content() { + BodyContent::Once(bytes) => { let actual = u64::try_from(bytes.len()) .map_err(|_error| ResponseEgressFramingError::ContentLengthMismatch)?; if actual != length { return Err(ResponseEgressFramingError::ContentLengthMismatch); } + Body::from_bytes(bytes) } - Body::Stream(_) => { - let body = mem::take(response.body_mut()); - if let Body::Stream(stream) = body { - *response.body_mut() = - Body::from_stream(limit_declared_stream(stream, length)); - } + BodyContent::Stream(stream) => { + Body::from_stream(limit_declared_stream(stream, length)) } - } + }; } } @@ -236,6 +235,7 @@ pub fn response_egress_source_error_category(error: &EdgeError) -> &'static str EdgeError::MethodNotAllowed { .. } => "method_not_allowed", EdgeError::NotFound { .. } => "not_found", EdgeError::NotImplemented { .. } => "not_implemented", + EdgeError::PayloadTooLarge { .. } => "payload_too_large", EdgeError::RequestHeaderFieldsTooLarge { .. } => "request_header_fields_too_large", EdgeError::RequestTimeout { .. } => "request_timeout", EdgeError::ResponseTooLarge { reason, .. } => match reason { @@ -359,6 +359,27 @@ mod tests { error } + #[test] + fn raw_managed_response_builders_are_normalized_at_egress() { + use std::num::NonZeroUsize; + for streaming in [false, true] { + let raw = if streaming { + Body::stream(stream::iter([Bytes::from_static(b"reply")])) + } else { + Body::from("reply") + }; + let body = raw.with_buffered_json_policy(NonZeroUsize::new(1).expect("cap"), true); + let response = Response::new(body); + let prepared = prepare_response_egress(&Method::GET, response).expect("framing"); + let normalized = prepared.into_response(); + assert!(!matches!(normalized.body(), Body::Managed(_))); + assert_eq!( + block_on(normalized.into_body().into_bytes_bounded(5)).expect("egress caller cap"), + "reply" + ); + } + } + #[test] fn normalizes_duplicate_content_length_and_strips_connection_fields() { let source_response = response_builder() diff --git a/docs/guide/adapters/axum.md b/docs/guide/adapters/axum.md index a68696f58..c85fbdefb 100644 --- a/docs/guide/adapters/axum.md +++ b/docs/guide/adapters/axum.md @@ -80,6 +80,48 @@ cargo build -p my-app-adapter-axum --release The binary is placed in `target/release/my-app-adapter-axum`. +## Buffered JSON body limits + +Every standard Axum hosting path defaults framework-managed JSON buffering to 2 MiB, +exactly 2,097,152 bytes. Valid JSON at the effective limit succeeds; one byte over returns +HTTP 413. Malformed JSON within the limit remains HTTP 400. + +For standalone development or production, select decimal integer bytes at startup: + +```bash +EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES=4194304 cargo run -p my-app-adapter-axum +``` + +The value must be positive and representable by the collector. Zero, signs, whitespace, +suffixes, fractions, overflowing values, and non-Unicode values fail startup before application +configuration or readiness. There is no unlimited sentinel, CLI flag, or manifest setting. +The selected value is captured once per run. + +For explicit production embedding or a router-only server, use the fallible builder: + +```rust +use edgezero_adapter_axum::dev_server::{AxumDevServer, AxumRunOptions}; + +let options = AxumRunOptions::new("127.0.0.1:8787".parse()?)? + .with_json_body_limit_bytes(4 * 1024 * 1024)?; +let server = AxumDevServer::new(router).with_json_body_limit_bytes(4 * 1024 * 1024)?; +``` + +Explicit options and router builders do not merge ambient settings. `AxumRunOptions::from_env` +is the deliberate environment-driven alternative. The low-level `into_core_request` converter +attaches the finite default without reading this environment setting. + +Ordinary `Json` and `ValidatedJson` use the selected ceiling, including overrides above the +portable 8 MiB fallback. Explicit JSON caps take the minimum of the caller cap and native ceiling. +JSON helpers enforce it regardless of Content-Type; generic buffered helpers enforce it for the +original `application/json` or `application/*+json` ingress classification. Ordinary Form retains +its 1 MiB caller cap. Header/extension mutation and context reconstruction cannot loosen policy. + +Taken streams and proxy forwarding receive no new aggregate size cap. Manual body collection +keeps its supplied cap and existing 400 overflow behavior. The ceiling bounds logical buffered +body bytes, not parsed JSON allocations, host buffers, concurrent requests, or process memory. +It does not promise client receipt or keep-alive reuse after rejection. + ## Outbound HTTP The Axum adapter injects `AxumOutboundClient`, backed by `reqwest`. Application handlers use the diff --git a/docs/guide/handlers.md b/docs/guide/handlers.md index 7ab055204..337d7383d 100644 --- a/docs/guide/handlers.md +++ b/docs/guide/handlers.md @@ -97,6 +97,28 @@ async fn login(Json(body): Json) -> Text { } ``` +#### Buffered body limits + +Portable ordinary JSON extraction defaults to 8 MiB. On Axum, ordinary `Json` and +`ValidatedJson` instead use the native ceiling, which defaults to 2 MiB, exactly 2,097,152 bytes. +See [Axum body-limit configuration](./adapters/axum.md#buffered-json-body-limits). + +`json_within(max)` and `ValidatedJsonWithin` may tighten that ceiling but cannot +raise it. Native JSON-policy overflow returns typed HTTP 413; malformed JSON remains 400. +JSON helpers apply the ceiling even without a JSON Content-Type. Generic buffered reads, +including `body_bytes`, `form_within`, and the Form family, also apply it on original +`application/json` or `application/*+json` ingress. Ordinary Form keeps its 1 MiB caller cap; +non-JSON generic reads keep their existing caps and 400 overflow behavior. + +The original ceiling and classification survive `into_request` followed by fresh or routed +context construction, even after headers or extensions change. Successful cached reconstruction +reuses the same bytes and retains non-poisoning stricter rechecks. Initial overflow stays sticky. + +`take_body` and stream forwarding remain incremental without a new total-size cap. Low-level +manual collection uses only its supplied cap. Public `Body` is non-exhaustive; ordinary callers +should prefer its accessors. Transport code can match both cases of the exhaustive `BodyContent` +returned by `Body::into_content`, which deliberately removes ingress metadata. + ### Validated Extractors Use `validator` crate integration for input validation: diff --git a/docs/superpowers/plans/2026-10-06-axum-buffered-json-body-limit.md b/docs/superpowers/plans/2026-10-06-axum-buffered-json-body-limit.md new file mode 100644 index 000000000..efd7d562a --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-axum-buffered-json-body-limit.md @@ -0,0 +1,415 @@ +# Axum buffered JSON request-body limit implementation plan + +Date: 2026-10-06 +Status: implementation present on the user-approved current hosting base. Native wire, workspace, docs, and WASM checks passed. Accepted #275 integration/re-audit remains deferred until the later rebase; provider runtime certification is not claimed. + +Issue: [#393](https://github.com/stackpop/edgezero/issues/393). +Spec: [Axum configurable buffered JSON request-body limits](../specs/2026-10-06-axum-buffered-json-body-limit-design.md). + +## Outcome and boundaries + +Through the standard Axum service, framework-managed JSON buffering defaults to exactly 2,097,152 bytes. Valid JSON exactly at the effective cap succeeds; one byte over returns typed HTTP 413. The existing collector checks actual bytes before append, stops polling rejected sources, and retains the existing cache, poison, deadline, and response-completion contracts. + +The configured ceiling also governs JSON helpers without a matching Content-Type, and generic buffered reads with captured JSON/+json ingress classification. Explicit application caps can tighten it. Ordinary JSON uses the native default instead of the portable 8 MiB fallback. Ordinary Form retains its 1 MiB caller cap. Other adapters retain their current defaults and overflow behavior. + +Taken streams, proxy forwarding, fallback discard reads, and application-owned manual buffering do not acquire a native JSON total-size cap. Do not add concurrency, queues, deadlines, handler cancellation, a new runner, a second collector, Tokio in core, or blanket Send bounds. The cap bounds logical collector/cache bytes, not process memory, allocations already made by the host, parsed JSON, or concurrent requests. + +## Inspected baseline and evidence limits + +| Item | Inspected state | +| --- | --- | +| Worktree | `/home/pav/worktrees/issue-393-buffered-body-limit-spec` | +| HEAD / parent hosting integration | `3ab22096fd117f7506cf437b5ba128e0789e059c` | +| Parent branch | `issue-392-396-production-hosting-spec` | +| Inherited #275 revision | `5527b58e2a7cb6307d5c7a8926cd5dfc6f4f8b4a` | +| GitHub #275 on 2026-10-06 | Open, head `950cb81d406d0dc269baf94314547329a4c1cef0`; not treated as accepted or integrated | +| Governing spec SHA256 | `6fb96ceead6002d3b85a1615e14fac43a51510e0294803912d83ad31748bf4a6` | + +`body.rs`, `http.rs`, `context.rs`, and Axum `request.rs` are unchanged between inspected HEAD and that #275 head. They establish the ownership constraint below. Other differences are not an integration prescription. In particular, the inspected #275 head prepares Axum response egress asynchronously, whereas this hosting base uses synchronous preparation. Preserve the parent startup/shutdown work when integrating the accepted prerequisite. + +The task checklists below retain the reviewed implementation sequence. Current results are recorded in the execution section, not inferred from those original unchecked items. Re-pin file locations, signatures, tests, and this plan after integration or any governing-spec change. + +Core file paths below are under `crates/edgezero-core/`; Axum paths are under `crates/edgezero-adapter-axum/`. The other adapter roots are `crates/edgezero-adapter-cloudflare/`, `crates/edgezero-adapter-fastly/`, and `crates/edgezero-adapter-spin/`. + +## Deferred gate 1: accept and integrate the prerequisite + +After final plan review, the user explicitly authorized implementation on this current hosting base without waiting for #275 acceptance or integration. Rebase later. The following accepted-baseline checks remain open; they no longer block provisional implementation. + +- [ ] Record the accepted #275 commit and the resulting hosting integration commit. Obtain approval for any branch integration operation; preserve the untracked spec and plan. +- [ ] Re-audit admitted request conversion, context collection/cache, error rendering, fallback discard, response egress, and native hosting entrypoints. Identify checks already satisfied by the accepted prerequisite and remove redundant implementation tasks. +- [ ] Verify head checks and admission still precede native body polling. Verify refusals, aborts, and ordinary 404/405 remain no-read paths. +- [ ] Run the baseline focused suites and record revision, features, result, elapsed time, and log: + +```sh +cargo test -p edgezero-core --all-targets +cargo test -p edgezero-adapter-axum --all-targets --all-features +``` + +Do not implement against the retired eager `to_bytes(..., usize::MAX)` path. Do not claim acceptance from an open PR head or from a direct tree comparison. Unrelated baseline failures must be reproduced and recorded separately; exclusions remain coverage gaps. + +## Gate 2: confirm the approved body API against the prerequisite + +On 2026-10-06, the user approved the body-owned carrier refactor, making public `Body` non-exhaustive, and the bounded shared-body consumer migration. Record that approval separately from prerequisite acceptance or implementation evidence. The contract below must still be checked against the accepted integration revision. + +### Why a carrier is necessary + +At the inspected base: + +- `crates/edgezero-core/src/http.rs:33` makes `Request` an alias for `http::Request`. +- `body.rs:15` exposes an exhaustive `Body::Once(Bytes) | Body::Stream(BodyStream)` enum, without a private metadata slot. +- `context.rs:374` reconstructs a request using only request parts and body. Its constructors at `437` and `452` create fresh context state. +- Public request/context headers and extensions can be changed or replaced. Router app-state extensions are applied before context construction at `router.rs:399`. + +A context field alone disappears during reconstruction. An extension alone can be cleared. Wrapping the stream with a byte limiter would violate the streaming exclusion and would not survive cached reconstruction. + +### Approved API contract + +Add a metadata-only opaque body variant and `#[non_exhaustive]` to public `Body`, keeping the existing `Request` alias, builders, parts API, and plain `Body` variants. Use the following planned names unless the accepted prerequisite already supplies equivalent APIs: + +```text +Body, public #[non_exhaustive] enum: + Once(Bytes) + Stream(BodyStream) + Managed(ManagedBody) + +ManagedBody, opaque public payload: + private policy + private transfer + +Policy, private: + native ceiling: NonZeroUsize + original ingress-is-JSON: bool + +Transfer, private: + Initial(BodyContent) + Cached(Bytes) + +BodyContent, public exhaustive enum: + Once(Bytes) + Stream(BodyStream) + +Body::with_buffered_json_policy( + self, native_ceiling: NonZeroUsize, ingress_is_json: bool +) -> Body +Body::into_content(self) -> BodyContent +``` + +`BodyContent` cannot contain `Managed`, so nested wrappers are structurally impossible. The ordinary JSON default derives from the ceiling; do not store a second numeric default. The adapter-facing attachment method receives the already-validated positive ceiling and captured classification; it does not parse configuration or poll/copy body content. Initial attachment records Initial provenance. Reattaching policy to an already managed body must retain the original policy and provenance, not replace them. + +Context construction consumes the carrier into private policy and the existing `BodyState`. Successful `into_request` restores the carrier. Only core reassembly creates cached transfer provenance. Initial bodies remain initial, including low-level initial `Once` bodies. Managed cached reconstruction restores `Cached(Bytes)` so a stricter cached check remains stateless and a permitted retry still succeeds. Bare unmanaged requests keep their existing reconstruction behavior. + +`take_body` returns plain content and discards policy/provenance. No grant, clock, deadline, request-start timestamp, route metadata, or response completion is added to the carrier. Existing native deadline wrappers remain owned by their streams. + +`Body::into_content` is the consuming raw-content handoff. It removes policy/provenance without polling, collecting, copying bytes, or adding a size cap. It is deliberately not used for context reconstruction; that path uses core-private transfer to preserve policy and cache state. Direct low-level collection, outbound construction, and response egress must not resolve the ingress JSON policy. Borrowing accessors retain logical buffered/streamed behavior. + +All adapter transport branches use the exhaustive `BodyContent` view, not wildcard matches on raw `Body`. Core maps every raw `Body` variant exhaustively to that view, so future variants require deliberate core normalization. Ordinary consumers should prefer existing accessors. No borrowed view enum, new Request wrapper, or BodyStream alias migration is proposed. + +### Compatibility cost and recorded approval + +Both adding `Managed` and marking the currently exhaustive `Body` enum non-exhaustive are source-breaking. External raw-Body matches require a wildcard even when they name every current variant; matches inside edgezero-core can remain exhaustive. Document both breaks together and recommend accessors or `Body::into_content`/`BodyContent` rather than treating wildcard fallbacks as transport normalization. + +The content view deliberately remains exhaustive because its two cases describe logical buffered/streamed ownership, not provider wrappers or policy states. Adapters handle both cases without a wildcard. Do not make `BodyContent` non-exhaustive or silently drop unknown raw bodies. + +- [x] User approved the carrier/non-exhaustive refactor, exhaustive content view, cached provenance, and the bounded compatibility scope on 2026-10-06. +- [x] Governing spec affected surfaces aligned with core body/API changes and all four adapters' shared-body consumers. +- [ ] Confirm the accepted prerequisite has not supplied a smaller equivalent mechanism or changed this contract. + +A protected `Request` wrapper would also preserve policy, but changes type identity, raw builder results, request parts/conversions, and more application consumers. Do not choose it here. Context/extension-only policy is smaller but would require explicit approval to narrow the reconstruction guarantee in the spec. Do not silently substitute it. + +If the carrier migration grows beyond the approved file set or requires new lifecycle machinery, stop and re-review scope. Final pre-implementation review completed without verified findings. The user separately authorized execution on the provisional base; accepted-prerequisite contract confirmation and affected acceptance must still be repeated after the deferred rebase. + +```mermaid +flowchart TD + A[Approved current hosting base and reviewed plan] --> D[Body ownership and typed error] + D --> E[Existing collector and extractor policy] + E --> F[Native configuration and service wiring] + F --> G[Real HTTP1 checks and cleanup proof] + G --> H[Documentation and cross-target verification] + H --> I[Later accepted prerequisite rebase] + I --> B{Accepted contracts still match?} + B -->|No| C[Stop and re-review changed scope] + B -->|Yes| J[Repeat affected acceptance] +``` + +## Task 1: implement non-exhaustive Body, durable ownership, and typed 413 + +Files: + +- Core `src/body.rs`, `src/context.rs`, `src/error.rs`. +- Core `src/outbound.rs`, `src/response.rs`, `src/response_egress_framing.rs`. +- Each adapter's `src/outbound.rs` and `src/response.rs`, only for shared-body compatibility. +- Axum `src/request.rs` shape-sensitive tests. +- Core `src/lib.rs` to re-export public `BodyContent` alongside `Body` and `BodyStream`. + +- [ ] Add failing colocated ownership tests first. Cover initial and cached reconstruction through fresh and routed contexts after header mutation and extension clearing/replacement, including router app-state injection. +- [ ] Add `#[non_exhaustive]` to `Body`, the opaque `Managed` payload, exhaustive `BodyContent`, and `Body::into_content`. Implement private policy-preserving context transfer. Restore `Initial` versus `Cached` on managed reconstruction. Retain original policy on repeated attachment. +- [ ] Add downstream-boundary compile-fail/pass rustdoc fixtures. The negative raw-Body match must name all current variants, including Managed, and omit only the wildcard. Its otherwise identical wildcard version must compile. A `BodyContent` match handling Once and Stream without a wildcard must compile. Core unit tests alone cannot prove external non-exhaustiveness. +- [ ] Prove initial attachment/reconstruction polls nothing. Prove cached reconstruction keeps the same byte allocation, restores `BodyKind::Cached`, and does not reread a source. +- [ ] Test reattachment to both managed initial and managed cached bodies using a larger replacement ceiling and opposite ingress classification. The original policy and cached provenance survive, and attachment does not poll the body. +- [ ] Make `as_bytes`, `into_bytes`, `into_stream`, `is_stream`, `to_json`, and debug output describe underlying content. Keep direct `Body::into_bytes_bounded` on its existing caller-cap/400 contract, even for managed content. +- [ ] Add `EdgeError::PayloadTooLarge` following the accepted error conventions. Supply a semantic constructor, status 413, stable kind, fixed non-sensitive default wire message, and total `StoredError` capture/replay. Update every exhaustive core error match, including egress source-error classification. Do not translate arbitrary 400/500 errors by message text. +- [ ] Normalize ingress metadata away at outbound body-accepting constructors/setters, inbound-to-outbound forwarding, response helpers, and arbitrary-response egress preparation. Raw response builders and `body_mut` must not bypass this normalization. +- [ ] Adapt transport consumers through `BodyContent` and existing logical-shape accessors, with no wildcard on the exhaustive content view and no generic wildcard handling of raw `Body`. Preserve buffered/streamed upload selection, GET/HEAD emptiness checks, buffered-response JSON access, response length/type inference, Fastly buffered batch eligibility, and the accepted Axum zero-length stream EOF check. +- [ ] Add focused managed-input tests for outbound constructors/setters, inbound forwarding, and arbitrary raw-response egress. Assert their output is unmanaged, not merely that they ignore the ceiling. With managed input, `Body::into_bytes_bounded` must succeed above the native ceiling when its supplied cap permits, and return the existing 400 when that supplied cap is exceeded. + +No other provider starts enforcing the Axum ceiling. No request alias, builder, or provider ingress migration is proposed. Do not duplicate source collection in compatibility branches. + +Focused proof: + +```sh +cargo test -p edgezero-core --all-targets +cargo test -p edgezero-core --doc +cargo test -p edgezero-adapter-axum --all-targets --all-features +cargo check --workspace --all-targets --features "fastly cloudflare spin" +``` + +Run targeted compile checks for provider feature/target combinations touched by the carrier before building further. A native feature-disabled adapter check is not enough to verify provider-only match arms. The full matrix is in Task 7. + +## Task 2: enforce one resolved policy in the existing collector + +Files: core `src/context.rs` and `src/extractor.rs`; extend colocated tests in those modules. + +- [ ] Introduce a private JSON-versus-generic read kind and a resolved policy containing the effective cap and overflow category. Resolve it before both initial collection and cached rechecks. +- [ ] Keep `body_bytes(max)` generic. Make `json_within(max)` select JSON collection explicitly. Keep `form_within` and the Form family on the generic path. +- [ ] Change ordinary `Json` fallback selection to native ceiling when present, otherwise the existing portable 8 MiB. `ValidatedJson` continues delegating; explicit JSON helpers/Within retain their caller cap and take the minimum with the ceiling. +- [ ] For generic reads, clamp only when original ingress classification is JSON/+json. Retain ordinary Form's 1 MiB caller cap and non-JSON generic overflow 400. +- [ ] Generate typed overflow directly at cached-length, checked-add, and pre-append checks. Keep one collector and byte-accounting pass. Retain source-error-before-size handling and existing deadline precedence. +- [ ] Retain `DrainGuard` cancellation poison, sticky first-drain errors, non-poisoning cached rechecks, and existing `take_body`/`into_request` failure behavior. +- [ ] Ensure fallback discard ignores the new policy and retains the application's byte cap and exact overflow/timeout responses. + +Required core regressions: + +| Case | Observation | +| --- | --- | +| Exact cap / one byte over | Exact succeeds; first rejected chunk is never appended; overflow is typed 413 | +| Explicit cap below/above ceiling | Effective minimum; retry cannot loosen an initial overflow | +| Ordinary JSON ceiling above 8 MiB | Portable fallback does not silently cap it | +| Cached stricter check, before/after managed reconstruction | 413 without poison; permitted retry succeeds with the same bytes | +| Initial stream and initial `Once` overflow | Sticky poison; no subsequent source polls | +| Source error, dropped drain future, deadline tie | Existing error/cancellation/408 semantics; source and partial read ownership drop once | +| Generic JSON/+json buffering | `body_bytes`, `form_within`, `Form`, `ValidatedForm`, `ValidatedFormWithin` use the selected policy | +| Non-JSON generic and policy-free portable request | Existing cap/default/400 behavior | +| Taken/forwarded stream larger than ceiling | Remains incremental; no native JSON size rejection | +| Manual low-level collection | Only its supplied cap applies; old 400 behavior remains | + +Use helper-appropriate fixtures. Form-family policy tests may use valid form data with a JSON header to exercise existing header-independent form parsing; do not introduce a new media-type rejection. + +Existing tests to retain include `body_bytes_drains_once_and_rechecks_each_callers_cap`, `initial_drain_overflow_is_sticky_and_never_retries_source`, `source_error_replays_typed_fields_without_repolling`, `cancelled_drain_poison_is_sticky`, `injected_clock_controls_read_deadline_and_sticky_poison`, and the take/reassembly tests. + +Use `futures::executor::block_on`, scripted polls, explicit drop counters, and an injected clock in core. Do not use Tokio tests or wall-clock sleeps there. + +Focused proof: `cargo test -p edgezero-core --all-targets`. + +## Task 3: validate native configuration once + +Files: Axum `src/run_options.rs` and `src/dev_server.rs`; colocated validation tests and startup fixture tests as needed. + +- [ ] Add one adapter-owned validated limit value with omission default 2,097,152 bytes. Add private fields to `AxumRunOptions` and `AxumDevServer`; do not add a required field to public `AxumDevServerConfig` literals. +- [ ] Add fallible `with_json_body_limit_bytes(limit: usize)` builders to both public owners. Explicit constructors/builders do not read ambient configuration. +- [ ] Parse `EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES` as ASCII decimal integer bytes. Reject empty, signed, fractional, whitespace/suffix, zero, overflowing, and unrepresentable input. No clamp, fallback, or unlimited/disable interpretation. +- [ ] Derive the upper representability bound from the accepted collector. If it remains `Vec`, use its `isize::MAX` capacity bound rather than inventing a workload maximum. Keep explicit portable helper-cap semantics unchanged. +- [ ] Extend strict `from_env` fixed-setting handling so non-Unicode supplied values produce safe `JSON_BODY_LIMIT_BYTES` category diagnostics before readiness. +- [ ] Add a native development capture boundary using `vars_os` and `EnvConfig::from_vars`. Validate the new setting before loss of OS-string information. Preserve existing Unicode bind/logging/store fallback rules; do not globally change portable `EnvConfig` or call strict production options from `run_app`. +- [ ] Capture the selected value once per run. No environment reads during requests and no silent ambient merge into explicit options. + +The development boundary is necessary because current `EnvConfig::from_env` uses `std::env::vars`, which can panic on non-Unicode values before the new setting is parsed. Development capture skips undecodable unrelated keys/values, including non-Unicode HOST/store values, so their existing omission defaults apply. A recognized `JSON_BODY_LIMIT_BYTES` key with an undecodable value is an error, never an omission. Preserve existing Unicode bind/store/logging fallback rules and leave strict production non-Unicode validation unchanged. + +Test omission; builders; valid smaller/larger limits; upper representation boundaries; malformed and non-Unicode supplied values; explicit options under invalid ambient configuration; development invalid-bind fallback; fixed category and single-level redacted diagnostics. Add development capture cases for an unrelated binary key/value and non-Unicode HOST/store values, alongside the recognized JSON-setting failure. Use explicit OS-string iterators or subprocess environments, not unsafe mutation of process-global environment in concurrent tests. + +Focused proof: + +```sh +cargo test -p edgezero-adapter-axum --all-targets --all-features +``` + +## Task 4: thread the selected limit through every native entrypoint + +Files: Axum `src/dev_server.rs`, `src/service.rs`, and `src/request.rs`. + +- [ ] Carry the validated limit through the existing prepared startup result to `AxumServiceState` construction. Extend the current result/parameters; do not build a parallel runner or change signal/shutdown ownership. +- [ ] Cover `run_app`, standalone production with/without initializer, explicit embedding, `AxumDevServer::new`/`with_config`, and prebound test paths. Do not accidentally reset an explicit override in a helper. +- [ ] Store the selected limit in private service state cloned per connection. Test-only default service constructors also select 2 MiB. +- [ ] In lazy request conversion after admission, classify original ingress media type and attach body-owned policy before core dispatch. Capture non-JSON classification too, so later JSON helper use still has the ceiling. +- [ ] Classify `application/json` and structured `application/*+json` case-insensitively with parameters handled. Reuse a suitable parser/classifier if present; otherwise add a small adapter-local classifier and syntax tests. Unrelated/malformed types remain non-JSON for generic reads. No new 415 policy. +- [ ] Preserve existing deadline wrappers, no-read outcomes, and application-selected fallback drain behavior. +- [ ] Document/test the public low-level converter's finite default without ambient lookup. Do not add a low-level override API unless a supported consumer need is identified and approved. + +Direct service tests must prove default/override selection, missing/wrong Content-Type JSON helpers, generic JSON-media reads after header/extension mutation, malformed JSON 400, custom renderer status restoration, and preservation of refusal/abort/fallback behavior. A source `BadRequest` containing `request body too large` must remain 400. + +Focused proof: `cargo test -p edgezero-adapter-axum --all-targets --all-features`. + +## Task 5: prove cleanup and error lifecycle through the service + +Files: Axum `src/service.rs` and `src/request.rs` tests; core regressions from Task 2 where necessary. + +- [ ] Reuse the existing tracked source, `DropSignal`, admission grant, and egress observer fixtures. Add a distinct trailing source item that must never be polled after overflow/error/interruption. +- [ ] Prove oversized collection stops at the first over-cap chunk, drops source/read-owned resources once, and returns 413 through `dispatch_request`, not a converter-only helper. +- [ ] Prove sticky typed 413 through later accessors, larger-cap retry, `take_body`, and failing `into_request` without new source polls. +- [ ] Cover source failure, cancellation after partial read, expired deadline, and deadline/body/error/EOF ties. Preserve existing categories and 408 precedence. +- [ ] Drain the generated error response through the existing egress path. Verify the custom renderer cannot replace the mandatory status and completion fires once when an attempt terminates. Preserve abandonment-before-attempt distinctions. +- [ ] Retain exact application fallback responses and no-read refusal/abort behavior. Do not mistake an existing fallback 413 test for JSON-limit acceptance. + +Retain `configured_admission_refuses_before_native_body_poll`, `admission_abort_skips_body_handler_middleware_and_egress`, `admitted_deadline_reaches_body_cell_as_request_timeout`, existing fallback tests, and unfinished-response completion tests. + +Focused proof: `cargo test -p edgezero-adapter-axum --all-targets --all-features`. + +## Task 6: prove the real HTTP/1 and hosting paths + +Files: Axum `tests/production_hosting.rs`, `tests/fixtures/production_host.rs`, and colocated router-only runner tests. Add no new server harness unless integration removes the existing fixture. + +- [ ] Add `#[action]` fixture routes for ordinary JSON, explicit-cap JSON, generic buffered reads, and taken streams. Return a small success summary rather than echoing the large request. Add extraction-success/handler-entry counters or bounded IPC markers. +- [ ] Extend the existing raw socket helper with POST Content-Length and terminal chunked framing. Use multiple data chunks and no Content-Length for the chunked cases. +- [ ] Exercise exactly 2,097,152 bytes and one byte over through owned native connections for both framings. Use valid exact-byte JSON fixtures whose delimiters count toward the cap. +- [ ] Repeat smaller override boundaries and an ordinary JSON override above 8 MiB. Include stricter application caps and attempted caps above the native ceiling. +- [ ] Cover JSON/vendor +json, parameters, mixed case, missing/wrong Content-Type JSON helpers, and malformed JSON within cap. Observe complete usable 413 response bytes, not only a server-side error or socket close. +- [ ] Cover development, standalone production, explicit embedding, and router-only default/override propagation. Explicit embedding under invalid ambient settings must use its own valid value. +- [ ] Extend startup-failure fixtures for invalid decimal and non-Unicode limit values. Add a bounded hook-entry marker to the fixture and confirm a valid startup emits it. Invalid environment-driven development/production starts must emit neither that marker nor readiness, and must fail with safe setting/category diagnostics, no leaked sentinel, and no accepting listener. Absence of the existing initializer-only `INITIALIZED` marker is not sufficient for bare development/production modes. +- [ ] Verify non-JSON and untouched JSON streams larger than the ceiling remain incremental. Keep test admission deadlines sufficiently long for valid uploads without changing shipped deadline policy. + +Reuse the bounded `Server` readiness/socket/wait helpers and Drop cleanup. Set read/write and whole-case timeouts. Separate writer and response-reader progress when testing rejection during an unfinished upload; do not deadlock by synchronously sending the entire rejected body before reading the response. Tolerate transport write failure only after observing the expected response/status. Make no keep-alive-reuse promise. + +Proposed required native test sentinel: `json_body_limit_wire_content_length_and_chunked`. It must not be ignored. The existing script verifies listing, not execution of an individual sentinel, so filter to the qualified test and require its passed result as well: + +```sh +set -o pipefail +wire_log=$(mktemp "${TMPDIR:-/tmp}/edgezero-393-wire.XXXXXX") +scripts/run_test_nonzero.sh json_body_limit_wire_content_length_and_chunked \ + cargo test -p edgezero-adapter-axum --features "axum test-utils" \ + --test production_hosting tests::json_body_limit_wire_content_length_and_chunked \ + --color never | tee "$wire_log" +grep -Fx 'test tests::json_body_limit_wire_content_length_and_chunked ... ok' "$wire_log" +``` + +Record the individual pass, nonzero executed count, tested state, and log. A listed or ignored sentinel is not acceptance evidence. + +The existing subprocess suite is Unix-gated. On other hosts, report that gap and run portable/core plus direct-service evidence; require the real subprocess/wire acceptance on a supported Unix CI host before completion. `tests/contract.rs` outbound origin servers bypass this ingress path and cannot substitute for these tests. + +## Task 7: documentation and stable integration verification + +Documentation files: + +- `docs/guide/adapters/axum.md`: default, decimal bytes, validation, standalone environment, explicit builders, and hosting parity. +- `docs/guide/handlers.md`: Axum exception to portable JSON buffering, explicit-cap precedence, generic JSON-media coverage, reconstruction, and unrestricted taken streams. +- `CHANGELOG.md`: deliberate native 2 MiB behavior change, override, both public Body source breaks, and migration to accessors or exhaustive `BodyContent`. +- `docs/superpowers/specs/2026-08-22-inbound-body-design.md`: narrow native policy/carrier addendum; preserve portable 8 MiB/400 defaults and historical context. +- Governing #393 spec/this plan: approved API/scope, accepted revisions, acceptance evidence, and unresolved gaps. + +- [ ] Explain logical buffering versus allocation/process-memory limits and host/client delivery limitations. Preserve Unsupported/BestEffort distinctions. +- [ ] Confirm generated entrypoints, CLI launch, and app-demo inherit the selected behavior. Current no-argument `run_app` signatures and inherited child environment need no generator or CLI flag migration. Change consumers only if the approved API actually requires it. +- [ ] Do not change the outbound `max_request_body_bytes` 8 MiB default in the capability matrix; it is unrelated. +- [ ] Run focused suites after each code change as required by `CLAUDE.md`. Serialize Cargo commands sharing a target directory; the coordinator owns final integration checks. +- [ ] At the stable combined checkpoint, run all repository gates: + +```sh +cargo test -p edgezero-core --all-targets +cargo test -p edgezero-core --doc +cargo test -p edgezero-adapter-axum --all-targets --all-features +cargo test --workspace --all-targets +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +Because the approved carrier touches shared provider code, run the native and WASM feature matrices: + +```sh +bash scripts/check_adapter_feature_matrix.sh axum native +bash scripts/check_adapter_feature_matrix.sh cloudflare native +bash scripts/check_adapter_feature_matrix.sh fastly native +bash scripts/check_adapter_feature_matrix.sh spin native +bash scripts/check_adapter_feature_matrix.sh cloudflare wasm32-unknown-unknown +bash scripts/check_adapter_feature_matrix.sh fastly wasm32-wasip1 +bash scripts/check_adapter_feature_matrix.sh spin wasm32-wasip2 +``` + +These scripts use `--offline --locked`; missing cached dependencies are an environment blocker, not a pass. Add targeted transport compatibility regressions for the affected provider match sites using their existing runners. WASM compilation is not provider runtime proof. + +Run core rustdoc tests explicitly. `cargo test --workspace --all-targets` does not substitute for the external compile-fail/pass API fixtures. Audit repository raw-Body match sites so no migration merely adds a catch-all that skips normalization or masks a buffered/streamed case. + +After published documentation changes: + +```sh +cd docs +npm ci +npm run format +npm run lint +npm run build +``` + +Internal `docs/superpowers` files are excluded from published-site formatting/build checks. Inspect their Markdown and links separately. Run `node scripts/check_outbound_docs_contract.mjs` if the inbound addendum or relevant contract wording changes. Do not weaken guards to accept a cross-provider semantic change. + +If demo/generated/CLI source changed, also run affected CLI generator tests and the excluded demo workspace's tests/builds. Do not add unrelated migrations merely to make those commands relevant. + +## Completion and handoff + +- [ ] Have an independent verifier inspect the final accepted integration revision, carrier consumers, effective-cap selection, and native evidence. Verify source locations and logs rather than accepting a worker summary alone. +- [ ] Record `Check | tested revision/working fingerprint | features/target | result | elapsed | log` in the implementation handoff. Reuse evidence only when its inputs still match. +- [ ] Match every #393 done-when item and every governing-spec acceptance row to a named executed test or explicit remaining gap. Do not close the issue while prerequisite acceptance, real wire 413, cleanup, or configured override evidence is missing. +- [ ] Record unavailable targets/provider runners and unrelated baseline failures separately. No whole-workspace or provider-runtime claim from partial checks. +- [ ] Obtain approval before any commit or publication operation. No implementation approval is implied by this planning document. + +## Decision log + +Two read-only investigators mapped ownership/public API constraints and native startup/wire-test boundaries, then independently proposed solutions. Both recommended a body-owned carrier over a new Request wrapper. The ownership proposal also identified cached reconstruction provenance as necessary to retain stateless cached rechecks for managed requests. The native proposal identified the development `vars_os` capture boundary and reusable subprocess fixture. + +On 2026-10-06, the user approved this body-owned carrier refactor and making `Body` non-exhaustive. The selected path keeps the HTTP request representation, existing BodyStream alias, and one collector. It adds an exhaustive content view so adapters do not need wildcard guesses. Both public source breaks and the shared consumer migration are within the approved scope. The user then authorized implementation on the current base and deferred #275 integration. The coordinator made all source edits and ran Cargo serially; workers remained read-only. + +Independent core/consumer and native/startup/wire verifiers inspected frozen source hashes. They found no runtime defect. Their stale 400 API-doc finding was corrected. Additional regressions address managed partial cancellation/drop-once, injected-clock deadline/overflow tie 408, routed initial/cache reconstruction after head mutation, exhaustive response-framing content handling, and actual router-only default-boundary requests. Both verifiers checked the final delta and source hashes. The router-only test awaits outer cancellation, not an explicit inner blocking-worker join; do not treat it as new shutdown certification. + +## Provisional execution record + +All Rust checks below used HEAD `3ab22096fd117f7506cf437b5ba128e0789e059c` plus the same 25 changed Rust/manifest/lock inputs. The source digest is `02ee920bb130bf3e6a55096cc02b09c5ea19c4d905fde355ad05395006dc1014`. `/tmp/393-final-source-fingerprint.json` records individual hashes. The coordinator verified them before and after every final command and wrote the command, HEAD, and digest into each log. Logs and manifests under `/tmp` are local artifacts, not durable CI evidence. + +| Check | Result | Elapsed | Log | +| --- | --- | --- | --- | +| Format check | passed | 2.18 s | `/tmp/393-final-fmt.log` | +| Core all targets | 724 unit plus 2 integration passed | 0.68 s | `/tmp/393-final-core.log` | +| Core rustdoc | 2 regular and 4 compile-fail passed; 13 existing examples ignored | 1.78 s | `/tmp/393-final-doc.log` | +| Axum all targets/all features | 192 unit, 13 contract, 16 subprocess passed | 7.29 s | `/tmp/393-final-axum.log` | +| Native wire sentinel suite | 16 passed; required individual sentinel reports `ok` | 6.53 s | `/tmp/393-final-wire-sentinel.log` | +| Qualified wire sentinel alone | 1 passed, 15 filtered; exact individual `ok` confirmed | 5.48 s | `/tmp/393-final-wire-individual.log` | +| Workspace all targets | passed | 18.41 s | `/tmp/393-final-workspace.log` | +| Workspace all-target/all-feature strict Clippy | passed | 0.58 s | `/tmp/393-final-clippy.log` | +| Workspace fastly/cloudflare/spin feature check | passed | 4.89 s | `/tmp/393-final-features.log` | +| Core wasm32-unknown-unknown | passed | 2.03 s | `/tmp/393-final-core-wasm.log` | +| Cloudflare all-target SDK WASM check | passed | 2.49 s | `/tmp/393-final-cloudflare-wasm.log` | +| Fastly all-target SDK WASM check | passed | 2.28 s | `/tmp/393-final-fastly-wasm.log` | +| Spin all-target SDK WASM check | passed | 2.08 s | `/tmp/393-final-spin-wasm.log` | +| Axum native feature matrix | all 8 combinations passed | 15.56 s | `/tmp/393-final-matrix-axum-native.log` | +| Cloudflare native feature matrix | all 8 combinations passed | 29.49 s | `/tmp/393-final-matrix-cloudflare-native.log` | +| Fastly native feature matrix | all 8 combinations passed | 35.01 s | `/tmp/393-final-matrix-fastly-native.log` | +| Spin native feature matrix | all 8 combinations passed | 26.57 s | `/tmp/393-final-matrix-spin-native.log` | +| Cloudflare wasm32-unknown-unknown matrix | all 8 combinations passed | 12.9 s | `/tmp/393-final-matrix-cloudflare-wasm32-unknown-unknown.log` | +| Fastly wasm32-wasip1 matrix | all 8 combinations passed | 14.04 s | `/tmp/393-final-matrix-fastly-wasm32-wasip1.log` | +| Spin wasm32-wasip2 matrix | all 8 combinations passed | 21.55 s | `/tmp/393-final-matrix-spin-wasm32-wasip2.log` | + +Published docs passed `npm ci`, `npm run format`, `npm run lint`, and `npm run build`. Only the two changed guide files were formatted; internal superpowers documents stay excluded. Logs are `/tmp/393-docs-{install,format,lint,build}.log`. No JS dependency changes or audit remediation were included. `node scripts/check_outbound_docs_contract.mjs` passed without weakening guards; log is `/tmp/393-outbound-docs-contract.log`. + +### Acceptance mapping and limits + +| Spec evidence | Named proof or remaining gap | +| --- | --- | +| Default and both HTTP/1 framings | `tests::json_body_limit_wire_content_length_and_chunked`, exact 2,097,152/+1 and complete typed 413 payload; handler-entry counts | +| Configuration overrides, media, explicit caps, generic mutation, streams | `tests::json_limit_overrides_helpers_generic_reads_and_taken_streams`, 64-byte boundaries and ordinary 9 MiB/+1 override; fresh reconstruction after clearing head/extensions | +| Validation and pre-hook startup failure | parser/builder/development capture tests; `tests::invalid_json_limits_fail_before_application_hooks_in_dev_and_production`; explicit embedding ignores invalid ambient setting | +| Initial/cache ownership and reattachment | `managed_initial_reconstruction_preserves_policy_without_polling`, `managed_cached_reconstruction_reuses_allocation_and_stateless_rechecks`, `managed_routed_reconstruction_restores_initial_and_cache_after_head_mutation` | +| JSON helpers and Form-family coverage | `managed_json_helpers_cap_non_json_ingress_but_generic_reads_do_not`, `managed_form_family_keeps_caller_caps_and_typed_json_overflow`; portable tests remain green | +| Manual reads, raw API, output normalization | `managed_content_accessors_and_manual_collection_ignore_ingress_ceiling`, external Body/BodyContent rustdoc fixtures, managed outbound/response/framing regressions | +| Sticky typed overflow and usable lifecycle | `managed_initial_once_overflow_is_sticky_and_taken_content_is_unmanaged`; `managed_json_overflow_drops_source_once_stops_polling_and_keeps_error_egress` proves no trailing poll, one drop, custom-renderer 413, and terminal completion | +| Source errors and interruption | `managed_source_bad_request_is_not_translated_by_message`, `managed_partial_cancellation_drops_source_once_and_poisons`, `managed_deadline_tie_wins_over_overflow_without_repolling`; existing shared collector/ingress deadline and failure tests passed | +| Admission/no-read/fallback and hosting lifecycle | existing Axum refusal/abort/fallback and production-hosting tests passed; no capability or lifecycle policy expanded | +| Router-only default | `router_only_default_json_boundary_for_both_constructors`, actual 2 MiB/+1 HTTP requests for new/with_config; selected-override property/preparation test | +| Shared provider compilation | all 56 feature-matrix combinations and SDK-target checks passed; not provider runtime proof | +| Accepted prerequisite integration | deferred by explicit user direction; rebase/re-audit and rerun affected acceptance later | + +Known limits remain. No dedicated large ordinary-JSON reconstruction case above 8 MiB, actual router app-state replacement dispatch case, or router-only override wire case was added; shared transfer/propagation tests support these paths. Media variants are exercised at the 64-byte configured boundary, not repeated at every 2 MiB framing boundary. Provider runtime suites were not run. Fastly all-feature native tests failed to link provider host ABI imports; portable Fastly tests passed 94. The installed standalone Viceroy is 0.17.0 rather than pinned 0.21.0, and no wasmtime or wasm-bindgen-test-runner is available. No provider-runtime acceptance is inferred from compilation. + +An inherited storage subprocess assertion on `selected-greeting` failed once during development, then passed individually and in subsequent whole suites, including the final runs. It remains an unresolved transient, not a reproduced clean-baseline failure. No exclusions were used in final native/workspace tests. At this verification checkpoint, no commit, PR write, prerequisite integration, or issue closure had occurred. The user subsequently authorized committing this work and opening a draft PR stacked above the hosting parent using `gh stack`; prerequisite integration and issue closure remain deferred. + +## Links + +- Spec: [Axum configurable buffered JSON request-body limits](../specs/2026-10-06-axum-buffered-json-body-limit-design.md) +- Parent: [Axum production startup and lifecycle design](../specs/2026-10-05-axum-production-startup-lifecycle-design.md) +- Issue: [#393](https://github.com/stackpop/edgezero/issues/393) +- Prerequisite: [PR #275](https://github.com/stackpop/edgezero/pull/275) diff --git a/docs/superpowers/specs/2026-08-22-inbound-body-design.md b/docs/superpowers/specs/2026-08-22-inbound-body-design.md index 2e47462c6..59da1dd20 100644 --- a/docs/superpowers/specs/2026-08-22-inbound-body-design.md +++ b/docs/superpowers/specs/2026-08-22-inbound-body-design.md @@ -11,6 +11,25 @@ > state machine. Outbound streaming proxy-forward consumes the resulting > `BodyCell`/`into_request()` contract but does not own it. +## Native JSON policy addendum, issue #393 + +The portable 8 MiB JSON default and unmanaged overflow 400 described below remain unchanged. +Axum-managed JSON buffering now selects a configurable 2 MiB default, exactly 2,097,152 bytes, +and typed `PayloadTooLarge`/413. JSON helpers always select the policy; generic buffering selects +it for captured JSON/+json ingress. Explicit caller caps may tighten it, and ordinary Form keeps +its 1 MiB cap. Taken streams, fallback discard, forwarding, and manual collection remain excluded. + +Public `Body` is non-exhaustive with an opaque, nonrecursive policy carrier. Private initial/cache +provenance preserves the original ceiling/classification across successful fresh/routed context +reconstruction despite head mutation. Cached rechecks stay stateless. Exhaustive `BodyContent` +exposes raw buffered/streamed ownership to transports without policy; no grant, clock, route, +deadline, or completion is added to the metadata carrier. + +Implementation is provisional on the hosting stack by explicit user approval. Accepted #275 +integration and affected evidence must be re-audited after the later rebase. See the +[#393 spec](2026-10-06-axum-buffered-json-body-limit-design.md) and its implementation plan for +configuration, compatibility, and required evidence. Historical examples below are not rewritten. + ## 1. Scope The goal is to stop adapters from pre-buffering unbounded inbound bodies while preserving diff --git a/docs/superpowers/specs/2026-10-06-axum-buffered-json-body-limit-design.md b/docs/superpowers/specs/2026-10-06-axum-buffered-json-body-limit-design.md new file mode 100644 index 000000000..8bee79647 --- /dev/null +++ b/docs/superpowers/specs/2026-10-06-axum-buffered-json-body-limit-design.md @@ -0,0 +1,376 @@ +# Axum configurable buffered JSON request-body limits + +Date: 2026-10-06 +Status: provisional implementation present on the user-approved current hosting base. Native wire, workspace, and WASM checks have passed; execution evidence is recorded in the implementation plan. Accepted #275 integration and re-audit are deferred until the later rebase. + +Issue: [#393](https://github.com/stackpop/edgezero/issues/393), under [Epic #391](https://github.com/stackpop/edgezero/issues/391). + +Prerequisite: [PR #275](https://github.com/stackpop/edgezero/pull/275). Its accepted ingress/body contracts remain authoritative. + +Stacked base: `issue-392-396-production-hosting-spec` at `3ab22096fd117f7506cf437b5ba128e0789e059c`. This includes the initial #392/#396 implementation and the #275 integration revision recorded in its [startup/lifecycle spec](2026-10-05-axum-production-startup-lifecycle-design.md). It is not the latest or accepted #275 head. + +Worktree: `/home/pav/worktrees/issue-393-buffered-body-limit-spec`. + +The ceiling, default, zero/disable behavior, buffered-read coverage, native configuration interface, and Axum-scoped error behavior below are agreed. All five policy questions are resolved. On 2026-10-06, the user approved the body-owned metadata carrier, making public `Body` non-exhaustive, and the bounded shared-body consumer migration. Final plan review then completed without verified findings. The user explicitly authorized implementation on the current hosting base without waiting for #275 acceptance or integration, with a later rebase. The [implementation plan](../plans/2026-10-06-axum-buffered-json-body-limit.md) records the work and remaining integration checks. + +## Goal and invariant + +The standard Axum service uses a configurable **2 MiB default, exactly 2,097,152 bytes**, for framework-managed buffered JSON request bodies. Collection checks actual incoming bytes before retaining them. Exactly-at-limit valid JSON succeeds; a body one byte over the effective limit produces HTTP 413 through the real service path. + +Keep request conversion lazy, retain admission-before-read ordering, and reuse the existing bounded collector, body cache, poison state, and response lifecycle. A rejected or interrupted drain stops consuming its source and releases read-owned resources. This does not promise client receipt of the error response or cancellation of arbitrary handler work. + +The default aligns with [Axum's buffering-extractor default](https://docs.rs/axum/0.8.9/axum/extract/struct.DefaultBodyLimit.html). The issue records the exact value from [axum-core request extensions](https://docs.rs/axum-core/0.5.6/src/axum_core/ext_traits/request.rs.html). It is not derived from provider quotas or workload measurements. + +## Agreed decisions + +- The native setting is a hard ceiling for framework-managed JSON buffering, not merely an extractor default. Explicit application caps may be tighter but cannot exceed it. +- The default is 2 MiB, exactly 2,097,152 bytes, based on Axum's established buffering-extractor default rather than application workload measurements. +- Use the same default in development and production. A valid explicit configuration replaces it. +- Require a positive value. Reject zero and expose no unlimited/disable sentinel. +- JSON helpers/extractors enforce the ceiling regardless of Content-Type. Generic framework buffered reads also enforce it for ingress `application/json` and `application/*+json` bodies. +- Untouched bodies consumed as streams gain no new total-size restriction, including JSON streams. This setting does not change other adapters' existing defaults or introduce new content-type rejection rules. +- Add no concurrency or timeout defaults in this issue. Existing admitted deadlines remain unchanged. +- Configure standalone hosting with `EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES`; use `with_json_body_limit_bytes` on `AxumRunOptions` and `AxumDevServer` for explicit configuration. +- Read the standalone environment setting once at startup. Explicit embedding options never silently merge ambient settings. Invalid supplied values fail startup in development and production. +- Add no manifest section or CLI flag for this setting. + +- Keep new HTTP 413 behavior scoped to Axum-managed buffered JSON reads. Malformed JSON remains 400; other adapters retain their existing overflow behavior. Add the minimal typed error and body-owned core policy carrier required by the reconstruction contract, with only the approved shared-body compatibility edits. No cross-provider overflow-status migration or error-message matching is included. +- Make public `Body` `#[non_exhaustive]` while retaining its existing plain variants and adding an opaque metadata carrier. Expose an exhaustive buffered-or-streamed `BodyContent` view through `Body::into_content` for transport code. Core remains responsible for mapping every `Body` variant to that view. + +These decisions were confirmed during spec discussion on 2026-10-06. Final internal API details must follow the reviewed prerequisite baseline. + +## Scope and ownership + +| Owner | Responsibility | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Axum adapter | Native option parsing/validation, selecting the per-run JSON buffering policy, attaching it before dispatch, standard service-path acceptance, and native documentation | +| Portable core | Body-owned buffering policy, non-exhaustive `Body`, exhaustive `BodyContent`, and typed oversized-request error integration with the existing collector/cache and error renderer | +| Shared transport consumers | Compatibility with the approved content view only; no Axum policy enforcement in other providers, outbound transport, fallback discard, or response egress | +| Application | Tighter explicit extractor limits, routes/middleware, admission decisions, handler allocations, and any deliberate manual buffering after taking a stream | +| #275 | Admission, body ownership, absolute read deadlines, sticky poison, response completion, and platform capability distinctions | +| #392/#396 | Runtime ownership, startup validation/readiness, stop handling, and explicit production versus permissive development entrypoints | +| #394 | Streaming transport and backpressure | + +This issue adds no concurrency ceiling, queue, stream-size limit, stream-lifetime limit, body idle timeout, total upload deadline, handler deadline, overload framework, or general cancellation policy. Existing admitted read deadlines remain intact; this work neither removes them nor chooses new values. + +Do not add Tokio dependencies or blanket Send bounds to core. Do not rebuild the retired eager prebuffer, introduce another HTTP server, or add a second independent collector. + +## Baseline audit + +The following historical audit is source inspection at the stacked base, not executed proof. Implementation proceeded on that base by explicit user direction. Repeat this audit against the accepted prerequisite/integration revision after rebasing; the execution record below is not accepted-baseline evidence. + +| Area | Existing behavior | Residual work for #393 | +| ----------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `crates/edgezero-adapter-axum/src/request.rs:34-47` | `into_core_request_parts` creates a lazy stream and optionally wraps reads in the admitted deadline. No content-type-based eager collection remains. | Preserve this path; attach policy without polling the body. | +| `crates/edgezero-adapter-axum/src/service.rs`, `dispatch_request` | Head validation and `begin_ingress` precede conversion; `dispatch_admitted` retains the owned response lifecycle. | Carry the selected buffering policy into the admitted request; prove 413 at this service boundary. | +| `crates/edgezero-core/src/context.rs:21-24` | Portable JSON extractor default is 8 MiB; form default is 1 MiB. | Supply the Axum default/override without silently changing other adapters. | +| `context.rs`, `body_bytes`, `drain_body` | One collector, checked pre-append accounting, cache, sticky drain errors, and cancellation guard already exist. | Resolve the effective cap here if a shared hook is needed; do not duplicate collection. | +| `context.rs`, `StoredError` | Captures/reconstructs each error variant so later accessors preserve the original drain failure. | Preserve a typed 413 failure through capture, retries, and request reassembly. | +| `context.rs`, `json_within`, and `extractor.rs`, `Json`/`ValidatedJsonWithin` | JSON helpers delegate to bounded collection; size overflow currently returns `BadRequest`, HTTP 400. | Distinguish adapter-managed body overflow from malformed JSON and other failures. | +| `crates/edgezero-core/src/body.rs`, `into_bytes_bounded` | Low-level standalone bounded collection also exists, with 400 overflow. | Do not rewrite all low-level body semantics to close this adapter issue. | +| `crates/edgezero-adapter-axum/src/run_options.rs` | `AxumRunOptions` validates explicit production options and selected environment settings. Explicit construction does not merge ambient settings. | Add the chosen native limit setting using that boundary. | +| `crates/edgezero-adapter-axum/src/dev_server.rs` | Development, production, embedding, and router-only callers share the owned runner/service. | Thread one validated policy through the same owner; preserve signatures where possible. | + +Existing tests cover bounded context reads, sticky overflow, source failure, cancellation, cached rechecks, and deadline precedence. Their presence is reusable design evidence, not proof that the proposed Axum setting or 413 behavior exists. + +The old `to_bytes(axum_body, usize::MAX)` path on audited main is superseded. Changing its argument or adding `DefaultBodyLimit` around it is not the proposed fix. Axum's default extractor limit does not automatically protect direct body reads, and a global transport body-size wrapper would also constrain streams this issue explicitly excludes. + +## Agreed configuration contract + +Agreed public names: + +```text +EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES=2097152 + +AxumRunOptions::with_json_body_limit_bytes(limit) +AxumDevServer::with_json_body_limit_bytes(limit) +``` + +These APIs are implemented provisionally. The router-only builder retains its existing ergonomics without adding a required field to every `AxumDevServerConfig` literal. + +Configuration behavior: + +- Omission selects 2,097,152 bytes in every standard Axum hosting path. The safeguard is not conditional on production mode, release builds, or container detection. +- A supplied environment value is decimal integer bytes, not a human-readable suffix such as `2MiB`. +- Validate once at native option construction, before readiness or body consumption. Reject malformed, signed, fractional, overflowing, non-Unicode, and unrepresentable supplied values with safe setting/category diagnostics. Never silently fall back or clamp. +- Reject zero and expose no disable/unlimited mode, as agreed during review. +- Do not reserve the existing unlimited sentinel as a supported way to disable protection. Exact upper representability checks should follow the collector's accounting types rather than invent a workload-based maximum. +- Explicit embedding options do not read ambient hosting environment. The caller can deliberately select `from_env`. +- The existing development environment entrypoint reads this new setting too. Supplied invalid values fail rather than becoming defaults. Existing permissive bind/store behavior remains unchanged. +- Capture the value once per run. Requests do not reread environment variables or change policy when the environment changes. +- No new manifest section, CLI flag, configuration file, per-route registry, or hot reload is proposed. Explicit extractor limits remain the route-level mechanism for tighter limits. + +The override must work both below and above 2 MiB. In particular, configuring a limit above the portable 8 MiB default must not leave ordinary Axum `Json` silently capped at 8 MiB. Raising the host ceiling still does not raise an application's explicit stricter cap. + +## Agreed policy semantics + +### One adapter ceiling, application caps may be tighter + +Treat the configured value as the maximum for framework-managed JSON buffering in that native run. Use it as the default for ordinary JSON extractors as well. + +```text +ordinary Json or ValidatedJson: + effective_limit = Axum configured limit + +explicit JSON helper or ValidatedJsonWithin with requested cap: + effective_limit = min(requested cap, Axum configured limit) + +portable request without the Axum policy: + preserve existing portable default and explicit-cap behavior +``` + +This avoids ambiguous precedence. To allow a larger JSON body on an Axum route, raise the native ceiling and select the desired explicit route cap. Merely asking a helper for a larger cap cannot bypass the native buffering safeguard. + +The 8 MiB portable constant is not globally changed. The ordinary JSON extractor must distinguish its fallback default from an explicitly requested cap, rather than always taking `min(8 MiB, configured limit)`. This requires a small core hook unless the accepted prerequisite already provides one. + +### Which reads are JSON buffering? + +Agreed coverage has two entry conditions: + +1. A framework JSON helper/extractor collects a body, regardless of Content-Type. This prevents a missing or inaccurate media-type header from bypassing the JSON helper's buffering policy. +2. A generic framework buffered helper collects a body whose ingress Content-Type is `application/json` or `application/*+json`. + +Generic entrypoints include `body_bytes`, `form_within`, `Form`, `ValidatedForm`, and `ValidatedFormWithin`. On JSON ingress, their effective cap is `min(caller cap, native ceiling)` and size overflow returns typed 413. They do not substitute the native JSON default for their caller cap. Ordinary Form retains its existing 1 MiB cap, which the native ceiling may tighten but never raise. On non-JSON ingress, these generic entrypoints retain their existing caps and overflow behavior, including HTTP 400. + +Parse media type without its parameters and compare ASCII case-insensitively. Include `application/problem+json` and vendor `+json` subtypes. Do not classify unrelated media types or malformed header strings as JSON. Reuse an existing classifier if available; add only a small classifier if the retired path removed it. + +This classification is for buffering policy, not a new 415/content-negotiation policy. Preserve existing JSON parsing/content-type acceptance behavior. + +Capture the ingress JSON classification and validated policy before middleware, so later header or public extension mutation cannot loosen the selected native ceiling. Keep framework-owned state private once `RequestContext` is constructed. No global mutable policy or caller-controlled environment lookup is needed inside core. + +A JSON Content-Type alone must not trigger collection. `take_body`/`into_request` on an untouched body preserve lazy streaming, including JSON proxy-forwarding. Such streams receive no new total-size restriction. If application code manually buffers a taken stream, those allocations remain application-owned and outside this safeguard. + +The agreed coverage separates bounded buffering from total streamed bytes. Wrapping the whole JSON stream with a total-byte limiter would not preserve that distinction. + +### Policy ownership across request reconstruction + +Successful `RequestContext::into_request` followed by `RequestContext::new` or routed context construction must preserve the original native ceiling, ordinary JSON default, and captured ingress JSON classification. This applies to both untouched initial bodies and successfully cached bodies. Request reconstruction is not an opt-out from framework-managed buffering policy. + +At the inspected baseline, `into_request` emits only request parts and body; it does not transfer private context fields. The implementation plan must identify how framework-owned policy follows body ownership into the reconstructed request and back into a context. Private context fields alone cannot cover this transfer, and a caller-clearable public extension cannot be its sole authority. Header changes, extension clearing/replacement through request or context parts, and router app-state injection must not discard or replace the original policy. Do not recapture ingress classification from mutated headers. + +Initial-body reconstruction must remain lazy. Cached-body reconstruction must reuse the existing bytes and apply the preserved policy before returning or parsing them. Taken streams and application-owned manual buffering remain outside the total-size cap. This requirement adds no grant/completion transfer or cancellation framework; preserve #275's existing lifecycle contracts. + +The approved implementation carries immutable policy with body ownership in an opaque, nonrecursive payload. Preserve initial versus cached provenance privately so managed cached reconstruction restores an intact cache and stricter rechecks remain stateless. Keep bare unmanaged request reconstruction unchanged. Reattachment cannot replace the original ceiling/classification or cached provenance. + +Make `Body` non-exhaustive at the same time as the carrier addition. `BodyContent` is deliberately exhaustive with only `Once(Bytes)` and `Stream(BodyStream)`. `Body::into_content` consumes a body, discards ingress policy/provenance, and exposes its logical content without polling or copying bytes. It is the explicit raw-content handoff used by transports and application-owned operations, not the context reconstruction path. Context construction/reassembly must use the private policy-preserving transfer instead. + +Retain logical buffered/streamed behavior in existing accessors. Normalize policy away at outbound constructors/setters and response preparation, including raw response builders. Do not implement adapter-specific wildcard fallbacks on raw `Body`; migrate transports to the exhaustive content view. Core's internal mapping must remain exhaustive so a future `Body` variant cannot silently bypass normalization. + +Re-verify the approved carrier and consumer scope against the accepted prerequisite after the deferred rebase. This bounded shared-body refactor is approved; a Request alias/builder migration, broader lifecycle transfer, or changes beyond the reviewed consumer set still require re-review. + +## Request flow + +```mermaid +flowchart TD + A[Validate native limit once at startup] --> B[Build the existing native service] + B --> C[Receive request and retain unread body] + C --> D[Existing head checks and ingress admission] + D -->|Admitted| E[Convert lazily and carry immutable buffering policy] + D -->|Refused or aborted| F[Existing no-read refusal or abort path] + E --> G[Existing core dispatch] + G --> H{How is the body consumed?} + H -->|Framework JSON buffering| I[Resolve one effective cap] + I --> J[Existing collector checks each chunk before append] + J -->|Clean EOF at or below cap| K[Cache bytes and parse JSON] + J -->|Over cap| L[Drop source and partial buffer; retain sticky 413] + L --> M[Existing error renderer and owned response egress] + H -->|Taken stream or non-JSON incremental body| N[Preserve existing streaming behavior] +``` + +The diagram omits the opt-in 404/405 fallback drain for clarity. Its application-selected byte cap and exact `on_exceeded` response remain unchanged; it does not become a JSON extraction path. Ordinary 404/405 handling, refusals, and aborts continue without polling bodies. + +### Pseudocode + +Names below describe behavior, not an additional collector or final Rust API: + +```text +resolve_buffer_policy(read_kind, ingress_media_type, requested_cap): + if no Axum buffering policy is attached: + return existing portable policy + + if read_kind is JSON or ingress_media_type is JSON: + return cap(min(requested_cap, native_cap)), typed_413_on_overflow + + return existing portable policy + +ordinary_json_extractor(context): + requested_cap = context.native_json_default_or(portable_8_MiB_default) + bytes = await context.existing_buffered_read(JSON, requested_cap) + return parse_json(bytes) # malformed JSON keeps existing 400 behavior + +existing_buffered_read(read_kind, requested_cap): + policy = resolve_buffer_policy(read_kind, captured_media_type, requested_cap) + + if body is cached: + check cached length against effective cap + return shared cached bytes or the appropriate size error + + if body is poisoned: + return the stored failure without polling source + + move the source into the existing guarded drain + mark body Draining + + repeat: + check existing admitted deadline + chunk_or_eof = await source.next() + check existing admitted deadline again # expiry wins ties + + if EOF: + mark body Cached + return accumulated bytes + + propagate source error using existing error semantics + + if checked_add(buffer.length, chunk.length) overflows or exceeds effective cap: + drop source and partial buffer + mark body Poisoned with typed oversized-request failure + return that failure + + append chunk + + on caller cancellation: + existing drop guard poisons the interrupted drain + source and partial buffer are dropped +``` + +Implementation should retain the existing ownership/drop structure rather than manually scattering cleanup across branches. No Content-Length-only enforcement, preallocation based on an untrusted length, second byte counter over the same collection, or duplicate buffer is required. + +Content-Length can support an optional early rejection only if it reuses the same effective policy and lifecycle. This draft does not require that optimization. Actual byte accounting is mandatory even without a declared length. + +## Errors, cache, and cleanup + +A body exceeding this adapter-managed buffering policy is a typed oversized-request failure with HTTP 413. Use a fixed, non-sensitive public message such as `request body too large`. Do not inspect error strings to decide whether a generic 400 or 500 should become 413. + +The error model at the inspected stacked base has no request-body-too-large variant. A minimal candidate is `EdgeError::PayloadTooLarge` with the existing semantic-constructor, status/kind, response-rendering, and StoredError conventions. Final naming and fields follow the accepted baseline; a broader request-limit taxonomy is not required. + +Distinguish these cases: + +| Result | Contract | +| ----------------------------------------------- | ------------------------------------------------------------------ | +| Exactly at effective byte cap, valid JSON | Size check passes and JSON extraction can succeed | +| First chunk that would exceed effective cap | Do not append that chunk; stop consumption and return typed 413 | +| Malformed JSON within cap | Preserve current JSON parse error, HTTP 400 | +| Source read error | Preserve existing source-error classification, not 413 | +| Existing admitted read deadline expires | Preserve HTTP 408 and expiry precedence | +| Caller drops the drain future | Preserve existing cancellation poison, not a successful empty body | +| Application admission refusal/fallback overflow | Preserve the application's selected response | + +A first-drain overflow remains sticky through every later accessor, including a larger-cap retry, `take_body`, and `into_request`. StoredError must preserve the new typed variant and 413. The source is never polled again. + +A stricter recheck of already-cached bytes remains a stateless size check. It does not poison an intact cache. A successful initial JSON-policy collection cannot cache more logical body bytes than the native ceiling. If bytes were already cached by an unrelated non-JSON read or supplied by a low-level caller, the JSON-policy accessor still checks their length before returning or parsing them. That later check cannot undo an earlier allocation outside this policy. + +Release body-source ownership and partial buffered bytes on overflow, source failure, deadline expiry, and cancellation. Verify drop counts/poll counts rather than inferring cleanup from a returned error. Dropping the current chunk does not undo host allocations that occurred before the chunk reached EdgeZero. + +Keep request grants and response completions in #275's established lifecycle. A generated error response retains its admitted completion and response policy. Do not release response-owned resources prematurely or promise a completion report when cancellation happens before an egress attempt begins. HostHandoff is not proof of client receipt. + +Custom application error renderers retain the framework-mandated 413 status through the existing centralized status policy. Arbitrary application code deliberately swallowing a failure and returning another response is outside the framework's status guarantee. + +## Memory and transport limits of the guarantee + +The cap bounds the logical body bytes retained by the framework collector/cache. It does not establish an exact allocation ceiling or process-wide memory budget. + +The current chunk can already exist when rejected. Vec growth capacity, final-buffer conversion, host/Hyper buffers, framing/trailers, allocator overhead, parsed JSON, deserialization allocations, middleware/handler allocations, and concurrent requests remain outside this value. No claim is made that rejecting a request frees all process memory or that the peer has stopped transmitting. + +The service must stop polling a rejected body; this does not require a new drain-to-EOF policy to salvage keep-alive. Preserve existing connection/body-drop behavior and test the usable error response through that real path. Do not make a new connection-reuse guarantee without evidence. + +Raw header accounting and parser-boundary framing remain Unsupported where the prerequisite says so. Existing Native/BestEffort read/egress distinctions remain unchanged. This safeguard does not certify raw parser memory, response backpressure, or end-client delivery. + +## Compatibility and affected surfaces + +The 2 MiB native default is a deliberate behavior change. Existing Axum applications accepting larger JSON need a documented override. Other providers retain their existing portable defaults and capability behavior. Non-JSON streams remain incremental without a new size or time policy. + +Adding the carrier and `#[non_exhaustive]` to public `Body` is an approved source-breaking change for downstream exhaustive matches. Document both changes in the same migration. Recommend existing accessors for ordinary callers and `Body::into_content`/`BodyContent` for transport branching. External raw-Body matches need a wildcard; `BodyContent` matches do not. Update all four adapters' shared-body consumers without changing their policies or request type identity. + +Preserve the no-argument development entrypoint, explicit production/embedding selection, startup initializer, logging ownership, and shutdown lifecycle inherited from the parent worktree. The new validated option is threaded through the same service construction, not a parallel production-only runner. + +Implemented areas to re-audit at accepted-baseline integration: + +- `run_options.rs`, `dev_server.rs`, `service.rs`, and request conversion in the Axum adapter. +- Core `body.rs` and `lib.rs` for the approved non-exhaustive `Body`, opaque carrier, exhaustive `BodyContent`, and re-export; `context.rs`, `extractor.rs`, and `error.rs` for policy enforcement and typed 413. +- Core `outbound.rs`, `response.rs`, and `response_egress_framing.rs`, plus each adapter's `outbound.rs`/`response.rs`, for policy removal and logical-content compatibility. Preserve existing upload modes, batch eligibility, framing, and stream EOF checks. +- Exhaustive core/adaptor error matches and StoredError round-trips affected by the new variant. +- Axum colocated tests, `tests/contract.rs`, and existing `tests/production_hosting.rs` fixtures. +- Native hosting and extractor guidance, unreleased changelog, and the inbound-body spec sections that currently specify 8 MiB/400. Amend them narrowly to describe the native override without rewriting historical designs wholesale. +- Generated/native entrypoints, CLI launch environment forwarding, app-demo, and router-only constructors for consistent default/override behavior. Do not force a generator migration unless the selected API actually requires one. + +The public low-level converter has no hosting options. It attaches the finite native default without reading environment; an explicit low-level override may be added only if an existing supported consumer needs it. Low-level converter-only tests do not replace standard service acceptance. + +## Acceptance and planned evidence + +The table defines required evidence. The implementation plan's execution record identifies passed checks on the provisional base and remaining gaps. Reuse those results only while their source fingerprint matches; repeat affected acceptance after integration. + +| Surface | Required evidence | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Prerequisite audit | Record accepted #275 and parent integration commits, existing checks reused, and remaining implementation/evidence. No superseded eager collection is restored. | +| Default | Through the standard service, valid JSON of exactly 2,097,152 bytes succeeds; one byte more receives 413. Handler invocation counters distinguish extraction rejection from handler work. | +| Media types | Repeat boundary cases for `application/json`, a vendor `application/*+json`, parameters, and mixed ASCII case. Include the agreed missing/wrong Content-Type JSON-helper behavior. | +| Framing | Exercise both valid Content-Length and chunked HTTP/1 requests without Content-Length through owned native connections. Use multiple data chunks; direct Body fixtures alone do not prove chunked wire behavior. | +| Configuration override | Smaller and larger configured caps change the service boundaries. Include an override above 8 MiB with an ordinary Json extractor so the portable fallback cannot silently win. | +| Explicit caps | Test a stricter application cap and the agreed behavior of a cap above the native ceiling. Verify every relevant JSON/helper entrypoint uses the same effective policy. | +| Generic buffered reads | Exercise `body_bytes`, `form_within`, and the Form-family extractors on JSON/+json ingress independently of JSON extractors. Test caller caps below/above the native ceiling, cached rechecks, and typed 413. Ordinary Form retains its 1 MiB cap; non-JSON generic overflow remains 400. | +| Captured policy | After ingress classification, mutate headers and clear/replace public extensions/parts before a generic buffered read. The original JSON/+json classification and ceiling still apply. Router app-state injection cannot replace the selected policy. | +| Request reconstruction | Test successful initial-body and cached-body `into_request` round trips into fresh/routed contexts, with header and extension mutation. Preserve a smaller ceiling and an ordinary JSON default above 8 MiB; generic JSON-media reads retain 413. Initial bodies stay unread during reconstruction; cached bytes are reused. | +| Validation | Omission default, malformed/signed/fractional/overflow/non-Unicode input, agreed zero semantics, explicit options without ambient merge, and safe diagnostics. Standard startup fails before Ready for invalid supplied values. | +| Lazy ordering | Observable first body poll happens only after admission and conversion. Refusal, abort, and ordinary 404/405 retain no-read behavior. | +| Overflow cleanup | First over-cap chunk is not appended, no subsequent source polls occur, source/read-owned drop counters settle once, and the real response status is 413 rather than 400/500. | +| Read failure/interruption | Scripted source error and cancellation release ownership and preserve existing sticky failure. Deadline tie behavior remains 408; no retry consumes a failed source. | +| Cache and poison | Repeated successful reads do not reread the source; stricter cached checks remain non-poisoning; first-drain oversized errors reproduce typed 413 through later accessors/reassembly. | +| Error lifecycle | Custom error rendering cannot change mandatory 413; response completion follows the existing egress path exactly once when an attempt terminates. Abandonment retains the existing no-report distinctions. | +| Streams | Non-JSON incremental bodies larger than the native JSON cap still work under existing policy. An untouched JSON body taken as a stream remains incremental and gains no new total-size cap. | +| Hosting integration | Development, explicit production, embedding, and router-only entrypoints share the default/selected value without changing prior startup/shutdown contracts. | +| Portability | Other adapters keep existing defaults, overflow categories, and WASM compatibility while migrating shared-body consumers to the exhaustive content view and supporting the shared typed error. | +| Public body API | External compile-fail/pass fixtures demonstrate non-exhaustive raw `Body` matching and exhaustive `BodyContent` matching. Include every current raw variant in the failure fixture so it tests the attribute, not just an omitted carrier case. Verify raw-content handoff is unmanaged, lazy, and byte-copy-free. | + +Use exact-byte valid JSON fixtures, such as a JSON string whose delimiters count toward the cap. Raw fixtures are appropriate here because exact byte boundaries are the subject of the test. Bound waits and clean up local fixtures. Ordinary tests need no provider credentials or external services; local sockets/subprocess fixtures exercise native wire behavior. + +At implementation checkpoints, run the repository-required gates and affected focused suites: + +```sh +cargo test -p edgezero-core --all-targets +cargo test -p edgezero-core --doc +cargo test -p edgezero-adapter-axum --all-targets --all-features +cargo test --workspace --all-targets +cargo fmt --all -- --check +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo check --workspace --all-targets --features "fastly cloudflare spin" +cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +``` + +Add affected CLI/generated-project checks only where entrypoint changes require them. Run docs formatting/lint/build for changed documentation at the stable integration checkpoint. Report unavailable targets or runtime evidence as gaps, not passes. + +## Resolved review questions + +### 1. Native ceiling and default, resolved + +Agreed: one configurable host ceiling, defaulting to 2,097,152 bytes. Explicit application caps may be tighter. A route cannot exceed the ceiling merely by using `ValidatedJsonWithin` or `json_within` with a larger number. Raising the native option is the explicit permission to retain more JSON bytes. + +Apply the default in both development and production. Preserve other adapters' existing defaults. The basis is Axum's existing buffering-extractor default, not a claim that 2 MiB suits every application. + +### 2. Buffered-read coverage, resolved + +Agreed: JSON helpers/extractors enforce the ceiling regardless of Content-Type. Generic framework buffered reads also enforce it when the ingress media type is `application/json` or `application/*+json`. + +Untouched bodies taken as streams remain outside this total-size cap, even with a JSON media type. No new content-type rejection rules are introduced. Omitting or changing the header cannot bypass the ceiling during JSON helper/extractor collection. + +### 3. Zero and disabling protection, resolved + +Agreed: reject zero for the hosting option and expose no unlimited/disable setting or sentinel. Existing explicit low-level helper caps retain their current zero behavior; this decision concerns the native hosting option. + +### 4. Native configuration interface, resolved + +Agreed: `EDGEZERO__ADAPTER__JSON_BODY_LIMIT_BYTES`, `AxumRunOptions::with_json_body_limit_bytes`, and `AxumDevServer::with_json_body_limit_bytes`. + +Read once in standalone hosting. Invalid supplied values fail startup in development and production. Explicit embedding options do not silently merge ambient environment settings. Add no manifest section or CLI flag. + +### 5. Axum-scoped error behavior, resolved + +Agreed: this work focuses on Axum buffering policy. Add the minimal shared typed 413 error and the approved body-owned policy carrier/consumer compatibility refactor. Preserve portable 8 MiB defaults and existing non-Axum overflow behavior. Malformed JSON remains HTTP 400. + +Never translate arbitrary BadRequest errors by message matching. A broader cross-provider overflow-status migration is outside this issue. + +## Review gate + +All five policy questions and the bounded non-exhaustive Body/carrier compatibility scope are approved. Final pre-implementation review completed, and the user separately authorized work on the current base while deferring #275 integration. Independent implementation verification found no runtime defects; its documentation correction and focused proof gaps have been addressed. Keep the exhaustive transport view and unchanged provider policy contracts explicit. Re-audit and repeat affected acceptance against the eventual accepted prerequisite baseline. + +This spec does not close #393, certify the inherited hosting changes, or claim the latest #275 has been integrated. The provisional runtime results do not establish accepted-baseline integration or unexecuted provider runtime behavior.