Skip to main content

rama_http/service/web/endpoint/extract/body/
multipart.rs

1//! Server-side `multipart/form-data` extractor.
2//!
3//! Built on top of the [`multer`] crate. Use [`Multipart`] as a handler
4//! argument to iterate over form fields. See the `http_multipart` example
5//! for usage.
6//!
7//! Spec references (vendored under `rama-http/specifications/`):
8//! - RFC 7578 — `multipart/form-data`
9//! - RFC 2046 — MIME Part Two: Media Types (boundary framing)
10//! - RFC 6266 / RFC 8187 — `Content-Disposition` / charset-encoded params
11//!
12//! Parsing leans accept-friendly: the underlying `multer` parser accepts
13//! the various non-ASCII filename forms surveyed by RFC 7578 §5.1.3
14//! (raw UTF-8, RFC 2047 encoded-words, RFC 2231 / RFC 8187 ext-value),
15//! tolerates transport padding around boundaries (RFC 2046 §5.1.1), and
16//! ignores preamble and epilogue bytes.
17
18use crate::Request;
19use crate::service::web::extract::FromRequest;
20use crate::utils::macros::{composite_http_rejection, define_http_rejection};
21use ahash::HashMap;
22use rama_core::bytes::Bytes;
23use rama_core::extensions::{Extension, ExtensionsRef};
24use rama_core::futures::{Stream, TryStream};
25use rama_http_types::{HeaderMap, StatusCode, header};
26use rama_utils::macros::generate_set_and_with;
27use std::borrow::Cow;
28use std::marker::PhantomData;
29use std::pin::Pin;
30use std::task::{Context, Poll};
31
32/// Per-field size constraints for [`Multipart`].
33///
34/// Insert a `MultipartConfig` as a request extension via a layer to apply
35/// project-wide limits, or pass it to
36/// [`Multipart::from_body_with_config`] for direct programmatic use.
37///
38/// **Combining sources is opt-in.** When multiple `MultipartConfig`
39/// extensions are inserted on the same request, the extractor reads the
40/// most-recently-inserted one — earlier values are *not* auto-merged.
41/// To compose limits across layers (and have the lowest value per field
42/// win), call [`merge_floor`](Self::merge_floor) explicitly in the
43/// inserting middleware before storing the resulting config.
44///
45/// The total payload size is governed by the standard body limit and is not
46/// configured here.
47#[derive(Debug, Clone, Default, Extension)]
48#[extension(tags(http))]
49pub struct MultipartConfig {
50    default_field_limit: Option<u64>,
51    field_limits: HashMap<Cow<'static, str>, u64>,
52}
53
54impl MultipartConfig {
55    /// Create an empty config. No limits are applied unless set via the
56    /// builder methods.
57    #[must_use]
58    pub fn new() -> Self {
59        Self::default()
60    }
61
62    generate_set_and_with! {
63        /// Default per-field byte limit applied when no field-specific
64        /// limit overrides it.
65        pub fn default_field_limit(mut self, limit: Option<u64>) -> Self {
66            self.default_field_limit = limit;
67            self
68        }
69    }
70
71    /// Set a byte limit for a specific field by name.
72    #[must_use]
73    pub fn with_field_limit(mut self, name: impl Into<Cow<'static, str>>, limit: u64) -> Self {
74        self.field_limits.insert(name.into(), limit);
75        self
76    }
77
78    /// Set a byte limit for a specific field by name.
79    pub fn set_field_limit(&mut self, name: impl Into<Cow<'static, str>>, limit: u64) -> &mut Self {
80        self.field_limits.insert(name.into(), limit);
81        self
82    }
83
84    /// Merge another configuration into this one using a floor: for every
85    /// limit set in either source, keep the lower value.
86    pub fn merge_floor(&mut self, other: &Self) {
87        if let Some(other_default) = other.default_field_limit {
88            self.default_field_limit = Some(match self.default_field_limit {
89                Some(cur) => cur.min(other_default),
90                None => other_default,
91            });
92        }
93        for (name, &other_limit) in &other.field_limits {
94            self.field_limits
95                .entry(name.clone())
96                .and_modify(|cur| *cur = (*cur).min(other_limit))
97                .or_insert(other_limit);
98        }
99    }
100
101    /// Build a fresh `multer::Constraints` from this config.
102    ///
103    /// Allocates a `String` per named field limit (multer requires
104    /// `Into<String>`); bounded by the small number of distinct field names
105    /// users typically configure. The extractor only calls this when a
106    /// `MultipartConfig` is actually present as a request extension —
107    /// otherwise the parser is built with no constraints at all.
108    fn to_constraints(&self) -> multer::Constraints {
109        let mut limit = multer::SizeLimit::new();
110        if let Some(default) = self.default_field_limit {
111            limit = limit.per_field(default);
112        }
113        for (name, &n) in &self.field_limits {
114            limit = limit.for_field(name.as_ref().to_owned(), n);
115        }
116        multer::Constraints::new().size_limit(limit)
117    }
118}
119
120/// Extractor for `multipart/form-data` request bodies.
121///
122/// Iterate fields with [`Multipart::next_field`]. Per-field limits are
123/// configured via [`MultipartConfig`], either inserted as a request
124/// extension by a layer (the path the extractor reads) or passed to
125/// [`from_body_with_config`](Self::from_body_with_config) for direct use.
126/// Combine multiple configs with [`MultipartConfig::merge_floor`]; the
127/// lowest limit per field wins.
128///
129/// `Multipart` enforces field exclusivity at compile time: each [`Field`]
130/// borrows from `&mut self`, so the previous field must be dropped before the
131/// next is requested.
132///
133/// For producing multipart bodies on the client, see
134/// [`crate::service::client::multipart`].
135#[derive(Debug)]
136pub struct Multipart {
137    inner: multer::Multipart<'static>,
138}
139
140impl Multipart {
141    /// Build a `Multipart` from a body and boundary string with no per-field
142    /// limits.
143    pub fn from_body(body: crate::Body, boundary: impl Into<String>) -> Self {
144        let stream = body.into_data_stream();
145        let inner = multer::Multipart::new(stream, boundary);
146        Self { inner }
147    }
148
149    /// Build a `Multipart` from a body and boundary string, applying the
150    /// per-field limits in `config`. Pass `None` to skip building any
151    /// `multer::Constraints` and use parser defaults.
152    pub fn from_body_with_config(
153        body: crate::Body,
154        boundary: impl Into<String>,
155        config: Option<&MultipartConfig>,
156    ) -> Self {
157        let stream = body.into_data_stream();
158        let inner = match config {
159            Some(c) => multer::Multipart::with_constraints(stream, boundary, c.to_constraints()),
160            None => multer::Multipart::new(stream, boundary),
161        };
162        Self { inner }
163    }
164
165    /// Construct from any stream of `Result<Bytes, _>` and a boundary, with the
166    /// per-field limits in `config`. Pass `None` to skip building any
167    /// `multer::Constraints` and use parser defaults.
168    pub fn from_stream_with_config<S, O, E, B>(
169        stream: S,
170        boundary: B,
171        config: Option<&MultipartConfig>,
172    ) -> Self
173    where
174        S: Stream<Item = Result<O, E>> + Send + 'static,
175        O: Into<Bytes> + 'static,
176        E: Into<Box<dyn std::error::Error + Send + Sync>> + 'static,
177        B: Into<String>,
178    {
179        let inner = match config {
180            Some(c) => multer::Multipart::with_constraints(stream, boundary, c.to_constraints()),
181            None => multer::Multipart::new(stream, boundary),
182        };
183        Self { inner }
184    }
185
186    /// Yield the next [`Field`] in the multipart stream, or `None` if the
187    /// stream is exhausted.
188    ///
189    /// The previous `Field` must be dropped before this returns; the borrow
190    /// checker enforces this.
191    pub async fn next_field(&mut self) -> Result<Option<Field<'_>>, MultipartError> {
192        match self.inner.next_field().await {
193            Ok(Some(inner)) => Ok(Some(Field {
194                inner,
195                _marker: PhantomData,
196            })),
197            Ok(None) => Ok(None),
198            Err(err) => Err(MultipartError::from(err)),
199        }
200    }
201}
202
203/// A single field inside a [`Multipart`] stream.
204///
205/// `Field` borrows from `&mut Multipart`, so only one field is live at a time.
206/// Read the body with [`bytes`](Self::bytes) or [`text`](Self::text), or pull
207/// chunks via [`chunk`](Self::chunk) or by iterating the field as a [`Stream`].
208#[derive(Debug)]
209pub struct Field<'a> {
210    inner: multer::Field<'static>,
211    _marker: PhantomData<&'a mut Multipart>,
212}
213
214impl Field<'_> {
215    /// Field name from `Content-Disposition: form-data; name="…"`.
216    #[must_use]
217    pub fn name(&self) -> Option<&str> {
218        self.inner.name()
219    }
220
221    /// File name from `Content-Disposition: form-data; filename="…"`.
222    #[must_use]
223    pub fn file_name(&self) -> Option<&str> {
224        self.inner.file_name()
225    }
226
227    /// Parsed `Content-Type` of the field, if any.
228    ///
229    /// Returns `None` when the part has no `Content-Type` header. Per
230    /// RFC 7578 §4.4 the default in that case is `text/plain`; users that
231    /// want to honor that default should substitute it themselves.
232    #[must_use]
233    pub fn content_type(&self) -> Option<&crate::mime::Mime> {
234        self.inner.content_type()
235    }
236
237    /// Header map of this field.
238    ///
239    /// Converted from the underlying `multer` field's hyperium `http::HeaderMap`;
240    /// errors only if a header name/value is not representable in rama.
241    pub fn headers(&self) -> Result<HeaderMap, rama_http_types::Error> {
242        rama_http_hyperium::TryIntoRamaHttp::try_into_rama_http(self.inner.headers())
243    }
244
245    /// Index of this field within the multipart stream (0-based).
246    #[must_use]
247    pub fn index(&self) -> usize {
248        self.inner.index()
249    }
250
251    /// Collect the entire field body as bytes.
252    pub async fn bytes(self) -> Result<Bytes, MultipartError> {
253        self.inner.bytes().await.map_err(MultipartError::from)
254    }
255
256    /// Collect the entire field body as a UTF-8 string. Honors a
257    /// `charset` parameter on the field's `Content-Type` if present.
258    pub async fn text(self) -> Result<String, MultipartError> {
259        self.inner.text().await.map_err(MultipartError::from)
260    }
261
262    /// Pull the next chunk of the field body, or `None` once it is exhausted.
263    pub async fn chunk(&mut self) -> Result<Option<Bytes>, MultipartError> {
264        self.inner.chunk().await.map_err(MultipartError::from)
265    }
266}
267
268impl Stream for Field<'_> {
269    type Item = Result<Bytes, MultipartError>;
270
271    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
272        let this = self.get_mut();
273        let inner = Pin::new(&mut this.inner);
274        inner
275            .try_poll_next(cx)
276            .map(|opt| opt.map(|res| res.map_err(MultipartError::from)))
277    }
278}
279
280/// Error type returned while reading a [`Multipart`] field.
281///
282/// Maps multer parser failures to suitable HTTP status codes when used as a
283/// rejection: parse errors return `400 Bad Request`, size violations return
284/// `413 Payload Too Large`, and unexpected stream read failures return
285/// `500 Internal Server Error`.
286#[derive(Debug)]
287pub struct MultipartError {
288    source: multer::Error,
289}
290
291impl MultipartError {
292    /// HTTP status used when this error is converted into a response.
293    #[must_use]
294    pub fn status(&self) -> StatusCode {
295        match &self.source {
296            multer::Error::FieldSizeExceeded { .. } | multer::Error::StreamSizeExceeded { .. } => {
297                StatusCode::PAYLOAD_TOO_LARGE
298            }
299            multer::Error::StreamReadFailed(_) | multer::Error::LockFailure => {
300                StatusCode::INTERNAL_SERVER_ERROR
301            }
302            _ => StatusCode::BAD_REQUEST,
303        }
304    }
305
306    /// Body text used for the rejection response.
307    #[must_use]
308    pub fn body_text(&self) -> String {
309        format!(
310            "Failed to parse `multipart/form-data` request: {}",
311            self.source
312        )
313    }
314}
315
316impl From<multer::Error> for MultipartError {
317    fn from(source: multer::Error) -> Self {
318        Self { source }
319    }
320}
321
322impl std::fmt::Display for MultipartError {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        write!(f, "Error parsing `multipart/form-data` request")
325    }
326}
327
328impl std::error::Error for MultipartError {
329    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
330        Some(&self.source)
331    }
332}
333
334impl crate::service::web::endpoint::IntoResponse for MultipartError {
335    fn into_response(self) -> crate::Response {
336        crate::utils::macros::log_http_rejection!(
337            rejection_type = MultipartError,
338            body_text = self.body_text(),
339            status = self.status(),
340        );
341        (self.status(), self.body_text()).into_response()
342    }
343}
344
345define_http_rejection! {
346    #[status = BAD_REQUEST]
347    #[body = "Multipart request is missing or has an invalid `Content-Type` boundary"]
348    /// Rejection used when no boundary parameter is present in the
349    /// `Content-Type` header (or when the header itself is missing).
350    pub struct InvalidMultipartBoundary;
351}
352
353define_http_rejection! {
354    #[status = UNSUPPORTED_MEDIA_TYPE]
355    #[body = "Multipart requests must have `Content-Type: multipart/form-data`"]
356    /// Rejection used when the `Content-Type` is not `multipart/form-data`
357    /// (e.g. `multipart/mixed` or another media type entirely).
358    pub struct InvalidMultipartContentType;
359}
360
361composite_http_rejection! {
362    /// Rejection type for the [`Multipart`] extractor.
363    pub enum MultipartRejection {
364        InvalidMultipartContentType,
365        InvalidMultipartBoundary,
366        MultipartError,
367    }
368}
369
370impl FromRequest for Multipart {
371    type Rejection = MultipartRejection;
372
373    async fn from_request(req: Request) -> Result<Self, Self::Rejection> {
374        let content_type = req
375            .headers()
376            .get(header::CONTENT_TYPE)
377            .and_then(|v| v.to_str().ok())
378            .ok_or(InvalidMultipartContentType)?;
379
380        // RFC 7578 §4.1 requires the media type to be `multipart/form-data`.
381        // `multer::parse_boundary` only checks for a `boundary=` parameter
382        // and would otherwise accept `multipart/mixed`, `application/foo`,
383        // etc. Reject anything else with 415.
384        if !is_multipart_form_data(content_type) {
385            return Err(InvalidMultipartContentType.into());
386        }
387        let boundary =
388            multer::parse_boundary(content_type).map_err(|_e| InvalidMultipartBoundary)?;
389
390        // Look up the optional `MultipartConfig` extension via `get_arc` to
391        // avoid cloning the inner field-limits map on every request. When no
392        // config is set we skip building `multer::Constraints` entirely.
393        let config = req.extensions().get_arc::<MultipartConfig>();
394        Ok(Self::from_body_with_config(
395            req.into_body(),
396            boundary,
397            config.as_deref(),
398        ))
399    }
400}
401
402fn is_multipart_form_data(content_type: &str) -> bool {
403    content_type
404        .parse::<crate::mime::Mime>()
405        .ok()
406        .is_some_and(|m| {
407            m.type_() == crate::mime::MULTIPART && m.subtype() == crate::mime::FORM_DATA
408        })
409}
410
411#[cfg(test)]
412mod test {
413    use super::*;
414    use crate::StatusCode;
415    use crate::service::web::WebService;
416    use rama_core::Service;
417    use rama_utils::octets::kib_u64;
418
419    const BOUNDARY: &str = "X-RAMA-TEST-BOUNDARY";
420
421    fn body_with(parts: &[(&str, Option<&str>, Option<&str>, &[u8])]) -> Vec<u8> {
422        let mut out = Vec::new();
423        for (name, file_name, content_type, data) in parts {
424            out.extend_from_slice(b"--");
425            out.extend_from_slice(BOUNDARY.as_bytes());
426            out.extend_from_slice(b"\r\nContent-Disposition: form-data; name=\"");
427            out.extend_from_slice(name.as_bytes());
428            out.extend_from_slice(b"\"");
429            if let Some(fname) = file_name {
430                out.extend_from_slice(b"; filename=\"");
431                out.extend_from_slice(fname.as_bytes());
432                out.extend_from_slice(b"\"");
433            }
434            out.extend_from_slice(b"\r\n");
435            if let Some(ct) = content_type {
436                out.extend_from_slice(b"Content-Type: ");
437                out.extend_from_slice(ct.as_bytes());
438                out.extend_from_slice(b"\r\n");
439            }
440            out.extend_from_slice(b"\r\n");
441            out.extend_from_slice(data);
442            out.extend_from_slice(b"\r\n");
443        }
444        out.extend_from_slice(b"--");
445        out.extend_from_slice(BOUNDARY.as_bytes());
446        out.extend_from_slice(b"--\r\n");
447        out
448    }
449
450    fn ct() -> String {
451        format!("multipart/form-data; boundary={BOUNDARY}")
452    }
453
454    #[tokio::test]
455    async fn test_multipart_text_and_file() {
456        let service =
457            WebService::default().with_post("/", async |mut mp: Multipart| -> StatusCode {
458                let f1 = mp.next_field().await.unwrap().unwrap();
459                assert_eq!(f1.name(), Some("name"));
460                assert_eq!(f1.file_name(), None);
461                assert_eq!(f1.text().await.unwrap(), "glen");
462
463                let f2 = mp.next_field().await.unwrap().unwrap();
464                assert_eq!(f2.name(), Some("avatar"));
465                assert_eq!(f2.file_name(), Some("a.bin"));
466                assert_eq!(
467                    f2.content_type().map(|m| m.essence_str()),
468                    Some("application/octet-stream")
469                );
470                assert_eq!(f2.bytes().await.unwrap().as_ref(), b"\x00\x01\x02");
471
472                assert!(mp.next_field().await.unwrap().is_none());
473                StatusCode::OK
474            });
475
476        let body = body_with(&[
477            ("name", None, None, b"glen"),
478            (
479                "avatar",
480                Some("a.bin"),
481                Some("application/octet-stream"),
482                &[0, 1, 2],
483            ),
484        ]);
485        let req = rama_http_types::Request::builder()
486            .method(rama_http_types::Method::POST)
487            .header(rama_http_types::header::CONTENT_TYPE, ct())
488            .body(body.into())
489            .unwrap();
490        let resp = service.serve(req).await.unwrap();
491        assert_eq!(resp.status(), StatusCode::OK);
492    }
493
494    #[tokio::test]
495    async fn test_multipart_quoted_boundary_in_content_type() {
496        // RFC 2046 §5.1.1 allows the boundary to be quoted in the
497        // Content-Type header, especially when it contains characters that
498        // require it. multer's parser strips the quotes; our wrapper must
499        // handle it transparently.
500        let service =
501            WebService::default().with_post("/", async |mut mp: Multipart| -> StatusCode {
502                let f = mp.next_field().await.unwrap().unwrap();
503                assert_eq!(f.text().await.unwrap(), "v");
504                StatusCode::OK
505            });
506
507        let body = body_with(&[("k", None, None, b"v")]);
508        let req = rama_http_types::Request::builder()
509            .method(rama_http_types::Method::POST)
510            .header(
511                rama_http_types::header::CONTENT_TYPE,
512                format!("multipart/form-data; boundary=\"{BOUNDARY}\""),
513            )
514            .body(body.into())
515            .unwrap();
516        let resp = service.serve(req).await.unwrap();
517        assert_eq!(resp.status(), StatusCode::OK);
518    }
519
520    #[tokio::test]
521    async fn test_multipart_with_preamble_and_epilogue() {
522        // RFC 2046 §5.1.1 says implementations MUST ignore anything before
523        // the first boundary (preamble) and after the close boundary
524        // (epilogue).
525        let mut body = Vec::new();
526        body.extend_from_slice(b"This is a preamble that must be ignored.\r\n");
527        body.extend_from_slice(&body_with(&[("k", None, None, b"v")]));
528        body.extend_from_slice(b"trailing epilogue ignored\r\n");
529
530        let service =
531            WebService::default().with_post("/", async |mut mp: Multipart| -> StatusCode {
532                let f = mp.next_field().await.unwrap().unwrap();
533                assert_eq!(f.name(), Some("k"));
534                assert_eq!(f.text().await.unwrap(), "v");
535                StatusCode::OK
536            });
537
538        let req = rama_http_types::Request::builder()
539            .method(rama_http_types::Method::POST)
540            .header(rama_http_types::header::CONTENT_TYPE, ct())
541            .body(body.into())
542            .unwrap();
543        let resp = service.serve(req).await.unwrap();
544        assert_eq!(resp.status(), StatusCode::OK);
545    }
546
547    #[tokio::test]
548    async fn test_multipart_with_transport_padding() {
549        // RFC 2046 §5.1.1 receivers must accept linear-whitespace transport
550        // padding between the boundary delimiter and the trailing CRLF.
551        // Hand-craft a body with `--boundary  \r\n` (extra spaces).
552        let mut body = Vec::new();
553        body.extend_from_slice(b"--");
554        body.extend_from_slice(BOUNDARY.as_bytes());
555        body.extend_from_slice(b"   \r\n"); // padding
556        body.extend_from_slice(b"Content-Disposition: form-data; name=\"k\"\r\n\r\n");
557        body.extend_from_slice(b"v\r\n");
558        body.extend_from_slice(b"--");
559        body.extend_from_slice(BOUNDARY.as_bytes());
560        body.extend_from_slice(b"--\t \r\n"); // padding before final CRLF
561
562        let service =
563            WebService::default().with_post("/", async |mut mp: Multipart| -> StatusCode {
564                let f = mp.next_field().await.unwrap().unwrap();
565                assert_eq!(f.text().await.unwrap(), "v");
566                StatusCode::OK
567            });
568
569        let req = rama_http_types::Request::builder()
570            .method(rama_http_types::Method::POST)
571            .header(rama_http_types::header::CONTENT_TYPE, ct())
572            .body(body.into())
573            .unwrap();
574        let resp = service.serve(req).await.unwrap();
575        assert_eq!(resp.status(), StatusCode::OK);
576    }
577
578    #[tokio::test]
579    async fn test_multipart_filename_with_rfc5987_ext_value() {
580        // RFC 7578 §4.2 explicitly forbids senders from using the RFC 5987
581        // `filename*` ext-value in multipart/form-data; §5.1.3 likewise
582        // doesn't list it among the encodings receivers should accept.
583        // multer correspondingly does not decode `filename*` and returns
584        // `None` for `file_name()` when only that form is present. Our
585        // wrapper accepts the request (we don't 415/400 on it), the field
586        // body is intact, and we surface multer's choice unchanged. Pin
587        // this so any future change in multer or our pipeline is caught.
588        let mut body = Vec::new();
589        body.extend_from_slice(b"--");
590        body.extend_from_slice(BOUNDARY.as_bytes());
591        body.extend_from_slice(b"\r\n");
592        body.extend_from_slice(
593            b"Content-Disposition: form-data; name=\"file\"; filename*=UTF-8''r%C3%A9sum%C3%A9.txt\r\n",
594        );
595        body.extend_from_slice(b"\r\n");
596        body.extend_from_slice(b"hello\r\n");
597        body.extend_from_slice(b"--");
598        body.extend_from_slice(BOUNDARY.as_bytes());
599        body.extend_from_slice(b"--\r\n");
600
601        let service =
602            WebService::default().with_post("/", async |mut mp: Multipart| -> StatusCode {
603                let f = mp.next_field().await.unwrap().unwrap();
604                assert_eq!(f.name(), Some("file"));
605                // multer 3.1 does not decode `filename*` — pin that.
606                assert_eq!(f.file_name(), None);
607                assert_eq!(f.text().await.unwrap(), "hello");
608                StatusCode::OK
609            });
610
611        let req = rama_http_types::Request::builder()
612            .method(rama_http_types::Method::POST)
613            .header(rama_http_types::header::CONTENT_TYPE, ct())
614            .body(body.into())
615            .unwrap();
616        let resp = service.serve(req).await.unwrap();
617        assert_eq!(resp.status(), StatusCode::OK);
618    }
619
620    #[tokio::test]
621    async fn test_multipart_filename_utf8_passthrough() {
622        // RFC 7578 §5.1.1 says senders SHOULD use UTF-8 for non-ASCII
623        // names; §5.1.3 says receivers should accept unencoded UTF-8.
624        // multer passes raw bytes through verbatim. Pin the exact
625        // decoded value.
626        let mut body = Vec::new();
627        body.extend_from_slice(b"--");
628        body.extend_from_slice(BOUNDARY.as_bytes());
629        body.extend_from_slice(b"\r\n");
630        // "résumé.txt" as raw UTF-8 in the quoted-string form.
631        body.extend_from_slice(
632            "Content-Disposition: form-data; name=\"file\"; filename=\"résumé.txt\"\r\n".as_bytes(),
633        );
634        body.extend_from_slice(b"\r\n");
635        body.extend_from_slice(b"hello\r\n");
636        body.extend_from_slice(b"--");
637        body.extend_from_slice(BOUNDARY.as_bytes());
638        body.extend_from_slice(b"--\r\n");
639
640        let service =
641            WebService::default().with_post("/", async |mut mp: Multipart| -> StatusCode {
642                let f = mp.next_field().await.unwrap().unwrap();
643                assert_eq!(f.name(), Some("file"));
644                assert_eq!(f.file_name(), Some("résumé.txt"));
645                assert_eq!(f.text().await.unwrap(), "hello");
646                StatusCode::OK
647            });
648
649        let req = rama_http_types::Request::builder()
650            .method(rama_http_types::Method::POST)
651            .header(rama_http_types::header::CONTENT_TYPE, ct())
652            .body(body.into())
653            .unwrap();
654        let resp = service.serve(req).await.unwrap();
655        assert_eq!(resp.status(), StatusCode::OK);
656    }
657
658    #[tokio::test]
659    async fn test_multipart_filename_with_escaped_quote() {
660        // Quote-escaped filenames in quoted-string form (`\"`).
661        let mut body = Vec::new();
662        body.extend_from_slice(b"--");
663        body.extend_from_slice(BOUNDARY.as_bytes());
664        body.extend_from_slice(b"\r\n");
665        body.extend_from_slice(
666            br#"Content-Disposition: form-data; name="file"; filename="we\"ird.txt""#,
667        );
668        body.extend_from_slice(b"\r\n\r\nhello\r\n");
669        body.extend_from_slice(b"--");
670        body.extend_from_slice(BOUNDARY.as_bytes());
671        body.extend_from_slice(b"--\r\n");
672
673        let service =
674            WebService::default().with_post("/", async |mut mp: Multipart| -> StatusCode {
675                let f = mp.next_field().await.unwrap().unwrap();
676                assert_eq!(f.file_name(), Some("we\"ird.txt"));
677                assert_eq!(f.text().await.unwrap(), "hello");
678                StatusCode::OK
679            });
680
681        let req = rama_http_types::Request::builder()
682            .method(rama_http_types::Method::POST)
683            .header(rama_http_types::header::CONTENT_TYPE, ct())
684            .body(body.into())
685            .unwrap();
686        let resp = service.serve(req).await.unwrap();
687        assert_eq!(resp.status(), StatusCode::OK);
688    }
689
690    #[tokio::test]
691    async fn test_multipart_invalid_boundary() {
692        let service = WebService::default().with_post("/", async |_: Multipart| StatusCode::OK);
693
694        let req = rama_http_types::Request::builder()
695            .method(rama_http_types::Method::POST)
696            .header(rama_http_types::header::CONTENT_TYPE, "multipart/form-data")
697            .body("ignored".into())
698            .unwrap();
699        let resp = service.serve(req).await.unwrap();
700        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
701    }
702
703    #[tokio::test]
704    async fn test_multipart_rejects_non_form_data_subtype() {
705        // Regression: previously the extractor would accept any media type
706        // that carried a `boundary=` parameter (e.g. multipart/mixed).
707        // RFC 7578 §4.1 mandates multipart/form-data specifically.
708        let service = WebService::default().with_post("/", async |_: Multipart| StatusCode::OK);
709
710        let body = body_with(&[("k", None, None, b"v")]);
711        let req = rama_http_types::Request::builder()
712            .method(rama_http_types::Method::POST)
713            .header(
714                rama_http_types::header::CONTENT_TYPE,
715                format!("multipart/mixed; boundary={BOUNDARY}"),
716            )
717            .body(body.into())
718            .unwrap();
719        let resp = service.serve(req).await.unwrap();
720        assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
721    }
722
723    #[tokio::test]
724    async fn test_multipart_rejects_missing_content_type() {
725        let service = WebService::default().with_post("/", async |_: Multipart| StatusCode::OK);
726        let req = rama_http_types::Request::builder()
727            .method(rama_http_types::Method::POST)
728            .body("ignored".into())
729            .unwrap();
730        let resp = service.serve(req).await.unwrap();
731        assert_eq!(resp.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE);
732    }
733
734    #[tokio::test]
735    async fn test_multipart_field_limit_exceeded() {
736        let service = WebService::default().with_post(
737            "/",
738            async |mut mp: Multipart| -> Result<StatusCode, MultipartRejection> {
739                let f = mp.next_field().await?.unwrap();
740                _ = f.bytes().await?;
741                Ok(StatusCode::OK)
742            },
743        );
744
745        let body = body_with(&[("blob", None, None, &[42u8; 64])]);
746        let req = rama_http_types::Request::builder()
747            .method(rama_http_types::Method::POST)
748            .header(rama_http_types::header::CONTENT_TYPE, ct())
749            .body(body.into())
750            .unwrap();
751        req.extensions()
752            .insert(MultipartConfig::new().with_default_field_limit(8));
753        let resp = service.serve(req).await.unwrap();
754        assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE);
755    }
756
757    #[tokio::test]
758    async fn test_multipart_field_as_stream_chunks() {
759        let service =
760            WebService::default().with_post("/", async |mut mp: Multipart| -> StatusCode {
761                let mut field = mp.next_field().await.unwrap().unwrap();
762                let mut total = Vec::new();
763                while let Some(chunk) = field.chunk().await.unwrap() {
764                    total.extend_from_slice(&chunk);
765                }
766                assert_eq!(total, b"hello world");
767                StatusCode::OK
768            });
769
770        let body = body_with(&[("payload", None, None, b"hello world")]);
771        let req = rama_http_types::Request::builder()
772            .method(rama_http_types::Method::POST)
773            .header(rama_http_types::header::CONTENT_TYPE, ct())
774            .body(body.into())
775            .unwrap();
776        let resp = service.serve(req).await.unwrap();
777        assert_eq!(resp.status(), StatusCode::OK);
778    }
779
780    #[tokio::test]
781    async fn test_roundtrip_with_client_form() {
782        use crate::service::client::multipart as client;
783
784        let service =
785            WebService::default().with_post("/", async |mut mp: Multipart| -> StatusCode {
786                let f1 = mp.next_field().await.unwrap().unwrap();
787                assert_eq!(f1.name(), Some("greeting"));
788                assert_eq!(f1.text().await.unwrap(), "hello world");
789
790                let f2 = mp.next_field().await.unwrap().unwrap();
791                assert_eq!(f2.name(), Some("payload"));
792                assert_eq!(f2.file_name(), Some("blob.bin"));
793                assert_eq!(
794                    f2.content_type().map(|m| m.essence_str()),
795                    Some("application/octet-stream")
796                );
797                assert_eq!(f2.bytes().await.unwrap().as_ref(), &[7u8, 8, 9, 10]);
798
799                assert!(mp.next_field().await.unwrap().is_none());
800                StatusCode::OK
801            });
802
803        let form = client::Form::new().text("greeting", "hello world").part(
804            "payload",
805            client::Part::bytes([7u8, 8, 9, 10].as_slice())
806                .with_file_name("blob.bin")
807                .with_mime(crate::mime::APPLICATION_OCTET_STREAM),
808        );
809        let content_type = form.content_type();
810        let req = rama_http_types::Request::builder()
811            .method(rama_http_types::Method::POST)
812            .header(rama_http_types::header::CONTENT_TYPE, content_type)
813            .body(form.into_body())
814            .unwrap();
815        let resp = service.serve(req).await.unwrap();
816        assert_eq!(resp.status(), StatusCode::OK);
817    }
818
819    #[test]
820    fn test_config_merge_floor_keeps_min() {
821        let mut a = MultipartConfig::new()
822            .with_default_field_limit(1024)
823            .with_field_limit("avatar", kib_u64(8));
824        let b = MultipartConfig::new()
825            .with_default_field_limit(2048)
826            .with_field_limit("avatar", kib_u64(4))
827            .with_field_limit("doc", 100);
828        a.merge_floor(&b);
829        assert_eq!(a.default_field_limit, Some(1024));
830        assert_eq!(a.field_limits.get("avatar").copied(), Some(kib_u64(4)));
831        assert_eq!(a.field_limits.get("doc").copied(), Some(100));
832    }
833}