1use actus_controller::{Params, Verb};
6use actus_reply::WebError;
7use bytes::Bytes;
8use http::HeaderMap;
9use http_body_util::{BodyExt, LengthLimitError, Limited};
10use std::collections::HashMap;
11use std::sync::Arc;
12use std::time::Duration;
13use tokio::sync::{OwnedSemaphorePermit, Semaphore};
14
15#[derive(Clone, Debug)]
18pub struct Request {
19 pub method: http::Method,
21 pub path_parts: Vec<String>,
32 pub query_params: HashMap<String, Vec<String>>,
36 pub body: Bytes,
41 pub headers: HeaderMap,
43 pub rate_limit_class: Option<&'static str>,
56}
57
58fn collect_pairs(pairs: Vec<(String, String)>) -> HashMap<String, Vec<String>> {
61 let mut map: HashMap<String, Vec<String>> = HashMap::new();
62 for (name, value) in pairs {
63 map.entry(name).or_default().push(value);
64 }
65 map
66}
67
68async fn collect_body_capped<B>(
76 body: B,
77 max_bytes: usize,
78 budget: Option<&Arc<Semaphore>>,
79) -> Result<Bytes, WebError>
80where
81 B: hyper::body::Body<Data = Bytes>,
82 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
83{
84 let _permit = reserve(budget, max_bytes)?;
87 match Limited::new(body, max_bytes).collect().await {
88 Ok(collected) => Ok(collected.to_bytes()),
89 Err(e) => Err(read_error(e)),
90 }
91}
92
93fn reserve(
100 budget: Option<&Arc<Semaphore>>,
101 max_bytes: usize,
102) -> Result<Option<OwnedSemaphorePermit>, WebError> {
103 let Some(s) = budget else {
104 return Ok(None);
105 };
106 let n = u32::try_from(max_bytes).unwrap_or(u32::MAX);
108 match s.clone().try_acquire_many_owned(n) {
109 Ok(p) => Ok(Some(p)),
110 Err(_) => Err(WebError::Busy(Some(Duration::from_secs(1)))),
111 }
112}
113
114fn read_error(e: Box<dyn std::error::Error + Send + Sync>) -> WebError {
117 if e.downcast_ref::<LengthLimitError>().is_some() {
118 WebError::PayloadTooLarge
119 } else {
120 WebError::BadRequest(format!("could not read request body: {e}"))
121 }
122}
123
124#[cfg(feature = "compression")]
126mod gzip {
127 use super::*;
128 use std::io::{self, Write};
129
130 pub(super) async fn collect_capped<B>(
138 body: B,
139 max_bytes: usize,
140 budget: Option<&Arc<Semaphore>>,
141 ) -> Result<Bytes, WebError>
142 where
143 B: hyper::body::Body<Data = Bytes>,
144 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
145 {
146 let _permit = reserve(budget, max_bytes)?;
147 let mut body = std::pin::pin!(Limited::new(body, max_bytes));
148 let mut decoder = flate2::write::MultiGzDecoder::new(CappedBuf {
150 bytes: Vec::new(),
151 cap: max_bytes,
152 });
153 while let Some(frame) = body.frame().await {
154 if let Some(data) = frame.map_err(read_error)?.data_ref() {
155 decoder.write_all(data).map_err(error)?;
156 }
157 }
158 let decoded = decoder.finish().map_err(error)?;
161 Ok(Bytes::from(decoded.bytes))
162 }
163
164 struct CappedBuf {
167 bytes: Vec<u8>,
168 cap: usize,
169 }
170
171 impl Write for CappedBuf {
172 fn write(&mut self, data: &[u8]) -> io::Result<usize> {
173 if self.bytes.len() + data.len() > self.cap {
174 return Err(io::Error::other(DecodedTooLarge));
175 }
176 self.bytes.extend_from_slice(data);
177 Ok(data.len())
178 }
179
180 fn flush(&mut self) -> io::Result<()> {
181 Ok(())
182 }
183 }
184
185 #[derive(Debug)]
187 struct DecodedTooLarge;
188
189 impl std::fmt::Display for DecodedTooLarge {
190 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
191 f.write_str("the decoded request body exceeds the body cap")
192 }
193 }
194
195 impl std::error::Error for DecodedTooLarge {}
196
197 fn error(e: io::Error) -> WebError {
200 if e.get_ref()
201 .is_some_and(|inner| inner.is::<DecodedTooLarge>())
202 {
203 WebError::PayloadTooLarge
204 } else {
205 WebError::BadRequest(format!("request body is not valid gzip: {e}"))
206 }
207 }
208
209 #[cfg(test)]
210 mod tests {
211 use super::*;
212 use flate2::Compression;
213 use flate2::write::GzEncoder;
214
215 fn gzip(data: &[u8]) -> Vec<u8> {
216 let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
217 encoder.write_all(data).unwrap();
218 encoder.finish().unwrap()
219 }
220
221 async fn decode(coded: Vec<u8>, max_bytes: usize) -> Result<Bytes, WebError> {
222 collect_capped(
223 http_body_util::Full::new(Bytes::from(coded)),
224 max_bytes,
225 None,
226 )
227 .await
228 }
229
230 #[tokio::test]
231 async fn a_gzip_body_decodes_to_its_content() {
232 let decoded = decode(gzip(b"hello, world"), 1024).await.unwrap();
233 assert_eq!(decoded, Bytes::from_static(b"hello, world"));
234 }
235
236 #[tokio::test]
237 async fn members_back_to_back_decode_in_order() {
238 let mut coded = gzip(b"hello, ");
239 coded.extend(gzip(b"world"));
240 let decoded = decode(coded, 1024).await.unwrap();
241 assert_eq!(decoded, Bytes::from_static(b"hello, world"));
242 }
243
244 #[tokio::test]
245 async fn decoding_past_the_cap_is_413() {
246 let coded = gzip(&vec![0u8; 1 << 20]);
249 assert!(coded.len() < 2048, "coded to {} bytes", coded.len());
250 match decode(coded, 2048).await {
251 Err(WebError::PayloadTooLarge) => {}
252 other => panic!("expected PayloadTooLarge, got {other:?}"),
253 }
254 }
255
256 #[tokio::test]
257 async fn coded_bytes_past_the_cap_are_413() {
258 let mut x: u32 = 0x9e37_79b9;
261 let noise: Vec<u8> = (0..1000)
262 .map(|_| {
263 x ^= x << 13;
264 x ^= x >> 17;
265 x ^= x << 5;
266 (x >> 24) as u8
267 })
268 .collect();
269 let coded = gzip(&noise);
270 let cap = 1010;
271 assert!(
272 noise.len() <= cap && coded.len() > cap,
273 "coded to {}",
274 coded.len()
275 );
276 match decode(coded, cap).await {
277 Err(WebError::PayloadTooLarge) => {}
278 other => panic!("expected PayloadTooLarge, got {other:?}"),
279 }
280 }
281
282 #[tokio::test]
283 async fn not_gzip_corrupt_or_cut_short_is_400() {
284 let coded = gzip(b"hello, world");
285 let mut corrupt = coded.clone();
286 let middle = corrupt.len() / 2;
287 corrupt[middle] ^= 0xff;
288 let cut_short = coded[..coded.len() - 4].to_vec();
289 for (what, body) in [
290 ("not gzip", b"hello, world".to_vec()),
291 ("corrupt", corrupt),
292 ("cut short", cut_short),
293 ] {
294 match decode(body, 1024).await {
295 Err(WebError::BadRequest(msg)) => {
296 assert!(msg.contains("gzip"), "{what}: {msg}")
297 }
298 other => panic!("{what}: expected BadRequest, got {other:?}"),
299 }
300 }
301 }
302 }
303}
304
305impl Request {
306 pub fn from_hyper_parts(
317 req: hyper::Request<hyper::body::Incoming>,
318 ) -> (Self, hyper::body::Incoming) {
319 let (parts, body) = req.into_parts();
320
321 let path_parts: Vec<String> = parts
322 .uri
323 .path()
324 .trim_matches('/')
325 .split('/')
326 .map(String::from)
327 .filter(|s| !s.is_empty())
328 .collect();
329
330 let query_params = parts
331 .uri
332 .query()
333 .map(|q| {
334 collect_pairs(
335 serde_urlencoded::from_str::<Vec<(String, String)>>(q).unwrap_or_default(),
336 )
337 })
338 .unwrap_or_default();
339
340 let skeleton = Self {
341 method: parts.method,
342 path_parts,
343 query_params,
344 body: Bytes::new(),
347 headers: parts.headers,
348 rate_limit_class: None,
351 };
352 (skeleton, body)
353 }
354
355 #[allow(clippy::result_large_err)]
371 pub async fn collect_body(
372 mut self,
373 body: hyper::body::Incoming,
374 max_body_bytes: usize,
375 inflight_budget: Option<&Arc<Semaphore>>,
376 ) -> Result<Self, (Self, WebError)> {
377 match collect_body_capped(body, max_body_bytes, inflight_budget).await {
378 Ok(body_bytes) => {
379 self.body = body_bytes;
380 Ok(self)
381 }
382 Err(e) => Err((self, e)),
383 }
384 }
385
386 #[cfg(feature = "compression")]
394 #[allow(clippy::result_large_err)]
395 pub(crate) async fn collect_gzip_body(
396 mut self,
397 body: hyper::body::Incoming,
398 max_body_bytes: usize,
399 inflight_budget: Option<&Arc<Semaphore>>,
400 ) -> Result<Self, (Self, WebError)> {
401 match gzip::collect_capped(body, max_body_bytes, inflight_budget).await {
402 Ok(decoded) => {
403 self.body = decoded;
404 self.headers.remove(http::header::CONTENT_ENCODING);
405 self.headers.remove(http::header::CONTENT_LENGTH);
406 Ok(self)
407 }
408 Err(e) => Err((self, e)),
409 }
410 }
411
412 #[allow(clippy::result_large_err)]
431 pub async fn from_hyper(
432 req: hyper::Request<hyper::body::Incoming>,
433 max_body_bytes: usize,
434 inflight_budget: Option<&Arc<Semaphore>>,
435 ) -> Result<Self, (Self, WebError)> {
436 let (skeleton, body) = Self::from_hyper_parts(req);
437 skeleton
438 .collect_body(body, max_body_bytes, inflight_budget)
439 .await
440 }
441
442 pub fn to_params(&self) -> Result<Params, WebError> {
468 let mut all_params = self.query_params.clone();
469
470 let content_type = self
471 .headers
472 .get(http::header::CONTENT_TYPE)
473 .and_then(|v| v.to_str().ok())
474 .map(|s| {
475 s.split(';')
476 .next()
477 .unwrap_or("")
478 .trim()
479 .to_ascii_lowercase()
480 });
481
482 let json_body = if self.body.is_empty() {
483 None
484 } else {
485 match content_type.as_deref() {
486 Some(ct) if is_json_content_type(ct) => {
487 Some(serde_json::from_slice(&self.body).map_err(|e| {
488 WebError::BadRequest(format!("body is not valid JSON: {e}"))
489 })?)
490 }
491 Some("application/x-www-form-urlencoded") => {
492 let form_pairs: Vec<(String, String)> =
493 serde_urlencoded::from_bytes(&self.body).map_err(|e| {
494 WebError::BadRequest(format!("body is not valid form-urlencoded: {e}"))
495 })?;
496 for (name, value) in form_pairs {
497 all_params.entry(name).or_default().push(value);
498 }
499 None
500 }
501 Some(_) => None,
502 None => {
503 return Err(WebError::BadRequest(
504 "non-empty request body requires a Content-Type header".into(),
505 ));
506 }
507 }
508 };
509
510 let mut headers: HashMap<String, Vec<String>> = HashMap::new();
516 for (name, value) in self.headers.iter() {
517 if let Ok(v) = value.to_str() {
518 headers
519 .entry(name.as_str().to_ascii_lowercase())
520 .or_default()
521 .push(v.to_string());
522 }
523 }
524
525 Ok(Params::new(
526 method_to_verb(&self.method),
527 all_params,
528 json_body,
529 self.body.clone(),
530 headers,
531 ))
532 }
533}
534
535fn is_json_content_type(ct: &str) -> bool {
540 ct == "application/json" || ct.ends_with("+json")
541}
542
543#[derive(Clone, Copy, Debug, PartialEq, Eq)]
545pub(crate) enum ContentCoding {
546 Identity,
549 Gzip,
551 Other,
555}
556
557pub(crate) fn content_coding(headers: &HeaderMap) -> ContentCoding {
561 let mut codings = Vec::new();
562 for value in headers.get_all(http::header::CONTENT_ENCODING) {
563 let Ok(list) = value.to_str() else {
564 return ContentCoding::Other;
565 };
566 codings.extend(
567 list.split(',')
568 .map(str::trim)
569 .filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("identity")),
570 );
571 }
572 match codings.as_slice() {
573 [] => ContentCoding::Identity,
574 [one] if one.eq_ignore_ascii_case("gzip") || one.eq_ignore_ascii_case("x-gzip") => {
575 ContentCoding::Gzip
576 }
577 _ => ContentCoding::Other,
578 }
579}
580
581pub(crate) fn has_undecoded_transfer_coding(headers: &HeaderMap) -> bool {
586 let mut codings = Vec::new();
587 for value in headers.get_all(http::header::TRANSFER_ENCODING) {
588 let Ok(list) = value.to_str() else {
589 return true;
590 };
591 codings.extend(list.split(',').map(str::trim).filter(|c| !c.is_empty()));
592 }
593 match codings.as_slice() {
594 [] => false,
595 [only] => !only.eq_ignore_ascii_case("chunked"),
596 _ => true,
597 }
598}
599
600fn method_to_verb(method: &http::Method) -> Verb {
601 match method {
602 m if m == http::Method::GET => Verb::GET,
603 m if m == http::Method::POST => Verb::POST,
604 m if m == http::Method::PUT => Verb::PUT,
605 m if m == http::Method::DELETE => Verb::DELETE,
606 m if m == http::Method::PATCH => Verb::PATCH,
607 m if m == http::Method::HEAD => Verb::HEAD,
608 m if m == http::Method::OPTIONS => Verb::OPTIONS,
609 _ => Verb::GET,
610 }
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use http::HeaderValue;
617
618 fn req(content_type: Option<&str>, body: &[u8]) -> Request {
619 let mut headers = HeaderMap::new();
620 if let Some(ct) = content_type {
621 headers.insert(
622 http::header::CONTENT_TYPE,
623 HeaderValue::from_str(ct).unwrap(),
624 );
625 }
626 Request {
627 method: http::Method::POST,
628 path_parts: vec!["whatever".into()],
629 query_params: HashMap::new(),
630 body: Bytes::copy_from_slice(body),
631 headers,
632 rate_limit_class: None,
633 }
634 }
635
636 #[test]
637 fn empty_body_no_content_type_is_ok() {
638 let params = req(None, b"").to_params().expect("empty + no CT is fine");
639 assert!(params.body_bytes().is_empty());
640 }
641
642 #[test]
643 fn json_body_is_parsed() {
644 let params = req(Some("application/json"), br#"{"x":1}"#)
645 .to_params()
646 .expect("valid JSON");
647 let v = params.json_body().expect("body present");
649 assert_eq!(v["x"], 1);
650 assert_eq!(params.body_bytes().as_ref(), br#"{"x":1}"#);
652 }
653
654 #[test]
655 fn json_body_with_charset_param_is_parsed() {
656 let params = req(Some("application/json; charset=utf-8"), br#"{"x":1}"#)
660 .to_params()
661 .expect("valid JSON with charset");
662 assert_eq!(params.json_body().unwrap()["x"], 1);
663 }
664
665 #[test]
666 fn vendor_plus_json_subtype_is_parsed() {
667 let params = req(Some("application/vnd.example+json"), br#"{"x":1}"#)
671 .to_params()
672 .expect("valid +json");
673 assert_eq!(params.json_body().unwrap()["x"], 1);
674 }
675
676 #[test]
677 fn malformed_json_is_a_400() {
678 match req(Some("application/json"), b"not json").to_params() {
679 Err(WebError::BadRequest(msg)) => assert!(msg.contains("valid JSON"), "msg = {msg:?}"),
680 Err(other) => panic!("expected BadRequest, got {other:?}"),
681 Ok(_) => panic!("must reject malformed JSON"),
682 }
683 }
684
685 #[test]
686 fn form_body_merges_into_query() {
687 let params = req(
688 Some("application/x-www-form-urlencoded"),
689 b"username=alice&kind=admin",
690 )
691 .to_params()
692 .expect("valid form");
693 assert!(params.json_body().is_err());
696 assert_eq!(params.body_bytes().as_ref(), b"username=alice&kind=admin");
698 }
699
700 #[test]
701 fn binary_body_round_trips() {
702 let zip_bytes = b"PK\x03\x04\x00\x00fake-zip";
703 let params = req(Some("application/zip"), zip_bytes)
704 .to_params()
705 .expect("opaque CT is fine");
706 assert!(params.json_body().is_err());
708 assert_eq!(params.body_bytes().as_ref(), zip_bytes);
710 }
711
712 #[test]
713 fn non_empty_body_without_content_type_is_a_400() {
714 match req(None, b"some payload").to_params() {
715 Err(WebError::BadRequest(msg)) => {
716 assert!(msg.contains("Content-Type"), "msg = {msg:?}")
717 }
718 Err(other) => panic!("expected BadRequest, got {other:?}"),
719 Ok(_) => panic!("must reject non-empty body without Content-Type"),
720 }
721 }
722
723 fn lines(name: http::HeaderName, values: &[&[u8]]) -> HeaderMap {
725 let mut headers = HeaderMap::new();
726 for value in values {
727 headers.append(name.clone(), HeaderValue::from_bytes(value).unwrap());
728 }
729 headers
730 }
731
732 #[test]
733 fn identity_and_absence_are_no_content_coding() {
734 let coding =
735 |values: &[&[u8]]| content_coding(&lines(http::header::CONTENT_ENCODING, values));
736 assert_eq!(coding(&[]), ContentCoding::Identity);
737 assert_eq!(coding(&[b"identity"]), ContentCoding::Identity);
738 assert_eq!(coding(&[b"IDENTITY"]), ContentCoding::Identity);
739 assert_eq!(coding(&[b" identity , ,"]), ContentCoding::Identity);
740 assert_eq!(coding(&[b""]), ContentCoding::Identity);
741 }
742
743 #[test]
744 fn a_single_gzip_is_gzip() {
745 let coding =
746 |values: &[&[u8]]| content_coding(&lines(http::header::CONTENT_ENCODING, values));
747 assert_eq!(coding(&[b"gzip"]), ContentCoding::Gzip);
748 assert_eq!(coding(&[b"GZip"]), ContentCoding::Gzip);
749 assert_eq!(coding(&[b"x-gzip"]), ContentCoding::Gzip);
750 assert_eq!(coding(&[b"identity, gzip"]), ContentCoding::Gzip);
751 assert_eq!(coding(&[b"identity", b"gzip"]), ContentCoding::Gzip);
752 }
753
754 #[test]
755 fn any_other_content_coding_is_other() {
756 let coding =
757 |values: &[&[u8]]| content_coding(&lines(http::header::CONTENT_ENCODING, values));
758 assert_eq!(coding(&[b"br"]), ContentCoding::Other);
759 assert_eq!(coding(&[b"identity", b"zstd"]), ContentCoding::Other);
761 assert_eq!(coding(&[b"gzip, gzip"]), ContentCoding::Other);
763 assert_eq!(coding(&[b"gzip", b"br"]), ContentCoding::Other);
764 assert_eq!(coding(&[b"gz\xffip"]), ContentCoding::Other);
766 }
767
768 #[test]
769 fn a_lone_chunked_is_the_only_transfer_coding_decoded() {
770 let undecoded = |values: &[&[u8]]| {
771 has_undecoded_transfer_coding(&lines(http::header::TRANSFER_ENCODING, values))
772 };
773 assert!(!undecoded(&[]));
774 assert!(!undecoded(&[b"chunked"]));
775 assert!(!undecoded(&[b"Chunked"]));
776 assert!(undecoded(&[b"gzip, chunked"]));
777 assert!(undecoded(&[b"gzip", b"chunked"]));
779 assert!(undecoded(&[b"chunked, chunked"]));
780 }
781
782 #[test]
783 fn collect_pairs_keeps_repeated_keys_in_order() {
784 let m = collect_pairs(vec![
785 ("tag".into(), "a".into()),
786 ("page".into(), "1".into()),
787 ("tag".into(), "b".into()),
788 ]);
789 assert_eq!(m.get("tag").unwrap(), &["a".to_string(), "b".to_string()]);
790 assert_eq!(m.get("page").unwrap(), &["1".to_string()]);
791 }
792
793 #[tokio::test]
794 async fn body_within_limit_round_trips() {
795 let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
796 assert_eq!(
797 collect_body_capped(body, 1024, None).await.unwrap(),
798 Bytes::from_static(b"hello")
799 );
800 }
801
802 #[tokio::test]
803 async fn body_over_limit_is_413() {
804 let body = http_body_util::Full::new(Bytes::from(vec![0u8; 2048]));
805 match collect_body_capped(body, 1024, None).await {
806 Err(WebError::PayloadTooLarge) => {}
807 other => panic!("expected PayloadTooLarge, got {other:?}"),
808 }
809 }
810
811 #[tokio::test]
812 async fn body_refused_when_inflight_budget_exhausted() {
813 let budget = Arc::new(Semaphore::new(100));
816 let _hold = budget.clone().try_acquire_many_owned(80).expect("acquire");
819 let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
820 match collect_body_capped(body, 80, Some(&budget)).await {
821 Err(WebError::Busy(Some(d))) => assert_eq!(d, Duration::from_secs(1)),
822 other => panic!("expected Busy, got {other:?}"),
823 }
824 drop(_hold);
826 let body = http_body_util::Full::new(Bytes::from_static(b"hello"));
827 assert!(collect_body_capped(body, 80, Some(&budget)).await.is_ok());
828 }
829
830 #[test]
831 fn duplicate_request_headers_survive_into_params() {
832 let mut headers = HeaderMap::new();
835 headers.append(
836 http::HeaderName::from_static("forwarded"),
837 HeaderValue::from_static("for=1.2.3.4"),
838 );
839 headers.append(
840 http::HeaderName::from_static("forwarded"),
841 HeaderValue::from_static("for=10.0.0.1"),
842 );
843 let request = Request {
844 method: http::Method::GET,
845 path_parts: vec![],
846 query_params: HashMap::new(),
847 body: Bytes::new(),
848 headers,
849 rate_limit_class: None,
850 };
851 let params = request.to_params().expect("ok");
852 assert_eq!(params.header("Forwarded"), Some("for=1.2.3.4"));
853 assert_eq!(
854 params.header_all("Forwarded"),
855 ["for=1.2.3.4", "for=10.0.0.1"]
856 );
857 }
858
859 #[test]
860 fn form_body_appends_to_query_multimap_instead_of_overwriting() {
861 let mut query = HashMap::new();
862 query.insert("tag".to_string(), vec!["from-query".to_string()]);
863 let request = Request {
864 method: http::Method::POST,
865 path_parts: vec!["whatever".into()],
866 query_params: query,
867 body: Bytes::from_static(b"tag=from-body¬e=hi"),
868 headers: {
869 let mut h = HeaderMap::new();
870 h.insert(
871 http::header::CONTENT_TYPE,
872 HeaderValue::from_static("application/x-www-form-urlencoded"),
873 );
874 h
875 },
876 rate_limit_class: None,
877 };
878 let params = request.to_params().expect("valid form");
879 assert_eq!(params.get_all("tag").unwrap(), ["from-query", "from-body"]);
881 assert_eq!(params.require("tag").unwrap(), "from-query");
883 assert_eq!(params.get_all("note").unwrap(), ["hi"]);
884 }
885}