guard_core_engine/request_limits.rs
1//! The request size and content-type gate: `max_request_size` and
2//! `allowed_content_types`.
3//!
4//! This is the Rust family's port of the reference engine's
5//! `request_size_content` check
6//! (`guard_core/core/checks/implementations/request_size_content.py`). One
7//! call, [`decide`], carries a request's size and content-type header values
8//! through the reference's two sub-checks in its order:
9//!
10//! ```text
11//! no max_request_size: skip the size check
12//! no content-length header: pass the size check
13//! content-length <= max_request_size: pass
14//! content-length > max_request_size: 413 "Request too large"
15//! no allowed_content_types: skip the type check
16//! media type (before ";") in the allowed list: pass
17//! anything else (a missing header included): 415 "Unsupported content type"
18//! ```
19//!
20//! Both limits are route-scoped in the reference (`RouteConfig.max_request_size`,
21//! `RouteConfig.allowed_content_types`); there is no global knob, so the
22//! config value is `Option`-shaped on both fields and the all-`None` default
23//! is inert: [`decide`] passes everything.
24//!
25//! ## Details that mirror the reference exactly
26//!
27//! - The size comparison is `int(content_length) <= max_request_size`: a
28//! request at exactly the limit passes, one byte over is blocked, and a
29//! missing or empty `content-length` header passes (`if not content_length`
30//! in the reference). A content-length that is not an integer raises
31//! `ValueError` in the reference, which the pipeline answers with the
32//! fail-secure `500 "Security check failed"`; [`decide`] mirrors that with
33//! an [`Err`] return and a negative content-length passes (`-1 <= limit`),
34//! like Python's signed `int()`.
35//! - The media type is `content_type.split(";")[0]` with no trim: the exact
36//! prefix before the first `;` is compared case-sensitively against the
37//! configured list, so `application/json; charset=utf-8` matches
38//! `application/json` and `application/json ; charset=utf-8` does not
39//! (trailing space), exactly as in Python. A missing `content-type`
40//! header compares as the empty string and is blocked by any non-empty
41//! allowed list.
42//! - The size check runs first and wins: a request over the size limit with
43//! a disallowed content type is answered `413`, never `415`.
44//!
45//! # Example
46//!
47//! ```
48//! use guard_core_engine::request_limits::{decide, ContentLimits};
49//!
50//! let limits = ContentLimits {
51//! max_request_size: Some(1024),
52//! allowed_content_types: Some(vec!["application/json".to_owned()]),
53//! };
54//!
55//! // At the limit passes, one byte over blocks with the 413 shape.
56//! assert!(decide(&limits, Some("1024"), Some("application/json"))
57//! .unwrap()
58//! .is_none());
59//! let block = decide(&limits, Some("1025"), Some("application/json"))
60//! .unwrap()
61//! .expect("over the limit");
62//! assert_eq!(block.status, 413);
63//! assert_eq!(block.body, "Request too large");
64//!
65//! // The media type is the prefix before ";", matched exactly.
66//! assert!(decide(&limits, Some("10"), Some("application/json; charset=utf-8"))
67//! .unwrap()
68//! .is_none());
69//! let block = decide(&limits, Some("10"), Some("text/plain"))
70//! .unwrap()
71//! .expect("disallowed type");
72//! assert_eq!(block.status, 415);
73//! assert_eq!(block.body, "Unsupported content type");
74//!
75//! // The default is inert.
76//! assert!(decide(&ContentLimits::default(), Some("999999999"), Some("text/plain"))
77//! .unwrap()
78//! .is_none());
79//! ```
80
81use std::fmt;
82
83/// The route-level content limits (the reference `RouteConfig.max_request_size`
84/// and `RouteConfig.allowed_content_types`).
85///
86/// The default is both fields unset: the gate is inert and every request
87/// passes, the reference's no-decorator shape (`applies_to` registers the
88/// check only when a route carries either limit).
89#[derive(Debug, Clone, Default, PartialEq, Eq)]
90pub struct ContentLimits {
91 /// `max_request_size`: the largest accepted `content-length` in bytes.
92 /// `None` (or the reference's `0`-falsy shape) skips the size check.
93 pub max_request_size: Option<u64>,
94 /// `allowed_content_types`: the exact media types accepted. `None` skips
95 /// the type check; an empty list behaves like `None` (the reference only
96 /// registers the check for a truthy list).
97 pub allowed_content_types: Option<Vec<String>>,
98}
99
100impl ContentLimits {
101 /// Whether the gate has anything to enforce (the reference
102 /// `applies_to` predicate: `max_request_size is not None or
103 /// bool(allowed_content_types)`).
104 #[must_use]
105 pub fn has_rules(&self) -> bool {
106 self.max_request_size.is_some()
107 || self
108 .allowed_content_types
109 .as_ref()
110 .is_some_and(|types| !types.is_empty())
111 }
112}
113
114/// Which sub-check blocked the request (the reference's
115/// `violation_type` event fields, carried for observability).
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum ContentViolation {
118 /// The `content-length` crossed `max_request_size`
119 /// (`violation_type="max_request_size"`).
120 MaxRequestSize,
121 /// The media type was not in `allowed_content_types`
122 /// (`violation_type="content_type"`).
123 ContentType,
124}
125
126/// The block answer of a failed size or type check: the status and default
127/// message body the reference's `create_error_response` answers with, plus
128/// the `log_activity` reason string.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct ContentBlock {
131 /// Which sub-check blocked.
132 pub violation: ContentViolation,
133 /// `413` for the size block, `415` for the type block.
134 pub status: u16,
135 /// The reference default message body.
136 pub body: &'static str,
137 /// The reference `log_activity` reason (`"Request size {n} exceeds
138 /// limit: {max}"` / `"Invalid content type: {type}"`).
139 pub reason: String,
140}
141
142/// A `content-length` header value that is not an integer: the error
143/// [`decide`] fails with.
144///
145/// This mirrors the reference's `int(content_length)` `ValueError`, which
146/// the pipeline answers with the fail-secure `500 "Security check failed"`.
147#[derive(Debug, Clone, PartialEq, Eq)]
148pub struct ContentLengthError {
149 /// The rejected header value.
150 pub value: String,
151}
152
153impl fmt::Display for ContentLengthError {
154 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
155 write!(
156 f,
157 "invalid content_length value '{}': expected an integer",
158 self.value
159 )
160 }
161}
162
163impl std::error::Error for ContentLengthError {}
164
165/// The media type of a `content-type` header value: the exact prefix before
166/// the first `;`, with no trim (the reference `split(";")[0]`). A missing
167/// header reads as the empty string.
168#[must_use]
169pub fn media_type(content_type: &str) -> &str {
170 match content_type.split_once(';') {
171 Some((prefix, _)) => prefix,
172 None => content_type,
173 }
174}
175
176/// Carry one request's size and content-type header values through the
177/// reference's two sub-checks.
178///
179/// `content_length` is the raw `content-length` header value (`None` when
180/// the header is absent) and `content_type` the raw `content-type` header
181/// value (`None` when absent, compared as the empty string). `Ok(None)` means
182/// the request passes; `Ok(Some(block))` is the reference's block answer; the
183/// [`Err`] shape mirrors the reference's non-integer `content-length`
184/// exception (answer the fail-secure `500 "Security check failed"`).
185pub fn decide(
186 limits: &ContentLimits,
187 content_length: Option<&str>,
188 content_type: Option<&str>,
189) -> Result<Option<ContentBlock>, ContentLengthError> {
190 if let Some(response) = size_block(limits, content_length)? {
191 return Ok(Some(response));
192 }
193 Ok(content_type_block(limits, content_type))
194}
195
196/// The size sub-check: `None` passes, `Err` mirrors the reference's
197/// `int(content_length)` exception.
198fn size_block(
199 limits: &ContentLimits,
200 content_length: Option<&str>,
201) -> Result<Option<ContentBlock>, ContentLengthError> {
202 let Some(max) = limits.max_request_size else {
203 return Ok(None);
204 };
205 let Some(raw) = content_length.filter(|raw| !raw.is_empty()) else {
206 // `if not content_length` in the reference: an absent or empty
207 // header passes the size check.
208 return Ok(None);
209 };
210 // Python's int() strips surrounding whitespace and accepts a leading
211 // sign; a negative value is `<= limit` and passes, like Python.
212 let trimmed = raw.trim();
213 let parsed: i64 = trimmed.parse().map_err(|_| ContentLengthError {
214 value: raw.to_owned(),
215 })?;
216 if parsed <= 0 || u64::try_from(parsed).is_ok_and(|size| size <= max) {
217 return Ok(None);
218 }
219 Ok(Some(ContentBlock {
220 violation: ContentViolation::MaxRequestSize,
221 status: 413,
222 body: "Request too large",
223 reason: format!("Request size {raw} exceeds limit: {max}"),
224 }))
225}
226
227/// The content-type sub-check: `None` passes.
228fn content_type_block(limits: &ContentLimits, content_type: Option<&str>) -> Option<ContentBlock> {
229 let allowed = limits
230 .allowed_content_types
231 .as_ref()
232 .filter(|types| !types.is_empty())?;
233 let value = content_type.unwrap_or("");
234 let media = media_type(value);
235 if allowed.iter().any(|entry| entry == media) {
236 return None;
237 }
238 Some(ContentBlock {
239 violation: ContentViolation::ContentType,
240 status: 415,
241 body: "Unsupported content type",
242 reason: format!("Invalid content type: {media}"),
243 })
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 fn json_only(max: u64) -> ContentLimits {
251 ContentLimits {
252 max_request_size: Some(max),
253 allowed_content_types: Some(vec!["application/json".to_owned()]),
254 }
255 }
256
257 #[test]
258 fn default_is_inert_and_has_no_rules() {
259 let limits = ContentLimits::default();
260 assert!(!limits.has_rules());
261 assert!(
262 decide(&limits, Some("999999999999"), Some("text/plain"))
263 .unwrap()
264 .is_none()
265 );
266 assert!(decide(&limits, None, None).unwrap().is_none());
267 }
268
269 #[test]
270 fn has_rules_tracks_each_field() {
271 assert!(!ContentLimits::default().has_rules());
272 assert!(
273 ContentLimits {
274 max_request_size: Some(1),
275 ..ContentLimits::default()
276 }
277 .has_rules()
278 );
279 assert!(
280 ContentLimits {
281 allowed_content_types: Some(vec!["application/json".to_owned()]),
282 ..ContentLimits::default()
283 }
284 .has_rules()
285 );
286 // An empty allowed list is the reference's falsy list: no rules.
287 assert!(
288 !ContentLimits {
289 max_request_size: None,
290 allowed_content_types: Some(Vec::new()),
291 }
292 .has_rules()
293 );
294 assert!(
295 decide(
296 &ContentLimits {
297 max_request_size: None,
298 allowed_content_types: Some(Vec::new()),
299 },
300 Some("999999999"),
301 Some("text/plain")
302 )
303 .unwrap()
304 .is_none(),
305 "an empty allowed list never blocks"
306 );
307 }
308
309 #[test]
310 fn size_boundary_is_at_the_limit() {
311 let limits = ContentLimits {
312 max_request_size: Some(1024),
313 ..ContentLimits::default()
314 };
315 assert!(decide(&limits, Some("1024"), None).unwrap().is_none());
316 let block = decide(&limits, Some("1025"), None).unwrap().expect("over");
317 assert_eq!(block.violation, ContentViolation::MaxRequestSize);
318 assert_eq!(block.status, 413);
319 assert_eq!(block.body, "Request too large");
320 assert_eq!(block.reason, "Request size 1025 exceeds limit: 1024");
321 }
322
323 #[test]
324 fn missing_or_empty_content_length_passes_the_size_check() {
325 let limits = ContentLimits {
326 max_request_size: Some(10),
327 ..ContentLimits::default()
328 };
329 assert!(decide(&limits, None, None).unwrap().is_none());
330 assert!(decide(&limits, Some(""), None).unwrap().is_none());
331 }
332
333 #[test]
334 fn signed_and_padded_content_lengths_mirror_python_int() {
335 let limits = ContentLimits {
336 max_request_size: Some(10),
337 ..ContentLimits::default()
338 };
339 // Surrounding whitespace is stripped before the parse.
340 assert!(decide(&limits, Some(" 10 "), None).unwrap().is_none());
341 // A leading sign parses; negative values pass like Python's `-1 <= 10`.
342 assert!(decide(&limits, Some("-1"), None).unwrap().is_none());
343 assert!(decide(&limits, Some("+5"), None).unwrap().is_none());
344 let block = decide(&limits, Some(" 11 "), None).unwrap().expect("over");
345 assert_eq!(block.reason, "Request size 11 exceeds limit: 10");
346 }
347
348 #[test]
349 fn junk_content_length_is_an_error_not_a_block() {
350 let limits = ContentLimits {
351 max_request_size: Some(10),
352 ..ContentLimits::default()
353 };
354 let error = decide(&limits, Some("about-twenty"), None).unwrap_err();
355 assert_eq!(error.value, "about-twenty");
356 assert_eq!(
357 error.to_string(),
358 "invalid content_length value 'about-twenty': expected an integer"
359 );
360 // Without a size limit the junk header is never read.
361 assert!(
362 decide(&ContentLimits::default(), Some("junk"), None)
363 .unwrap()
364 .is_none()
365 );
366 }
367
368 #[test]
369 fn content_type_matches_the_exact_media_type_prefix() {
370 let limits = ContentLimits {
371 max_request_size: None,
372 allowed_content_types: Some(vec![
373 "application/json".to_owned(),
374 "application/merge-patch+json".to_owned(),
375 ]),
376 };
377 // Parameters are cut away, not stripped.
378 assert!(
379 decide(&limits, None, Some("application/json; charset=utf-8"))
380 .unwrap()
381 .is_none()
382 );
383 assert!(
384 decide(&limits, None, Some("application/merge-patch+json"))
385 .unwrap()
386 .is_none(),
387 "a type that merely contains an allowed entry as a substring must not pass"
388 );
389 // No trim: a space before the parameters stays part of the media type.
390 let block = decide(&limits, None, Some("application/json ; charset=utf-8"))
391 .unwrap()
392 .expect("padded separator");
393 assert_eq!(block.reason, "Invalid content type: application/json ");
394 assert_eq!(block.status, 415);
395 assert_eq!(block.body, "Unsupported content type");
396 assert_eq!(block.violation, ContentViolation::ContentType);
397 // Comparison is case-sensitive, like the reference `in` check.
398 assert!(
399 decide(&limits, None, Some("Application/JSON"))
400 .unwrap()
401 .is_some()
402 );
403 }
404
405 #[test]
406 fn missing_content_type_is_blocked_by_an_allowed_list() {
407 let limits = ContentLimits {
408 max_request_size: None,
409 allowed_content_types: Some(vec!["application/json".to_owned()]),
410 };
411 let block = decide(&limits, None, None).unwrap().expect("no header");
412 assert_eq!(block.status, 415);
413 assert_eq!(block.reason, "Invalid content type: ");
414 // The empty-string media type only passes if a route allows it.
415 let limits = ContentLimits {
416 max_request_size: None,
417 allowed_content_types: Some(vec![String::new()]),
418 };
419 assert!(decide(&limits, None, None).unwrap().is_none());
420 }
421
422 #[test]
423 fn size_block_wins_over_the_type_block() {
424 let limits = json_only(10);
425 let block = decide(&limits, Some("11"), Some("text/plain"))
426 .unwrap()
427 .expect("blocked");
428 assert_eq!(block.violation, ContentViolation::MaxRequestSize);
429 assert_eq!(block.status, 413);
430 // Under the size limit the type check answers.
431 let block = decide(&limits, Some("10"), Some("text/plain"))
432 .unwrap()
433 .expect("blocked");
434 assert_eq!(block.violation, ContentViolation::ContentType);
435 assert_eq!(block.status, 415);
436 }
437
438 #[test]
439 fn media_type_cuts_at_the_first_separator() {
440 assert_eq!(
441 media_type("application/json; charset=utf-8"),
442 "application/json"
443 );
444 assert_eq!(media_type("application/json"), "application/json");
445 assert_eq!(media_type(""), "");
446 assert_eq!(media_type(";a"), "");
447 assert_eq!(media_type("text/plain;;a=b"), "text/plain");
448 }
449}