Skip to main content

volo_http/client/
multipart.rs

1//! Client-side `multipart/form-data` builder.
2//!
3//! This module provides [`Form`] and [`Part`] for building a `multipart/form-data` body for
4//! client requests, which is the counterpart of the server-side
5//! [`Multipart`](crate::server::utils::multipart::Multipart) extractor.
6//!
7//! [`Form`] collects a series of [`Part`]s and can be sent with
8//! [`RequestBuilder::multipart`](crate::client::RequestBuilder::multipart). Each [`Part`] can be
9//! built from in-memory bytes/text, an arbitrary [`AsyncRead`] reader, or a file path (which is
10//! streamed lazily).
11//!
12//! # Example
13//!
14//! ```rust
15//! use volo_http::client::multipart::{Form, Part};
16//!
17//! # async fn upload(client: volo_http::client::Client) -> Result<(), Box<dyn std::error::Error>> {
18//! let form = Form::new()
19//!     .text("key", "value")
20//!     .part(
21//!         "file",
22//!         Part::text("hello, world")
23//!             .file_name("hello.txt")
24//!             .mime_str("text/plain")?,
25//!     );
26//!
27//! let resp = client.post("http://127.0.0.1:8080/upload").multipart(form).send().await?;
28//! # let _ = resp;
29//! # Ok(())
30//! # }
31//! ```
32
33use std::{
34    borrow::Cow,
35    io,
36    path::Path,
37    pin::Pin,
38    task::{Context, Poll},
39};
40
41use bytes::{Bytes, BytesMut};
42use futures_util::{StreamExt, stream::Stream};
43use http::header::HeaderValue;
44use http_body::{Frame, SizeHint};
45use http_body_util::StreamBody;
46use mime::Mime;
47use pin_project::pin_project;
48use tokio::io::AsyncRead;
49use tokio_util::io::ReaderStream;
50
51use crate::{body::Body, error::BoxError};
52
53// A boxed stream that is both `Send` and `Sync`, matching the bound of [`Body::from_stream`]. Note
54// that `futures_util`'s `BoxStream` is only `Send`, so we define our own alias here (the same way
55// as `crate::body`).
56type FrameStream = Pin<Box<dyn Stream<Item = Result<Frame<Bytes>, BoxError>> + Send + Sync>>;
57
58// Hyper only needs an exact `size_hint` to switch HTTP/1 requests from chunked transfer to
59// `Content-Length`. Wrap the multipart stream so we can keep streaming the file bytes lazily while
60// still reporting the exact total length when every part length is known.
61#[pin_project]
62struct SizedStreamBody<S> {
63    #[pin]
64    inner: StreamBody<S>,
65    exact_size: u64,
66}
67
68impl<S> http_body::Body for SizedStreamBody<S>
69where
70    S: Stream<Item = Result<Frame<Bytes>, BoxError>>,
71{
72    type Data = Bytes;
73    type Error = BoxError;
74
75    fn poll_frame(
76        self: Pin<&mut Self>,
77        cx: &mut Context<'_>,
78    ) -> Poll<Option<Result<Frame<Self::Data>, Self::Error>>> {
79        http_body::Body::poll_frame(self.project().inner, cx)
80    }
81
82    fn is_end_stream(&self) -> bool {
83        http_body::Body::is_end_stream(&self.inner)
84    }
85
86    fn size_hint(&self) -> SizeHint {
87        SizeHint::with_exact(self.exact_size)
88    }
89}
90
91fn exact_size_stream_body<S>(stream: S, exact_size: u64) -> Body
92where
93    S: Stream<Item = Result<Frame<Bytes>, BoxError>> + Send + Sync + 'static,
94{
95    Body::from_body(SizedStreamBody {
96        inner: StreamBody::new(stream),
97        exact_size,
98    })
99}
100
101/// A `multipart/form-data` request body.
102///
103/// A [`Form`] is a series of [`Part`]s, it can be sent through
104/// [`RequestBuilder::multipart`](crate::client::RequestBuilder::multipart), which will set the
105/// `Content-Type` header (with the generated boundary) and the body automatically.
106#[must_use]
107pub struct Form {
108    boundary: String,
109    parts: Vec<(Cow<'static, str>, Part)>,
110}
111
112impl Default for Form {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118impl Form {
119    /// Create an empty [`Form`] with a randomly generated boundary.
120    pub fn new() -> Self {
121        Self {
122            boundary: gen_boundary(),
123            parts: Vec::new(),
124        }
125    }
126
127    /// Get the boundary that this form will use.
128    pub fn boundary(&self) -> &str {
129        &self.boundary
130    }
131
132    /// Add a text field to the form.
133    ///
134    /// This is a shortcut for [`Form::part`] with [`Part::text`].
135    pub fn text<N, V>(self, name: N, value: V) -> Self
136    where
137        N: Into<Cow<'static, str>>,
138        V: Into<Cow<'static, str>>,
139    {
140        self.part(name, Part::text(value))
141    }
142
143    /// Add a [`Part`] to the form with the given field name.
144    pub fn part<N>(mut self, name: N, part: Part) -> Self
145    where
146        N: Into<Cow<'static, str>>,
147    {
148        self.parts.push((name.into(), part));
149        self
150    }
151
152    /// Add a file field to the form, the file will be read and streamed lazily.
153    ///
154    /// The `Content-Type` is guessed from the file extension, and the `filename` is taken from the
155    /// path if it is not overridden. This is a shortcut for [`Form::part`] with [`Part::file`].
156    pub async fn file<N, P>(self, name: N, path: P) -> io::Result<Self>
157    where
158        N: Into<Cow<'static, str>>,
159        P: AsRef<Path>,
160    {
161        Ok(self.part(name, Part::file(path).await?))
162    }
163
164    /// Generate the `Content-Type` header value, i.e. `multipart/form-data; boundary=xxx`.
165    pub(crate) fn content_type(&self) -> HeaderValue {
166        // SAFETY: The boundary is generated from ascii-only characters, so the whole value is
167        // always a valid header value.
168        HeaderValue::from_str(&format!("multipart/form-data; boundary={}", self.boundary))
169            .expect("multipart boundary should always be a valid header value")
170    }
171
172    /// Consume the form and encode it into a [`Body`].
173    pub(crate) fn into_body(self) -> Body {
174        let boundary = self.boundary;
175
176        // Fast path: if every part is already in memory, assemble the whole body into a single
177        // contiguous buffer. The resulting body has a known length, so the request is sent with a
178        // `Content-Length` header instead of `Transfer-Encoding: chunked`, which some strict
179        // servers prefer. Parts backed by a reader or a file have an unknown length and take the
180        // streaming path below.
181        if self.parts.iter().all(|(_, part)| part.data.is_in_memory()) {
182            // Rough capacity: the part data dominates; add a fixed per-part overhead for the
183            // boundary and headers to avoid most reallocations. An under-estimate only costs a
184            // realloc.
185            let cap = self
186                .parts
187                .iter()
188                .map(|(name, part)| {
189                    part.data.as_bytes().map_or(0, Bytes::len)
190                        + name.len()
191                        + part.file_name.as_ref().map_or(0, |f| f.len())
192                        + part.mime.as_ref().map_or(0, |m| m.as_ref().len())
193                        + boundary.len()
194                        + 96
195                })
196                .sum::<usize>()
197                + boundary.len()
198                + 8;
199            let mut buf = BytesMut::with_capacity(cap);
200            for (name, part) in &self.parts {
201                buf.extend_from_slice(&part.encode_header(&boundary, name));
202                // `is_in_memory` was checked for every part above, so this is always `Some`.
203                if let Some(bytes) = part.data.as_bytes() {
204                    buf.extend_from_slice(bytes);
205                }
206                buf.extend_from_slice(b"\r\n");
207            }
208            buf.extend_from_slice(format!("--{boundary}--\r\n").as_bytes());
209            return Body::from(buf.freeze());
210        }
211
212        // Streaming path: each part becomes `--boundary\r\n<headers>\r\n\r\n<data>\r\n`, and the
213        // whole body ends with a closing `--boundary--\r\n`.
214        //
215        // If every part length is known up front (for example, in-memory fields plus files whose
216        // size comes from metadata), we still publish an exact size hint so HTTP/1 can send
217        // `Content-Length` instead of chunked transfer.
218        let closing_boundary = Bytes::from(format!("--{boundary}--\r\n"));
219        let mut exact_len = Some(closing_boundary.len() as u64);
220        let mut streams: Vec<FrameStream> = Vec::with_capacity(self.parts.len() * 3 + 1);
221
222        for (name, part) in self.parts {
223            let header = part.encode_header(&boundary, &name);
224            exact_len = exact_len.and_then(|total| {
225                let data_len = part.len()?;
226                total
227                    .checked_add(header.len() as u64)?
228                    .checked_add(data_len)?
229                    .checked_add(2)
230            });
231            streams.push(once_frame(header));
232            streams.push(part.data.into_stream());
233            streams.push(once_frame(Bytes::from_static(b"\r\n")));
234        }
235        streams.push(once_frame(closing_boundary));
236
237        let stream = futures_util::stream::iter(streams).flatten();
238        match exact_len {
239            Some(exact_len) => exact_size_stream_body(stream, exact_len),
240            None => Body::from_stream(stream),
241        }
242    }
243}
244
245/// A single field of a [`Form`].
246///
247/// A [`Part`] can be created from in-memory bytes/text ([`Part::text`], [`Part::bytes`]), an
248/// arbitrary async reader ([`Part::reader`], the counterpart of `SetFileReader`), or a file path
249/// ([`Part::file`]). Additional metadata such as `filename` and `Content-Type` can be attached
250/// with [`Part::file_name`] and [`Part::mime_str`]/[`Part::mime`].
251#[must_use]
252pub struct Part {
253    data: PartData,
254    length: Option<u64>,
255    file_name: Option<Cow<'static, str>>,
256    mime: Option<Mime>,
257}
258
259enum PartData {
260    Bytes(Bytes),
261    Stream(FrameStream),
262}
263
264impl PartData {
265    fn into_stream(self) -> FrameStream {
266        match self {
267            PartData::Bytes(bytes) => once_frame(bytes),
268            PartData::Stream(stream) => stream,
269        }
270    }
271
272    fn len(&self) -> Option<u64> {
273        match self {
274            PartData::Bytes(bytes) => Some(bytes.len() as u64),
275            PartData::Stream(_) => None,
276        }
277    }
278
279    /// Whether the data is already fully in memory (i.e. not a lazily streamed reader/file).
280    fn is_in_memory(&self) -> bool {
281        matches!(self, PartData::Bytes(_))
282    }
283
284    /// Borrow the in-memory bytes, or `None` if the data is a stream.
285    fn as_bytes(&self) -> Option<&Bytes> {
286        match self {
287            PartData::Bytes(bytes) => Some(bytes),
288            PartData::Stream(_) => None,
289        }
290    }
291}
292
293impl Part {
294    /// Create a text [`Part`] from a UTF-8 string.
295    pub fn text<T>(value: T) -> Self
296    where
297        T: Into<Cow<'static, str>>,
298    {
299        let bytes = match value.into() {
300            Cow::Borrowed(s) => Bytes::from_static(s.as_bytes()),
301            Cow::Owned(s) => Bytes::from(s),
302        };
303        Self::new(PartData::Bytes(bytes))
304    }
305
306    /// Create a [`Part`] from in-memory bytes.
307    pub fn bytes<T>(value: T) -> Self
308    where
309        T: Into<Bytes>,
310    {
311        Self::new(PartData::Bytes(value.into()))
312    }
313
314    /// Create a [`Part`] from an arbitrary [`AsyncRead`] reader, whose content is streamed lazily.
315    ///
316    /// This is the counterpart of `SetFileReader`: any reader (a file, a socket, a pipe, ...) can
317    /// be used as the source of a part without buffering the whole content in memory.
318    pub fn reader<R>(reader: R) -> Self
319    where
320        R: AsyncRead + Send + Sync + 'static,
321    {
322        let stream =
323            ReaderStream::new(reader).map(|res| res.map(Frame::data).map_err(BoxError::from));
324        Self::new(PartData::Stream(Box::pin(stream)))
325    }
326
327    /// Create a [`Part`] from a file path, whose content is streamed lazily.
328    ///
329    /// The `filename` defaults to the file name of the path, and the `Content-Type` is guessed
330    /// from the file extension. Both can be overridden by [`Part::file_name`] and
331    /// [`Part::mime_str`]/[`Part::mime`].
332    pub async fn file<P>(path: P) -> io::Result<Self>
333    where
334        P: AsRef<Path>,
335    {
336        let path = path.as_ref();
337        let file_name = path
338            .file_name()
339            .map(|name| name.to_string_lossy().into_owned());
340        let mime = mime_guess::from_path(path).first();
341        let file = tokio::fs::File::open(path).await?;
342        let file_len = file.metadata().await?.len();
343
344        let mut part = Self::reader(file);
345        // Preserve the file length so a streaming multipart body can still expose an exact
346        // `Content-Length` when every part size is known.
347        part.length = Some(file_len);
348        if let Some(file_name) = file_name {
349            part = part.file_name(file_name);
350        }
351        if let Some(mime) = mime {
352            part = part.mime(mime);
353        }
354        Ok(part)
355    }
356
357    fn new(data: PartData) -> Self {
358        let length = data.len();
359        Self {
360            data,
361            length,
362            file_name: None,
363            mime: None,
364        }
365    }
366
367    fn len(&self) -> Option<u64> {
368        self.length
369    }
370
371    /// Set the `filename` of the part.
372    pub fn file_name<T>(mut self, file_name: T) -> Self
373    where
374        T: Into<Cow<'static, str>>,
375    {
376        self.file_name = Some(file_name.into());
377        self
378    }
379
380    /// Set the `Content-Type` of the part from a string.
381    ///
382    /// # Errors
383    ///
384    /// Returns an error if the given string is not a valid MIME type.
385    pub fn mime_str(self, mime: &str) -> Result<Self, mime::FromStrError> {
386        Ok(self.mime(mime.parse()?))
387    }
388
389    /// Set the `Content-Type` of the part.
390    pub fn mime(mut self, mime: Mime) -> Self {
391        self.mime = Some(mime);
392        self
393    }
394
395    /// Encode the leading boundary and headers of the part, i.e.
396    /// `--boundary\r\nContent-Disposition: ...\r\n[Content-Type: ...\r\n]\r\n`.
397    fn encode_header(&self, boundary: &str, name: &str) -> Bytes {
398        // Pre-size the buffer for the common case (no escaping) so the header is built in a single
399        // allocation instead of growing by repeated doubling. An under-estimate is still correct,
400        // it only costs a realloc.
401        let cap = 96
402            + boundary.len()
403            + name.len()
404            + self.file_name.as_ref().map_or(0, |f| f.len() + 16)
405            + self.mime.as_ref().map_or(0, |m| m.as_ref().len() + 16);
406        let mut buf = BytesMut::with_capacity(cap);
407        buf.extend_from_slice(b"--");
408        buf.extend_from_slice(boundary.as_bytes());
409        buf.extend_from_slice(b"\r\nContent-Disposition: form-data; name=\"");
410        extend_escaped(&mut buf, name);
411        buf.extend_from_slice(b"\"");
412        if let Some(file_name) = &self.file_name {
413            buf.extend_from_slice(b"; filename=\"");
414            extend_escaped(&mut buf, file_name);
415            buf.extend_from_slice(b"\"");
416        }
417        buf.extend_from_slice(b"\r\n");
418        if let Some(mime) = &self.mime {
419            buf.extend_from_slice(b"Content-Type: ");
420            buf.extend_from_slice(mime.as_ref().as_bytes());
421            buf.extend_from_slice(b"\r\n");
422        }
423        buf.extend_from_slice(b"\r\n");
424        buf.freeze()
425    }
426}
427
428/// Build a single-frame stream from a chunk of [`Bytes`].
429fn once_frame(bytes: Bytes) -> FrameStream {
430    Box::pin(futures_util::stream::once(
431        async move { Ok(Frame::data(bytes)) },
432    ))
433}
434
435/// Escape a field/file name for use inside a `Content-Disposition` quoted-string.
436///
437/// The value is emitted as an RFC 7578 / RFC 2616 `quoted-string`: `\` and `"` are backslash
438/// escaped (this is exactly what the server-side parser [`multer`](multer) un-escapes), and `\r` /
439/// `\n` are replaced with a space since a bare CR/LF is never legal inside a header value and would
440/// otherwise break framing. This matches the behavior of `reqwest` and browsers so a name/filename
441/// containing special characters round-trips correctly.
442fn extend_escaped(buf: &mut BytesMut, value: &str) {
443    let bytes = value.as_bytes();
444    // Bulk-copy runs of ordinary bytes and only handle the (rare) special characters one at a
445    // time, so a name/filename with no special characters is copied in a single `extend_from_slice`
446    // instead of byte by byte.
447    let mut start = 0;
448    for (i, &byte) in bytes.iter().enumerate() {
449        let replacement: &[u8] = match byte {
450            b'\\' => b"\\\\",
451            b'"' => b"\\\"",
452            b'\r' | b'\n' => b" ",
453            _ => continue,
454        };
455        buf.extend_from_slice(&bytes[start..i]);
456        buf.extend_from_slice(replacement);
457        start = i + 1;
458    }
459    buf.extend_from_slice(&bytes[start..]);
460}
461
462/// Generate a boundary that is unlikely to appear in the body.
463///
464/// The boundary mixes a random value (so it is not predictable, which matters when a part's
465/// content is attacker-influenced) with a per-process monotonic counter (so two forms created in
466/// the same process never collide even if the RNG were to repeat). The result is well within the
467/// 1-70 character limit of RFC 2046 and only uses characters that are valid in a boundary.
468fn gen_boundary() -> String {
469    use std::sync::atomic::{AtomicU64, Ordering};
470
471    static COUNTER: AtomicU64 = AtomicU64::new(0);
472
473    let rand = rand::random::<u64>();
474    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
475
476    format!("volo-http-boundary-{rand:016x}{seq:016x}")
477}
478
479#[cfg(test)]
480mod tests {
481    use http_body_util::BodyExt;
482    use tempfile::NamedTempFile;
483
484    use super::*;
485
486    async fn body_to_string(body: Body) -> String {
487        let bytes = body.collect().await.unwrap().to_bytes();
488        String::from_utf8(bytes.to_vec()).unwrap()
489    }
490
491    #[tokio::test]
492    async fn encode_text_fields() {
493        let form = Form::new().text("key1", "val1").text("key2", "val2");
494        let boundary = form.boundary().to_owned();
495        let content_type = form.content_type();
496        let body = body_to_string(form.into_body()).await;
497
498        assert_eq!(
499            content_type.to_str().unwrap(),
500            format!("multipart/form-data; boundary={boundary}")
501        );
502        let expected = format!(
503            "--{boundary}\r\nContent-Disposition: form-data; \
504             name=\"key1\"\r\n\r\nval1\r\n--{boundary}\r\nContent-Disposition: form-data; \
505             name=\"key2\"\r\n\r\nval2\r\n--{boundary}--\r\n"
506        );
507        assert_eq!(body, expected);
508    }
509
510    #[tokio::test]
511    async fn encode_reader_part_with_metadata() {
512        let form = Form::new().part(
513            "file",
514            Part::reader(std::io::Cursor::new(b"file-content".to_vec()))
515                .file_name("a.txt")
516                .mime_str("text/plain")
517                .unwrap(),
518        );
519        let boundary = form.boundary().to_owned();
520        let body = body_to_string(form.into_body()).await;
521
522        let expected = format!(
523            "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; \
524             filename=\"a.txt\"\r\nContent-Type: \
525             text/plain\r\n\r\nfile-content\r\n--{boundary}--\r\n"
526        );
527        assert_eq!(body, expected);
528    }
529
530    #[tokio::test]
531    async fn in_memory_form_has_known_length() {
532        use http_body::Body as _;
533
534        // An all-in-memory form should produce a body with an exact length so the request is sent
535        // with `Content-Length` rather than chunked.
536        let form = Form::new()
537            .text("key", "value")
538            .part("bytes", Part::bytes(&b"raw-bytes"[..]).file_name("a.bin"));
539        let body = form.into_body();
540
541        let hint = body.size_hint();
542        let exact = hint
543            .exact()
544            .expect("in-memory form should have a known length");
545        // The reported length must match the actual encoded bytes exactly, otherwise the framing
546        // would be corrupted on the wire.
547        let encoded = body.collect().await.unwrap().to_bytes();
548        assert_eq!(exact, encoded.len() as u64);
549    }
550
551    #[tokio::test]
552    async fn streaming_form_has_unknown_length() {
553        use http_body::Body as _;
554
555        // A form containing a streamed part cannot know its length upfront, so it falls back to a
556        // chunked body (no exact size hint).
557        let form = Form::new().text("key", "value").part(
558            "file",
559            Part::reader(std::io::Cursor::new(b"streamed".to_vec())),
560        );
561        let body = form.into_body();
562
563        assert!(body.size_hint().exact().is_none());
564    }
565
566    #[tokio::test]
567    async fn file_backed_form_has_known_length() {
568        use http_body::Body as _;
569
570        let temp = NamedTempFile::new().unwrap();
571        tokio::fs::write(temp.path(), b"file-content-from-disk")
572            .await
573            .unwrap();
574
575        let form = Form::new()
576            .text("key", "value")
577            .part("file", Part::file(temp.path()).await.unwrap());
578        let body = form.into_body();
579
580        let exact = body
581            .size_hint()
582            .exact()
583            .expect("file-backed form should keep a known length");
584        let encoded = body.collect().await.unwrap().to_bytes();
585        assert_eq!(exact, encoded.len() as u64);
586    }
587
588    #[test]
589    fn boundaries_are_unique_and_valid() {
590        let a = gen_boundary();
591        let b = gen_boundary();
592        // Two forms must not share a boundary (the counter guarantees this within a process).
593        assert_ne!(a, b);
594        // Well within the RFC 2046 1-70 character limit, and only boundary-legal characters.
595        assert!(a.len() <= 70);
596        assert!(
597            a.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'-'),
598            "boundary contains an invalid character: {a}"
599        );
600    }
601
602    #[test]
603    fn escape_special_chars() {
604        let mut buf = BytesMut::new();
605        extend_escaped(&mut buf, "a\"b\\c\r\nd");
606        // `"` -> `\"`, `\` -> `\\`, and `\r`/`\n` collapse to a space.
607        assert_eq!(&buf[..], b"a\\\"b\\\\c  d");
608
609        // No special characters: copied verbatim (fast path).
610        let mut buf = BytesMut::new();
611        extend_escaped(&mut buf, "plain_name.txt");
612        assert_eq!(&buf[..], b"plain_name.txt");
613
614        // Empty input and special chars at the very start/end (run boundaries).
615        let mut buf = BytesMut::new();
616        extend_escaped(&mut buf, "");
617        assert_eq!(&buf[..], b"");
618
619        let mut buf = BytesMut::new();
620        extend_escaped(&mut buf, "\"ab\"");
621        assert_eq!(&buf[..], b"\\\"ab\\\"");
622    }
623
624    #[tokio::test]
625    async fn quoted_name_roundtrips_through_multer() {
626        // A name/filename containing a `"` must survive a round-trip through the server-side
627        // parser (`multer`), which un-escapes the backslash-escaped quote.
628        //
629        // Note: a literal backslash is intentionally not asserted here. We escape it to `\\` (RFC
630        // quoted-string, same as reqwest) so it can never escape the closing delimiter and corrupt
631        // framing, but `multer` only collapses `\"` and leaves `\\` doubled, so a raw backslash
632        // cannot round-trip through it regardless of what the client emits.
633        let form = Form::new().part("my\"field", Part::text("value").file_name("a\"b.txt"));
634        let boundary = form.boundary().to_owned();
635        let bytes = form.into_body().collect().await.unwrap().to_bytes();
636
637        let stream =
638            futures_util::stream::once(async move { Ok::<_, std::convert::Infallible>(bytes) });
639        let mut multipart = multer::Multipart::new(stream, boundary);
640        let field = multipart.next_field().await.unwrap().unwrap();
641
642        assert_eq!(field.name().unwrap(), "my\"field");
643        assert_eq!(field.file_name().unwrap(), "a\"b.txt");
644        assert_eq!(field.bytes().await.unwrap(), &b"value"[..]);
645    }
646}