From fc7a555ba8cf77d221126b9f2c328a8528872352 Mon Sep 17 00:00:00 2001 From: Martyn Date: Mon, 14 Sep 2026 20:21:05 +0100 Subject: [PATCH 1/6] Add time and rate parsers --- core/http/Cargo.toml | 1 + core/http/src/lib.rs | 3 + core/http/src/policy.rs | 396 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 core/http/src/policy.rs diff --git a/core/http/Cargo.toml b/core/http/Cargo.toml index 16489e35c..dada82c53 100644 --- a/core/http/Cargo.toml +++ b/core/http/Cargo.toml @@ -36,6 +36,7 @@ memchr = "2" stable-pattern = "0.1" cookie = { version = "0.18", features = ["percent-encode"] } state = "0.6" +ubyte = "0.10.2" [dependencies.serde] version = "1.0" diff --git a/core/http/src/lib.rs b/core/http/src/lib.rs index eee76c76a..e5cb527cb 100644 --- a/core/http/src/lib.rs +++ b/core/http/src/lib.rs @@ -16,6 +16,7 @@ pub mod uri; mod header; mod method; mod parse; +mod policy; mod raw_str; mod status; @@ -38,6 +39,8 @@ pub mod private { pub use crate::header::*; pub use crate::method::Method; +pub use crate::policy::{format_duration, parse_duration}; +pub use crate::policy::{DurationParseError, Rate, RateParseError}; pub use crate::raw_str::{RawStr, RawStrBuf}; pub use crate::status::{Status, StatusClass}; diff --git a/core/http/src/policy.rs b/core/http/src/policy.rs new file mode 100644 index 000000000..7c74146da --- /dev/null +++ b/core/http/src/policy.rs @@ -0,0 +1,396 @@ +//! Parsers for the duration and rate strings used to configure request body +//! policy. +//! +//! The same strings appear in global configuration (`Rocket.toml`, environment +//! variables) and, eventually, in route attributes, so they are parsed +//! identically at runtime and at compile time. These parsers live here, in +//! `rkt_http`, because `rkt_codegen` depends on this crate and needs them. +//! +//! # Durations +//! +//! A duration is a sequence of `` components in strictly +//! descending unit order, where the unit is one of `h`, `m`, `s`, or `ms`. A +//! string consisting of a bare integer, and nothing else, is a count of +//! seconds. A duration must be non-zero. +//! +//! ```rust +//! use std::time::Duration; +//! use rkt::http::parse_duration; +//! +//! assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30)); +//! assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250)); +//! assert_eq!(parse_duration("1m30s").unwrap(), Duration::from_secs(90)); +//! assert_eq!(parse_duration("60").unwrap(), Duration::from_secs(60)); +//! +//! assert!(parse_duration("0s").is_err()); +//! assert!(parse_duration("30s1m").is_err()); +//! assert!(parse_duration("-5s").is_err()); +//! ``` +//! +//! # Rates +//! +//! A [`Rate`] is a size and a period separated by a `/`: `8KiB/s`, `1MiB/10s`, +//! `512/s`. The size is parsed by [`ByteUnit`], so a bare integer is a count of +//! bytes. A period given as a bare unit, as in `8KiB/s`, means one of that +//! unit. +//! +//! ```rust +//! use std::time::Duration; +//! use rkt::http::Rate; +//! +//! let rate: Rate = "8KiB/s".parse().unwrap(); +//! assert_eq!(rate.bytes, 8192); +//! assert_eq!(rate.per, Duration::from_secs(1)); +//! +//! assert!("30".parse::().is_err()); +//! assert!("0/s".parse::().is_err()); +//! ``` + +use std::fmt; +use std::str::FromStr; +use std::time::Duration; + +use ubyte::ByteUnit; + +/// The millisecond value and ordering rank of a duration unit. +/// +/// Components must appear in strictly descending unit order, which the rank +/// makes cheap to check: `h` < `m` < `s` < `ms`. +fn unit(suffix: &str) -> Option<(u8, u128)> { + match suffix { + "h" => Some((1, 3_600_000)), + "m" => Some((2, 60_000)), + "s" => Some((3, 1_000)), + "ms" => Some((4, 1)), + _ => None, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum DurationErrorKind { + Empty, + Unexpected(char), + UnknownUnit, + UnitOrder, + Overflow, + Zero, +} + +/// An error returned by [`parse_duration()`] when a duration string is invalid. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DurationParseError(DurationErrorKind); + +impl fmt::Display for DurationParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + DurationErrorKind::Empty => f.write_str("the input was empty"), + DurationErrorKind::Unexpected(c) => write!(f, "unexpected character {c:?}"), + DurationErrorKind::UnknownUnit => { + f.write_str("unknown duration unit: expected one of `h`, `m`, `s`, `ms`") + } + DurationErrorKind::UnitOrder => { + f.write_str("duration units must be unique and in descending order") + } + DurationErrorKind::Overflow => f.write_str("duration is too large"), + DurationErrorKind::Zero => f.write_str("duration must be non-zero"), + } + } +} + +impl std::error::Error for DurationParseError {} + +/// Parses a duration string such as `"30s"`, `"250ms"`, `"1m30s"`, or `"2h"`. +/// +/// A string consisting of a bare integer is a count of seconds: `"60"` is +/// `"60s"`. Any other component must carry one of the units `h`, `m`, `s`, or +/// `ms`, and components must appear in strictly descending unit order. The +/// resulting duration must be non-zero. +/// +/// # Example +/// +/// ```rust +/// use std::time::Duration; +/// use rkt::http::parse_duration; +/// +/// assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200)); +/// assert_eq!(parse_duration("1m30s").unwrap(), Duration::from_secs(90)); +/// ``` +pub fn parse_duration(string: &str) -> Result { + use DurationErrorKind::*; + + if string.is_empty() { + return Err(DurationParseError(Empty)); + } + + let bytes = string.as_bytes(); + let (mut i, mut millis, mut last_rank) = (0, 0u128, 0u8); + while i < bytes.len() { + let digits = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + + if i == digits { + let c = string[digits..].chars().next().expect("non-empty remainder"); + return Err(DurationParseError(Unexpected(c))); + } + + let value: u128 = string[digits..i].parse().map_err(|_| DurationParseError(Overflow))?; + + let suffix = i; + while i < bytes.len() && bytes[i].is_ascii_alphabetic() { + i += 1; + } + + // A bare integer, and only a bare integer, is a count of seconds. + let (rank, scale) = match &string[suffix..i] { + "" if digits == 0 && i == bytes.len() => (3, 1_000), + suffix => unit(suffix).ok_or(DurationParseError(UnknownUnit))?, + }; + + if rank <= last_rank { + return Err(DurationParseError(UnitOrder)); + } + + last_rank = rank; + millis = value + .checked_mul(scale) + .and_then(|v| millis.checked_add(v)) + .ok_or(DurationParseError(Overflow))?; + } + + let millis = u64::try_from(millis).map_err(|_| DurationParseError(Overflow))?; + match millis { + 0 => Err(DurationParseError(Zero)), + millis => Ok(Duration::from_millis(millis)), + } +} + +/// Renders `duration` in the canonical form read by [`parse_duration()`]. +/// +/// Sub-millisecond precision is truncated. This is the inverse of +/// [`parse_duration()`] for any duration it produces. +/// +/// # Example +/// +/// ```rust +/// use std::time::Duration; +/// use rkt::http::format_duration; +/// +/// assert_eq!(format_duration(Duration::from_secs(90)), "1m30s"); +/// assert_eq!(format_duration(Duration::from_millis(250)), "250ms"); +/// ``` +pub fn format_duration(duration: Duration) -> String { + let mut remaining = duration.as_millis(); + if remaining == 0 { + return "0s".into(); + } + + let mut string = String::new(); + for (suffix, scale) in [("h", 3_600_000u128), ("m", 60_000), ("s", 1_000), ("ms", 1)] { + let count = remaining / scale; + if count > 0 { + string.push_str(&count.to_string()); + string.push_str(suffix); + remaining -= count * scale; + } + } + + string +} + +/// A data rate: [`bytes`](Rate::bytes) bytes per [`per`](Rate::per). +/// +/// Parsed from a size and a period separated by a `/`, as in `"8KiB/s"`, +/// `"1MiB/10s"`, or `"512/s"`. See the [module docs](self) for the accepted +/// forms. +/// +/// # Example +/// +/// ```rust +/// use std::time::Duration; +/// use rkt::http::Rate; +/// +/// let rate: Rate = "1MiB/s".parse().unwrap(); +/// assert_eq!(rate.bytes, 1024 * 1024); +/// assert_eq!(rate.per, Duration::from_secs(1)); +/// assert_eq!(rate.to_string(), "1MiB/1s"); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct Rate { + /// The number of bytes transferred per [`per`](Rate::per). Always non-zero. + pub bytes: u64, + /// The period over which [`bytes`](Rate::bytes) are transferred. Always + /// non-zero. + pub per: Duration, +} + +#[derive(Debug, Clone)] +enum RateErrorKind { + MissingPeriod, + BadSize(ubyte::Error), + ZeroSize, + BadPeriod(DurationParseError), +} + +/// An error returned when a [`Rate`] string is invalid. +#[derive(Debug, Clone)] +pub struct RateParseError(RateErrorKind); + +impl fmt::Display for RateParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + RateErrorKind::MissingPeriod => { + f.write_str("a rate must be a size and a period separated by `/`, as in `8KiB/s`") + } + RateErrorKind::BadSize(e) => write!(f, "invalid rate size: {e}"), + RateErrorKind::ZeroSize => f.write_str("rate size must be non-zero"), + RateErrorKind::BadPeriod(e) => write!(f, "invalid rate period: {e}"), + } + } +} + +impl std::error::Error for RateParseError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.0 { + RateErrorKind::BadPeriod(e) => Some(e), + _ => None, + } + } +} + +impl FromStr for Rate { + type Err = RateParseError; + + fn from_str(string: &str) -> Result { + use RateErrorKind::*; + + let (size, period) = string.split_once('/').ok_or(RateParseError(MissingPeriod))?; + let bytes = ByteUnit::from_str(size).map_err(|e| RateParseError(BadSize(e)))?.as_u64(); + if bytes == 0 { + return Err(RateParseError(ZeroSize)); + } + + if period.is_empty() { + return Err(RateParseError(MissingPeriod)); + } + + // A period written as a bare unit, as in `8KiB/s`, means one of it. + let per = match period.as_bytes()[0].is_ascii_digit() { + true => parse_duration(period), + false => parse_duration(&format!("1{period}")), + }; + + Ok(Rate { bytes, per: per.map_err(|e| RateParseError(BadPeriod(e)))? }) + } +} + +impl fmt::Display for Rate { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}/{}", ByteUnit::from(self.bytes), format_duration(self.per)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! assert_parses { + ($($string:expr => $duration:expr),* $(,)?) => ($( + match parse_duration($string) { + Ok(actual) => assert_eq!(actual, $duration, "parsing {:?}", $string), + Err(e) => panic!("{:?} failed to parse: {}", $string, e), + } + )*) + } + + macro_rules! assert_rejects { + ($($string:expr),* $(,)?) => ($( + if let Ok(d) = parse_duration($string) { + panic!("{:?} parsed as {:?}", $string, d); + } + )*) + } + + #[test] + fn parses_durations() { + assert_parses! { + "30s" => Duration::from_secs(30), + "250ms" => Duration::from_millis(250), + "1ms" => Duration::from_millis(1), + "1m" => Duration::from_secs(60), + "1m30s" => Duration::from_secs(90), + "2h" => Duration::from_secs(7200), + "1h2m3s4ms" => Duration::from_millis(3_723_004), + "60" => Duration::from_secs(60), + "1" => Duration::from_secs(1), + } + } + + #[test] + fn rejects_bad_durations() { + assert_rejects! { + "", "0", "0s", "0ms", "0h0m0s", + "-5s", "+5s", "5.5s", "s", "ms", "5x", "5S", "5MS", + "1m 30s", " 30s", "30s ", "30s,1m", + "30s1m", "1s1s", "1ms1s", "1m30", "30s5", + "99999999999999999999999999999999999999999h", + "18446744073709551616ms", + } + } + + #[test] + fn formats_durations() { + assert_eq!(format_duration(Duration::from_secs(30)), "30s"); + assert_eq!(format_duration(Duration::from_millis(250)), "250ms"); + assert_eq!(format_duration(Duration::from_secs(90)), "1m30s"); + assert_eq!(format_duration(Duration::from_secs(7200)), "2h"); + assert_eq!(format_duration(Duration::from_millis(3_723_004)), "1h2m3s4ms"); + assert_eq!(format_duration(Duration::ZERO), "0s"); + } + + #[test] + fn duration_round_trips() { + for string in ["30s", "250ms", "1m30s", "2h", "1h2m3s4ms"] { + let duration = parse_duration(string).unwrap(); + assert_eq!(format_duration(duration), string); + assert_eq!(parse_duration(&format_duration(duration)).unwrap(), duration); + } + } + + #[test] + fn parses_rates() { + let cases = [ + ("8KiB/s", 8 * 1024, Duration::from_secs(1)), + ("1MiB/s", 1024 * 1024, Duration::from_secs(1)), + ("512/s", 512, Duration::from_secs(1)), + ("1MiB/m", 1024 * 1024, Duration::from_secs(60)), + ("1MiB/10s", 1024 * 1024, Duration::from_secs(10)), + ("1kB/500ms", 1000, Duration::from_millis(500)), + ]; + + for (string, bytes, per) in cases { + let rate: Rate = string.parse().unwrap_or_else(|e| panic!("{string:?}: {e}")); + assert_eq!(rate, Rate { bytes, per }, "parsing {string:?}"); + } + } + + #[test] + fn rejects_bad_rates() { + for string in ["", "30", "8KiB", "/s", "8KiB/", "0/s", "0KiB/s", "8KiB/0s", + "8KiB/x", "8XiB/s", "8KiB/s/s", "-8KiB/s", "8 KiB / s"] { + if let Ok(rate) = string.parse::() { + panic!("{string:?} parsed as {rate:?}"); + } + } + } + + #[test] + fn rate_round_trips() { + for string in ["8KiB/1s", "1MiB/1m", "512B/500ms"] { + let rate: Rate = string.parse().unwrap(); + assert_eq!(rate.to_string(), string); + assert_eq!(rate.to_string().parse::().unwrap(), rate); + } + } +} From 77e8258f7ef598759cc94649928f0c58ee1d82db Mon Sep 17 00:00:00 2001 From: Martyn Date: Thu, 17 Sep 2026 16:20:55 +0100 Subject: [PATCH 2/6] Policy configuration --- core/http/src/policy.rs | 34 +++++ core/lib/src/config/config.rs | 72 +++++++++- core/lib/src/config/duration.rs | 105 +++++++++++++++ core/lib/src/config/mod.rs | 2 + core/lib/src/config/tests.rs | 229 ++++++++++++++++++++++++++++++++ core/lib/src/data/mod.rs | 2 + core/lib/src/data/policy.rs | 135 +++++++++++++++++++ core/lib/src/rocket.rs | 3 + core/lib/src/trace/traceable.rs | 9 +- 9 files changed, 586 insertions(+), 5 deletions(-) create mode 100644 core/lib/src/config/duration.rs create mode 100644 core/lib/src/data/policy.rs diff --git a/core/http/src/policy.rs b/core/http/src/policy.rs index 7c74146da..b99778d51 100644 --- a/core/http/src/policy.rs +++ b/core/http/src/policy.rs @@ -291,6 +291,40 @@ impl fmt::Display for Rate { } } +#[cfg(feature = "serde")] +mod serde_impl { + use super::*; + + use serde::de::{Deserialize, Deserializer, Error, Unexpected, Visitor}; + use serde::ser::{Serialize, Serializer}; + + impl Serialize for Rate { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } + } + + struct DeVisitor; + + impl<'de> Visitor<'de> for DeVisitor { + type Value = Rate; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "a rate string such as `8KiB/s`") + } + + fn visit_str(self, v: &str) -> Result { + Rate::from_str(v).map_err(|_| E::invalid_value(Unexpected::Str(v), &self)) + } + } + + impl<'de> Deserialize<'de> for Rate { + fn deserialize>(deserializer: D) -> Result { + deserializer.deserialize_str(DeVisitor) + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/core/lib/src/config/config.rs b/core/lib/src/config/config.rs index dbb2efc79..4ef134082 100644 --- a/core/lib/src/config/config.rs +++ b/core/lib/src/config/config.rs @@ -5,8 +5,9 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "secrets")] use crate::config::SecretKey; -use crate::config::{CliColors, Ident, Level, ShutdownConfig, TraceFormat}; -use crate::data::Limits; +use crate::config::{CliColors, ConfigDuration, Ident, Level, ShutdownConfig, TraceFormat}; +use crate::data::{BodyPolicySetting, ByteUnit, Limits, ToByteUnit}; +use crate::http::Rate; use crate::http::uncased::Uncased; use crate::request::{self, FromRequest, Request}; @@ -138,6 +139,44 @@ pub struct Config { /// /// **(default: `false`)** pub debug_headers: bool, + /// Maximum time a request body may take to be received in full, measured + /// from the moment the body becomes available. + /// + /// Mutually exclusive with [`Config::body_min_rate`] and + /// [`Config::body_policy`]. When none of the three is set, a `60s` deadline + /// applies; see [`Config::body_policy()`] for the resolved policy. + /// + /// **(default: `None`, resolving to `"60s"`)** + pub body_timeout: Option, + /// Minimum average rate, such as `"8KiB/s"`, at which a request body must + /// be received once [`Config::body_grace`] has elapsed. + /// + /// Mutually exclusive with [`Config::body_timeout`] and + /// [`Config::body_policy`]. + /// + /// **(default: `None`)** + pub body_min_rate: Option, + /// Initial period during which [`Config::body_min_rate`] is not enforced. + /// + /// Only meaningful alongside [`Config::body_min_rate`]; setting it without + /// one is a configuration error. + /// + /// **(default: `None`, resolving to `"15s"`)** + pub body_grace: Option, + /// Hard limit on the size of a complete request body, enforced regardless + /// of which data guard reads it. **(default: `8MiB`)** + /// + /// This is independent of the progress policy and of the per-guard + /// [`Config::limits`]: the allowance for a guard is the stricter of the two. + pub body_limit: ByteUnit, + /// Set to `"unlimited"` to impose no bound on the time taken to receive a + /// request body. [`Config::body_limit`] continues to apply. + /// + /// Mutually exclusive with [`Config::body_timeout`] and + /// [`Config::body_min_rate`]. + /// + /// **(default: `None`)** + pub body_policy: Option, /// PRIVATE: This structure may grow (but never change otherwise) in a /// non-breaking release. As such, constructing this structure should /// _always_ be done using a public constructor or update syntax: @@ -213,6 +252,11 @@ impl Config { log_format: TraceFormat::Pretty, cli_colors: CliColors::Auto, debug_headers: false, + body_timeout: None, + body_min_rate: None, + body_grace: None, + body_limit: 8.mebibytes(), + body_policy: None, __non_exhaustive: (), } } @@ -394,6 +438,21 @@ impl Config { /// The stringy parameter name for setting/extracting [`Config::debug_headers`]. pub const DEBUG_HEADERS: &'static str = "debug_headers"; + /// The stringy parameter name for setting/extracting [`Config::body_timeout`]. + pub const BODY_TIMEOUT: &'static str = "body_timeout"; + + /// The stringy parameter name for setting/extracting [`Config::body_min_rate`]. + pub const BODY_MIN_RATE: &'static str = "body_min_rate"; + + /// The stringy parameter name for setting/extracting [`Config::body_grace`]. + pub const BODY_GRACE: &'static str = "body_grace"; + + /// The stringy parameter name for setting/extracting [`Config::body_limit`]. + pub const BODY_LIMIT: &'static str = "body_limit"; + + /// The stringy parameter name for setting/extracting [`Config::body_policy`]. + pub const BODY_POLICY: &'static str = "body_policy"; + /// An array of all of the stringy parameter names. pub const PARAMETERS: &'static [&'static str] = &[ Self::WORKERS, @@ -410,6 +469,11 @@ impl Config { Self::SHUTDOWN, Self::CLI_COLORS, Self::DEBUG_HEADERS, + Self::BODY_TIMEOUT, + Self::BODY_MIN_RATE, + Self::BODY_GRACE, + Self::BODY_LIMIT, + Self::BODY_POLICY, ]; /// The stringy parameter name for setting/extracting [`Config::profile`]. @@ -421,8 +485,8 @@ impl Config { pub(crate) const DEPRECATED_KEYS: &'static [(&'static str, Option<&'static str>)] = &[ ("env", Some(Self::PROFILE)), ("log", Some(Self::LOG_LEVEL)), - ("read_timeout", None), - ("write_timeout", None), + ("read_timeout", Some(Self::BODY_TIMEOUT)), + ("write_timeout", Some(Self::BODY_TIMEOUT)), ]; /// Secret keys that have been used in docs or leaked otherwise. diff --git a/core/lib/src/config/duration.rs b/core/lib/src/config/duration.rs new file mode 100644 index 000000000..3931039e8 --- /dev/null +++ b/core/lib/src/config/duration.rs @@ -0,0 +1,105 @@ +use std::fmt; +use std::ops::Deref; +use std::str::FromStr; +use std::time::Duration; + +use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; + +use crate::http::{format_duration, parse_duration, DurationParseError}; + +/// A [`Duration`] that reads and writes rkt's duration syntax. +/// +/// [`Duration`]'s own `serde` implementation is a `{ secs, nanos }` structure, +/// which is unusable in a configuration file. This `serde` implementation +/// deserializes from either a duration string, as parsed by +/// [`parse_duration()`], or a bare integer number of seconds, and always +/// serializes to the canonical string produced by [`format_duration()`]. Zero +/// and negative durations are rejected. +/// +/// # Example +/// +/// ```rust +/// use std::time::Duration; +/// use rkt::config::ConfigDuration; +/// +/// let thirty_seconds = ConfigDuration::from(Duration::from_secs(30)); +/// assert_eq!(thirty_seconds.to_string(), "30s"); +/// assert_eq!("30s".parse::().unwrap(), thirty_seconds); +/// assert_eq!(*thirty_seconds, Duration::from_secs(30)); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ConfigDuration(pub Duration); + +impl From for ConfigDuration { + fn from(duration: Duration) -> Self { + ConfigDuration(duration) + } +} + +impl From for Duration { + fn from(duration: ConfigDuration) -> Self { + duration.0 + } +} + +impl Deref for ConfigDuration { + type Target = Duration; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl FromStr for ConfigDuration { + type Err = DurationParseError; + + fn from_str(string: &str) -> Result { + parse_duration(string).map(ConfigDuration) + } +} + +impl fmt::Display for ConfigDuration { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&format_duration(self.0)) + } +} + +impl Serialize for ConfigDuration { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&format_duration(self.0)) + } +} + +struct DeVisitor; + +const EXPECTED: &str = "a duration string such as `30s` or a number of seconds"; + +impl<'de> de::Visitor<'de> for DeVisitor { + type Value = ConfigDuration; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(EXPECTED) + } + + fn visit_u64(self, v: u64) -> Result { + // A bare integer is a count of seconds, exactly as in a duration + // string, so route it through the same parser to reject zero. + self.visit_str(&v.to_string()) + } + + fn visit_i64(self, v: i64) -> Result { + u64::try_from(v) + .map_err(|_| E::invalid_value(de::Unexpected::Signed(v), &EXPECTED)) + .and_then(|v| self.visit_u64(v)) + } + + fn visit_str(self, v: &str) -> Result { + ConfigDuration::from_str(v).map_err(|_| E::invalid_value(de::Unexpected::Str(v), &self)) + } +} + +impl<'de> Deserialize<'de> for ConfigDuration { + fn deserialize>(de: D) -> Result { + de.deserialize_str(DeVisitor) + } +} diff --git a/core/lib/src/config/mod.rs b/core/lib/src/config/mod.rs index 5c50a222f..2b3658026 100644 --- a/core/lib/src/config/mod.rs +++ b/core/lib/src/config/mod.rs @@ -114,12 +114,14 @@ mod ident; mod cli_colors; mod config; +mod duration; mod http_header; #[cfg(test)] mod tests; pub use cli_colors::CliColors; pub use config::Config; +pub use duration::ConfigDuration; pub use ident::Ident; pub use crate::shutdown::ShutdownConfig; diff --git a/core/lib/src/config/tests.rs b/core/lib/src/config/tests.rs index 996b9a949..46b43b507 100644 --- a/core/lib/src/config/tests.rs +++ b/core/lib/src/config/tests.rs @@ -289,6 +289,235 @@ fn test_debug_headers() { }) } +#[test] +fn test_body_policy_defaults() { + use std::time::Duration; + + use crate::data::BodyProgress; + + figment::Jail::expect_with(|_| { + let config = Config::from(Config::figment()); + assert_eq!(config.body_timeout, None); + assert_eq!(config.body_min_rate, None); + assert_eq!(config.body_grace, None); + assert_eq!(config.body_limit, 8.mebibytes()); + assert_eq!(config.body_policy, None); + + let policy = config.body_policy(); + assert_eq!(policy.progress, BodyProgress::Timeout(Duration::from_secs(60))); + assert_eq!(policy.limit, 8.mebibytes()); + + Ok(()) + }) +} + +#[test] +fn test_body_policy_toml() { + use std::time::Duration; + + use crate::data::{BodyPolicySetting, BodyProgress}; + + figment::Jail::expect_with(|jail| { + jail.create_file( + "Rocket.toml", + r#" + [default] + body_timeout = "1m30s" + body_limit = "2MiB" + "#, + )?; + + let config = Config::from(Config::figment()); + assert_eq!(config.body_timeout.map(|t| *t), Some(Duration::from_secs(90))); + assert_eq!(config.body_limit, 2.mebibytes()); + assert_eq!( + config.body_policy(), + crate::data::BodyPolicy { + progress: BodyProgress::Timeout(Duration::from_secs(90)), + limit: 2.mebibytes(), + } + ); + + // A bare integer is a count of seconds, as in a duration string. + jail.create_file( + "Rocket.toml", + r#" + [default] + body_timeout = 30 + "#, + )?; + + let config = Config::from(Config::figment()); + assert_eq!(config.body_timeout.map(|t| *t), Some(Duration::from_secs(30))); + + jail.create_file( + "Rocket.toml", + r#" + [default] + body_min_rate = "8KiB/s" + body_grace = "250ms" + "#, + )?; + + let config = Config::from(Config::figment()); + assert_eq!(config.body_min_rate, Some("8KiB/s".parse().unwrap())); + assert_eq!(config.body_grace.map(|g| *g), Some(Duration::from_millis(250))); + assert_eq!( + config.body_policy().progress, + BodyProgress::MinRate { + rate: "8KiB/s".parse().unwrap(), + grace: Duration::from_millis(250), + } + ); + + // Without a grace period, the minimum rate takes the default grace. + jail.create_file( + "Rocket.toml", + r#" + [default] + body_min_rate = "8KiB/s" + "#, + )?; + + let config = Config::from(Config::figment()); + assert_eq!( + config.body_policy().progress, + BodyProgress::MinRate { + rate: "8KiB/s".parse().unwrap(), + grace: Duration::from_secs(15), + } + ); + + jail.create_file( + "Rocket.toml", + r#" + [default] + body_policy = "unlimited" + "#, + )?; + + let config = Config::from(Config::figment()); + assert_eq!(config.body_policy, Some(BodyPolicySetting::Unlimited)); + assert_eq!(config.body_policy().progress, BodyProgress::Unlimited); + + Ok(()) + }) +} + +#[test] +fn test_body_policy_env() { + use std::time::Duration; + + figment::Jail::expect_with(|jail| { + jail.set_env("ROCKET_BODY_TIMEOUT", "45s"); + let config = Config::from(Config::figment()); + assert_eq!(config.body_timeout.map(|t| *t), Some(Duration::from_secs(45))); + + jail.set_env("ROCKET_BODY_TIMEOUT", 45); + let config = Config::from(Config::figment()); + assert_eq!(config.body_timeout.map(|t| *t), Some(Duration::from_secs(45))); + + jail.set_env("ROCKET_BODY_LIMIT", "16MiB"); + let config = Config::from(Config::figment()); + assert_eq!(config.body_limit, 16.mebibytes()); + + Ok(()) + }) +} + +#[test] +fn test_body_policy_rejects_bad_values() { + figment::Jail::expect_with(|jail| { + for value in ["0s", "-5s", "soon", "1s1s", ""] { + jail.set_env("ROCKET_BODY_TIMEOUT", format!("\"{value}\"")); + assert!(Config::figment().extract::().is_err(), "{value:?} parsed"); + } + + jail.set_env("ROCKET_BODY_TIMEOUT", 0); + assert!(Config::figment().extract::().is_err()); + jail.set_env("ROCKET_BODY_TIMEOUT", -1); + assert!(Config::figment().extract::().is_err()); + + Ok(()) + }) +} + +#[test] +fn test_body_policy_round_trip() { + use std::time::Duration; + + use crate::config::ConfigDuration; + + figment::Jail::expect_with(|_| { + let original = Config { + body_min_rate: Some("8KiB/s".parse().unwrap()), + body_grace: Some(ConfigDuration(Duration::from_millis(1500))), + body_limit: 5.gibibytes(), + ..Config::default() + }; + + let config = Config::from(Figment::from(original.clone())); + assert_eq!(config, original); + + Ok(()) + }) +} + +#[test] +fn test_body_policy_mutual_exclusion() { + use std::time::Duration; + + use crate::config::ConfigDuration; + use crate::data::BodyPolicySetting; + use crate::http::Rate; + + let timeout = Some(ConfigDuration(Duration::from_secs(30))); + let rate = Some("1KiB/s".parse::().unwrap()); + let unlimited = Some(BodyPolicySetting::Unlimited); + + let accepted = [ + Config::default(), + Config { body_timeout: timeout, ..Config::default() }, + Config { body_min_rate: rate, ..Config::default() }, + Config { body_min_rate: rate, body_grace: timeout, ..Config::default() }, + Config { body_policy: unlimited, ..Config::default() }, + ]; + + for config in accepted { + config.validate_body_policy().expect("valid body policy"); + } + + let rejected = [ + Config { body_timeout: timeout, body_min_rate: rate, ..Config::default() }, + Config { body_timeout: timeout, body_policy: unlimited, ..Config::default() }, + Config { body_min_rate: rate, body_policy: unlimited, ..Config::default() }, + Config { body_grace: timeout, ..Config::default() }, + Config { body_grace: timeout, body_policy: unlimited, ..Config::default() }, + ]; + + for config in rejected { + config.validate_body_policy().expect_err("invalid body policy"); + } +} + +#[test] +fn test_err_on_conflicting_body_policy() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "Rocket.toml", + r#" + [default] + body_timeout = "30s" + body_min_rate = "1KiB/s" + "#, + )?; + + let rocket = crate::custom(Config::figment()); + crate::local::blocking::Client::untracked(rocket).expect_err("conflicting body policy"); + Ok(()) + }) +} + #[test] fn test_profiles_merge() { figment::Jail::expect_with(|jail| { diff --git a/core/lib/src/data/mod.rs b/core/lib/src/data/mod.rs index e649a6eb3..f0f155586 100644 --- a/core/lib/src/data/mod.rs +++ b/core/lib/src/data/mod.rs @@ -8,6 +8,7 @@ mod from_data; mod io_stream; mod limits; mod peekable; +mod policy; mod transform; pub use self::capped::{Capped, N}; @@ -16,6 +17,7 @@ pub use self::data_stream::DataStream; pub use self::from_data::{FromData, Outcome}; pub use self::io_stream::{IoHandler, IoStream}; pub use self::limits::Limits; +pub use self::policy::{BodyPolicy, BodyPolicySetting, BodyProgress}; pub use self::transform::{Transform, TransformBuf}; pub use ubyte::{ByteUnit, ToByteUnit}; diff --git a/core/lib/src/data/policy.rs b/core/lib/src/data/policy.rs new file mode 100644 index 000000000..ee411246d --- /dev/null +++ b/core/lib/src/data/policy.rs @@ -0,0 +1,135 @@ +use std::fmt; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use crate::config::Config; +use crate::data::ByteUnit; +use crate::http::{format_duration, Rate}; + +/// The progress deadline applied when no progress policy is configured. +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); + +/// The grace period applied when [`Config::body_min_rate`] is configured +/// without [`Config::body_grace`]. +const DEFAULT_GRACE: Duration = Duration::from_secs(15); + +/// The value of the [`body_policy`](Config::body_policy) configuration key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum BodyPolicySetting { + /// Impose no bound at all on the time taken to receive a request body. The + /// hard size limit in [`Config::body_limit`] continues to apply. + Unlimited, +} + +/// How the progress of an incoming request body is bounded. +/// +/// This is the resolved form of the mutually exclusive [`Config::body_timeout`], +/// [`Config::body_min_rate`], and [`Config::body_policy`] settings. Obtain it +/// via [`Config::body_policy()`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyProgress { + /// The complete body must be received within this duration, measured from + /// the moment the body becomes available. Receiving data neither resets nor + /// extends the deadline. + Timeout(Duration), + /// The body must be received at an average of at least `rate` once `grace` + /// has elapsed, which is to say by `grace + received bytes / rate`. + MinRate { + /// The minimum average rate at which payload bytes must arrive. + rate: Rate, + /// The initial period during which no rate is required. + grace: Duration, + }, + /// The time taken to receive the body is not bounded. + Unlimited, +} + +/// The request body policy in effect: a progress bound and a hard size limit. +/// +/// The two are independent: a body that arrives fast enough is still rejected +/// once it exceeds [`limit`](BodyPolicy::limit). Obtain the globally configured +/// policy via [`Config::body_policy()`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BodyPolicy { + /// The bound on how long the body may take to arrive. + pub progress: BodyProgress, + /// The hard limit on the size of the complete request body. + pub limit: ByteUnit, +} + +impl fmt::Display for BodyProgress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + BodyProgress::Timeout(timeout) => write!(f, "deadline {}", format_duration(*timeout)), + BodyProgress::MinRate { rate, grace } => { + write!(f, "min rate {rate} after {} grace", format_duration(*grace)) + } + BodyProgress::Unlimited => f.write_str("unlimited"), + } + } +} + +impl Config { + /// Returns the request body policy resolved from this configuration. + /// + /// The progress bound is [`BodyProgress::Unlimited`] if + /// [`body_policy`](Config::body_policy) is `"unlimited"`, + /// [`BodyProgress::MinRate`] if [`body_min_rate`](Config::body_min_rate) is + /// set, and a [`BodyProgress::Timeout`] otherwise. Unset values take their + /// documented defaults: a `60s` deadline and a `15s` grace period. + /// + /// # Example + /// + /// ```rust + /// use std::time::Duration; + /// use rkt::Config; + /// use rkt::data::{BodyProgress, ToByteUnit}; + /// + /// let config = Config::default(); + /// let policy = config.body_policy(); + /// assert_eq!(policy.progress, BodyProgress::Timeout(Duration::from_secs(60))); + /// assert_eq!(policy.limit, 8.mebibytes()); + /// ``` + pub fn body_policy(&self) -> BodyPolicy { + let progress = if self.body_policy == Some(BodyPolicySetting::Unlimited) { + BodyProgress::Unlimited + } else if let Some(rate) = self.body_min_rate { + let grace = self.body_grace.map_or(DEFAULT_GRACE, |grace| grace.0); + BodyProgress::MinRate { rate, grace } + } else { + BodyProgress::Timeout(self.body_timeout.map_or(DEFAULT_TIMEOUT, |t| t.0)) + }; + + BodyPolicy { progress, limit: self.body_limit } + } + + /// Checks the body policy keys for combinations the deserializer cannot + /// reject on its own: at most one progress policy may be selected, and a + /// grace period is meaningless without a minimum rate. + pub(crate) fn validate_body_policy(&self) -> figment::error::Result<()> { + let mut selected = Vec::new(); + if self.body_timeout.is_some() { + selected.push(Self::BODY_TIMEOUT); + } + if self.body_min_rate.is_some() { + selected.push(Self::BODY_MIN_RATE); + } + if self.body_policy.is_some() { + selected.push(Self::BODY_POLICY); + } + + if selected.len() > 1 { + let keys = selected.join("`, `"); + return Err(format!("`{keys}` are mutually exclusive: set at most one").into()); + } + + if self.body_grace.is_some() && self.body_min_rate.is_none() { + let (grace, rate) = (Self::BODY_GRACE, Self::BODY_MIN_RATE); + return Err(format!("`{grace}` is only meaningful with `{rate}`").into()); + } + + Ok(()) + } +} diff --git a/core/lib/src/rocket.rs b/core/lib/src/rocket.rs index 9fd3e2465..7508faff0 100644 --- a/core/lib/src/rocket.rs +++ b/core/lib/src/rocket.rs @@ -556,6 +556,9 @@ impl Rocket { let mut config = Config::try_from(&self.figment).map_err(ErrorKind::Config)?; crate::trace::init(&config); + // Check for body policy key combinations the deserializer can't reject. + config.validate_body_policy().map_err(ErrorKind::Config)?; + // Check for safely configured secrets. #[cfg(feature = "secrets")] if !config.secret_key.is_provided() { diff --git a/core/lib/src/trace/traceable.rs b/core/lib/src/trace/traceable.rs index 4d550e870..60579f883 100644 --- a/core/lib/src/trace/traceable.rs +++ b/core/lib/src/trace/traceable.rs @@ -93,12 +93,17 @@ impl Trace for Figment { // Check for now deprecated config values. for (key, replacement) in Config::DEPRECATED_KEYS { if let Some(source) = self.find_metadata(key) { + let advice = match replacement { + Some(new) => format!("use `{new}` instead"), + None => "it has no meaning".to_string(), + }; + event! { Level::WARN, "deprecated", key, replacement, %source.name, source.source = source.source.as_ref().map(display), - "config key `{key}` is deprecated and has no meaning" + "config key `{key}` is deprecated: {advice}" } } } @@ -137,6 +142,8 @@ impl Trace for Config { shutdown.grace = self.shutdown.grace, shutdown.mercy = self.shutdown.mercy, shutdown.force = self.shutdown.force, + body_policy = %self.body_policy().progress, + body_limit = %self.body_limit, } #[cfg(feature = "secrets")] From 800d61a2851bf1abb4384619e9e1038bc9d85cab Mon Sep 17 00:00:00 2001 From: Martyn Date: Sat, 19 Sep 2026 10:18:15 +0100 Subject: [PATCH 3/6] Policy implementation --- core/lib/Cargo.toml | 2 +- core/lib/src/data/data.rs | 46 +- core/lib/src/data/data_stream.rs | 34 +- core/lib/src/data/mod.rs | 2 +- core/lib/src/data/policy.rs | 781 ++++++++++++++++++++- core/lib/src/erased.rs | 5 +- core/lib/src/form/parser.rs | 2 +- core/lib/src/local/asynchronous/request.rs | 2 +- 8 files changed, 853 insertions(+), 21 deletions(-) diff --git a/core/lib/Cargo.toml b/core/lib/Cargo.toml index e48995e76..c464a592e 100644 --- a/core/lib/Cargo.toml +++ b/core/lib/Cargo.toml @@ -167,6 +167,6 @@ libc = "0.2.149" version_check = "0.9.1" [dev-dependencies] -tokio = { version = "1", features = ["macros", "io-std"] } +tokio = { version = "1", features = ["macros", "io-std", "test-util"] } figment = { version = "0.10.17", features = ["test"] } pretty_assertions = "1" diff --git a/core/lib/src/data/data.rs b/core/lib/src/data/data.rs index c19bc7937..2b92795be 100644 --- a/core/lib/src/data/data.rs +++ b/core/lib/src/data/data.rs @@ -3,8 +3,9 @@ use std::pin::Pin; use crate::data::data_stream::{DataStream, RawReader, RawStream}; use crate::data::peekable::Peekable; +use crate::data::policy::PolicedStream; use crate::data::transform::{InPlaceMap, Inspect, Transform, TransformBuf}; -use crate::data::ByteUnit; +use crate::data::{BodyPolicy, ByteUnit}; /// Type representing the body data of a request. /// @@ -38,13 +39,21 @@ use crate::data::ByteUnit; /// The `peek` method returns a slice containing at most 512 bytes of buffered /// body data. This enables partially or fully reading from a `Data` object /// without consuming the `Data` object. +/// +/// # Body Policy +/// +/// Every read of body data, whether via `peek` or via the stream returned by +/// `open`, is subject to the [`BodyPolicy`] in effect: the body must arrive +/// within the configured progress bound and must not exceed the hard size +/// limit. A read that violates the policy fails with an [`io::Error`] carrying +/// a [`BodyPolicyError`](crate::data::BodyPolicyError), and every read +/// thereafter fails with the same error. The body clock starts when the body +/// becomes available and is never restarted by forwarding or opening the data. pub struct Data<'r> { stream: Peekable<512, RawReader<'r>>, transforms: Vec>>, } -// TODO: Before `async`, we had a read timeout of 5s. Such a short read timeout -// is likely no longer necessary, but an idle timeout should be implemented. impl<'r> Data<'r> { #[inline] pub(crate) fn new(stream: Peekable<512, RawReader<'r>>) -> Self { @@ -54,19 +63,34 @@ impl<'r> Data<'r> { } } + /// Creates a `Data` for a request body that has just become available. + /// The body clock starts now, and `policy` is enforced on every read. + #[inline] + pub(crate) fn from>>(stream: S, policy: BodyPolicy) -> Data<'r> { + let stream = PolicedStream::new(stream.into(), policy); + Data::new(Peekable::new(RawReader::new(stream))) + } + + /// Creates a `Data` for a multipart field of a larger body. The field is + /// read through that body's stream, which counts and bounds every byte it + /// yields, so the field itself enforces no policy of its own. #[inline] - pub(crate) fn from>>(stream: S) -> Data<'r> { - Data::new(Peekable::new(RawReader::new(stream.into()))) + pub(crate) fn from_field(field: multer::Field<'r>) -> Data<'r> { + let stream = PolicedStream::inherited(field.into()); + Data::new(Peekable::new(RawReader::new(stream))) } /// This creates a `data` object from a local data source `data`. + /// + /// The data is already in hand, so the progress bound of `policy` cannot + /// be violated, but its size limit is applied as it would be to a received + /// body: if `data` exceeds the limit, reading past it fails. #[inline] - pub(crate) fn local(data: Vec) -> Data<'r> { - Data::new(Peekable::with_buffer( - data, - true, - RawReader::new(RawStream::Empty), - )) + pub(crate) fn local(data: Vec, policy: BodyPolicy) -> Data<'r> { + let mut stream = PolicedStream::new(RawStream::Empty, policy); + // A failure is recorded in the stream and surfaces from its first read. + let _ = stream.count(data.len()); + Data::new(Peekable::with_buffer(data, true, RawReader::new(stream))) } /// Returns the raw data stream, limited to `limit` bytes. diff --git a/core/lib/src/data/data_stream.rs b/core/lib/src/data/data_stream.rs index 9e67c3161..b2b589c65 100644 --- a/core/lib/src/data/data_stream.rs +++ b/core/lib/src/data/data_stream.rs @@ -10,8 +10,9 @@ use tokio::fs::File; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, ReadBuf, Take}; use tokio_util::io::StreamReader; +use crate::data::policy::PolicedStream; use crate::data::transform::Transform; -use crate::data::{Capped, N}; +use crate::data::{BodyPolicyError, Capped, N}; use crate::util::Chain; use super::peekable::Peekable; @@ -63,8 +64,9 @@ pub struct TransformReader<'r> { /// Limited, pre-buffered reader to the underlying data stream. pub type BaseReader<'r> = Take>, RawReader<'r>>>; -/// Direct reader to the underlying data stream. Not limited in any manner. -pub type RawReader<'r> = StreamReader, Bytes>; +/// Direct reader to the underlying data stream. Not limited in size by any +/// data guard, but subject to the body policy via [`PolicedStream`]. +pub type RawReader<'r> = StreamReader, Bytes>; /// Raw underlying data stream. #[allow(clippy::large_enum_variant)] @@ -74,6 +76,8 @@ pub enum RawStream<'r> { #[cfg(feature = "http3-preview")] H3Body(crate::listener::Cancellable), Multipart(multer::Field<'r>), + #[cfg(test)] + Test(Pin> + Send + Sync + 'r>>), } impl<'r> TransformReader<'r> { @@ -361,8 +365,10 @@ impl Stream for RawStream<'_> { .map_err(io::Error::other), #[cfg(feature = "http3-preview")] RawStream::H3Body(stream) => Pin::new(stream).poll_next(cx), - RawStream::Multipart(s) => Pin::new(s).poll_next(cx).map_err(io::Error::other), + RawStream::Multipart(s) => Pin::new(s).poll_next(cx).map_err(multipart_error), RawStream::Empty => Poll::Ready(None), + #[cfg(test)] + RawStream::Test(stream) => stream.as_mut().poll_next(cx), } } @@ -377,10 +383,28 @@ impl Stream for RawStream<'_> { RawStream::H3Body(_) => (0, Some(0)), RawStream::Multipart(mp) => mp.size_hint(), RawStream::Empty => (0, Some(0)), + #[cfg(test)] + RawStream::Test(stream) => stream.size_hint(), } } } +/// Converts an error from reading a multipart field into an `io::Error`. +/// +/// A field is read through the [`DataStream`] of the enclosing body, so a body +/// policy failure reaches the field wrapped by multer. Unwrap it so that it +/// surfaces as the same distinct [`BodyPolicyError`] `io::Error` it would from +/// the body itself. +fn multipart_error(error: multer::Error) -> io::Error { + if let multer::Error::StreamReadFailed(source) = &error { + if let Some(e) = source.downcast_ref::().and_then(BodyPolicyError::from_io) { + return io::Error::from(*e); + } + } + + io::Error::other(error) +} + impl std::fmt::Display for RawStream<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -389,6 +413,8 @@ impl std::fmt::Display for RawStream<'_> { #[cfg(feature = "http3-preview")] RawStream::H3Body(_) => f.write_str("http3 quic stream"), RawStream::Multipart(_) => f.write_str("multipart form field"), + #[cfg(test)] + RawStream::Test(_) => f.write_str("test stream"), } } } diff --git a/core/lib/src/data/mod.rs b/core/lib/src/data/mod.rs index f0f155586..11a4e9d54 100644 --- a/core/lib/src/data/mod.rs +++ b/core/lib/src/data/mod.rs @@ -17,7 +17,7 @@ pub use self::data_stream::DataStream; pub use self::from_data::{FromData, Outcome}; pub use self::io_stream::{IoHandler, IoStream}; pub use self::limits::Limits; -pub use self::policy::{BodyPolicy, BodyPolicySetting, BodyProgress}; +pub use self::policy::{BodyPolicy, BodyPolicyError, BodyPolicySetting, BodyProgress}; pub use self::transform::{Transform, TransformBuf}; pub use ubyte::{ByteUnit, ToByteUnit}; diff --git a/core/lib/src/data/policy.rs b/core/lib/src/data/policy.rs index ee411246d..6afbf66f3 100644 --- a/core/lib/src/data/policy.rs +++ b/core/lib/src/data/policy.rs @@ -1,9 +1,16 @@ -use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; use std::time::Duration; +use std::{fmt, io}; +use futures::stream::Stream; +use hyper::body::Bytes; use serde::{Deserialize, Serialize}; +use tokio::time::{Instant, Sleep}; use crate::config::Config; +use crate::data::data_stream::RawStream; use crate::data::ByteUnit; use crate::http::{format_duration, Rate}; @@ -133,3 +140,775 @@ impl Config { Ok(()) } } + +/// An error produced when a request body violates its [`BodyPolicy`]. +/// +/// Readers of [`Data`](crate::data::Data) and [`DataStream`](crate::data::DataStream) +/// receive this error as an [`io::Error`] whose [`kind()`](io::Error::kind) is +/// [`io::ErrorKind::TimedOut`] for a progress failure and +/// [`io::ErrorKind::FileTooLarge`] for a size failure, and whose inner error +/// is the `BodyPolicyError` itself. Use [`BodyPolicyError::from_io()`] to +/// recover it from an `io::Error`, including one that wraps it. +/// +/// A policy failure is terminal: once a body has failed, every subsequent read +/// of any [`Data`](crate::data::Data) or [`DataStream`](crate::data::DataStream) +/// derived from it returns the same error. +/// +/// # Example +/// +/// ```rust +/// use std::io; +/// use rkt::data::{Data, BodyPolicyError, ToByteUnit}; +/// +/// async fn handler(data: Data<'_>) -> io::Result { +/// match data.open(1.mebibytes()).into_string().await { +/// Ok(string) => Ok(string.into_inner()), +/// Err(e) => match BodyPolicyError::from_io(&e) { +/// Some(BodyPolicyError::LimitExceeded { limit, .. }) => { +/// Err(io::Error::other(format!("body larger than {limit}"))) +/// } +/// Some(policy_error) => { +/// Err(io::Error::other(format!("body too slow: {policy_error}"))) +/// } +/// None => Err(e), +/// } +/// } +/// } +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BodyPolicyError { + /// The body was not completely received within the + /// [`BodyProgress::Timeout`] deadline. + Timeout { + /// The deadline, measured from the moment the body became available. + timeout: Duration, + /// The time that had elapsed since the body became available. + elapsed: Duration, + /// The number of payload bytes received before the failure. + received: u64, + }, + /// The body's average rate fell below the [`BodyProgress::MinRate`] rate + /// once the grace period had elapsed. + MinRate { + /// The minimum average rate required. + rate: Rate, + /// The initial period during which no rate was required. + grace: Duration, + /// The time that had elapsed since the body became available. + elapsed: Duration, + /// The number of payload bytes received before the failure. + received: u64, + }, + /// More payload bytes were received than the [`BodyPolicy::limit`] allows. + LimitExceeded { + /// The hard limit on the size of the body. + limit: ByteUnit, + /// The time that had elapsed since the body became available. + elapsed: Duration, + /// The number of payload bytes received, including those that exceeded + /// the limit. + received: u64, + }, +} + +impl BodyPolicyError { + /// Returns the `BodyPolicyError` that `error` carries, if any. + /// + /// The error is found whether it is the inner error of `error` itself or + /// is reachable through any chain of wrapping errors, such as the wrapping + /// applied when a multipart field is read from a body that has failed. + /// + /// # Example + /// + /// ```rust + /// use std::io; + /// use rkt::data::BodyPolicyError; + /// + /// fn is_policy_failure(error: &io::Error) -> bool { + /// BodyPolicyError::from_io(error).is_some() + /// } + /// + /// let error = io::Error::new(io::ErrorKind::Other, "some other error"); + /// assert!(!is_policy_failure(&error)); + /// ``` + pub fn from_io(error: &io::Error) -> Option<&Self> { + let mut error: &(dyn std::error::Error + 'static) = error.get_ref()?; + loop { + if let Some(policy_error) = error.downcast_ref::() { + return Some(policy_error); + } + + // `io::Error::source()` skips its inner error, so unwrap it by hand. + error = match error.downcast_ref::() { + Some(io_error) => io_error.get_ref()?, + None => error.source()?, + }; + } + } + + /// The [`io::ErrorKind`] of the [`io::Error`] this error is surfaced as: + /// [`TimedOut`](io::ErrorKind::TimedOut) for a progress failure and + /// [`FileTooLarge`](io::ErrorKind::FileTooLarge) for a size failure. + pub fn kind(&self) -> io::ErrorKind { + match self { + BodyPolicyError::Timeout { .. } | BodyPolicyError::MinRate { .. } => { + io::ErrorKind::TimedOut + } + BodyPolicyError::LimitExceeded { .. } => io::ErrorKind::FileTooLarge, + } + } + + /// The time that had elapsed since the body became available when the + /// failure occurred. + pub fn elapsed(&self) -> Duration { + match *self { + BodyPolicyError::Timeout { elapsed, .. } + | BodyPolicyError::MinRate { elapsed, .. } + | BodyPolicyError::LimitExceeded { elapsed, .. } => elapsed, + } + } + + /// The number of payload bytes received when the failure occurred. + pub fn received(&self) -> u64 { + match *self { + BodyPolicyError::Timeout { received, .. } + | BodyPolicyError::MinRate { received, .. } + | BodyPolicyError::LimitExceeded { received, .. } => received, + } + } +} + +impl fmt::Display for BodyPolicyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (elapsed, received) = (format_duration(self.elapsed()), ByteUnit::from(self.received())); + match self { + BodyPolicyError::Timeout { timeout, .. } => write!( + f, + "request body not received within {}: {received} received after {elapsed}", + format_duration(*timeout) + ), + BodyPolicyError::MinRate { rate, grace, .. } => write!( + f, + "request body fell below the minimum rate of {rate} after {} grace: \ + {received} received after {elapsed}", + format_duration(*grace) + ), + BodyPolicyError::LimitExceeded { limit, .. } => write!( + f, + "request body exceeds the {limit} limit: {received} received after {elapsed}" + ), + } + } +} + +impl std::error::Error for BodyPolicyError {} + +impl From for io::Error { + fn from(error: BodyPolicyError) -> Self { + io::Error::new(error.kind(), error) + } +} + +/// The time `rate` allows for `bytes` bytes: `bytes * rate.per / rate.bytes`. +fn time_for(rate: Rate, bytes: u64) -> Duration { + const NANOS_PER_SEC: u128 = 1_000_000_000; + + let nanos = rate.per.as_nanos().saturating_mul(bytes as u128) / rate.bytes as u128; + match u64::try_from(nanos / NANOS_PER_SEC) { + Ok(secs) => Duration::new(secs, (nanos % NANOS_PER_SEC) as u32), + Err(_) => Duration::MAX, + } +} + +/// A [`RawStream`] whose payload is subject to a [`BodyPolicy`]. +/// +/// This is the single point through which every payload byte of a request body +/// flows, whether read via [`Data::peek()`](crate::data::Data::peek()) or a +/// [`DataStream`](crate::data::DataStream), and thus the single point at which +/// the policy is enforced. It owns the state of the body: the moment it became +/// available, the number of payload bytes received so far, and whether it has +/// ended or failed. That state moves with the stream, so forwarding a +/// [`Data`](crate::data::Data) or opening it never restarts the clock. +/// +/// Every poll that would wait on the client arms a timer at the current +/// deadline, so a stalled client is cut off at the deadline whether or not it +/// ever sends another byte. Only non-empty payload chunks count as progress. +pub struct PolicedStream<'r> { + stream: RawStream<'r>, + /// `None` for a stream, such as a multipart field, that is read from a + /// body which enforces its own policy on every byte this stream yields. + policy: Option, + origin: Instant, + received: u64, + complete: bool, + failure: Option, + /// Armed at the current deadline while the underlying stream is pending. + sleep: Option>>, +} + +impl<'r> PolicedStream<'r> { + /// Creates a policed stream for a body that has just become available. + /// The body clock starts now. + pub(crate) fn new(stream: RawStream<'r>, policy: BodyPolicy) -> Self { + Self { + stream, + policy: Some(policy), + origin: Instant::now(), + received: 0, + complete: false, + failure: None, + sleep: None, + } + } + + /// Creates a stream for a portion of a body, such as a multipart field, + /// that is read through the policed stream of that body. Its bytes are + /// counted and bounded there, so this stream enforces nothing itself. + pub(crate) fn inherited(stream: RawStream<'r>) -> Self { + Self { + stream, + policy: None, + origin: Instant::now(), + received: 0, + complete: false, + failure: None, + sleep: None, + } + } + + /// Counts `len` payload bytes as received, recording a size failure if the + /// total now exceeds the hard limit. The failure is returned and also + /// surfaces from every subsequent poll. + pub(crate) fn count(&mut self, len: usize) -> Result<(), io::Error> { + self.received = self.received.saturating_add(len as u64); + match self.policy { + Some(policy) if self.received > policy.limit => { + let (limit, received) = (policy.limit, self.received); + let elapsed = self.elapsed(Instant::now()); + Err(self.fail(BodyPolicyError::LimitExceeded { limit, elapsed, received })) + } + _ => Ok(()), + } + } + + /// The time elapsed between the body becoming available and `now`. + fn elapsed(&self, now: Instant) -> Duration { + now.saturating_duration_since(self.origin) + } + + /// The instant at or after which the body has failed its progress policy, + /// given the bytes received so far. `None` if progress is not bounded. + fn deadline(&self) -> Option { + let allowance = match self.policy?.progress { + BodyProgress::Timeout(timeout) => timeout, + BodyProgress::MinRate { rate, grace } => { + grace.saturating_add(time_for(rate, self.received)) + } + BodyProgress::Unlimited => return None, + }; + + // A deadline too far in the future to represent is not a deadline. + self.origin.checked_add(allowance) + } + + /// The progress failure for the policy in effect at `elapsed`. `None` if + /// progress is not bounded. + fn progress_error(&self, elapsed: Duration) -> Option { + let received = self.received; + match self.policy?.progress { + BodyProgress::Timeout(timeout) => { + Some(BodyPolicyError::Timeout { timeout, elapsed, received }) + } + BodyProgress::MinRate { rate, grace } => { + Some(BodyPolicyError::MinRate { rate, grace, elapsed, received }) + } + BodyProgress::Unlimited => None, + } + } + + /// Records `error` as the terminal failure of this body and returns it as + /// the `io::Error` to surface. + fn fail(&mut self, error: BodyPolicyError) -> io::Error { + self.failure = Some(error); + self.sleep = None; + error.into() + } + + /// Polls the deadline timer, arming or re-arming it at `deadline` first. + /// Under a [`BodyProgress::MinRate`] policy the deadline moves later as + /// bytes arrive; under a [`BodyProgress::Timeout`] it never moves. + fn poll_deadline(&mut self, cx: &mut Context<'_>, deadline: Instant) -> Poll<()> { + let sleep = match &mut self.sleep { + Some(sleep) => { + if sleep.deadline() != deadline { + sleep.as_mut().reset(deadline); + } + + sleep + } + None => self.sleep.insert(Box::pin(tokio::time::sleep_until(deadline))), + }; + + sleep.as_mut().poll(cx) + } +} + +impl Stream for PolicedStream<'_> { + type Item = io::Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + if let Some(error) = this.failure { + return Poll::Ready(Some(Err(error.into()))); + } + + // The policy bounds receiving the body; once it has ended, it no + // longer applies. A body whose bytes are counted elsewhere is not + // bounded here at all. + if this.complete { + return Poll::Ready(None); + } else if this.policy.is_none() { + return Pin::new(&mut this.stream).poll_next(cx); + } + + // Under `BodyProgress::Unlimited` there is no deadline, and this costs + // no more than a match: the clock is not even read. + let deadline = this.deadline(); + if let Some(deadline) = deadline { + let now = Instant::now(); + if now >= deadline { + if let Some(error) = this.progress_error(this.elapsed(now)) { + return Poll::Ready(Some(Err(this.fail(error)))); + } + } + } + + match Pin::new(&mut this.stream).poll_next(cx) { + Poll::Ready(Some(Ok(bytes))) => { + if !bytes.is_empty() { + this.count(bytes.len())?; + } + + Poll::Ready(Some(Ok(bytes))) + } + Poll::Ready(None) => { + this.complete = true; + this.sleep = None; + Poll::Ready(None) + } + Poll::Ready(Some(Err(error))) => Poll::Ready(Some(Err(error))), + Poll::Pending => match deadline { + Some(deadline) => match this.poll_deadline(cx, deadline) { + Poll::Ready(()) => match this.progress_error(this.elapsed(Instant::now())) { + Some(error) => Poll::Ready(Some(Err(this.fail(error)))), + None => Poll::Pending, + }, + Poll::Pending => Poll::Pending, + }, + None => Poll::Pending, + }, + } + } + + fn size_hint(&self) -> (usize, Option) { + self.stream.size_hint() + } +} + +impl fmt::Display for PolicedStream<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.stream.fmt(f) + } +} + +#[cfg(test)] +mod tests { + use std::future::{poll_fn, Future}; + use std::pin::Pin; + use std::task::{Context, Poll}; + use std::time::Duration; + + use tokio::io::AsyncReadExt; + use tokio::sync::mpsc; + use tokio::time::{advance, Instant}; + use ubyte::ToByteUnit; + + use super::*; + use crate::data::Data; + + type Sender = mpsc::Sender>; + + /// A body whose chunks arrive when sent and which ends when the sender is + /// dropped. Until then, a read that has consumed every chunk is pending. + struct Channel(mpsc::Receiver>); + + impl Stream for Channel { + type Item = io::Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.0.poll_recv(cx) + } + } + + fn body(policy: BodyPolicy) -> (Sender, Data<'static>) { + let (tx, rx) = mpsc::channel(64); + (tx, Data::from(RawStream::Test(Box::pin(Channel(rx))), policy)) + } + + fn send(tx: &Sender, bytes: &[u8]) { + tx.try_send(Ok(Bytes::copy_from_slice(bytes))).expect("channel has capacity"); + } + + fn timeout(secs: u64, limit: ByteUnit) -> BodyPolicy { + let progress = BodyProgress::Timeout(Duration::from_secs(secs)); + BodyPolicy { progress, limit } + } + + fn min_rate(bytes_per_sec: u64, grace_secs: u64, limit: ByteUnit) -> BodyPolicy { + let rate = Rate { bytes: bytes_per_sec, per: Duration::from_secs(1) }; + let progress = BodyProgress::MinRate { rate, grace: Duration::from_secs(grace_secs) }; + BodyPolicy { progress, limit } + } + + fn unlimited(limit: ByteUnit) -> BodyPolicy { + BodyPolicy { progress: BodyProgress::Unlimited, limit } + } + + /// Polls `future` exactly once, with a real waker. + async fn poll_once(future: &mut F) -> Poll { + poll_fn(|cx| Poll::Ready(Pin::new(&mut *future).poll(cx))).await + } + + /// The policy error `result` failed with, or a panic if it didn't. + fn policy_error(result: &io::Result) -> BodyPolicyError { + let error = result.as_ref().expect_err("expected a body policy failure"); + let policy_error = BodyPolicyError::from_io(error).expect("expected a body policy error"); + assert_eq!(error.kind(), policy_error.kind()); + *policy_error + } + + #[tokio::test(start_paused = true)] + async fn stalled_body_fails_at_deadline_without_a_byte() { + let (_tx, data) = body(timeout(60, 1.kibibytes())); + let origin = Instant::now(); + + // Await the read in a task so that only the deadline timer can wake it. + let read = tokio::spawn(data.open(1.kibibytes()).into_bytes()); + tokio::task::yield_now().await; + assert!(!read.is_finished()); + + advance(Duration::from_secs(59)).await; + tokio::task::yield_now().await; + assert!(!read.is_finished(), "failed before the deadline"); + + advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(read.is_finished(), "not cut off at the deadline"); + assert_eq!(Instant::now() - origin, Duration::from_secs(60)); + + let result = read.await.unwrap(); + assert_eq!(policy_error(&result), BodyPolicyError::Timeout { + timeout: Duration::from_secs(60), + elapsed: Duration::from_secs(60), + received: 0, + }); + } + + #[tokio::test(start_paused = true)] + async fn timeout_is_not_extended_by_trickled_bytes() { + let (tx, data) = body(timeout(60, 1.kibibytes())); + let mut read = Box::pin(data.open(1.kibibytes()).into_bytes()); + + send(&tx, b"a"); + assert!(poll_once(&mut read).await.is_pending()); + + advance(Duration::from_secs(30)).await; + send(&tx, b"b"); + assert!(poll_once(&mut read).await.is_pending()); + + advance(Duration::from_secs(29)).await; + send(&tx, b"c"); + assert!(poll_once(&mut read).await.is_pending()); + + advance(Duration::from_secs(1)).await; + let result = poll_once(&mut read).await; + let Poll::Ready(result) = result else { panic!("deadline was extended") }; + assert_eq!(policy_error(&result), BodyPolicyError::Timeout { + timeout: Duration::from_secs(60), + elapsed: Duration::from_secs(60), + received: 3, + }); + } + + #[tokio::test(start_paused = true)] + async fn min_rate_extends_deadline_in_proportion_to_bytes() { + // 10 bytes per second after 5 seconds of grace. + let (tx, data) = body(min_rate(10, 5, 1.kibibytes())); + let mut read = Box::pin(data.open(1.kibibytes()).into_bytes()); + assert!(poll_once(&mut read).await.is_pending()); + + // 100 bytes buy 10 seconds: the deadline moves from 5s to 15s. + advance(Duration::from_secs(4)).await; + send(&tx, &[0; 100]); + assert!(poll_once(&mut read).await.is_pending()); + + advance(Duration::from_secs(10)).await; + assert!(poll_once(&mut read).await.is_pending(), "deadline not extended"); + + // Another 50 bytes buy 5 more seconds: 20s. + send(&tx, &[0; 50]); + assert!(poll_once(&mut read).await.is_pending()); + advance(Duration::from_secs(5)).await; + assert!(poll_once(&mut read).await.is_pending(), "deadline not extended"); + + advance(Duration::from_secs(1)).await; + let Poll::Ready(result) = poll_once(&mut read).await else { panic!("not cut off") }; + assert_eq!(policy_error(&result), BodyPolicyError::MinRate { + rate: Rate { bytes: 10, per: Duration::from_secs(1) }, + grace: Duration::from_secs(5), + elapsed: Duration::from_secs(20), + received: 150, + }); + } + + #[tokio::test(start_paused = true)] + async fn min_rate_grace_period_is_honoured() { + let (tx, data) = body(min_rate(1024, 30, 1.kibibytes())); + let mut read = Box::pin(data.open(1.kibibytes()).into_bytes()); + + // Nothing at all is required during the grace period. + advance(Duration::from_secs(29)).await; + assert!(poll_once(&mut read).await.is_pending(), "failed during grace"); + + // A single byte at 1KiB/s extends the deadline past the grace period + // by under a millisecond: grace is where the rate starts to count. + send(&tx, b"a"); + assert!(poll_once(&mut read).await.is_pending()); + advance(Duration::from_secs(1)).await; + assert!(poll_once(&mut read).await.is_pending(), "byte did not extend deadline"); + advance(Duration::from_millis(1)).await; + let Poll::Ready(result) = poll_once(&mut read).await else { panic!("not cut off") }; + assert!(matches!(policy_error(&result), BodyPolicyError::MinRate { received: 1, .. })); + } + + #[tokio::test(start_paused = true)] + async fn stalled_min_rate_body_is_cut_off_by_timer() { + let (tx, data) = body(min_rate(10, 5, 1.kibibytes())); + send(&tx, &[0; 100]); + + let read = tokio::spawn(data.open(1.kibibytes()).into_bytes()); + tokio::task::yield_now().await; + + // The timer must have been re-armed from 5s to 15s by the 100 bytes. + advance(Duration::from_secs(14)).await; + tokio::task::yield_now().await; + assert!(!read.is_finished(), "timer not re-armed for received bytes"); + + advance(Duration::from_secs(1)).await; + tokio::task::yield_now().await; + assert!(read.is_finished(), "not cut off at the extended deadline"); + assert!(matches!( + policy_error(&read.await.unwrap()), + BodyPolicyError::MinRate { received: 100, .. } + )); + } + + #[tokio::test(start_paused = true)] + async fn hard_limit_applies_beneath_a_larger_guard_limit() { + let (tx, data) = body(timeout(60, 10.bytes())); + send(&tx, &[0; 8]); + send(&tx, &[0; 8]); + + let result = data.open(100.bytes()).into_bytes().await; + assert_eq!(policy_error(&result), BodyPolicyError::LimitExceeded { + limit: 10.bytes(), + elapsed: Duration::ZERO, + received: 16, + }); + } + + #[tokio::test(start_paused = true)] + async fn guard_limit_still_caps_when_stricter() { + let (tx, data) = body(timeout(60, 100.bytes())); + send(&tx, &[0; 10]); + drop(tx); + + let bytes = data.open(4.bytes()).into_bytes().await.unwrap(); + assert_eq!(bytes.len(), 4); + assert!(!bytes.is_complete()); + + let (tx, data) = body(timeout(60, 100.bytes())); + send(&tx, &[0; 10]); + drop(tx); + + let bytes = data.open(10.bytes()).into_bytes().await.unwrap(); + assert_eq!(bytes.len(), 10); + assert!(bytes.is_complete()); + } + + #[tokio::test(start_paused = true)] + async fn unlimited_progress_never_times_out() { + let (tx, data) = body(unlimited(1.kibibytes())); + let mut read = Box::pin(data.open(1.kibibytes()).into_bytes()); + assert!(poll_once(&mut read).await.is_pending()); + + advance(Duration::from_secs(365 * 24 * 60 * 60)).await; + assert!(poll_once(&mut read).await.is_pending(), "unlimited body timed out"); + + send(&tx, b"hello"); + drop(tx); + let Poll::Ready(result) = poll_once(&mut read).await else { panic!("not read") }; + let bytes = result.unwrap(); + assert_eq!(bytes.value, b"hello"); + assert!(bytes.is_complete()); + } + + #[tokio::test(start_paused = true)] + async fn unlimited_progress_still_applies_hard_limit() { + let (tx, data) = body(unlimited(4.bytes())); + send(&tx, b"hello"); + + let result = data.open(100.bytes()).into_bytes().await; + assert!(matches!( + policy_error(&result), + BodyPolicyError::LimitExceeded { received: 5, .. } + )); + } + + #[tokio::test(start_paused = true)] + async fn ended_body_is_not_failed_after_its_deadline() { + let (tx, data) = body(timeout(60, 1.kibibytes())); + send(&tx, b"hello"); + drop(tx); + + let mut stream = data.open(1.kibibytes()); + let mut buf = Vec::new(); + assert_eq!(stream.read_to_end(&mut buf).await.unwrap(), 5); + + advance(Duration::from_secs(120)).await; + assert_eq!(stream.read(&mut [0; 8]).await.unwrap(), 0); + } + + #[tokio::test(start_paused = true)] + async fn failure_is_terminal_across_reads() { + let (tx, data) = body(timeout(60, 1.kibibytes())); + send(&tx, b"hello"); + + let mut stream = data.open(1.kibibytes()); + let mut buf = [0; 8]; + assert_eq!(stream.read(&mut buf).await.unwrap(), 5); + + advance(Duration::from_secs(60)).await; + let first = policy_error(&stream.read(&mut buf).await); + assert!(matches!(first, BodyPolicyError::Timeout { received: 5, .. })); + + // Time passing and bytes arriving change nothing once failed. + advance(Duration::from_secs(60)).await; + send(&tx, b"world"); + assert_eq!(policy_error(&stream.read(&mut buf).await), first); + assert_eq!(policy_error(&stream.read(&mut buf).await), first); + } + + #[tokio::test(start_paused = true)] + async fn failure_during_peek_is_terminal_for_open() { + let (tx, mut data) = body(timeout(60, 1.kibibytes())); + send(&tx, b"hi"); + + // `peek` swallows the error, returning what it has. + let mut peek = Box::pin(data.peek(4)); + assert!(poll_once(&mut peek).await.is_pending()); + advance(Duration::from_secs(60)).await; + let Poll::Ready(peeked) = poll_once(&mut peek).await else { panic!("not cut off") }; + assert_eq!(peeked, b"hi"); + drop(peek); + + assert!(!data.peek_complete()); + let result = data.open(1.kibibytes()).into_bytes().await; + assert!(matches!(policy_error(&result), BodyPolicyError::Timeout { received: 2, .. })); + } + + #[tokio::test(start_paused = true)] + async fn multipart_field_inherits_body_failure() { + let (tx, data) = body(timeout(60, 1.kibibytes())); + send(&tx, b"--X\r\nContent-Disposition: form-data; name=\"f\"; filename=\"a\"\r\n\r\nhello"); + + let mut multipart = multer::Multipart::with_reader(data.open(1.kibibytes()), "X"); + let field = multipart.next_field().await.unwrap().expect("a field"); + + // The field's data ends only at the next boundary, which never comes. + let mut read = Box::pin(Data::from_field(field).open(1.kibibytes()).into_bytes()); + assert!(poll_once(&mut read).await.is_pending()); + advance(Duration::from_secs(60)).await; + let Poll::Ready(result) = poll_once(&mut read).await else { panic!("not cut off") }; + assert!(matches!(policy_error(&result), BodyPolicyError::Timeout { .. })); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::TimedOut); + } + + #[tokio::test(start_paused = true)] + async fn local_data_counts_toward_hard_limit() { + let data = Data::local(vec![0; 20], timeout(60, 10.bytes())); + let result = data.open(100.bytes()).into_bytes().await; + assert!(matches!( + policy_error(&result), + BodyPolicyError::LimitExceeded { received: 20, .. } + )); + + let data = Data::local(vec![0; 10], timeout(60, 10.bytes())); + let bytes = data.open(100.bytes()).into_bytes().await.unwrap(); + assert!(bytes.is_complete()); + assert_eq!(bytes.len(), 10); + } + + #[test] + fn errors_are_distinct_and_downcastable() { + let policy_error = BodyPolicyError::Timeout { + timeout: Duration::from_secs(60), + elapsed: Duration::from_secs(61), + received: 42, + }; + + let error = io::Error::from(policy_error); + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert_eq!(BodyPolicyError::from_io(&error), Some(&policy_error)); + assert_eq!(policy_error.elapsed(), Duration::from_secs(61)); + assert_eq!(policy_error.received(), 42); + + // Through arbitrary wrapping, including multer's. + let wrapped = io::Error::other(io::Error::other(error)); + assert_eq!(BodyPolicyError::from_io(&wrapped), Some(&policy_error)); + let multer_error = multer::Error::StreamReadFailed(Box::new(wrapped)); + let wrapped = io::Error::other(multer_error); + assert_eq!(BodyPolicyError::from_io(&wrapped), Some(&policy_error)); + + let size_error = BodyPolicyError::LimitExceeded { + limit: 8.mebibytes(), + elapsed: Duration::from_secs(1), + received: 9.mebibytes().as_u64(), + }; + assert_eq!(io::Error::from(size_error).kind(), io::ErrorKind::FileTooLarge); + + let rate_error = BodyPolicyError::MinRate { + rate: Rate { bytes: 1024, per: Duration::from_secs(1) }, + grace: Duration::from_secs(15), + elapsed: Duration::from_secs(20), + received: 100, + }; + assert_eq!(io::Error::from(rate_error).kind(), io::ErrorKind::TimedOut); + + assert!(BodyPolicyError::from_io(&io::Error::other("unrelated")).is_none()); + assert!(BodyPolicyError::from_io(&io::Error::from(io::ErrorKind::TimedOut)).is_none()); + } + + #[test] + fn time_for_rate() { + let rate = Rate { bytes: 1024, per: Duration::from_secs(1) }; + assert_eq!(time_for(rate, 0), Duration::ZERO); + assert_eq!(time_for(rate, 1024), Duration::from_secs(1)); + assert_eq!(time_for(rate, 512), Duration::from_millis(500)); + assert_eq!(time_for(rate, 10 * 1024), Duration::from_secs(10)); + + let rate = Rate { bytes: 1, per: Duration::from_secs(3600) }; + assert_eq!(time_for(rate, u64::MAX), Duration::MAX); + } +} diff --git a/core/lib/src/erased.rs b/core/lib/src/erased.rs index 347fd1a8d..f86368602 100644 --- a/core/lib/src/erased.rs +++ b/core/lib/src/erased.rs @@ -106,7 +106,10 @@ impl ErasedRequest { T: Send + Sync + 'static, D: for<'r> Into>, { - let mut data: Data<'_> = Data::from(raw_stream); + // The body is available now that the headers have been received, so + // the body clock starts here, under the globally configured policy. + let policy = self._rocket.config().body_policy(); + let mut data: Data<'_> = Data::from(raw_stream, policy); let mut parent = Arc::new(self); let token: T = { let parent: &mut ErasedRequest = Arc::get_mut(&mut parent).unwrap(); diff --git a/core/lib/src/form/parser.rs b/core/lib/src/form/parser.rs index b34b8b667..9feccca03 100644 --- a/core/lib/src/form/parser.rs +++ b/core/lib/src/form/parser.rs @@ -167,7 +167,7 @@ impl<'r, 'i> MultipartParser<'r, 'i> { request: self.request, name: NameView::new(name), file_name: file_name.map(crate::fs::FileName::new), - data: Data::from(field), + data: Data::from_field(field), }) } else { let (mut buf, len) = match field.name() { diff --git a/core/lib/src/local/asynchronous/request.rs b/core/lib/src/local/asynchronous/request.rs index 149a6ec4c..f1b1db0ac 100644 --- a/core/lib/src/local/asynchronous/request.rs +++ b/core/lib/src/local/asynchronous/request.rs @@ -106,7 +106,7 @@ impl<'c> LocalRequest<'c> { } // Actually dispatch the request. - let mut data = Data::local(self.data); + let mut data = Data::local(self.data, rocket.config().body_policy()); let token = rocket.preprocess(&mut self.request, &mut data).await; let response = LocalResponse::new(self.request, move |req| rocket.dispatch(token, req, data)).await; From 5035db35b4d41a94963707252f35276f22c9910a Mon Sep 17 00:00:00 2001 From: Martyn Date: Sun, 20 Sep 2026 07:27:40 +0100 Subject: [PATCH 4/6] Early reject for content-length too parge --- core/lib/src/data/data.rs | 22 +++- core/lib/src/data/policy.rs | 135 ++++++++++++++++++-- core/lib/src/lifecycle.rs | 36 ++++-- core/lib/tests/body-limit-content-length.rs | 63 +++++++++ 4 files changed, 229 insertions(+), 27 deletions(-) create mode 100644 core/lib/tests/body-limit-content-length.rs diff --git a/core/lib/src/data/data.rs b/core/lib/src/data/data.rs index 2b92795be..316fe3ca5 100644 --- a/core/lib/src/data/data.rs +++ b/core/lib/src/data/data.rs @@ -5,7 +5,7 @@ use crate::data::data_stream::{DataStream, RawReader, RawStream}; use crate::data::peekable::Peekable; use crate::data::policy::PolicedStream; use crate::data::transform::{InPlaceMap, Inspect, Transform, TransformBuf}; -use crate::data::{BodyPolicy, ByteUnit}; +use crate::data::{BodyPolicy, BodyPolicyError, ByteUnit}; /// Type representing the body data of a request. /// @@ -84,15 +84,29 @@ impl<'r> Data<'r> { /// /// The data is already in hand, so the progress bound of `policy` cannot /// be violated, but its size limit is applied as it would be to a received - /// body: if `data` exceeds the limit, reading past it fails. + /// body of the same declared length: if `data` exceeds the limit, the body + /// is rejected before being read, and otherwise its bytes count as + /// received. #[inline] pub(crate) fn local(data: Vec, policy: BodyPolicy) -> Data<'r> { let mut stream = PolicedStream::new(RawStream::Empty, policy); - // A failure is recorded in the stream and surfaces from its first read. - let _ = stream.count(data.len()); + stream.declare(data.len() as u64); + if stream.failure().is_none() { + // Cannot fail: the declared length was within the limit. + let _ = stream.count(data.len()); + } + Data::new(Peekable::with_buffer(data, true, RawReader::new(stream))) } + /// Returns the policy failure that has terminated this body, if any: one + /// recorded when the body was created, because it was declared larger than + /// the hard limit, or by an earlier read that violated the policy. + #[inline] + pub(crate) fn policy_failure(&self) -> Option { + self.stream.reader.get_ref().failure() + } + /// Returns the raw data stream, limited to `limit` bytes. /// /// The stream contains all of the data in the body of the request, diff --git a/core/lib/src/data/policy.rs b/core/lib/src/data/policy.rs index 6afbf66f3..6626ec948 100644 --- a/core/lib/src/data/policy.rs +++ b/core/lib/src/data/policy.rs @@ -12,7 +12,7 @@ use tokio::time::{Instant, Sleep}; use crate::config::Config; use crate::data::data_stream::RawStream; use crate::data::ByteUnit; -use crate::http::{format_duration, Rate}; +use crate::http::{format_duration, Rate, Status}; /// The progress deadline applied when no progress policy is configured. const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); @@ -210,6 +210,15 @@ pub enum BodyPolicyError { /// the limit. received: u64, }, + /// The body was declared, via `Content-Length`, to be larger than the + /// [`BodyPolicy::limit`] allows. The body was rejected before any of it + /// was read. + DeclaredTooLarge { + /// The hard limit on the size of the body. + limit: ByteUnit, + /// The declared size of the body. + declared: u64, + }, } impl BodyPolicyError { @@ -255,26 +264,40 @@ impl BodyPolicyError { BodyPolicyError::Timeout { .. } | BodyPolicyError::MinRate { .. } => { io::ErrorKind::TimedOut } - BodyPolicyError::LimitExceeded { .. } => io::ErrorKind::FileTooLarge, + BodyPolicyError::LimitExceeded { .. } | BodyPolicyError::DeclaredTooLarge { .. } => { + io::ErrorKind::FileTooLarge + } + } + } + + /// The HTTP status that answers this failure: `408 Request Timeout` for a + /// progress failure and `413 Content Too Large` for a size failure. + pub(crate) fn status(&self) -> Status { + match self.kind() { + io::ErrorKind::TimedOut => Status::RequestTimeout, + _ => Status::PayloadTooLarge, } } /// The time that had elapsed since the body became available when the - /// failure occurred. + /// failure occurred. Zero for a body rejected before being read. pub fn elapsed(&self) -> Duration { match *self { BodyPolicyError::Timeout { elapsed, .. } | BodyPolicyError::MinRate { elapsed, .. } | BodyPolicyError::LimitExceeded { elapsed, .. } => elapsed, + BodyPolicyError::DeclaredTooLarge { .. } => Duration::ZERO, } } - /// The number of payload bytes received when the failure occurred. + /// The number of payload bytes received when the failure occurred. Zero + /// for a body rejected before being read. pub fn received(&self) -> u64 { match *self { BodyPolicyError::Timeout { received, .. } | BodyPolicyError::MinRate { received, .. } | BodyPolicyError::LimitExceeded { received, .. } => received, + BodyPolicyError::DeclaredTooLarge { .. } => 0, } } } @@ -298,6 +321,11 @@ impl fmt::Display for BodyPolicyError { f, "request body exceeds the {limit} limit: {received} received after {elapsed}" ), + BodyPolicyError::DeclaredTooLarge { limit, declared } => write!( + f, + "request body declared as {} exceeds the {limit} limit", + ByteUnit::from(*declared) + ), } } } @@ -351,7 +379,7 @@ impl<'r> PolicedStream<'r> { /// Creates a policed stream for a body that has just become available. /// The body clock starts now. pub(crate) fn new(stream: RawStream<'r>, policy: BodyPolicy) -> Self { - Self { + let mut this = Self { stream, policy: Some(policy), origin: Instant::now(), @@ -359,9 +387,32 @@ impl<'r> PolicedStream<'r> { complete: false, failure: None, sleep: None, + }; + + // A body that declares, via `Content-Length`, that it will carry more + // than the limit allows is rejected before a byte of it is read. + let (declared, _) = this.stream.size_hint(); + this.declare(declared as u64); + this + } + + /// Records a size failure if a body declared to be `declared` bytes long + /// exceeds the hard limit. Nothing is counted as received: the declared + /// bytes have not been read, and if the body is rejected, never will be. + pub(crate) fn declare(&mut self, declared: u64) { + if let Some(policy) = self.policy { + if declared > policy.limit { + let limit = policy.limit; + self.fail(BodyPolicyError::DeclaredTooLarge { limit, declared }); + } } } + /// The failure that has terminated this body, if any. + pub(crate) fn failure(&self) -> Option { + self.failure + } + /// Creates a stream for a portion of a body, such as a multipart field, /// that is read through the policed stream of that body. Its bytes are /// counted and bounded there, so this stream enforces nothing itself. @@ -846,20 +897,84 @@ mod tests { } #[tokio::test(start_paused = true)] - async fn local_data_counts_toward_hard_limit() { + async fn local_data_is_declared_against_hard_limit() { let data = Data::local(vec![0; 20], timeout(60, 10.bytes())); + let failure = data.policy_failure().expect("rejected on creation"); + assert_eq!(failure, BodyPolicyError::DeclaredTooLarge { limit: 10.bytes(), declared: 20 }); let result = data.open(100.bytes()).into_bytes().await; - assert!(matches!( - policy_error(&result), - BodyPolicyError::LimitExceeded { received: 20, .. } - )); + assert_eq!(policy_error(&result), failure); let data = Data::local(vec![0; 10], timeout(60, 10.bytes())); + assert!(data.policy_failure().is_none()); let bytes = data.open(100.bytes()).into_bytes().await.unwrap(); assert!(bytes.is_complete()); assert_eq!(bytes.len(), 10); } + /// A body declaring `hint` bytes. With no channel behind it, it must never + /// be polled: doing so panics. + struct Declared(usize, Option); + + impl Stream for Declared { + type Item = io::Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + match &mut self.1 { + Some(channel) => Pin::new(channel).poll_next(cx), + None => panic!("a rejected body was read"), + } + } + + fn size_hint(&self) -> (usize, Option) { + (self.0, Some(self.0)) + } + } + + #[tokio::test(start_paused = true)] + async fn declared_size_over_limit_is_rejected_unread() { + let stream = RawStream::Test(Box::pin(Declared(11, None))); + let mut data = Data::from(stream, timeout(60, 10.bytes())); + let expected = BodyPolicyError::DeclaredTooLarge { limit: 10.bytes(), declared: 11 }; + assert_eq!(data.policy_failure(), Some(expected)); + assert_eq!(expected.received(), 0); + assert_eq!(expected.elapsed(), Duration::ZERO); + assert_eq!(expected.kind(), io::ErrorKind::FileTooLarge); + assert_eq!(expected.status(), Status::PayloadTooLarge); + + // `peek` swallows the error; `open` surfaces it. Neither reads. + assert!(data.peek(4).await.is_empty()); + let result = data.open(100.bytes()).into_bytes().await; + assert_eq!(policy_error(&result), expected); + } + + #[tokio::test(start_paused = true)] + async fn declared_size_at_limit_is_accepted() { + let (tx, rx) = mpsc::channel(64); + let stream = RawStream::Test(Box::pin(Declared(10, Some(Channel(rx))))); + let data = Data::from(stream, timeout(60, 10.bytes())); + assert!(data.policy_failure().is_none()); + + send(&tx, &[0; 10]); + drop(tx); + let bytes = data.open(100.bytes()).into_bytes().await.unwrap(); + assert!(bytes.is_complete()); + assert_eq!(bytes.len(), 10); + } + + #[test] + fn failure_status() { + let rate = Rate { bytes: 1, per: Duration::from_secs(1) }; + let (elapsed, received, grace) = (Duration::ZERO, 0, Duration::ZERO); + let timeout = BodyPolicyError::Timeout { timeout: elapsed, elapsed, received }; + let min_rate = BodyPolicyError::MinRate { rate, grace, elapsed, received }; + let exceeded = BodyPolicyError::LimitExceeded { limit: 1.bytes(), elapsed, received }; + let declared = BodyPolicyError::DeclaredTooLarge { limit: 1.bytes(), declared: 2 }; + assert_eq!(timeout.status(), Status::RequestTimeout); + assert_eq!(min_rate.status(), Status::RequestTimeout); + assert_eq!(exceeded.status(), Status::PayloadTooLarge); + assert_eq!(declared.status(), Status::PayloadTooLarge); + } + #[test] fn errors_are_distinct_and_downcastable() { let policy_error = BodyPolicyError::Timeout { diff --git a/core/lib/src/lifecycle.rs b/core/lib/src/lifecycle.rs index 80ae43000..459b7ab40 100644 --- a/core/lib/src/lifecycle.rs +++ b/core/lib/src/lifecycle.rs @@ -105,22 +105,32 @@ impl Rocket { // Remember if the request is `HEAD` for later body stripping. let was_head_request = request.method() == Method::Head; - // Route the request and run the user's handlers. - let mut response = match self.route(request, data).await { - Outcome::Success(response) => response, - Outcome::Forward((data, _)) if request.method() == Method::Head => { - tracing::Span::current().record("autohandled", true); + // Route the request and run the user's handlers. A body already known + // to violate its policy, because it was declared larger than the hard + // limit or failed while a request fairing read it, is not routed at + // all: the response is the failure's status. + let mut response = if let Some(failure) = data.policy_failure() { + info!(%failure, "request body rejected"); + self.dispatch_error(failure.status(), request).await + } else { + match self.route(request, data).await { + Outcome::Success(response) => response, + Outcome::Forward((data, _)) if request.method() == Method::Head => { + tracing::Span::current().record("autohandled", true); - // Dispatch the request again with Method `GET`. - request._set_method(Method::Get); - match self.route(request, data).await { - Outcome::Success(response) => response, - Outcome::Error(status) => self.dispatch_error(status, request).await, - Outcome::Forward((_, status)) => self.dispatch_error(status, request).await, + // Dispatch the request again with Method `GET`. + request._set_method(Method::Get); + match self.route(request, data).await { + Outcome::Success(response) => response, + Outcome::Error(status) => self.dispatch_error(status, request).await, + Outcome::Forward((_, status)) => { + self.dispatch_error(status, request).await + } + } } + Outcome::Forward((_, status)) => self.dispatch_error(status, request).await, + Outcome::Error(status) => self.dispatch_error(status, request).await, } - Outcome::Forward((_, status)) => self.dispatch_error(status, request).await, - Outcome::Error(status) => self.dispatch_error(status, request).await, }; // Set the cookies. Note that error responses will only include cookies diff --git a/core/lib/tests/body-limit-content-length.rs b/core/lib/tests/body-limit-content-length.rs new file mode 100644 index 000000000..e458e9cd4 --- /dev/null +++ b/core/lib/tests/body-limit-content-length.rs @@ -0,0 +1,63 @@ +#[macro_use] +extern crate rkt; + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use rkt::data::{Data, ToByteUnit}; +use rkt::http::Status; +use rkt::local::blocking::Client; +use rkt::{Build, Config, Rocket, State}; +use ubyte::ByteUnit; + +/// The number of times the route has run. +struct Handled(AtomicUsize); + +#[post("/", data = "")] +async fn index(data: Data<'_>, handled: &State) -> std::io::Result { + handled.0.fetch_add(1, Ordering::SeqCst); + let string = data.open(1.kibibytes()).into_string().await?; + Ok(string.into_inner()) +} + +fn rocket_with_body_limit(limit: ByteUnit) -> Rocket { + rkt::custom(Config { + body_limit: limit, + ..Config::debug_default() + }) + .manage(Handled(AtomicUsize::new(0))) + .mount("/", routes![index]) +} + +fn handled(client: &Client) -> usize { + client.rocket().state::().unwrap().0.load(Ordering::SeqCst) +} + +#[test] +fn body_within_limit_is_handled() { + let client = Client::debug(rocket_with_body_limit(10.bytes())).unwrap(); + + let response = client.post("/").body("hello").dispatch(); + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.into_string().as_deref(), Some("hello")); + assert_eq!(handled(&client), 1); + + // Exactly the limit is within it. + let response = client.post("/").body("0123456789").dispatch(); + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.into_string().as_deref(), Some("0123456789")); + assert_eq!(handled(&client), 2); +} + +#[test] +fn body_declared_over_limit_is_rejected_before_routing() { + let client = Client::debug(rocket_with_body_limit(10.bytes())).unwrap(); + + let response = client.post("/").body("0123456789!").dispatch(); + assert_eq!(response.status(), Status::PayloadTooLarge); + assert_eq!(handled(&client), 0, "route ran for a rejected body"); + + // The client remains usable afterwards. + let response = client.post("/").body("ok").dispatch(); + assert_eq!(response.status(), Status::Ok); + assert_eq!(handled(&client), 1); +} From 4548a5d5cac6ddc8f1df970592ddfd1ae030a3f2 Mon Sep 17 00:00:00 2001 From: Martyn Date: Fri, 25 Sep 2026 17:54:08 +0100 Subject: [PATCH 5/6] for review --- core/lib/src/data/data.rs | 15 ++-- core/lib/src/data/policy.rs | 156 +++++++++++++++++++++++++++++++++--- core/lib/src/lifecycle.rs | 32 ++++++-- 3 files changed, 179 insertions(+), 24 deletions(-) diff --git a/core/lib/src/data/data.rs b/core/lib/src/data/data.rs index 316fe3ca5..714191008 100644 --- a/core/lib/src/data/data.rs +++ b/core/lib/src/data/data.rs @@ -5,7 +5,8 @@ use crate::data::data_stream::{DataStream, RawReader, RawStream}; use crate::data::peekable::Peekable; use crate::data::policy::PolicedStream; use crate::data::transform::{InPlaceMap, Inspect, Transform, TransformBuf}; -use crate::data::{BodyPolicy, BodyPolicyError, ByteUnit}; +use crate::data::policy::FailureSlot; +use crate::data::{BodyPolicy, ByteUnit}; /// Type representing the body data of a request. /// @@ -99,12 +100,14 @@ impl<'r> Data<'r> { Data::new(Peekable::with_buffer(data, true, RawReader::new(stream))) } - /// Returns the policy failure that has terminated this body, if any: one - /// recorded when the body was created, because it was declared larger than - /// the hard limit, or by an earlier read that violated the policy. + /// Returns a handle to the policy failure that has terminated this body, + /// if any: one recorded when the body was created, because it was declared + /// larger than the hard limit, or by a read that violated the policy. The + /// handle remains readable after the `Data` has been consumed, such as by + /// a data guard, and reports a failure recorded after it was taken. #[inline] - pub(crate) fn policy_failure(&self) -> Option { - self.stream.reader.get_ref().failure() + pub(crate) fn policy_failure_slot(&self) -> FailureSlot { + self.stream.reader.get_ref().failure_slot() } /// Returns the raw data stream, limited to `limit` bytes. diff --git a/core/lib/src/data/policy.rs b/core/lib/src/data/policy.rs index 6626ec948..4a996c97b 100644 --- a/core/lib/src/data/policy.rs +++ b/core/lib/src/data/policy.rs @@ -1,5 +1,6 @@ use std::future::Future; use std::pin::Pin; +use std::sync::{Arc, OnceLock}; use std::task::{Context, Poll}; use std::time::Duration; use std::{fmt, io}; @@ -349,6 +350,27 @@ fn time_for(rate: Rate, bytes: u64) -> Duration { } } +/// The failure that has terminated a body, shared between the body's stream +/// and whoever needs to know its fate after the stream has been consumed: a +/// data guard takes the [`Data`](crate::data::Data) by value, so the failure +/// it swallowed can only be recovered from here. Write-once: the first failure +/// is the one that terminated the body. +#[derive(Debug, Clone, Default)] +pub(crate) struct FailureSlot(Arc>); + +impl FailureSlot { + /// The failure that has terminated the body, if any. + pub(crate) fn get(&self) -> Option { + self.0.get().copied() + } + + /// Records `error` unless a failure is already recorded, returning the + /// failure that terminated the body. + fn record(&self, error: BodyPolicyError) -> BodyPolicyError { + *self.0.get_or_init(|| error) + } +} + /// A [`RawStream`] whose payload is subject to a [`BodyPolicy`]. /// /// This is the single point through which every payload byte of a request body @@ -370,7 +392,7 @@ pub struct PolicedStream<'r> { origin: Instant, received: u64, complete: bool, - failure: Option, + failure: FailureSlot, /// Armed at the current deadline while the underlying stream is pending. sleep: Option>>, } @@ -385,7 +407,7 @@ impl<'r> PolicedStream<'r> { origin: Instant::now(), received: 0, complete: false, - failure: None, + failure: FailureSlot::default(), sleep: None, }; @@ -410,7 +432,12 @@ impl<'r> PolicedStream<'r> { /// The failure that has terminated this body, if any. pub(crate) fn failure(&self) -> Option { - self.failure + self.failure.get() + } + + /// A handle to this body's failure that outlives the stream. + pub(crate) fn failure_slot(&self) -> FailureSlot { + self.failure.clone() } /// Creates a stream for a portion of a body, such as a multipart field, @@ -423,7 +450,7 @@ impl<'r> PolicedStream<'r> { origin: Instant::now(), received: 0, complete: false, - failure: None, + failure: FailureSlot::default(), sleep: None, } } @@ -481,7 +508,7 @@ impl<'r> PolicedStream<'r> { /// Records `error` as the terminal failure of this body and returns it as /// the `io::Error` to surface. fn fail(&mut self, error: BodyPolicyError) -> io::Error { - self.failure = Some(error); + let error = self.failure.record(error); self.sleep = None; error.into() } @@ -510,7 +537,7 @@ impl Stream for PolicedStream<'_> { fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { let this = self.get_mut(); - if let Some(error) = this.failure { + if let Some(error) = this.failure.get() { return Poll::Ready(Some(Err(error.into()))); } @@ -899,13 +926,13 @@ mod tests { #[tokio::test(start_paused = true)] async fn local_data_is_declared_against_hard_limit() { let data = Data::local(vec![0; 20], timeout(60, 10.bytes())); - let failure = data.policy_failure().expect("rejected on creation"); + let failure = data.policy_failure_slot().get().expect("rejected on creation"); assert_eq!(failure, BodyPolicyError::DeclaredTooLarge { limit: 10.bytes(), declared: 20 }); let result = data.open(100.bytes()).into_bytes().await; assert_eq!(policy_error(&result), failure); let data = Data::local(vec![0; 10], timeout(60, 10.bytes())); - assert!(data.policy_failure().is_none()); + assert!(data.policy_failure_slot().get().is_none()); let bytes = data.open(100.bytes()).into_bytes().await.unwrap(); assert!(bytes.is_complete()); assert_eq!(bytes.len(), 10); @@ -935,7 +962,7 @@ mod tests { let stream = RawStream::Test(Box::pin(Declared(11, None))); let mut data = Data::from(stream, timeout(60, 10.bytes())); let expected = BodyPolicyError::DeclaredTooLarge { limit: 10.bytes(), declared: 11 }; - assert_eq!(data.policy_failure(), Some(expected)); + assert_eq!(data.policy_failure_slot().get(), Some(expected)); assert_eq!(expected.received(), 0); assert_eq!(expected.elapsed(), Duration::ZERO); assert_eq!(expected.kind(), io::ErrorKind::FileTooLarge); @@ -952,7 +979,7 @@ mod tests { let (tx, rx) = mpsc::channel(64); let stream = RawStream::Test(Box::pin(Declared(10, Some(Channel(rx))))); let data = Data::from(stream, timeout(60, 10.bytes())); - assert!(data.policy_failure().is_none()); + assert!(data.policy_failure_slot().get().is_none()); send(&tx, &[0; 10]); drop(tx); @@ -975,6 +1002,115 @@ mod tests { assert_eq!(declared.status(), Status::PayloadTooLarge); } + #[tokio::test(start_paused = true)] + async fn failure_slot_outlives_the_data() { + let (tx, data) = body(timeout(60, 10.bytes())); + let slot = data.policy_failure_slot(); + assert!(slot.get().is_none()); + + send(&tx, &[0; 11]); + let result = data.open(100.bytes()).into_bytes().await; + let failure = policy_error(&result); + assert!(matches!(failure, BodyPolicyError::LimitExceeded { received: 11, .. })); + + // The data and its stream are gone; the slot still knows. + assert_eq!(slot.get(), Some(failure)); + } + + /// Dispatch tests: a data guard that reads a failing body swallows the + /// error as its own, but the client is answered with the failure's status. + /// The body is a chunked stream, which declares no length and so passes + /// the `Content-Length` check that would otherwise reject it unread. + mod dispatch { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use tokio::sync::mpsc; + use ubyte::ToByteUnit; + + use super::{send, timeout, Channel}; + use crate::data::data_stream::RawStream; + use crate::data::Data; + use crate::http::{Method, Status}; + use crate::local::asynchronous::Client; + use crate::{Build, Rocket, State}; + + /// The number of times `recover` has run. + struct Handled(AtomicUsize); + + /// Reads the body through the `String` guard, which reports a policy + /// failure as a `400 Bad Request`. + #[crate::post("/guard", data = "")] + fn guard(body: String) -> String { + body + } + + /// Reads the body itself and succeeds regardless of the outcome. + #[crate::post("/recover", data = "")] + async fn recover(body: Data<'_>, handled: &State) -> &'static str { + handled.0.fetch_add(1, Ordering::SeqCst); + let _ = body.open(1.kibibytes()).into_string().await; + "recovered" + } + + fn rocket() -> Rocket { + crate::custom(crate::Config::debug_default()) + .manage(Handled(AtomicUsize::new(0))) + .mount("/", crate::routes![guard, recover]) + } + + /// Dispatches a `POST` to `path` whose body is `stream`, policed by + /// `policy`, and returns the response status. + async fn dispatch(path: &str, stream: Channel, policy: super::BodyPolicy) -> Status { + let client = Client::debug(rocket()).await.unwrap(); + let rocket = client.rocket(); + let local = client.req(Method::Post, path); + let mut request = local.inner().clone(); + let mut data = Data::from(RawStream::Test(Box::pin(stream)), policy); + let token = rocket.preprocess(&mut request, &mut data).await; + let response = rocket.dispatch(token, &request, data).await; + let handled = rocket.state::().unwrap().0.load(Ordering::SeqCst); + assert_eq!(handled, (path == "/recover") as usize); + response.status() + } + + #[tokio::test(start_paused = true)] + async fn guard_swallowed_size_failure_answers_413() { + let (tx, rx) = mpsc::channel(64); + send(&tx, &[b'a'; 11]); + drop(tx); + let status = dispatch("/guard", Channel(rx), timeout(60, 10.bytes())).await; + assert_eq!(status, Status::PayloadTooLarge); + } + + #[tokio::test(start_paused = true)] + async fn guard_swallowed_progress_failure_answers_408() { + let (tx, rx) = mpsc::channel(64); + send(&tx, b"partial"); + // The sender is kept: the body stalls until the timeout elapses. + let status = dispatch("/guard", Channel(rx), timeout(60, 10.bytes())).await; + assert_eq!(status, Status::RequestTimeout); + drop(tx); + } + + #[tokio::test(start_paused = true)] + async fn guard_without_failure_is_not_remapped() { + let (tx, rx) = mpsc::channel(64); + send(&tx, b"hello"); + drop(tx); + let status = dispatch("/guard", Channel(rx), timeout(60, 10.bytes())).await; + assert_eq!(status, Status::Ok); + } + + #[tokio::test(start_paused = true)] + async fn handler_success_is_not_overridden() { + let (tx, rx) = mpsc::channel(64); + send(&tx, &[b'a'; 11]); + drop(tx); + let status = dispatch("/recover", Channel(rx), timeout(60, 10.bytes())).await; + assert_eq!(status, Status::Ok); + } + } + #[test] fn errors_are_distinct_and_downcastable() { let policy_error = BodyPolicyError::Timeout { diff --git a/core/lib/src/lifecycle.rs b/core/lib/src/lifecycle.rs index 459b7ab40..0e01e26cc 100644 --- a/core/lib/src/lifecycle.rs +++ b/core/lib/src/lifecycle.rs @@ -105,13 +105,29 @@ impl Rocket { // Remember if the request is `HEAD` for later body stripping. let was_head_request = request.method() == Method::Head; + // The body's failure outlives the `Data`, which a data guard consumes. + let failure = data.policy_failure_slot(); + + // The status that answers a request whose routing ended in `status`: + // if the body violated its policy, the failure's status instead. A + // guard that read the body reports a failure as its own error, and a + // client that sent too much or too slowly is told so, not that its + // request was malformed. A handler that succeeds regardless keeps its + // response. + let status = |status: Status| match failure.get() { + Some(failure) => { + info!(%failure, "request body rejected"); + failure.status() + } + None => status, + }; + // Route the request and run the user's handlers. A body already known // to violate its policy, because it was declared larger than the hard // limit or failed while a request fairing read it, is not routed at // all: the response is the failure's status. - let mut response = if let Some(failure) = data.policy_failure() { - info!(%failure, "request body rejected"); - self.dispatch_error(failure.status(), request).await + let mut response = if failure.get().is_some() { + self.dispatch_error(status(Status::BadRequest), request).await } else { match self.route(request, data).await { Outcome::Success(response) => response, @@ -122,14 +138,14 @@ impl Rocket { request._set_method(Method::Get); match self.route(request, data).await { Outcome::Success(response) => response, - Outcome::Error(status) => self.dispatch_error(status, request).await, - Outcome::Forward((_, status)) => { - self.dispatch_error(status, request).await + Outcome::Error(s) | Outcome::Forward((_, s)) => { + self.dispatch_error(status(s), request).await } } } - Outcome::Forward((_, status)) => self.dispatch_error(status, request).await, - Outcome::Error(status) => self.dispatch_error(status, request).await, + Outcome::Error(s) | Outcome::Forward((_, s)) => { + self.dispatch_error(status(s), request).await + } } }; From 84cb01705b62c9b888e96a6c09755409b6bd1978 Mon Sep 17 00:00:00 2001 From: Martyn Date: Tue, 20 Oct 2026 11:09:12 +0100 Subject: [PATCH 6/6] Implement route-based configuraton --- core/codegen/Cargo.toml | 1 + core/codegen/src/attribute/route/mod.rs | 5 + core/codegen/src/attribute/route/parse.rs | 130 +++- core/codegen/tests/route-body-policy.rs | 85 +++ core/codegen/tests/ui-fail-nightly/catch.rs | 2 +- .../ui-fail-nightly/route-body-policy.rs | 91 +++ .../ui-fail-nightly/route-body-policy.stderr | 228 +++++++ core/codegen/tests/ui-fail-stable/catch.rs | 2 +- .../codegen/tests/ui-fail-stable/catch.stderr | 81 ++- .../tests/ui-fail-stable/route-body-policy.rs | 91 +++ .../ui-fail-stable/route-body-policy.stderr | 212 +++++++ .../tests/ui-fail/route-body-policy.rs | 91 +++ core/codegen/tests/ui-fail/synchronize.sh | 12 +- core/lib/Cargo.toml | 2 + core/lib/tests/body-policy-connection.rs | 179 ++++++ core/lib/tests/body-policy-failure.rs | 561 ++++++++++++++++++ core/lib/tests/body-policy-lifecycle.rs | 241 ++++++++ core/lib/tests/body-policy-resolution.rs | 291 +++++++++ core/lib/tests/body-policy-support/mod.rs | 229 +++++++ 19 files changed, 2516 insertions(+), 18 deletions(-) create mode 100644 core/codegen/tests/route-body-policy.rs create mode 100644 core/codegen/tests/ui-fail-nightly/route-body-policy.rs create mode 100644 core/codegen/tests/ui-fail-nightly/route-body-policy.stderr create mode 100644 core/codegen/tests/ui-fail-stable/route-body-policy.rs create mode 100644 core/codegen/tests/ui-fail-stable/route-body-policy.stderr create mode 100644 core/codegen/tests/ui-fail/route-body-policy.rs create mode 100644 core/lib/tests/body-policy-connection.rs create mode 100644 core/lib/tests/body-policy-failure.rs create mode 100644 core/lib/tests/body-policy-lifecycle.rs create mode 100644 core/lib/tests/body-policy-resolution.rs create mode 100644 core/lib/tests/body-policy-support/mod.rs diff --git a/core/codegen/Cargo.toml b/core/codegen/Cargo.toml index 26c27cf17..ad2c9f5dc 100644 --- a/core/codegen/Cargo.toml +++ b/core/codegen/Cargo.toml @@ -25,6 +25,7 @@ syn = { version = "2.0", features = ["full", "visit", "visit-mut", "extra-traits proc-macro2 = "1.0.60" devise = "0.4" rkt_http = { workspace = true } +ubyte = "0.10.2" unicode-xid = "0.2" version_check = "0.9" glob = "0.3" diff --git a/core/codegen/src/attribute/route/mod.rs b/core/codegen/src/attribute/route/mod.rs index d4989ce9d..ce190882d 100644 --- a/core/codegen/src/attribute/route/mod.rs +++ b/core/codegen/src/attribute/route/mod.rs @@ -513,6 +513,11 @@ fn incomplete_route( data: method_attribute.data, format: method_attribute.format, rank: method_attribute.rank, + body_timeout: method_attribute.body_timeout, + body_min_rate: method_attribute.body_min_rate, + body_grace: method_attribute.body_grace, + body_policy: method_attribute.body_policy, + body_limit: method_attribute.body_limit, }; codegen_route(Route::from(attribute, function)?) diff --git a/core/codegen/src/attribute/route/parse.rs b/core/codegen/src/attribute/route/parse.rs index 2d500411e..d4488f4ec 100644 --- a/core/codegen/src/attribute/route/parse.rs +++ b/core/codegen/src/attribute/route/parse.rs @@ -2,14 +2,16 @@ use devise::ext::{SpanDiagnosticExt, TypeExt}; use devise::{FromMeta, Result, SpanWrapped, Spanned}; use indexmap::{IndexMap, IndexSet}; use proc_macro2::Span; +use ubyte::ByteUnit; use crate::attribute::param::{Dynamic, Guard, Parameter}; use crate::attribute::suppress::Lint; use crate::http::ext::IntoOwned; +use crate::http::{parse_duration, Rate}; use crate::http::uri::{fmt, Origin}; use crate::http_codegen::{MediaType, Method}; use crate::name::Name; -use crate::proc_macro_ext::Diagnostics; +use crate::proc_macro_ext::{Diagnostics, StringLit}; use crate::syn_ext::FnArgExt; /// This structure represents the parsed `route` attribute and associated items. @@ -48,6 +50,12 @@ pub struct Attribute { pub data: Option>, pub format: Option, pub rank: Option, + pub body_timeout: Option>, + pub body_min_rate: Option>, + pub body_grace: Option>, + pub body_policy: Option>, + #[allow(dead_code)] // Emitted as route metadata in a later step. + pub body_limit: Option>, } /// The parsed `#[method(..)]` (e.g, `get`, `put`, etc.) attribute. @@ -58,6 +66,98 @@ pub struct MethodAttribute { pub data: Option>, pub format: Option, pub rank: Option, + pub body_timeout: Option>, + pub body_min_rate: Option>, + pub body_grace: Option>, + pub body_policy: Option>, + pub body_limit: Option>, +} + +/// The parsed value of `body_timeout`: a non-zero duration. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] // Emitted as route metadata in a later step. +pub struct BodyTimeout(pub std::time::Duration); + +/// The parsed value of `body_min_rate`: a non-zero rate. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] // Emitted as route metadata in a later step. +pub struct BodyMinRate(pub Rate); + +/// The parsed value of `body_grace`: a non-zero duration. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] // Emitted as route metadata in a later step. +pub struct BodyGrace(pub std::time::Duration); + +/// The parsed value of `body_policy`: only `"unlimited"` is accepted. +#[derive(Debug, Clone, Copy)] +pub struct BodyPolicy; + +/// The parsed value of `body_limit`: a non-zero number of bytes. +#[derive(Debug, Clone, Copy)] +#[allow(dead_code)] // Emitted as route metadata in a later step. +pub struct BodyLimit(pub ByteUnit); + +fn parse_body_duration(meta: &devise::MetaItem) -> Result { + let string = StringLit::from_meta(meta)?; + parse_duration(&string).map_err(|e| { + string.span() + .error(format!("invalid duration: {}", e)) + .help("expected a duration such as \"30s\", \"250ms\", or \"1m30s\"") + }) +} + +impl FromMeta for BodyTimeout { + fn from_meta(meta: &devise::MetaItem) -> Result { + parse_body_duration(meta).map(BodyTimeout) + } +} + +impl FromMeta for BodyGrace { + fn from_meta(meta: &devise::MetaItem) -> Result { + parse_body_duration(meta).map(BodyGrace) + } +} + +impl FromMeta for BodyMinRate { + fn from_meta(meta: &devise::MetaItem) -> Result { + let string = StringLit::from_meta(meta)?; + string.parse::().map(BodyMinRate).map_err(|e| { + string.span() + .error(format!("invalid rate: {}", e)) + .help("expected a rate such as \"8KiB/s\" or \"1MiB/10s\"") + }) + } +} + +impl FromMeta for BodyPolicy { + fn from_meta(meta: &devise::MetaItem) -> Result { + let string = StringLit::from_meta(meta)?; + match &*string { + "unlimited" => Ok(BodyPolicy), + _ => Err(string.span() + .error("invalid body policy") + .help("the only supported body policy is \"unlimited\"")), + } + } +} + +impl FromMeta for BodyLimit { + fn from_meta(meta: &devise::MetaItem) -> Result { + let string = StringLit::from_meta(meta)?; + let limit = string.parse::().map_err(|e| { + string.span() + .error(format!("invalid body limit: {}", e)) + .help("expected a byte size such as \"2MiB\" or \"64KiB\"") + })?; + + if limit.as_u64() == 0 { + return Err(string.span() + .error("body limit must be non-zero") + .help("the default body limit is finite and nonzero; use a non-zero size")); + } + + Ok(BodyLimit(limit)) + } } #[derive(Debug)] @@ -168,6 +268,34 @@ impl Route { } } + // Validate the body policy options: at most one progress policy, and + // a grace period only alongside a minimum rate. + let progress = [ + attr.body_timeout.as_ref().map(|o| ("body_timeout", o.full_span)), + attr.body_min_rate.as_ref().map(|o| ("body_min_rate", o.full_span)), + attr.body_policy.as_ref().map(|o| ("body_policy", o.full_span)), + ]; + + let mut progress = progress.into_iter().flatten(); + if let Some((first, first_span)) = progress.next() { + for (name, span) in progress { + diags.push( + span.error(format!("`{}` cannot be used with `{}`", name, first)) + .span_note(first_span, "the progress policy was first set here") + .help("a route can have only one of `body_timeout`, `body_min_rate`, \ + and `body_policy`"), + ); + } + } + + if let (Some(grace), None) = (&attr.body_grace, &attr.body_min_rate) { + diags.push( + grace.full_span + .error("`body_grace` requires `body_min_rate`") + .help("the grace period applies only to rate-based body policies"), + ); + } + // Check the validity of function arguments. let span = handler.sig.paren_token.span.join(); let mut arguments = Arguments { diff --git a/core/codegen/tests/route-body-policy.rs b/core/codegen/tests/route-body-policy.rs new file mode 100644 index 000000000..a828745a5 --- /dev/null +++ b/core/codegen/tests/route-body-policy.rs @@ -0,0 +1,85 @@ +// Every valid combination of the body policy route options compiles, mounts, +// and leaves the route usable. The options are parsed and validated here; +// their runtime effect is tested in `rkt`'s `body-policy-*` tests. + +#[macro_use] extern crate rkt; + +use rkt::local::blocking::Client; +use rkt::http::Status; + +#[post("/none", data = "<_d>")] +fn none(_d: Vec) -> &'static str { "none" } + +#[post("/timeout", data = "<_d>", body_timeout = "30s")] +fn timeout(_d: Vec) -> &'static str { "timeout" } + +#[post("/compound", data = "<_d>", body_timeout = "1m30s")] +fn compound(_d: Vec) -> &'static str { "compound" } + +#[post("/rate", data = "<_d>", body_min_rate = "8KiB/s")] +fn rate(_d: Vec) -> &'static str { "rate" } + +#[post("/rate_period", data = "<_d>", body_min_rate = "1MiB/10s")] +fn rate_period(_d: Vec) -> &'static str { "rate_period" } + +#[post("/rate_grace", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "30s")] +fn rate_grace(_d: Vec) -> &'static str { "rate_grace" } + +#[post("/unlimited", data = "<_d>", body_policy = "unlimited")] +fn unlimited(_d: Vec) -> &'static str { "unlimited" } + +#[post("/limit", data = "<_d>", body_limit = "2MiB")] +fn limit(_d: Vec) -> &'static str { "limit" } + +#[post("/timeout_limit", data = "<_d>", body_timeout = "30s", body_limit = "2MiB")] +fn timeout_limit(_d: Vec) -> &'static str { "timeout_limit" } + +#[post( + "/rate_grace_limit", + data = "<_d>", + body_min_rate = "8KiB/s", + body_grace = "30s", + body_limit = "5GiB" +)] +fn rate_grace_limit(_d: Vec) -> &'static str { "rate_grace_limit" } + +#[post("/unlimited_limit", data = "<_d>", body_policy = "unlimited", body_limit = "64MiB")] +fn unlimited_limit(_d: Vec) -> &'static str { "unlimited_limit" } + +// Option order is irrelevant, and the options are accepted without `data`. +#[post("/reordered", body_limit = "1KiB", body_grace = "5s", body_min_rate = "1KiB/s", data = "<_d>")] +fn reordered(_d: Vec) -> &'static str { "reordered" } + +#[put("/no_data", body_timeout = "5s")] +fn no_data() -> &'static str { "no_data" } + +// The generic `route` attribute accepts the same options. +#[route("/generic", method = POST, data = "<_d>", body_timeout = "30s", body_limit = "1MiB")] +fn generic(_d: Vec) -> &'static str { "generic" } + +#[test] +fn valid_body_policy_options_mount_and_route() { + let rocket = rkt::build().mount("/", routes![ + none, timeout, compound, rate, rate_period, rate_grace, unlimited, limit, + timeout_limit, rate_grace_limit, unlimited_limit, reordered, no_data, generic, + ]); + + let client = Client::debug(rocket).unwrap(); + let cases = [ + ("none", "none"), ("timeout", "timeout"), ("compound", "compound"), + ("rate", "rate"), ("rate_period", "rate_period"), ("rate_grace", "rate_grace"), + ("unlimited", "unlimited"), ("limit", "limit"), ("timeout_limit", "timeout_limit"), + ("rate_grace_limit", "rate_grace_limit"), ("unlimited_limit", "unlimited_limit"), + ("reordered", "reordered"), ("generic", "generic"), + ]; + + for (path, expected) in cases { + let response = client.post(format!("/{path}")).body("hello").dispatch(); + assert_eq!(response.status(), Status::Ok, "POST /{path}"); + assert_eq!(response.into_string().unwrap(), expected, "POST /{path}"); + } + + let response = client.put("/no_data").dispatch(); + assert_eq!(response.status(), Status::Ok); + assert_eq!(response.into_string().unwrap(), "no_data"); +} diff --git a/core/codegen/tests/ui-fail-nightly/catch.rs b/core/codegen/tests/ui-fail-nightly/catch.rs index 355e7a405..17f4b382f 100644 --- a/core/codegen/tests/ui-fail-nightly/catch.rs +++ b/core/codegen/tests/ui-fail-nightly/catch.rs @@ -1,4 +1,4 @@ -#[macro_use] extern crate rocket; +#[macro_use] extern crate rkt; use rkt::Request; diff --git a/core/codegen/tests/ui-fail-nightly/route-body-policy.rs b/core/codegen/tests/ui-fail-nightly/route-body-policy.rs new file mode 100644 index 000000000..cfb4ac22d --- /dev/null +++ b/core/codegen/tests/ui-fail-nightly/route-body-policy.rs @@ -0,0 +1,91 @@ +#[macro_use] extern crate rkt; + +// Only one progress policy per route. +#[post("/", data = "<_d>", body_timeout = "30s", body_min_rate = "8KiB/s")] +fn a0(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] +fn a1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_policy = "unlimited")] +fn a2(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "unlimited", body_timeout = "30s")] +fn a3(_d: Vec) {} + +// A grace period needs a rate. +#[post("/", data = "<_d>", body_grace = "30s")] +fn b0(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = "30s", body_grace = "30s")] +fn b1(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "unlimited", body_grace = "30s")] +fn b2(_d: Vec) {} + +// Unparsable durations, rates, and sizes. +#[post("/", data = "<_d>", body_timeout = "soon")] +fn c0(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = "30 seconds")] +fn c1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB")] +fn c2(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "fast/s")] +fn c3(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "later")] +fn c4(_d: Vec) {} + +#[post("/", data = "<_d>", body_limit = "lots")] +fn c5(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = 30)] +fn c6(_d: Vec) {} + +// Only `"unlimited"` is a valid policy. +#[post("/", data = "<_d>", body_policy = "limited")] +fn d0(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "")] +fn d1(_d: Vec) {} + +// Zero values are rejected. +#[post("/", data = "<_d>", body_timeout = "0s")] +fn e0(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "0/s")] +fn e1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/0s")] +fn e2(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "0s")] +fn e3(_d: Vec) {} + +#[post("/", data = "<_d>", body_limit = "0B")] +fn e4(_d: Vec) {} + +// Duplicate options. +#[post("/", data = "<_d>", body_timeout = "30s", body_timeout = "60s")] +fn f0(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_min_rate = "1KiB/s")] +fn f1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "1s", body_grace = "2s")] +fn f2(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "unlimited", body_policy = "unlimited")] +fn f3(_d: Vec) {} + +#[post("/", data = "<_d>", body_limit = "1MiB", body_limit = "2MiB")] +fn f4(_d: Vec) {} + +// The same checks apply through the generic `route` attribute. +#[route("/", method = POST, data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] +fn g0(_d: Vec) {} + +fn main() { } diff --git a/core/codegen/tests/ui-fail-nightly/route-body-policy.stderr b/core/codegen/tests/ui-fail-nightly/route-body-policy.stderr new file mode 100644 index 000000000..d51260154 --- /dev/null +++ b/core/codegen/tests/ui-fail-nightly/route-body-policy.stderr @@ -0,0 +1,228 @@ +error: `body_min_rate` cannot be used with `body_timeout` + --> tests/ui-fail-nightly/route-body-policy.rs:4:50 + | +4 | #[post("/", data = "<_d>", body_timeout = "30s", body_min_rate = "8KiB/s")] + | ^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the progress policy was first set here + --> tests/ui-fail-nightly/route-body-policy.rs:4:28 + | +4 | #[post("/", data = "<_d>", body_timeout = "30s", body_min_rate = "8KiB/s")] + | ^^^^^^^^^^^^^^^^^^^^ + = help: a route can have only one of `body_timeout`, `body_min_rate`, and `body_policy` + +error: `body_policy` cannot be used with `body_timeout` + --> tests/ui-fail-nightly/route-body-policy.rs:7:50 + | +7 | #[post("/", data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the progress policy was first set here + --> tests/ui-fail-nightly/route-body-policy.rs:7:28 + | +7 | #[post("/", data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] + | ^^^^^^^^^^^^^^^^^^^^ + = help: a route can have only one of `body_timeout`, `body_min_rate`, and `body_policy` + +error: `body_policy` cannot be used with `body_min_rate` + --> tests/ui-fail-nightly/route-body-policy.rs:10:54 + | +10 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_policy = "unlimited")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the progress policy was first set here + --> tests/ui-fail-nightly/route-body-policy.rs:10:28 + | +10 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_policy = "unlimited")] + | ^^^^^^^^^^^^^^^^^^^^^^^^ + = help: a route can have only one of `body_timeout`, `body_min_rate`, and `body_policy` + +error: `body_policy` cannot be used with `body_timeout` + --> tests/ui-fail-nightly/route-body-policy.rs:13:28 + | +13 | #[post("/", data = "<_d>", body_policy = "unlimited", body_timeout = "30s")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the progress policy was first set here + --> tests/ui-fail-nightly/route-body-policy.rs:13:55 + | +13 | #[post("/", data = "<_d>", body_policy = "unlimited", body_timeout = "30s")] + | ^^^^^^^^^^^^^^^^^^^^ + = help: a route can have only one of `body_timeout`, `body_min_rate`, and `body_policy` + +error: `body_grace` requires `body_min_rate` + --> tests/ui-fail-nightly/route-body-policy.rs:17:28 + | +17 | #[post("/", data = "<_d>", body_grace = "30s")] + | ^^^^^^^^^^^^^^^^^^ + | + = help: the grace period applies only to rate-based body policies + +error: `body_grace` requires `body_min_rate` + --> tests/ui-fail-nightly/route-body-policy.rs:20:50 + | +20 | #[post("/", data = "<_d>", body_timeout = "30s", body_grace = "30s")] + | ^^^^^^^^^^^^^^^^^^ + | + = help: the grace period applies only to rate-based body policies + +error: `body_grace` requires `body_min_rate` + --> tests/ui-fail-nightly/route-body-policy.rs:23:55 + | +23 | #[post("/", data = "<_d>", body_policy = "unlimited", body_grace = "30s")] + | ^^^^^^^^^^^^^^^^^^ + | + = help: the grace period applies only to rate-based body policies + +error: invalid duration: unexpected character 's' + --> tests/ui-fail-nightly/route-body-policy.rs:27:43 + | +27 | #[post("/", data = "<_d>", body_timeout = "soon")] + | ^^^^^^ + | + = help: expected a duration such as "30s", "250ms", or "1m30s" + +error: invalid duration: unknown duration unit: expected one of `h`, `m`, `s`, `ms` + --> tests/ui-fail-nightly/route-body-policy.rs:30:43 + | +30 | #[post("/", data = "<_d>", body_timeout = "30 seconds")] + | ^^^^^^^^^^^^ + | + = help: expected a duration such as "30s", "250ms", or "1m30s" + +error: invalid rate: a rate must be a size and a period separated by `/`, as in `8KiB/s` + --> tests/ui-fail-nightly/route-body-policy.rs:33:44 + | +33 | #[post("/", data = "<_d>", body_min_rate = "8KiB")] + | ^^^^^^ + | + = help: expected a rate such as "8KiB/s" or "1MiB/10s" + +error: invalid rate: invalid rate size: unexpected character 'f' at index `0` + --> tests/ui-fail-nightly/route-body-policy.rs:36:44 + | +36 | #[post("/", data = "<_d>", body_min_rate = "fast/s")] + | ^^^^^^^^ + | + = help: expected a rate such as "8KiB/s" or "1MiB/10s" + +error: invalid duration: unexpected character 'l' + --> tests/ui-fail-nightly/route-body-policy.rs:39:67 + | +39 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "later")] + | ^^^^^^^ + | + = help: expected a duration such as "30s", "250ms", or "1m30s" + +error: invalid body limit: unexpected character 'l' at index `0` + --> tests/ui-fail-nightly/route-body-policy.rs:42:41 + | +42 | #[post("/", data = "<_d>", body_limit = "lots")] + | ^^^^^^ + | + = help: expected a byte size such as "2MiB" or "64KiB" + +error: invalid value: expected string literal + --> tests/ui-fail-nightly/route-body-policy.rs:45:43 + | +45 | #[post("/", data = "<_d>", body_timeout = 30)] + | ^^ + +error: invalid body policy + --> tests/ui-fail-nightly/route-body-policy.rs:49:42 + | +49 | #[post("/", data = "<_d>", body_policy = "limited")] + | ^^^^^^^^^ + | + = help: the only supported body policy is "unlimited" + +error: invalid body policy + --> tests/ui-fail-nightly/route-body-policy.rs:52:42 + | +52 | #[post("/", data = "<_d>", body_policy = "")] + | ^^ + | + = help: the only supported body policy is "unlimited" + +error: invalid duration: duration must be non-zero + --> tests/ui-fail-nightly/route-body-policy.rs:56:43 + | +56 | #[post("/", data = "<_d>", body_timeout = "0s")] + | ^^^^ + | + = help: expected a duration such as "30s", "250ms", or "1m30s" + +error: invalid rate: rate size must be non-zero + --> tests/ui-fail-nightly/route-body-policy.rs:59:44 + | +59 | #[post("/", data = "<_d>", body_min_rate = "0/s")] + | ^^^^^ + | + = help: expected a rate such as "8KiB/s" or "1MiB/10s" + +error: invalid rate: invalid rate period: duration must be non-zero + --> tests/ui-fail-nightly/route-body-policy.rs:62:44 + | +62 | #[post("/", data = "<_d>", body_min_rate = "8KiB/0s")] + | ^^^^^^^^^ + | + = help: expected a rate such as "8KiB/s" or "1MiB/10s" + +error: invalid duration: duration must be non-zero + --> tests/ui-fail-nightly/route-body-policy.rs:65:67 + | +65 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "0s")] + | ^^^^ + | + = help: expected a duration such as "30s", "250ms", or "1m30s" + +error: body limit must be non-zero + --> tests/ui-fail-nightly/route-body-policy.rs:68:41 + | +68 | #[post("/", data = "<_d>", body_limit = "0B")] + | ^^^^ + | + = help: the default body limit is finite and nonzero; use a non-zero size + +error: duplicate attribute parameter: body_timeout + --> tests/ui-fail-nightly/route-body-policy.rs:72:50 + | +72 | #[post("/", data = "<_d>", body_timeout = "30s", body_timeout = "60s")] + | ^^^^^^^^^^^^^^^^^^^^ + +error: duplicate attribute parameter: body_min_rate + --> tests/ui-fail-nightly/route-body-policy.rs:75:54 + | +75 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_min_rate = "1KiB/s")] + | ^^^^^^^^^^^^^^^^^^^^^^^^ + +error: duplicate attribute parameter: body_grace + --> tests/ui-fail-nightly/route-body-policy.rs:78:73 + | +78 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "1s", body_grace = "2s")] + | ^^^^^^^^^^^^^^^^^ + +error: duplicate attribute parameter: body_policy + --> tests/ui-fail-nightly/route-body-policy.rs:81:55 + | +81 | #[post("/", data = "<_d>", body_policy = "unlimited", body_policy = "unlimited")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + +error: duplicate attribute parameter: body_limit + --> tests/ui-fail-nightly/route-body-policy.rs:84:49 + | +84 | #[post("/", data = "<_d>", body_limit = "1MiB", body_limit = "2MiB")] + | ^^^^^^^^^^^^^^^^^^^ + +error: `body_policy` cannot be used with `body_timeout` + --> tests/ui-fail-nightly/route-body-policy.rs:88:66 + | +88 | #[route("/", method = POST, data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the progress policy was first set here + --> tests/ui-fail-nightly/route-body-policy.rs:88:44 + | +88 | #[route("/", method = POST, data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] + | ^^^^^^^^^^^^^^^^^^^^ + = help: a route can have only one of `body_timeout`, `body_min_rate`, and `body_policy` diff --git a/core/codegen/tests/ui-fail-stable/catch.rs b/core/codegen/tests/ui-fail-stable/catch.rs index 355e7a405..17f4b382f 100644 --- a/core/codegen/tests/ui-fail-stable/catch.rs +++ b/core/codegen/tests/ui-fail-stable/catch.rs @@ -1,4 +1,4 @@ -#[macro_use] extern crate rocket; +#[macro_use] extern crate rkt; use rkt::Request; diff --git a/core/codegen/tests/ui-fail-stable/catch.stderr b/core/codegen/tests/ui-fail-stable/catch.stderr index 56dfb8a9d..972718ea9 100644 --- a/core/codegen/tests/ui-fail-stable/catch.stderr +++ b/core/codegen/tests/ui-fail-stable/catch.stderr @@ -1,16 +1,73 @@ -error[E0463]: can't find crate for `rocket` - --> tests/ui-fail-stable/catch.rs:1:14 +error: expected `fn` + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39m`#[catch]` can only be used on functions␛[0m + --> tests/ui-fail-stable/catch.rs:6:1 | -1 | #[macro_use] extern crate rocket; - | ^^^^^^^^^^^^^^^^^^^^ can't find crate +6 | struct Catcher(String); + | ^^^^^^ -error[E0428]: the name `e5` is defined multiple times - --> tests/ui-fail-stable/catch.rs:27:1 +error: expected `fn` + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39m`#[catch]` can only be used on functions␛[0m + --> tests/ui-fail-stable/catch.rs:9:7 + | +9 | const CATCH: &str = "Catcher"; + | ^^^^^ + +error: expected integer or `default`, found string literal + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39m`#[catch]` expects a status code int or `default`: `#[catch(404)]` or `#[catch(default)]`␛[0m + --> tests/ui-fail-stable/catch.rs:11:9 + | +11 | #[catch("404")] + | ^^^^^ + +error: unexpected keyed parameter: expected literal or identifier + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39m`#[catch]` expects a status code int or `default`: `#[catch(404)]` or `#[catch(default)]`␛[0m + --> tests/ui-fail-stable/catch.rs:14:9 + | +14 | #[catch(code = "404")] + | ^^^^ + +error: unexpected keyed parameter: expected literal or identifier + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39m`#[catch]` expects a status code int or `default`: `#[catch(404)]` or `#[catch(default)]`␛[0m + --> tests/ui-fail-stable/catch.rs:17:9 + | +17 | #[catch(code = 404)] + | ^^^^ + +error: status must be in range [100, 599] + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39m`#[catch]` expects a status code int or `default`: `#[catch(404)]` or `#[catch(default)]`␛[0m + --> tests/ui-fail-stable/catch.rs:20:9 + | +20 | #[catch(99)] + | ^^ + +error: status must be in range [100, 599] + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39m`#[catch]` expects a status code int or `default`: `#[catch(404)]` or `#[catch(default)]`␛[0m + --> tests/ui-fail-stable/catch.rs:23:9 + | +23 | #[catch(600)] + | ^^^ + +error: unexpected attribute parameter: `message` + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39m`#[catch]` expects a status code int or `default`: `#[catch(404)]` or `#[catch(default)]`␛[0m + --> tests/ui-fail-stable/catch.rs:26:14 + | +26 | #[catch(400, message = "foo")] + | ^^^^^^^ + +error[E0308]: arguments to this function are incorrect + --> tests/ui-fail-stable/catch.rs:30:4 + | +30 | fn f3(_request: &Request, _other: bool) { } + | ^^ - ---- argument #2 of type `bool` is missing + | | + | unexpected argument #1 of type `Status` + | +note: function defined here + --> tests/ui-fail-stable/catch.rs:30:4 | -24 | fn e5(_request: &Request) { } - | ------------------------- previous definition of the value `e5` here -... -27 | fn e5(_request: &Request) { } - | ^^^^^^^^^^^^^^^^^^^^^^^^^ `e5` redefined here +30 | fn f3(_request: &Request, _other: bool) { } + | ^^ ------------ +help: provide the argument | - = note: `e5` must be defined only once in the value namespace of this module +30 | fn f3(bool, /* bool */)(_request: &Request, _other: bool) { } + | ++++++++++++++++++ diff --git a/core/codegen/tests/ui-fail-stable/route-body-policy.rs b/core/codegen/tests/ui-fail-stable/route-body-policy.rs new file mode 100644 index 000000000..cfb4ac22d --- /dev/null +++ b/core/codegen/tests/ui-fail-stable/route-body-policy.rs @@ -0,0 +1,91 @@ +#[macro_use] extern crate rkt; + +// Only one progress policy per route. +#[post("/", data = "<_d>", body_timeout = "30s", body_min_rate = "8KiB/s")] +fn a0(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] +fn a1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_policy = "unlimited")] +fn a2(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "unlimited", body_timeout = "30s")] +fn a3(_d: Vec) {} + +// A grace period needs a rate. +#[post("/", data = "<_d>", body_grace = "30s")] +fn b0(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = "30s", body_grace = "30s")] +fn b1(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "unlimited", body_grace = "30s")] +fn b2(_d: Vec) {} + +// Unparsable durations, rates, and sizes. +#[post("/", data = "<_d>", body_timeout = "soon")] +fn c0(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = "30 seconds")] +fn c1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB")] +fn c2(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "fast/s")] +fn c3(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "later")] +fn c4(_d: Vec) {} + +#[post("/", data = "<_d>", body_limit = "lots")] +fn c5(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = 30)] +fn c6(_d: Vec) {} + +// Only `"unlimited"` is a valid policy. +#[post("/", data = "<_d>", body_policy = "limited")] +fn d0(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "")] +fn d1(_d: Vec) {} + +// Zero values are rejected. +#[post("/", data = "<_d>", body_timeout = "0s")] +fn e0(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "0/s")] +fn e1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/0s")] +fn e2(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "0s")] +fn e3(_d: Vec) {} + +#[post("/", data = "<_d>", body_limit = "0B")] +fn e4(_d: Vec) {} + +// Duplicate options. +#[post("/", data = "<_d>", body_timeout = "30s", body_timeout = "60s")] +fn f0(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_min_rate = "1KiB/s")] +fn f1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "1s", body_grace = "2s")] +fn f2(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "unlimited", body_policy = "unlimited")] +fn f3(_d: Vec) {} + +#[post("/", data = "<_d>", body_limit = "1MiB", body_limit = "2MiB")] +fn f4(_d: Vec) {} + +// The same checks apply through the generic `route` attribute. +#[route("/", method = POST, data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] +fn g0(_d: Vec) {} + +fn main() { } diff --git a/core/codegen/tests/ui-fail-stable/route-body-policy.stderr b/core/codegen/tests/ui-fail-stable/route-body-policy.stderr new file mode 100644 index 000000000..dc9d5ee80 --- /dev/null +++ b/core/codegen/tests/ui-fail-stable/route-body-policy.stderr @@ -0,0 +1,212 @@ +error: `body_min_rate` cannot be used with `body_timeout` + --> tests/ui-fail-stable/route-body-policy.rs:4:50 + | +4 | #[post("/", data = "<_d>", body_timeout = "30s", body_min_rate = "8KiB/s")] + | ^^^^^^^^^^^^^ + +error: ␛[1;34m[␛[0m␛[1;32mnote␛[0m␛[1;34m] ␛[0m␛[1;39mthe progress policy was first set here␛[0m + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39ma route can have only one of `body_timeout`, `body_min_rate`, and `body_policy`␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:4:28 + | +4 | #[post("/", data = "<_d>", body_timeout = "30s", body_min_rate = "8KiB/s")] + | ^^^^^^^^^^^^ + +error: `body_policy` cannot be used with `body_timeout` + --> tests/ui-fail-stable/route-body-policy.rs:7:50 + | +7 | #[post("/", data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] + | ^^^^^^^^^^^ + +error: ␛[1;34m[␛[0m␛[1;32mnote␛[0m␛[1;34m] ␛[0m␛[1;39mthe progress policy was first set here␛[0m + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39ma route can have only one of `body_timeout`, `body_min_rate`, and `body_policy`␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:7:28 + | +7 | #[post("/", data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] + | ^^^^^^^^^^^^ + +error: `body_policy` cannot be used with `body_min_rate` + --> tests/ui-fail-stable/route-body-policy.rs:10:54 + | +10 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_policy = "unlimited")] + | ^^^^^^^^^^^ + +error: ␛[1;34m[␛[0m␛[1;32mnote␛[0m␛[1;34m] ␛[0m␛[1;39mthe progress policy was first set here␛[0m + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39ma route can have only one of `body_timeout`, `body_min_rate`, and `body_policy`␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:10:28 + | +10 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_policy = "unlimited")] + | ^^^^^^^^^^^^^ + +error: `body_policy` cannot be used with `body_timeout` + --> tests/ui-fail-stable/route-body-policy.rs:13:28 + | +13 | #[post("/", data = "<_d>", body_policy = "unlimited", body_timeout = "30s")] + | ^^^^^^^^^^^ + +error: ␛[1;34m[␛[0m␛[1;32mnote␛[0m␛[1;34m] ␛[0m␛[1;39mthe progress policy was first set here␛[0m + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39ma route can have only one of `body_timeout`, `body_min_rate`, and `body_policy`␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:13:55 + | +13 | #[post("/", data = "<_d>", body_policy = "unlimited", body_timeout = "30s")] + | ^^^^^^^^^^^^ + +error: `body_grace` requires `body_min_rate` + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mthe grace period applies only to rate-based body policies␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:17:28 + | +17 | #[post("/", data = "<_d>", body_grace = "30s")] + | ^^^^^^^^^^ + +error: `body_grace` requires `body_min_rate` + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mthe grace period applies only to rate-based body policies␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:20:50 + | +20 | #[post("/", data = "<_d>", body_timeout = "30s", body_grace = "30s")] + | ^^^^^^^^^^ + +error: `body_grace` requires `body_min_rate` + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mthe grace period applies only to rate-based body policies␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:23:55 + | +23 | #[post("/", data = "<_d>", body_policy = "unlimited", body_grace = "30s")] + | ^^^^^^^^^^ + +error: invalid duration: unexpected character 's' + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mexpected a duration such as "30s", "250ms", or "1m30s"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:27:43 + | +27 | #[post("/", data = "<_d>", body_timeout = "soon")] + | ^^^^^^ + +error: invalid duration: unknown duration unit: expected one of `h`, `m`, `s`, `ms` + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mexpected a duration such as "30s", "250ms", or "1m30s"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:30:43 + | +30 | #[post("/", data = "<_d>", body_timeout = "30 seconds")] + | ^^^^^^^^^^^^ + +error: invalid rate: a rate must be a size and a period separated by `/`, as in `8KiB/s` + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mexpected a rate such as "8KiB/s" or "1MiB/10s"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:33:44 + | +33 | #[post("/", data = "<_d>", body_min_rate = "8KiB")] + | ^^^^^^ + +error: invalid rate: invalid rate size: unexpected character 'f' at index `0` + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mexpected a rate such as "8KiB/s" or "1MiB/10s"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:36:44 + | +36 | #[post("/", data = "<_d>", body_min_rate = "fast/s")] + | ^^^^^^^^ + +error: invalid duration: unexpected character 'l' + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mexpected a duration such as "30s", "250ms", or "1m30s"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:39:67 + | +39 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "later")] + | ^^^^^^^ + +error: invalid body limit: unexpected character 'l' at index `0` + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mexpected a byte size such as "2MiB" or "64KiB"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:42:41 + | +42 | #[post("/", data = "<_d>", body_limit = "lots")] + | ^^^^^^ + +error: invalid value: expected string literal + --> tests/ui-fail-stable/route-body-policy.rs:45:43 + | +45 | #[post("/", data = "<_d>", body_timeout = 30)] + | ^^ + +error: invalid body policy + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mthe only supported body policy is "unlimited"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:49:42 + | +49 | #[post("/", data = "<_d>", body_policy = "limited")] + | ^^^^^^^^^ + +error: invalid body policy + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mthe only supported body policy is "unlimited"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:52:42 + | +52 | #[post("/", data = "<_d>", body_policy = "")] + | ^^ + +error: invalid duration: duration must be non-zero + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mexpected a duration such as "30s", "250ms", or "1m30s"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:56:43 + | +56 | #[post("/", data = "<_d>", body_timeout = "0s")] + | ^^^^ + +error: invalid rate: rate size must be non-zero + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mexpected a rate such as "8KiB/s" or "1MiB/10s"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:59:44 + | +59 | #[post("/", data = "<_d>", body_min_rate = "0/s")] + | ^^^^^ + +error: invalid rate: invalid rate period: duration must be non-zero + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mexpected a rate such as "8KiB/s" or "1MiB/10s"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:62:44 + | +62 | #[post("/", data = "<_d>", body_min_rate = "8KiB/0s")] + | ^^^^^^^^^ + +error: invalid duration: duration must be non-zero + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mexpected a duration such as "30s", "250ms", or "1m30s"␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:65:67 + | +65 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "0s")] + | ^^^^ + +error: body limit must be non-zero + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39mthe default body limit is finite and nonzero; use a non-zero size␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:68:41 + | +68 | #[post("/", data = "<_d>", body_limit = "0B")] + | ^^^^ + +error: duplicate attribute parameter: body_timeout + --> tests/ui-fail-stable/route-body-policy.rs:72:50 + | +72 | #[post("/", data = "<_d>", body_timeout = "30s", body_timeout = "60s")] + | ^^^^^^^^^^^^ + +error: duplicate attribute parameter: body_min_rate + --> tests/ui-fail-stable/route-body-policy.rs:75:54 + | +75 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_min_rate = "1KiB/s")] + | ^^^^^^^^^^^^^ + +error: duplicate attribute parameter: body_grace + --> tests/ui-fail-stable/route-body-policy.rs:78:73 + | +78 | #[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "1s", body_grace = "2s")] + | ^^^^^^^^^^ + +error: duplicate attribute parameter: body_policy + --> tests/ui-fail-stable/route-body-policy.rs:81:55 + | +81 | #[post("/", data = "<_d>", body_policy = "unlimited", body_policy = "unlimited")] + | ^^^^^^^^^^^ + +error: duplicate attribute parameter: body_limit + --> tests/ui-fail-stable/route-body-policy.rs:84:49 + | +84 | #[post("/", data = "<_d>", body_limit = "1MiB", body_limit = "2MiB")] + | ^^^^^^^^^^ + +error: `body_policy` cannot be used with `body_timeout` + --> tests/ui-fail-stable/route-body-policy.rs:88:66 + | +88 | #[route("/", method = POST, data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] + | ^^^^^^^^^^^ + +error: ␛[1;34m[␛[0m␛[1;32mnote␛[0m␛[1;34m] ␛[0m␛[1;39mthe progress policy was first set here␛[0m + ␛[1;34m= ␛[0m␛[1;36mhelp␛[0m␛[39m: ␛[0m␛[1;39ma route can have only one of `body_timeout`, `body_min_rate`, and `body_policy`␛[0m + --> tests/ui-fail-stable/route-body-policy.rs:88:44 + | +88 | #[route("/", method = POST, data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] + | ^^^^^^^^^^^^ diff --git a/core/codegen/tests/ui-fail/route-body-policy.rs b/core/codegen/tests/ui-fail/route-body-policy.rs new file mode 100644 index 000000000..cfb4ac22d --- /dev/null +++ b/core/codegen/tests/ui-fail/route-body-policy.rs @@ -0,0 +1,91 @@ +#[macro_use] extern crate rkt; + +// Only one progress policy per route. +#[post("/", data = "<_d>", body_timeout = "30s", body_min_rate = "8KiB/s")] +fn a0(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] +fn a1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_policy = "unlimited")] +fn a2(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "unlimited", body_timeout = "30s")] +fn a3(_d: Vec) {} + +// A grace period needs a rate. +#[post("/", data = "<_d>", body_grace = "30s")] +fn b0(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = "30s", body_grace = "30s")] +fn b1(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "unlimited", body_grace = "30s")] +fn b2(_d: Vec) {} + +// Unparsable durations, rates, and sizes. +#[post("/", data = "<_d>", body_timeout = "soon")] +fn c0(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = "30 seconds")] +fn c1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB")] +fn c2(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "fast/s")] +fn c3(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "later")] +fn c4(_d: Vec) {} + +#[post("/", data = "<_d>", body_limit = "lots")] +fn c5(_d: Vec) {} + +#[post("/", data = "<_d>", body_timeout = 30)] +fn c6(_d: Vec) {} + +// Only `"unlimited"` is a valid policy. +#[post("/", data = "<_d>", body_policy = "limited")] +fn d0(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "")] +fn d1(_d: Vec) {} + +// Zero values are rejected. +#[post("/", data = "<_d>", body_timeout = "0s")] +fn e0(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "0/s")] +fn e1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/0s")] +fn e2(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "0s")] +fn e3(_d: Vec) {} + +#[post("/", data = "<_d>", body_limit = "0B")] +fn e4(_d: Vec) {} + +// Duplicate options. +#[post("/", data = "<_d>", body_timeout = "30s", body_timeout = "60s")] +fn f0(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_min_rate = "1KiB/s")] +fn f1(_d: Vec) {} + +#[post("/", data = "<_d>", body_min_rate = "8KiB/s", body_grace = "1s", body_grace = "2s")] +fn f2(_d: Vec) {} + +#[post("/", data = "<_d>", body_policy = "unlimited", body_policy = "unlimited")] +fn f3(_d: Vec) {} + +#[post("/", data = "<_d>", body_limit = "1MiB", body_limit = "2MiB")] +fn f4(_d: Vec) {} + +// The same checks apply through the generic `route` attribute. +#[route("/", method = POST, data = "<_d>", body_timeout = "30s", body_policy = "unlimited")] +fn g0(_d: Vec) {} + +fn main() { } diff --git a/core/codegen/tests/ui-fail/synchronize.sh b/core/codegen/tests/ui-fail/synchronize.sh index dc5c69bd5..17268c3e0 100755 --- a/core/codegen/tests/ui-fail/synchronize.sh +++ b/core/codegen/tests/ui-fail/synchronize.sh @@ -1,8 +1,10 @@ #!/usr/bin/env bash set -e -# Symlinks all of the tests in this directory with those in sibling -# `ui-fail-stable` and `ui-fail-nightly` directories. +# Copies all of the tests in this directory into sibling `ui-fail-stable` and +# `ui-fail-nightly` directories. The copies are tracked as regular files, not +# symlinks, so stale copies are removed and existing ones overwritten. The +# `.stderr` snapshots in those directories are left untouched. SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" @@ -16,9 +18,13 @@ echo " nightly: ${nightly}" echo " anchor: ${anchor}" for dir in "${stable}" "${nightly}"; do + # Remove symlinks and copies whose source no longer exists. find "${dir}" -type l -delete + for file in "${dir}"/*.rs; do + [ -e "${SCRIPT_DIR}/$(basename "${file}")" ] || rm -f "${file}" + done for file in "${SCRIPT_DIR}"/*.rs; do - ln -s "../${anchor}/$(basename $file)" "${dir}/" + cp "${file}" "${dir}/" done done diff --git a/core/lib/Cargo.toml b/core/lib/Cargo.toml index c464a592e..7739d1311 100644 --- a/core/lib/Cargo.toml +++ b/core/lib/Cargo.toml @@ -170,3 +170,5 @@ version_check = "0.9.1" tokio = { version = "1", features = ["macros", "io-std", "test-util"] } figment = { version = "0.10.17", features = ["test"] } pretty_assertions = "1" +h2 = "0.4" +http = "1" diff --git a/core/lib/tests/body-policy-connection.rs b/core/lib/tests/body-policy-connection.rs new file mode 100644 index 000000000..1749eef7b --- /dev/null +++ b/core/lib/tests/body-policy-connection.rs @@ -0,0 +1,179 @@ +// What happens to the transport after a body policy failure. +// +// Expected to fail until step 1c (connection handling after a policy failure). +// 1b is not needed: these use the global policy. +// +// http1_connection_closes_after_timeout 1c (no `Connection: close`) +// http1_connection_closes_after_size_failure 1c (no `Connection: close`) +// +// Passing today, as regression guards that 1c must keep passing. hyper happens +// to drop an HTTP/1 connection whose request body was left unread, and the h2 +// stack resets a stream answered before its request ended, but neither is +// announced or guaranteed by rkt: +// +// http1_second_request_on_the_socket_is_not_served +// http1_response_is_flushed_before_close +// http2_only_failing_stream_is_reset +// http2_connection_survives_a_failed_stream +// +// Written against a real socket, since the local client has no connection to +// observe. HTTP/2 is spoken in cleartext with prior knowledge (h2c) using the +// `h2` crate as a client, which the server's protocol detection accepts. TLS +// and HTTP/3 are not exercised here: neither is needed to reach the same code +// path, and 1c documents any transport that cannot meet the requirement. +// +// The HTTP/2 tests are therefore not expected to drive 1c; the HTTP/1 tests do. + +#[macro_use] +extern crate rkt; + +use std::time::Duration; + +use rkt::data::{Data, ToByteUnit}; +use rkt::http::Status; +use rkt::tokio::net::TcpStream; +use rkt::tokio::time::timeout; +use rkt::Config; + +#[path = "body-policy-support/mod.rs"] +mod support; + +use support::*; + +#[post("/echo", data = "")] +async fn echo(data: Data<'_>) -> Result { + let bytes = data.open(1.mebibytes()).into_bytes().await.map_err(|_| Status::BadRequest)?; + Ok(bytes.len().to_string()) +} + +#[get("/ping")] +fn ping() -> &'static str { "pong" } + +fn rocket() -> rkt::Rocket { + let config = Config { body_timeout: duration(300), body_limit: 16.bytes(), ..config() }; + rkt::custom(config).mount("/", routes![echo, ping]) +} + +const PING: &str = "GET /ping HTTP/1.1\r\nHost: localhost\r\n\r\n"; + +/// After `first` has been answered with `expected`, the connection must +/// announce and carry out its closure. +async fn assert_closes(first: Vec, expected: u16) { + let port = launch(rocket()).await; + let mut client = Client::connect(port).await; + let _script = client.play(first); + + let response = client.response().await.expect("the failure is answered"); + assert_eq!(response.status, expected); + assert_eq!(response.header("connection"), Some("close"), "{response:?}"); + assert!(client.closed_within(Duration::from_secs(2)).await, "connection left open"); +} + +#[rkt::async_test] +async fn http1_connection_closes_after_timeout() { + let stalled = vec![head("POST", "/echo", 10), send("hello"), wait(1500)]; + assert_closes(stalled, 408).await; +} + +#[rkt::async_test] +async fn http1_connection_closes_after_size_failure() { + let over = vec![head_chunked("POST", "/echo"), chunk([b'x'; 10]), chunk([b'x'; 10]), wait(1500)]; + assert_closes(over, 413).await; +} + +#[rkt::async_test] +async fn http1_second_request_on_the_socket_is_not_served() { + let port = launch(rocket()).await; + let mut client = Client::connect(port).await; + + // Pipelined on one socket: a request that fails its body, then a ping. + let _script = client.play(vec![ + head("POST", "/echo", 10), send("hello"), wait(600), send(PING), + ]); + + let first = client.response().await.expect("the failure is answered"); + assert_eq!(first.status, 408); + assert_eq!(client.response().await.map(|r| r.status), None, "second request was served"); +} + +#[rkt::async_test] +async fn http1_response_is_flushed_before_close() { + // The client has not finished sending when the server answers: the response + // must still arrive whole, not as a connection reset. + let port = launch(rocket()).await; + let mut client = Client::connect(port).await; + let _script = client.play(vec![head("POST", "/echo", 10), send([b'x'; 8]), wait(2000)]); + + let response = client.response().await.expect("response arrives before the close"); + assert_eq!(response.status, 408); + assert!(response.body.contains("Request Timeout"), "{response:?}"); +} + +/// Opens an h2c connection with prior knowledge. +async fn h2_connect(port: u16) -> h2::client::SendRequest { + let tcp = TcpStream::connect(("127.0.0.1", port)).await.expect("connect"); + let (send, connection) = h2::client::handshake(tcp).await.expect("h2 handshake"); + rkt::tokio::spawn(connection); + send +} + +fn h2_request(method: &str, path: &str) -> http::Request<()> { + http::Request::builder() + .method(method) + .uri(format!("http://localhost{path}")) + .body(()) + .unwrap() +} + +/// Sends a complete small request on `send` and returns its status. +async fn h2_complete(send: &h2::client::SendRequest) -> Option { + let mut ready = send.clone().ready().await.ok()?; + let (response, mut body) = ready.send_request(h2_request("POST", "/echo"), false).ok()?; + body.send_data(bytes::Bytes::from_static(b"hello"), true).ok()?; + let response = timeout(RESPONSE_WAIT, response).await.ok()?.ok()?; + Some(response.status().as_u16()) +} + +#[rkt::async_test] +async fn http2_only_failing_stream_is_reset() { + let port = launch(rocket()).await; + let send = h2_connect(port).await; + + // The failing stream sends some of a body and then stalls. + let mut ready = send.clone().ready().await.expect("ready"); + let (response, mut body) = ready.send_request(h2_request("POST", "/echo"), false).unwrap(); + body.send_data(bytes::Bytes::from_static(b"hello"), false).unwrap(); + + // A concurrent stream on the same connection completes while it stalls. + assert_eq!(h2_complete(&send).await, Some(200), "concurrent stream"); + + // The failing stream is answered, then reset: it cannot be written to. + let response = timeout(RESPONSE_WAIT, response).await.expect("answered").expect("response"); + assert_eq!(response.status().as_u16(), 408); + + let reset = timeout(RESPONSE_WAIT, futures_poll_reset(&mut body)).await; + assert!(matches!(reset, Ok(Some(_))), "failing stream was not reset: {reset:?}"); + + // The connection and its other streams are unaffected. + assert_eq!(h2_complete(&send).await, Some(200), "connection survives"); +} + +#[rkt::async_test] +async fn http2_connection_survives_a_failed_stream() { + let port = launch(rocket()).await; + let send = h2_connect(port).await; + + // An oversized body fails its stream; later streams on the connection work. + let mut ready = send.clone().ready().await.expect("ready"); + let (response, mut body) = ready.send_request(h2_request("POST", "/echo"), false).unwrap(); + body.send_data(bytes::Bytes::from(vec![b'x'; 32]), true).unwrap(); + let response = timeout(RESPONSE_WAIT, response).await.expect("answered").expect("response"); + assert_eq!(response.status().as_u16(), 413); + + assert_eq!(h2_complete(&send).await, Some(200), "later stream"); +} + +/// Waits for the peer to reset the stream, returning the reason. +async fn futures_poll_reset(body: &mut h2::SendStream) -> Option { + std::future::poll_fn(|cx| body.poll_reset(cx)).await.ok() +} diff --git a/core/lib/tests/body-policy-failure.rs b/core/lib/tests/body-policy-failure.rs new file mode 100644 index 000000000..ea13ebcc3 --- /dev/null +++ b/core/lib/tests/body-policy-failure.rs @@ -0,0 +1,561 @@ +// Body policy failures as the client and the application see them. +// +// Expected to fail until step 1b (route storage and policy resolution), because +// they rely on a route's own body options taking effect: +// +// stalled_body_under_route_timeout_returns_408 1b +// trickle_under_route_rate_returns_408 1b +// guards_time_out_under_route_policy 1b +// guards_reject_oversized_under_route_policy 1b +// +// Everything else passes today and guards the global policy's behaviour: +// +// stalled_body_returns_408 +// trickle_under_global_rate_returns_408 +// payload_bytes_earn_rate_time_but_chunk_metadata_does_not +// trailers_do_not_earn_rate_time +// chunked_body_over_limit_returns_413 +// raw_consumers_see_timeout / _rate / _limit +// raw_consumers_do_not_confuse_malformed_input_with_policy_failure +// raw_consumers_do_not_confuse_peer_disconnect_with_policy_failure +// guards_time_out_under_global_policy +// guards_reject_oversized_under_global_policy +// failed_body_stays_failed +// +// Empty data frames exist only in HTTP/2, so the "empty frames earn nothing" +// case is covered at the stream level by the unit tests in `data/policy.rs`. +// +// `Data::peek()` swallows non-policy read errors (malformed input, disconnects), +// so those two raw-consumer tests cover `open()` only. This is existing +// behaviour, not part of 1b or 1c. +// +// Real sockets and real time are used; see `body-policy-support`. + +#[macro_use] +extern crate rkt; + +use std::io; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rkt::data::{BodyPolicyError, Data, ToByteUnit}; +use rkt::form::Form; +use rkt::fs::TempFile; +use rkt::http::Status; +use rkt::tokio::io::AsyncReadExt; +use rkt::tokio::time::sleep; +use rkt::{Build, Config, Rocket}; + +#[path = "body-policy-support/mod.rs"] +mod support; + +use support::*; + +/// What the application observed, in order. +type Seen = Arc>>; + +/// How a read failure presents to an application: which kind of policy failure +/// it is, or that it is not one. +fn classify(error: &io::Error) -> String { + match BodyPolicyError::from_io(error) { + Some(BodyPolicyError::Timeout { .. }) => "timeout".into(), + Some(BodyPolicyError::MinRate { .. }) => "rate".into(), + Some(BodyPolicyError::LimitExceeded { .. }) => "limit".into(), + Some(BodyPolicyError::DeclaredTooLarge { .. }) => "declared".into(), + Some(other) => format!("policy:{other}"), + None => format!("io:{:?}", error.kind()), + } +} + +fn record(seen: &Seen, what: String) { + seen.lock().unwrap().push(what); +} + +/// Records `cancelled` if dropped before the handler reports: hyper cancels the +/// handler of a request whose client has disconnected. +struct Report<'a>(&'a Seen, bool); + +impl<'a> Report<'a> { + fn new(seen: &'a Seen) -> Self { + Report(seen, false) + } + + fn read(mut self, result: io::Result<()>) { + self.1 = true; + record(self.0, result.map_or_else(|e| classify(&e), |_| "ok".into())); + } +} + +impl Drop for Report<'_> { + fn drop(&mut self) { + if !self.1 { + record(self.0, "cancelled".into()); + } + } +} + +async fn slurp(data: Data<'_>) -> Result { + let bytes = data.open(1.mebibytes()).into_bytes().await.map_err(|_: io::Error| Status::BadRequest)?; + Ok(bytes.len().to_string()) +} + +async fn peek_then_read(mut data: Data<'_>) -> Result { + data.peek(100).await; + slurp(data).await +} + +#[post("/slurp", data = "")] +async fn plain(data: Data<'_>) -> Result { slurp(data).await } + +#[post("/r_timeout", data = "", body_timeout = "300ms")] +async fn route_timeout(data: Data<'_>) -> Result { slurp(data).await } + +#[post("/r_rate", data = "", body_min_rate = "1KiB/s", body_grace = "200ms")] +async fn route_rate(data: Data<'_>) -> Result { slurp(data).await } + +// Raw consumers report what they saw instead of failing the request. + +#[post("/raw/data", data = "")] +async fn raw_data(data: Data<'_>, seen: &rkt::State) -> &'static str { + let report = Report::new(seen); + report.read(data.open(1.mebibytes()).into_bytes().await.map(|_| ())); + "done" +} + +#[post("/raw/stream", data = "")] +async fn raw_stream(data: Data<'_>, seen: &rkt::State) -> &'static str { + let report = Report::new(seen); + let mut stream = data.open(1.mebibytes()); + let mut buffer = [0u8; 64]; + let result = loop { + match stream.read(&mut buffer).await { + Ok(0) => break Ok(()), + Ok(_) => continue, + Err(e) => break Err(e), + } + }; + + report.read(result); + "done" +} + +#[post("/raw/peek", data = "")] +async fn raw_peek(mut data: Data<'_>, seen: &rkt::State) -> &'static str { + let report = Report::new(seen); + data.peek(100).await; + report.read(data.open(1.mebibytes()).into_bytes().await.map(|_| ())); + "done" +} + +#[post("/sticky", data = "")] +async fn sticky(data: Data<'_>, seen: &rkt::State) -> &'static str { + let mut stream = data.open(1.mebibytes()); + let mut buffer = [0u8; 64]; + let mut errors = vec![]; + loop { + match stream.read(&mut buffer).await { + Ok(0) => break errors.push(None), + Ok(_) => continue, + Err(e) => break errors.push(BodyPolicyError::from_io(&e).copied()), + } + } + + // Every read after the failure must fail with the same error. + for _ in 0..3 { + match stream.read(&mut buffer).await { + Err(e) => errors.push(BodyPolicyError::from_io(&e).copied()), + Ok(_) => errors.push(None), + } + } + + let first = errors[0]; + match first.is_some() && errors.iter().all(|e| *e == first) { + true => record(seen, "sticky".into()), + false => record(seen, format!("not sticky: {errors:?}")), + } + + "done" +} + +// One route per guard, for each of the global, route-timeout, and route-limit +// policies: `/g/*`, `/rt/*`, and `/rl/*`. + +#[derive(FromForm)] +struct Input { #[allow(dead_code)] value: String } + +#[derive(FromForm)] +struct Upload<'r> { #[allow(dead_code)] file: TempFile<'r> } + +#[post("/g/form", data = "<_d>")] +fn g_form(_d: Form) {} +#[post("/g/file", data = "<_d>")] +fn g_file(_d: TempFile<'_>) {} +#[post("/g/multipart", data = "<_d>")] +fn g_multipart(_d: Form>) {} +#[post("/g/peek", data = "")] +async fn g_peek(d: Data<'_>) -> Result { peek_then_read(d).await } + +#[post("/rt/form", data = "<_d>", body_timeout = "300ms")] +fn rt_form(_d: Form) {} +#[post("/rt/file", data = "<_d>", body_timeout = "300ms")] +fn rt_file(_d: TempFile<'_>) {} +#[post("/rt/multipart", data = "<_d>", body_timeout = "300ms")] +fn rt_multipart(_d: Form>) {} +#[post("/rt/peek", data = "", body_timeout = "300ms")] +async fn rt_peek(d: Data<'_>) -> Result { peek_then_read(d).await } + +#[post("/rl/form", data = "<_d>", body_limit = "16B")] +fn rl_form(_d: Form) {} +#[post("/rl/file", data = "<_d>", body_limit = "16B")] +fn rl_file(_d: TempFile<'_>) {} +#[post("/rl/multipart", data = "<_d>", body_limit = "16B")] +fn rl_multipart(_d: Form>) {} +#[post("/rl/peek", data = "", body_limit = "16B")] +async fn rl_peek(d: Data<'_>) -> Result { peek_then_read(d).await } + +#[cfg(feature = "json")] +mod json_routes { + use super::*; + use rkt::serde::json::Json; + + #[post("/g/json", data = "<_d>")] + pub fn g_json(_d: Json) {} + #[post("/rt/json", data = "<_d>", body_timeout = "300ms")] + pub fn rt_json(_d: Json) {} + #[post("/rl/json", data = "<_d>", body_limit = "16B")] + pub fn rl_json(_d: Json) {} +} + +fn rocket(config: Config, seen: &Seen) -> Rocket { + let rocket = rkt::custom(config).manage(seen.clone()).mount("/", routes![ + plain, route_timeout, route_rate, raw_data, raw_stream, raw_peek, sticky, + g_form, g_file, g_multipart, g_peek, + rt_form, rt_file, rt_multipart, rt_peek, + rl_form, rl_file, rl_multipart, rl_peek, + ]); + + #[cfg(feature = "json")] + let rocket = rocket.mount("/", routes![ + json_routes::g_json, json_routes::rt_json, json_routes::rl_json, + ]); + + rocket +} + +fn strict() -> Config { + Config { body_timeout: duration(300), body_limit: 16.bytes(), ..config() } +} + +fn timeout_only() -> Config { + Config { body_timeout: duration(300), ..config() } +} + +fn limit_only() -> Config { + Config { body_limit: 16.bytes(), ..config() } +} + +fn new_rocket(config: Config) -> (Rocket, Seen) { + let seen = Seen::default(); + (rocket(config, &seen), seen) +} + +/// Waits for the application to report, and returns what it saw. +async fn observed(seen: &Seen) -> Vec { + for _ in 0..60 { + if !seen.lock().unwrap().is_empty() { + break; + } + + sleep(Duration::from_millis(50)).await; + } + + seen.lock().unwrap().clone() +} + +#[rkt::async_test] +async fn stalled_body_returns_408() { + let port = launch(new_rocket(strict()).0).await; + let stalled = vec![head("POST", "/slurp", 10), send("hello"), wait(1000), send("world")]; + assert_eq!(status(port, stalled).await, Some(408)); + + // A body that never starts is stalled too. + let silent = vec![head("POST", "/slurp", 10), wait(1000)]; + assert_eq!(status(port, silent).await, Some(408)); +} + +#[rkt::async_test] +async fn stalled_body_under_route_timeout_returns_408() { + let port = launch(new_rocket(config()).0).await; + let stalled = vec![head("POST", "/r_timeout", 10), send("hello"), wait(1000), send("world")]; + assert_eq!(status(port, stalled).await, Some(408)); +} + +/// One byte every 100ms for three seconds, of a body declared as 100 bytes. +fn trickle(path: &str) -> Vec { + let mut steps = vec![head("POST", path, 100)]; + for _ in 0..30 { + steps.push(send("x")); + steps.push(wait(100)); + } + + steps +} + +fn global_rate() -> Config { + Config { body_min_rate: Some("1KiB/s".parse().unwrap()), body_grace: duration(200), ..config() } +} + +#[rkt::async_test] +async fn trickle_under_global_rate_returns_408() { + let port = launch(new_rocket(global_rate()).0).await; + assert_eq!(status(port, trickle("/slurp")).await, Some(408)); +} + +#[rkt::async_test] +async fn trickle_under_route_rate_returns_408() { + let port = launch(new_rocket(config()).0).await; + assert_eq!(status(port, trickle("/r_rate")).await, Some(408)); +} + +#[rkt::async_test] +async fn payload_bytes_earn_rate_time_but_chunk_metadata_does_not() { + let config = Config { + body_min_rate: Some("10B/s".parse().unwrap()), + body_grace: duration(300), + ..config() + }; + + let port = launch(new_rocket(config).0).await; + + // Control: 20 payload bytes per second against a 10B/s minimum is enough. + let mut payload = vec![head_chunked("POST", "/slurp")]; + for _ in 0..20 { + payload.push(chunk("x")); + payload.push(wait(50)); + } + payload.push(end()); + assert_eq!(status(port, payload).await, Some(200), "payload bytes should earn time"); + + // The same cadence of bytes spent on one never-finished chunk header is + // not payload, and earns nothing. + let mut metadata = vec![head_chunked("POST", "/slurp"), send("5;")]; + for _ in 0..40 { + metadata.push(send("a")); + metadata.push(wait(50)); + } + assert_eq!(status(port, metadata).await, Some(408), "chunk metadata should not earn time"); +} + +#[rkt::async_test] +async fn trailers_do_not_earn_rate_time() { + let port = launch(new_rocket(global_rate()).0).await; + + // The payload is complete; trailer bytes then arrive without ever finishing. + let mut trailers = vec![ + head_with("POST", "/slurp", "Transfer-Encoding: chunked\r\nTrailer: X-Trailer"), + chunk("hello"), + send("0\r\nX-Trailer: "), + ]; + for _ in 0..40 { + trailers.push(send("a")); + trailers.push(wait(50)); + } + + assert_eq!(status(port, trailers).await, Some(408)); +} + +#[rkt::async_test] +async fn chunked_body_over_limit_returns_413() { + let port = launch(new_rocket(strict()).0).await; + + // No `Content-Length`: only counting the bytes can enforce the limit. + let over = vec![head_chunked("POST", "/slurp"), chunk([b'x'; 10]), chunk([b'x'; 10]), end()]; + assert_eq!(status(port, over).await, Some(413)); +} + +async fn raw_routes( + config: Config, + paths: &[&str], + steps: impl Fn(&str) -> Vec, +) -> Vec<(String, Vec)> { + let mut results = vec![]; + for path in paths { + let (rocket, seen) = new_rocket(config.clone()); + let port = launch(rocket).await; + let _client = { + let mut client = Client::connect(port).await; + let _script = client.play(steps(path)); + client + }; + + results.push((path.to_string(), observed(&seen).await)); + } + + results +} + +const RAW: [&str; 3] = ["/raw/data", "/raw/stream", "/raw/peek"]; + +fn assert_all_saw(results: Vec<(String, Vec)>, expected: &str) { + for (path, seen) in results { + assert_eq!(seen, vec![expected.to_string()], "{path}"); + } +} + +#[rkt::async_test] +async fn raw_consumers_see_timeout() { + let stalled = |path: &str| vec![head("POST", path, 10), send("hello"), wait(1000)]; + assert_all_saw(raw_routes(strict(), &RAW, stalled).await, "timeout"); +} + +#[rkt::async_test] +async fn raw_consumers_see_rate() { + assert_all_saw(raw_routes(global_rate(), &RAW, |path| trickle(path)).await, "rate"); +} + +#[rkt::async_test] +async fn raw_consumers_see_limit() { + let over = |path: &str| vec![head_chunked("POST", path), chunk([b'x'; 10]), chunk([b'x'; 10]), end()]; + assert_all_saw(raw_routes(strict(), &RAW, over).await, "limit"); +} + +#[rkt::async_test] +async fn raw_consumers_do_not_confuse_malformed_input_with_policy_failure() { + let malformed = |path: &str| vec![head_chunked("POST", path), chunk("hello"), send("zz\r\n")]; + // `Data::peek()` swallows a read error, so only `open()` can be checked. + for (path, seen) in raw_routes(strict(), &RAW[..2], malformed).await { + assert!(matches!(&seen[..], [s] if s.starts_with("io:")), "{path}: saw {seen:?}"); + } +} + +#[rkt::async_test] +async fn raw_consumers_do_not_confuse_peer_disconnect_with_policy_failure() { + let disconnect = |path: &str| vec![head("POST", path, 10), send("hello"), wait(50), close()]; + // `Data::peek()` swallows a read error, so only `open()` can be checked. + for (path, seen) in raw_routes(strict(), &RAW[..2], disconnect).await { + let expected = |s: &String| s.starts_with("io:") || s == "cancelled"; + assert!(matches!(&seen[..], [s] if expected(s)), "{path}: saw {seen:?}"); + } +} + +#[rkt::async_test] +async fn failed_body_stays_failed() { + let (rocket, seen) = new_rocket(strict()); + let port = launch(rocket).await; + + let stalled = vec![head("POST", "/sticky", 10), send("hello"), wait(1000)]; + let _ = request(port, stalled).await; + assert_eq!(observed(&seen).await, vec!["sticky"], "after a timeout"); + + seen.lock().unwrap().clear(); + let over = vec![head_chunked("POST", "/sticky"), chunk([b'x'; 10]), chunk([b'x'; 10]), end()]; + let _ = request(port, over).await; + assert_eq!(observed(&seen).await, vec!["sticky"], "after exceeding the limit"); +} + +/// The guards under test, with a request whose body stalls and one that is +/// over a 16 byte limit for each. +struct Kind { + name: &'static str, + content_type: &'static str, + /// A valid beginning of a body, and the whole body padded to `n` bytes. + start: &'static str, + whole: fn(usize) -> Vec, +} + +fn multipart(payload: usize) -> Vec { + format!( + "--B\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\n\ + Content-Type: text/plain\r\n\r\n{}\r\n--B--\r\n", + "a".repeat(payload) + ).into_bytes() +} + +fn kinds() -> Vec { + let mut kinds = vec![ + Kind { + name: "form", + content_type: "application/x-www-form-urlencoded", + start: "value=aaaa", + whole: |n| format!("value={}", "a".repeat(n)).into_bytes(), + }, + Kind { name: "file", content_type: "application/octet-stream", start: "aaaa", + whole: |n| vec![b'a'; n] }, + Kind { + name: "multipart", + content_type: "multipart/form-data; boundary=B", + start: "--B\r\nContent-Disposition: form-data; name=\"file\"; filename=\"a.txt\"\r\n\ + Content-Type: text/plain\r\n\r\naaaa", + whole: multipart, + }, + Kind { name: "peek", content_type: "application/octet-stream", start: "aaaa", + whole: |n| vec![b'a'; n] }, + ]; + + if cfg!(feature = "json") { + kinds.push(Kind { + name: "json", + content_type: "application/json", + start: "\"aaaa", + whole: |n| format!("\"{}\"", "a".repeat(n)).into_bytes(), + }); + } + + kinds +} + +/// Sends each kind's stalled (or oversized) body to `{prefix}/{kind}` and +/// reports every kind that did not get `expected`. +async fn check_guards(port: u16, prefix: &str, stall: bool, expected: u16) -> Vec { + let attempts = kinds().into_iter().map(|kind| async move { + let path = format!("{prefix}/{}", kind.name); + let headers = format!("Content-Type: {}", kind.content_type); + let steps = match stall { + true => vec![ + head_with("POST", &path, &format!("{headers}\r\nContent-Length: 1000")), + send(kind.start), + wait(1200), + ], + false => vec![ + head_with("POST", &path, &format!("{headers}\r\nTransfer-Encoding: chunked")), + chunk((kind.whole)(100)), + end(), + ], + }; + + match status(port, steps).await { + Some(status) if status == expected => None, + other => Some(format!("{path}: expected {expected}, got {other:?}")), + } + }); + + rkt::futures::future::join_all(attempts).await.into_iter().flatten().collect() +} + +fn assert_no_failures(failures: Vec) { + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[rkt::async_test] +async fn guards_time_out_under_global_policy() { + let port = launch(new_rocket(timeout_only()).0).await; + assert_no_failures(check_guards(port, "/g", true, 408).await); +} + +#[rkt::async_test] +async fn guards_time_out_under_route_policy() { + let port = launch(new_rocket(config()).0).await; + assert_no_failures(check_guards(port, "/rt", true, 408).await); +} + +#[rkt::async_test] +async fn guards_reject_oversized_under_global_policy() { + let port = launch(new_rocket(limit_only()).0).await; + assert_no_failures(check_guards(port, "/g", false, 413).await); +} + +#[rkt::async_test] +async fn guards_reject_oversized_under_route_policy() { + let port = launch(new_rocket(config()).0).await; + assert_no_failures(check_guards(port, "/rl", false, 413).await); +} diff --git a/core/lib/tests/body-policy-lifecycle.rs b/core/lib/tests/body-policy-lifecycle.rs new file mode 100644 index 000000000..3f2e27b18 --- /dev/null +++ b/core/lib/tests/body-policy-lifecycle.rs @@ -0,0 +1,241 @@ +// Body policy through the request lifecycle: fairings, data guards, handlers, +// forwarding, and `HEAD` autohandling. +// +// Expected to fail until step 1b (route storage and policy resolution), because +// they need a route's body options to take effect once it is selected: +// +// route_override_applies_to_data_guard 1b +// route_override_applies_to_handler 1b +// forwarding_does_not_restart_the_clock 1b +// forwarding_keeps_the_received_byte_count 1b +// head_autohandling_applies_get_route_override 1b +// head_forwarding_does_not_restart_the_clock 1b +// +// Passing now, as regression guards that 1b must keep passing. Today every +// route runs under the global policy, which is exactly right for these: +// +// request_fairings_read_under_global_policy +// forwarding_does_not_revive_a_timed_out_body +// forwarding_does_not_revive_an_oversized_body +// +// Elapsed time is real: a body that "spent 20s" on its first route is scaled +// down to 600ms here, against a 1s deadline on the next route. + +#[macro_use] +extern crate rkt; + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use rkt::data::{Data, FromData, Outcome, ToByteUnit}; +use rkt::fairing::AdHoc; +use rkt::http::Status; +use rkt::tokio::time::sleep; +use rkt::{Build, Config, Request, Rocket, State}; + +#[path = "body-policy-support/mod.rs"] +mod support; + +use support::*; + +/// Counts requests whose body was read to the end without a policy failure. +#[derive(Default)] +struct Completed(AtomicUsize); + +async fn slurp(data: Data<'_>, completed: &State) -> Result { + match data.open(1.mebibytes()).into_bytes().await { + Ok(bytes) => { + completed.0.fetch_add(1, Ordering::SeqCst); + Ok(bytes.len().to_string()) + } + Err(_) => Err(Status::BadRequest), + } +} + +/// Defines a data guard that peeks at `$peek` bytes of the body, spends +/// `$pause` ms on the route, and forwards the data on, as a guard that cannot +/// handle the request would. +macro_rules! forwarder { + ($name:ident, $peek:expr, $pause:expr) => { + struct $name; + + #[rkt::async_trait] + impl<'r> FromData<'r> for $name { + type Error = (); + + async fn from_data(_: &'r Request<'_>, mut data: Data<'r>) -> Outcome<'r, Self> { + data.peek($peek).await; + sleep(ms($pause)).await; + Outcome::Forward((data, Status::NotFound)) + } + } + }; +} + +forwarder!(SlowForwarder, 5, 600); +forwarder!(CountingForwarder, 400, 0); +forwarder!(FailedForwarder, 100, 0); + +// Request fairings run before routing, so under the global policy. + +#[post("/fairing", data = "", body_policy = "unlimited")] +async fn fairing_route(data: Data<'_>, c: &State) -> Result { + slurp(data, c).await +} + +// A selected route's override applies to its guards and handler. + +#[post("/guard", data = "<_d>", body_timeout = "3s")] +fn guard_route(_d: Vec, c: &State) -> &'static str { + c.0.fetch_add(1, Ordering::SeqCst); + "ok" +} + +#[post("/handler", data = "", body_timeout = "3s")] +async fn handler_route(data: Data<'_>, c: &State) -> Result { + slurp(data, c).await +} + +// Forwarding: the first route spends time (or bytes) and forwards. + +#[post("/clock", data = "<_d>", rank = 1, body_timeout = "5s")] +fn clock_first(_d: SlowForwarder) {} + +#[post("/clock", data = "", rank = 2, body_timeout = "1s")] +async fn clock_second(data: Data<'_>, c: &State) -> Result { + slurp(data, c).await +} + +#[post("/count", data = "<_d>", rank = 1)] +fn count_first(_d: CountingForwarder) {} + +#[post("/count", data = "", rank = 2, body_limit = "500B")] +async fn count_second(data: Data<'_>, c: &State) -> Result { + slurp(data, c).await +} + +#[post("/revive", data = "<_d>", rank = 1)] +fn revive_first(_d: FailedForwarder) {} + +#[post("/revive", data = "", rank = 2, body_timeout = "10s", body_limit = "1MiB")] +async fn revive_second(data: Data<'_>, c: &State) -> Result { + slurp(data, c).await +} + +// `HEAD` requests with no `HEAD` route are re-dispatched as `GET`. + +#[get("/head", data = "", body_timeout = "1s")] +#[suppress(dubious_payload)] +async fn head_get(data: Data<'_>, c: &State) -> Result { + slurp(data, c).await +} + +#[head("/head_fwd", data = "<_d>")] +#[suppress(dubious_payload)] +fn head_fwd_first(_d: SlowForwarder) {} + +#[get("/head_fwd", data = "", body_timeout = "1s")] +#[suppress(dubious_payload)] +async fn head_fwd_get(data: Data<'_>, c: &State) -> Result { + slurp(data, c).await +} + +fn rocket(config: Config) -> Rocket { + rkt::custom(config) + .manage(Completed::default()) + .attach(AdHoc::on_request("Peek Body", |req, data| Box::pin(async move { + if req.uri().path() == "/fairing" { + data.peek(100).await; + } + }))) + .mount("/", routes![ + fairing_route, guard_route, handler_route, clock_first, clock_second, + count_first, count_second, revive_first, revive_second, + head_get, head_fwd_first, head_fwd_get, + ]) +} + +fn global_timeout(timeout_ms: u64) -> Config { + Config { body_timeout: duration(timeout_ms), ..config() } +} + +#[rkt::async_test] +async fn request_fairings_read_under_global_policy() { + // The route is unlimited, but the fairing reads before the route is known: + // it waits for 100 bytes and the global 300ms deadline cuts it off. + let port = launch(rocket(global_timeout(300))).await; + let stalled = vec![head("POST", "/fairing", 10), send("hello"), wait(1000), send("world")]; + assert_eq!(status(port, stalled).await, Some(408)); +} + +#[rkt::async_test] +async fn route_override_applies_to_data_guard() { + let port = launch(rocket(global_timeout(300))).await; + let slow = vec![head("POST", "/guard", 10), send("hello"), wait(800), send("world")]; + assert_eq!(status(port, slow).await, Some(200), "the guard reads under the route's 3s deadline"); +} + +#[rkt::async_test] +async fn route_override_applies_to_handler() { + let port = launch(rocket(global_timeout(300))).await; + let slow = vec![head("POST", "/handler", 10), send("hello"), wait(800), send("world")]; + assert_eq!(status(port, slow).await, Some(200), "the handler reads under the route's 3s deadline"); +} + +#[rkt::async_test] +async fn forwarding_does_not_restart_the_clock() { + // The first route holds the body for 600ms and forwards. The second route's + // 1s deadline runs from when the body began, so it expires at 1s, not 1.6s. + let port = launch(rocket(global_timeout(60_000))).await; + let slow = vec![head("POST", "/clock", 10), send("hello"), wait(1300), send("world")]; + assert_eq!(status(port, slow).await, Some(408)); +} + +#[rkt::async_test] +async fn forwarding_keeps_the_received_byte_count() { + // 400 bytes are read by the first route; 300 more arrive for the second, + // whose 500B limit the 700 total exceed. + let port = launch(rocket(global_timeout(60_000))).await; + let over = vec![ + head_chunked("POST", "/count"), chunk([b'x'; 400]), wait(300), chunk([b'x'; 300]), end(), + ]; + + assert_eq!(status(port, over).await, Some(413)); +} + +#[rkt::async_test] +async fn forwarding_does_not_revive_a_timed_out_body() { + let port = launch(rocket(global_timeout(300))).await; + + // The first route's guard times out reading and forwards to a route with + // a 10s deadline, which must not revive the body. + let stalled = vec![head("POST", "/revive", 100), send([b'x'; 5]), wait(1000), send([b'x'; 95])]; + assert_eq!(status(port, stalled).await, Some(408)); +} + +#[rkt::async_test] +async fn forwarding_does_not_revive_an_oversized_body() { + let port = launch(rocket(Config { body_limit: 16.bytes(), ..config() })).await; + + // 100 chunked bytes exceed the global 16B limit in the first route's guard, + // which forwards to a route with a 1MiB limit. + let over = vec![head_chunked("POST", "/revive"), chunk([b'x'; 100]), end()]; + assert_eq!(status(port, over).await, Some(413)); +} + +#[rkt::async_test] +async fn head_autohandling_applies_get_route_override() { + // The `GET` route's 1s deadline applies to the re-dispatched `HEAD`; the + // global deadline is 60s. + let port = launch(rocket(global_timeout(60_000))).await; + let slow = vec![head("HEAD", "/head", 10), send("hello"), wait(1500), send("world")]; + assert_eq!(status(port, slow).await, Some(408)); +} + +#[rkt::async_test] +async fn head_forwarding_does_not_restart_the_clock() { + // A `HEAD` route holds the body for 600ms and forwards to the `GET` route, + // as in `forwarding_does_not_restart_the_clock`. + let port = launch(rocket(global_timeout(60_000))).await; + let slow = vec![head("HEAD", "/head_fwd", 10), send("hello"), wait(1300), send("world")]; + assert_eq!(status(port, slow).await, Some(408)); +} diff --git a/core/lib/tests/body-policy-resolution.rs b/core/lib/tests/body-policy-resolution.rs new file mode 100644 index 000000000..7926f88a5 --- /dev/null +++ b/core/lib/tests/body-policy-resolution.rs @@ -0,0 +1,291 @@ +// Policy resolution: how a route's body options combine with the global policy. +// +// Expected to fail until step 1b (route storage and policy resolution), which +// makes the route attribute's body options take effect. Until then every route +// runs under the global policy alone: +// +// route_timeout_replaces_global_rate 1b +// route_rate_replaces_global_deadline 1b +// route_rate_inherits_global_grace 1b +// route_grace_overrides_global_grace 1b +// route_unlimited_disables_progress 1b +// route_unlimited_keeps_its_limit 1b +// route_limit_overrides_only_size_chunked 1b +// route_limit_overrides_only_size_declared 1b (see note below) +// hard_limit_below_named_limit_wins 1b +// +// Passing now, as regression guards for 1b: +// +// route_without_options_inherits_global_policy +// route_limit_leaves_global_progress_policy +// named_limit_below_hard_limit_wins +// bodies_within_both_limits_are_accepted +// +// Note on `route_limit_overrides_only_size_declared`: a `Content-Length` over +// the *global* limit is rejected before routing today, which would also reject +// a route that raises its own limit above the global one. 1b has to defer that +// early rejection until the route, and so its limit, is known. +// +// Option spellings in route attributes are compile-time literals, so the +// durations below are fixed; the global configuration varies per test. + +#[macro_use] +extern crate rkt; + +use std::io; + +use rkt::data::{Data, ToByteUnit}; +use rkt::form::Form; +use rkt::fs::TempFile; +use rkt::http::{ContentType, Status}; +use rkt::local::blocking::Client as LocalClient; +use rkt::{Build, Config, Rocket}; + +#[path = "body-policy-support/mod.rs"] +mod support; + +use support::*; + +/// Reads the whole body, answering with its length. A body that fails is a +/// guard failure; the lifecycle answers it with the policy's status. +async fn slurp(data: Data<'_>) -> Result { + let bytes = data.open(1.mebibytes()).into_bytes().await.map_err(|_: io::Error| Status::BadRequest)?; + Ok(bytes.len().to_string()) +} + +#[post("/inherit", data = "")] +async fn inherit(data: Data<'_>) -> Result { slurp(data).await } + +#[post("/timeout", data = "", body_timeout = "2s")] +async fn timeout(data: Data<'_>) -> Result { slurp(data).await } + +#[post("/rate", data = "", body_min_rate = "1KiB/s")] +async fn rate(data: Data<'_>) -> Result { slurp(data).await } + +#[post("/rate_grace", data = "", body_min_rate = "1KiB/s", body_grace = "2s")] +async fn rate_grace(data: Data<'_>) -> Result { slurp(data).await } + +#[post("/unlimited", data = "", body_policy = "unlimited", body_limit = "16B")] +async fn unlimited(data: Data<'_>) -> Result { slurp(data).await } + +#[post("/limit", data = "", body_limit = "1KiB")] +async fn limit(data: Data<'_>) -> Result { slurp(data).await } + +fn mounted(config: Config) -> Rocket { + rkt::custom(config) + .mount("/", routes![inherit, timeout, rate, rate_grace, unlimited, limit]) +} + +fn global(timeout_ms: Option, limit: rkt::data::ByteUnit) -> Config { + Config { + body_timeout: timeout_ms.and_then(duration), + body_limit: limit, + ..config() + } +} + +fn global_rate(rate: &str, grace_ms: u64) -> Config { + Config { + body_min_rate: Some(rate.parse().unwrap()), + body_grace: duration(grace_ms), + ..config() + } +} + +#[rkt::async_test] +async fn route_without_options_inherits_global_policy() { + let port = launch(mounted(global(Some(300), 16.bytes()))).await; + + // The global deadline applies: the body stalls past it. + let stalled = vec![head("POST", "/inherit", 10), send("hello"), wait(1000), send("world")]; + assert_eq!(status(port, stalled).await, Some(408)); + + // The global limit applies. + let over = vec![head_chunked("POST", "/inherit"), chunk("0123456789abcdef!"), end()]; + assert_eq!(status(port, over).await, Some(413)); + + // A body within both is handled. + let ok = vec![head("POST", "/inherit", 10), send("0123456789")]; + assert_eq!(status(port, ok).await, Some(200)); +} + +#[rkt::async_test] +async fn route_timeout_replaces_global_rate() { + // Globally, 5 bytes buy almost no time beyond the 100ms grace. + let port = launch(mounted(global_rate("100KiB/s", 100))).await; + let slow = vec![head("POST", "/timeout", 10), send("hello"), wait(600), send("world")]; + assert_eq!(status(port, slow).await, Some(200), "the route's 2s deadline applies"); +} + +#[rkt::async_test] +async fn route_rate_replaces_global_deadline() { + let port = launch(mounted(global(Some(300), 1.mebibytes()))).await; + let slow = vec![head("POST", "/rate_grace", 10), send("hello"), wait(800), send("world")]; + assert_eq!(status(port, slow).await, Some(200), "the route's rate and grace apply"); +} + +#[rkt::async_test] +async fn route_rate_inherits_global_grace() { + // The global rate is negligible, so only the global 200ms grace matters + // once the route supplies a real rate. 100 bytes at 1KiB/s earn ~100ms. + let port = launch(mounted(global_rate("1B/s", 200))).await; + let stalled = vec![head("POST", "/rate", 200), send([b'x'; 100]), wait(1000), send([b'x'; 100])]; + assert_eq!(status(port, stalled).await, Some(408), "grace is the global 200ms, not the default"); +} + +#[rkt::async_test] +async fn route_grace_overrides_global_grace() { + // Globally 1KiB/s after 200ms: 100 bytes stalled for 1s would fail. + let port = launch(mounted(global_rate("1KiB/s", 200))).await; + let stalled = vec![head("POST", "/rate_grace", 200), send([b'x'; 100]), wait(1000), send([b'x'; 100])]; + assert_eq!(status(port, stalled).await, Some(200), "the route's 2s grace applies"); +} + +#[rkt::async_test] +async fn route_unlimited_disables_progress() { + let port = launch(mounted(global(Some(300), 1.mebibytes()))).await; + let stalled = vec![head("POST", "/unlimited", 10), send("hello"), wait(800), send("world")]; + assert_eq!(status(port, stalled).await, Some(200), "no deadline applies"); +} + +#[rkt::async_test] +async fn route_unlimited_keeps_its_limit() { + let port = launch(mounted(global(Some(300), 1.mebibytes()))).await; + let over = vec![head_chunked("POST", "/unlimited"), chunk("0123456789abcdef!"), end()]; + assert_eq!(status(port, over).await, Some(413), "the route's 16B limit applies"); + + let ok = vec![head_chunked("POST", "/unlimited"), chunk("0123456789abcdef"), end()]; + assert_eq!(status(port, ok).await, Some(200)); +} + +#[rkt::async_test] +async fn route_limit_overrides_only_size_chunked() { + let port = launch(mounted(global(Some(300), 16.bytes()))).await; + + let within = vec![head_chunked("POST", "/limit"), chunk([b'x'; 100]), end()]; + assert_eq!(status(port, within).await, Some(200), "the route's 1KiB limit applies"); + + let over = vec![head_chunked("POST", "/limit"), chunk([b'x'; 2048]), end()]; + assert_eq!(status(port, over).await, Some(413)); +} + +#[rkt::async_test] +async fn route_limit_overrides_only_size_declared() { + let port = launch(mounted(global(Some(300), 16.bytes()))).await; + + let within = vec![head("POST", "/limit", 100), send([b'x'; 100])]; + assert_eq!(status(port, within).await, Some(200), "declared length is within the route's limit"); + + let over = vec![head("POST", "/limit", 2048), send([b'x'; 2048])]; + assert_eq!(status(port, over).await, Some(413)); +} + +#[rkt::async_test] +async fn route_limit_leaves_global_progress_policy() { + let port = launch(mounted(global(Some(300), 16.bytes()))).await; + let stalled = vec![head("POST", "/limit", 10), send("hello"), wait(1000), send("world")]; + assert_eq!(status(port, stalled).await, Some(408), "the global deadline still applies"); +} + +// The stricter-of rule, through the local client. Each guard has a named limit +// (`bytes`, `form`, `file`, `json`); the route's `body_limit` is the other. + +#[derive(FromForm)] +struct Input { #[allow(dead_code)] value: String } + + +#[post("/hard/bytes", data = "<_d>", body_limit = "32B")] +fn hard_bytes(_d: Vec) -> &'static str { "ok" } + +#[post("/hard/form", data = "<_d>", body_limit = "32B")] +fn hard_form(_d: Form) -> &'static str { "ok" } + +#[post("/hard/file", data = "<_d>", body_limit = "32B")] +fn hard_file(_d: TempFile<'_>) -> &'static str { "ok" } + +#[post("/loose/bytes", data = "<_d>", body_limit = "1KiB")] +fn loose_bytes(_d: Vec) -> &'static str { "ok" } + +#[post("/loose/form", data = "<_d>", body_limit = "1KiB")] +fn loose_form(_d: Form) -> &'static str { "ok" } + +#[post("/loose/file", data = "<_d>", body_limit = "1KiB")] +fn loose_file(_d: TempFile<'_>) -> &'static str { "ok" } + +#[cfg(feature = "json")] +#[post("/hard/json", data = "<_d>", body_limit = "32B")] +fn hard_json(_d: rkt::serde::json::Json) -> &'static str { "ok" } + +#[cfg(feature = "json")] +#[post("/loose/json", data = "<_d>", body_limit = "1KiB")] +fn loose_json(_d: rkt::serde::json::Json) -> &'static str { "ok" } + +fn guard_client(config: Config) -> LocalClient { + let rocket = rkt::custom(config).mount("/", routes![ + hard_bytes, hard_form, hard_file, loose_bytes, loose_form, loose_file, + ]); + + #[cfg(feature = "json")] + let rocket = rocket.mount("/", routes![hard_json, loose_json]); + + LocalClient::debug(rocket).unwrap() +} + +/// A body of at least `len` bytes that every guard below accepts as input. +fn body_for(guard: &str, len: usize) -> (ContentType, String) { + match guard { + "form" => (ContentType::Form, format!("value={}", "a".repeat(len))), + "json" => (ContentType::JSON, format!("\"{}\"", "a".repeat(len))), + _ => (ContentType::Binary, "a".repeat(len)), + } +} + +fn guards() -> Vec<&'static str> { + let mut guards = vec!["bytes", "form", "file"]; + if cfg!(feature = "json") { + guards.push("json"); + } + + guards +} + +#[test] +fn named_limit_below_hard_limit_wins() { + // Every named limit is 16 bytes; the route allows 1KiB; the body is 100. + let limits = rkt::data::Limits::default() + .limit("bytes", 16.bytes()) + .limit("form", 16.bytes()) + .limit("file", 16.bytes()) + .limit("json", 16.bytes()); + + let client = guard_client(Config { limits, ..config() }); + for guard in guards() { + let (content_type, body) = body_for(guard, 100); + let response = client.post(format!("/loose/{guard}")).header(content_type).body(body).dispatch(); + // The named limit truncates the body, and strict guards answer a + // truncated body with 400 or 413 depending on the guard. + let status = response.status(); + assert!(matches!(status.code, 400 | 413), "{guard}: named limit applies, got {status}"); + } +} + +#[test] +fn hard_limit_below_named_limit_wins() { + // The default named limits are all far above 100 bytes; the route allows 32. + let client = guard_client(config()); + for guard in guards() { + let (content_type, body) = body_for(guard, 100); + let response = client.post(format!("/hard/{guard}")).header(content_type).body(body).dispatch(); + assert_eq!(response.status(), Status::PayloadTooLarge, "{guard}: route's body_limit applies"); + } +} + +#[test] +fn bodies_within_both_limits_are_accepted() { + let client = guard_client(config()); + for guard in guards() { + let (content_type, body) = body_for(guard, 10); + let response = client.post(format!("/hard/{guard}")).header(content_type).body(body).dispatch(); + assert_eq!(response.status(), Status::Ok, "{guard}"); + } +} diff --git a/core/lib/tests/body-policy-support/mod.rs b/core/lib/tests/body-policy-support/mod.rs new file mode 100644 index 000000000..7ba7e4065 --- /dev/null +++ b/core/lib/tests/body-policy-support/mod.rs @@ -0,0 +1,229 @@ +// Shared by the `body-policy-*` tests, which need to control exactly when the +// bytes of a request body arrive. The local client cannot: it hands the server +// a body it already holds. These helpers launch a real server on a loopback +// port and speak raw HTTP/1 to it, so a test can stall, trickle, or abandon a +// body at will. +// +// Time is real, not paused: with real sockets a paused clock would advance +// whenever the runtime went idle waiting on the network. Tests therefore use +// short but generously separated durations (a 300ms limit against an 800ms +// stall) so they stay quick without depending on scheduling. + +#![allow(dead_code)] + +use std::collections::HashMap; +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use rkt::config::{Config, ConfigDuration}; +use rkt::fairing::AdHoc; +use rkt::futures::channel::oneshot; +use rkt::listener::tcp::TcpListener; +use rkt::{Build, Rocket}; +use rkt::tokio::io::{AsyncReadExt, AsyncWriteExt}; +use rkt::tokio::net::TcpStream; +use rkt::tokio::time::{sleep, timeout}; + +/// How long a client waits for a response before concluding there is none. +pub const RESPONSE_WAIT: Duration = Duration::from_secs(4); + +pub fn ms(ms: u64) -> Duration { + Duration::from_millis(ms) +} + +pub fn duration(ms: u64) -> Option { + Some(ConfigDuration(Duration::from_millis(ms))) +} + +/// The configuration to start from. +pub fn config() -> Config { + Config { log_level: None, ..Config::debug_default() } +} + +/// Launches `rocket` on a free loopback port and returns the port. +pub async fn launch(rocket: Rocket) -> u16 { + let (tx, rx) = oneshot::channel(); + let rocket = rocket.attach(AdHoc::on_liftoff("Send Port", move |rocket| { + Box::pin(async move { + let tcp = rocket.endpoints().find_map(|v| v.tcp()); + tx.send(tcp.unwrap().port()).expect("send okay"); + }) + })); + + let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, 0)); + rkt::tokio::spawn(rocket.try_launch_on(TcpListener::bind(addr))); + rx.await.expect("server launched") +} + +/// What a client does while sending a request. +#[derive(Debug, Clone)] +pub enum Step { + Send(Vec), + Wait(Duration), + /// Closes the connection, as a client that disconnects mid-request. + Close, +} + +pub fn send(bytes: impl AsRef<[u8]>) -> Step { + Step::Send(bytes.as_ref().to_vec()) +} + +pub fn close() -> Step { + Step::Close +} + +pub fn wait(ms: u64) -> Step { + Step::Wait(Duration::from_millis(ms)) +} + +/// A chunk of a `Transfer-Encoding: chunked` body. +pub fn chunk(bytes: impl AsRef<[u8]>) -> Step { + let bytes = bytes.as_ref(); + let mut chunk = format!("{:x}\r\n", bytes.len()).into_bytes(); + chunk.extend_from_slice(bytes); + chunk.extend_from_slice(b"\r\n"); + Step::Send(chunk) +} + +/// The final chunk of a chunked body. +pub fn end() -> Step { + send("0\r\n\r\n") +} + +/// A request head declaring a body of `length` bytes. +pub fn head(method: &str, path: &str, length: usize) -> Step { + head_with(method, path, &format!("Content-Length: {length}")) +} + +/// A request head declaring a chunked body. +pub fn head_chunked(method: &str, path: &str) -> Step { + head_with(method, path, "Transfer-Encoding: chunked") +} + +/// A request head with `headers`, a block of `Name: value` lines. +pub fn head_with(method: &str, path: &str, headers: &str) -> Step { + send(format!("{method} {path} HTTP/1.1\r\nHost: localhost\r\n{headers}\r\n\r\n")) +} + +#[derive(Debug)] +pub struct Response { + pub status: u16, + pub headers: HashMap, + pub body: String, +} + +impl Response { + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(&name.to_ascii_lowercase()).map(|v| v.as_str()) + } +} + +/// An HTTP/1 client connection. +pub struct Client { + read: rkt::tokio::net::tcp::OwnedReadHalf, + write: Option, + buffer: Vec, + /// Set when the request is `HEAD`, whose response carries no body. + head: bool, +} + +impl Client { + pub async fn connect(port: u16) -> Client { + let stream = TcpStream::connect((Ipv4Addr::LOCALHOST, port)).await.expect("connect"); + let (read, write) = stream.into_split(); + Client { read, write: Some(write), buffer: vec![], head: false } + } + + /// Plays `steps` in the background, so the response can be read while the + /// client is still sending, as it can in life. Write errors, such as the + /// server closing the connection after a failure, end the script quietly. + pub fn play(&mut self, steps: Vec) -> rkt::tokio::task::JoinHandle<()> { + self.head = matches!(steps.first(), Some(Step::Send(b)) if b.starts_with(b"HEAD ")); + let mut write = self.write.take().expect("connection can still write"); + rkt::tokio::spawn(async move { + for step in steps { + match step { + Step::Wait(duration) => sleep(duration).await, + Step::Close => return, + Step::Send(bytes) => if write.write_all(&bytes).await.is_err() { + return; + } + } + } + + // Keep the connection open: closing it would look like a client + // that gave up, which is not what the script describes. + sleep(Duration::from_secs(30)).await; + }) + } + + /// Reads one response, or `None` if the connection closes or `RESPONSE_WAIT` + /// passes before a complete one arrives. + pub async fn response(&mut self) -> Option { + timeout(RESPONSE_WAIT, self.read_response()).await.ok().flatten() + } + + async fn fill(&mut self) -> bool { + let mut chunk = [0u8; 4096]; + match self.read.read(&mut chunk).await { + Ok(0) | Err(_) => false, + Ok(n) => { + self.buffer.extend_from_slice(&chunk[..n]); + true + } + } + } + + async fn read_response(&mut self) -> Option { + let split = loop { + if let Some(i) = self.buffer.windows(4).position(|w| w == b"\r\n\r\n") { + break i; + } + + if !self.fill().await { + return None; + } + }; + + let head = String::from_utf8_lossy(&self.buffer[..split]).into_owned(); + self.buffer.drain(..split + 4); + + let mut lines = head.lines(); + let status = lines.next()?.split_whitespace().nth(1)?.parse().ok()?; + let headers: HashMap = lines + .filter_map(|line| line.split_once(':')) + .map(|(k, v)| (k.trim().to_ascii_lowercase(), v.trim().to_string())) + .collect(); + + // Responses in these tests are sized or empty. + let length = headers.get("content-length").and_then(|v| v.parse().ok()).unwrap_or(0); + let length = if self.head { 0 } else { length }; + while self.buffer.len() < length { + if !self.fill().await { + return None; + } + } + + let body = String::from_utf8_lossy(&self.buffer[..length]).into_owned(); + self.buffer.drain(..length); + Some(Response { status, headers, body }) + } + + /// Whether the server has closed its end of the connection, within `wait`. + /// Bytes still in flight, such as an unread response, are discarded. + pub async fn closed_within(&mut self, wait: Duration) -> bool { + timeout(wait, async { while self.fill().await {} }).await.is_ok() + } +} + +/// Plays `steps` against a fresh connection and returns the first response. +pub async fn request(port: u16, steps: Vec) -> Option { + let mut client = Client::connect(port).await; + let _script = client.play(steps); + client.response().await +} + +/// The status of `request(port, steps)`. +pub async fn status(port: u16, steps: Vec) -> Option { + request(port, steps).await.map(|r| r.status) +}