Skip to main content

rama_http/service/client/
multipart.rs

1//! Client-side `multipart/form-data` form builder.
2//!
3//! Build a [`Form`] from text, byte, file, or streaming [`Part`]s and send it
4//! via [`RequestBuilder::multipart`](super::ext::RequestBuilder::multipart) or
5//! by converting it directly to a [`Body`](crate::Body).
6//!
7//! Each [`Part`] carries an optional content size. When every part of a form
8//! has a known size, the form has a known content length; otherwise the body
9//! is sent with chunked transfer encoding.
10//!
11//! Output is RFC 7578 (`multipart/form-data`) on top of RFC 2046 framing.
12//! Boundaries use only characters from RFC 2046 §5.1.1's `bcharsnospace` set
13//! (random hex with `-` separators, ≤ 70 bytes). Each part is emitted with a
14//! `Content-Disposition: form-data` header carrying `name` and, where
15//! applicable, `filename` per RFC 7578 §4.2; non-ASCII bytes in those values
16//! are passed through as raw UTF-8. The legacy `filename*` ext-value form is
17//! deliberately not produced (RFC 7578 §4.2 forbids it for senders); the
18//! `Content-Transfer-Encoding` header is likewise omitted (§4.7).
19
20use rama_core::bytes::{BufMut, Bytes, BytesMut};
21use rama_core::error::{BoxError, ErrorContext as _, ErrorExt as _};
22use rama_core::futures::{StreamExt, TryStreamExt, stream};
23use rama_core::stream::io::ReaderStream;
24use rama_core::telemetry::tracing;
25use rama_http_types::{HeaderMap, HeaderValue, header, mime};
26use rama_utils::collections::smallvec::SmallVec;
27use rama_utils::macros::generate_set_and_with;
28use rama_utils::str::smol_str::{SmolStr, format_smolstr};
29use rand::RngExt as _;
30use std::borrow::Cow;
31use std::path::Path;
32use std::pin::Pin;
33use tokio::io::AsyncReadExt as _;
34
35/// Most multipart forms have a small number of parts; the inline buffer
36/// avoids a heap allocation in the common case (e.g. a text field plus a
37/// single file upload).
38const PARTS_INLINE_CAP: usize = 4;
39
40const CRLF: &[u8] = b"\r\n";
41const DASH_DASH: &[u8] = b"--";
42const FIELD_DISPOSITION_PREFIX: &[u8] = b"Content-Disposition: form-data; name=\"";
43const FILENAME_PREFIX: &[u8] = b"; filename=\"";
44const CONTENT_TYPE_PREFIX: &[u8] = b"Content-Type: ";
45const QUOTE: &[u8] = b"\"";
46const HEADER_KV_SEP: &[u8] = b": ";
47
48type ChunkStream = Pin<Box<dyn rama_core::futures::Stream<Item = Result<Bytes, BoxError>> + Send>>;
49
50/// A multipart form body.
51///
52/// Generates a random boundary on construction. Add named [`Part`]s with
53/// [`text`](Self::text), [`bytes`](Self::bytes), [`file`](Self::file), or
54/// [`part`](Self::part), then convert to a [`Body`](crate::Body) (or feed via
55/// [`RequestBuilder::multipart`](super::ext::RequestBuilder::multipart)).
56#[derive(Debug)]
57#[must_use]
58pub struct Form {
59    boundary: SmolStr,
60    parts: SmallVec<[NamedPart; PARTS_INLINE_CAP]>,
61}
62
63#[derive(Debug)]
64struct NamedPart {
65    name: Cow<'static, str>,
66    part: Part,
67}
68
69impl Default for Form {
70    fn default() -> Self {
71        Self::new()
72    }
73}
74
75impl Form {
76    /// Create a new empty `Form` with a random boundary.
77    pub fn new() -> Self {
78        Self {
79            boundary: gen_boundary(),
80            parts: SmallVec::new(),
81        }
82    }
83
84    /// The boundary string used to separate this form's parts.
85    #[must_use]
86    pub fn boundary(&self) -> &str {
87        &self.boundary
88    }
89
90    /// The `Content-Type` header value (`multipart/form-data; boundary=…`).
91    #[must_use]
92    #[expect(
93        clippy::unreachable,
94        reason = "boundary is constructed from validated bytes; HeaderValue::try_from is infallible by construction here, the Err arm exists only to satisfy unwrap_used/expect_used"
95    )]
96    pub fn content_type(&self) -> HeaderValue {
97        let value = format!("multipart/form-data; boundary={}", self.boundary);
98        // Boundaries are randomly generated `[0-9a-f-]` (all valid
99        // header-value bytes), so this conversion is infallible by
100        // construction. The branch only exists to satisfy the
101        // `unwrap_used`/`expect_used` lints; fall through to an
102        // unreachable rather than a silent header without the boundary
103        // parameter (which would be a protocol bug).
104        match HeaderValue::try_from(value) {
105            Ok(v) => v,
106            Err(_) => unreachable!("multipart boundary always converts to a HeaderValue"),
107        }
108    }
109
110    /// Add a text part to the form.
111    pub fn text<N, V>(self, name: N, value: V) -> Self
112    where
113        N: Into<Cow<'static, str>>,
114        V: Into<Cow<'static, str>>,
115    {
116        self.part(name, Part::text(value))
117    }
118
119    /// Add a bytes part to the form.
120    pub fn bytes<N, B>(self, name: N, value: B) -> Self
121    where
122        N: Into<Cow<'static, str>>,
123        B: Into<Bytes>,
124    {
125        self.part(name, Part::bytes(value))
126    }
127
128    /// Add a file part to the form. Reads the file asynchronously, infers the
129    /// MIME type from the file extension (falling back to
130    /// `application/octet-stream`), and sets `filename` from the path.
131    pub async fn file<N, P>(self, name: N, path: P) -> std::io::Result<Self>
132    where
133        N: Into<Cow<'static, str>>,
134        P: AsRef<Path>,
135    {
136        let part = Part::file(path).await?;
137        Ok(self.part(name, part))
138    }
139
140    /// Add a part described by a compact `name=value` field-spec string.
141    ///
142    /// See [`FieldSpec`] for the supported syntax (the same convention used
143    /// by curl `-F`, httpie, and similar tools). Performs the I/O implied by
144    /// `=@` (file), `=<` (file-as-text), and `=@-` / `=<-` (stdin) sources;
145    /// the `=value` form is purely textual.
146    pub async fn with_field_spec(self, spec: &str) -> Result<Self, FieldSpecError> {
147        let parsed = FieldSpec::parse(spec)?;
148        let name = parsed.name.to_owned();
149        let part = parsed.into_part().await?;
150        Ok(self.part(name, part))
151    }
152
153    /// Add a custom [`Part`] to the form.
154    pub fn part<N>(mut self, name: N, part: Part) -> Self
155    where
156        N: Into<Cow<'static, str>>,
157    {
158        self.parts.push(NamedPart {
159            name: name.into(),
160            part,
161        });
162        self
163    }
164
165    /// Total content length of the encoded form, if every part has a known
166    /// size. Returns `None` otherwise (use chunked transfer encoding in that
167    /// case).
168    ///
169    /// Computed analytically — no headers are rendered into a buffer.
170    #[must_use]
171    pub fn content_length(&self) -> Option<u64> {
172        let mut total: u64 = 0;
173        for np in &self.parts {
174            let part_size = np.part.content_size?;
175            let header_len = part_headers_len(&self.boundary, &np.name, &np.part) as u64;
176            total = total.checked_add(header_len)?;
177            total = total.checked_add(part_size)?;
178            total = total.checked_add(CRLF.len() as u64)?;
179        }
180        let trailer_len =
181            (DASH_DASH.len() + self.boundary.len() + DASH_DASH.len() + CRLF.len()) as u64;
182        total = total.checked_add(trailer_len)?;
183        Some(total)
184    }
185
186    /// Convert this form into a stream of body chunks.
187    ///
188    /// Per-part overhead: one heap-allocated framing chunk (boundary
189    /// delimiter + headers, prefixed with CRLF on all but the first part)
190    /// and the part body. Bytes-bodied parts are emitted in a single chunk;
191    /// streamed bodies pass through their underlying chunks unchanged.
192    pub fn into_stream(
193        self,
194    ) -> impl rama_core::futures::Stream<Item = Result<Bytes, BoxError>> + Send {
195        let boundary = self.boundary;
196        let n_parts = self.parts.len();
197
198        // Build the closing trailer up front: the leading CRLF replaces the
199        // last part's body-trailing CRLF (the CRLF before each boundary
200        // delimiter is part of the delimiter per RFC 2046 §5.1.1).
201        let trailer = {
202            let cap = if n_parts == 0 { 0 } else { CRLF.len() }
203                + DASH_DASH.len()
204                + boundary.len()
205                + DASH_DASH.len()
206                + CRLF.len();
207            let mut buf = BytesMut::with_capacity(cap);
208            if n_parts > 0 {
209                buf.put_slice(CRLF);
210            }
211            buf.put_slice(DASH_DASH);
212            buf.put_slice(boundary.as_bytes());
213            buf.put_slice(DASH_DASH);
214            buf.put_slice(CRLF);
215            buf.freeze()
216        };
217
218        // 2 streams per part (framing + body) + 1 for the trailer.
219        let mut chunks: Vec<ChunkStream> = Vec::with_capacity(n_parts * 2 + 1);
220        for (i, np) in self.parts.into_iter().enumerate() {
221            // Framing: for parts after the first we prepend CRLF (the
222            // delimiter's leading CRLF, which doubles as the prior body's
223            // trailer per RFC 2046).
224            let framing = render_framing(&boundary, &np.name, &np.part, i > 0);
225            chunks.push(Box::pin(stream::iter([Ok::<Bytes, BoxError>(framing)])));
226            chunks.push(match np.part.body {
227                PartBody::Bytes(b) => Box::pin(stream::iter([Ok::<Bytes, BoxError>(b)])),
228                PartBody::Stream(s) => s,
229            });
230        }
231        chunks.push(Box::pin(stream::iter([Ok::<Bytes, BoxError>(trailer)])));
232
233        stream::iter(chunks).flatten()
234    }
235
236    /// Consume the form and produce a [`Body`](crate::Body) ready to be set on
237    /// a request. Use [`content_type`](Self::content_type) and
238    /// [`content_length`](Self::content_length) to set the relevant request
239    /// headers.
240    pub fn into_body(self) -> crate::Body {
241        crate::Body::from_stream(self.into_stream())
242    }
243}
244
245/// A single part of a multipart [`Form`].
246///
247/// Built via [`text`](Self::text), [`bytes`](Self::bytes),
248/// [`stream`](Self::stream), or [`file`](Self::file). Customise with
249/// [`with_file_name`](Self::with_file_name),
250/// [`try_with_mime_str`](Self::try_with_mime_str),
251/// [`with_content_size`](Self::with_content_size), or
252/// [`with_headers`](Self::with_headers).
253#[must_use]
254pub struct Part {
255    body: PartBody,
256    content_size: Option<u64>,
257    file_name: Option<Cow<'static, str>>,
258    mime: Option<mime::Mime>,
259    headers: HeaderMap,
260}
261
262impl std::fmt::Debug for Part {
263    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264        f.debug_struct("Part")
265            .field(
266                "body_kind",
267                &match &self.body {
268                    PartBody::Bytes(b) => format!("bytes ({} B)", b.len()),
269                    PartBody::Stream(_) => String::from("stream"),
270                },
271            )
272            .field("content_size", &self.content_size)
273            .field("file_name", &self.file_name)
274            .field("mime", &self.mime.as_ref().map(mime::Mime::essence_str))
275            .field("headers", &self.headers)
276            .finish()
277    }
278}
279
280enum PartBody {
281    Bytes(Bytes),
282    Stream(ChunkStream),
283}
284
285impl Part {
286    /// Create a text part.
287    pub fn text<V: Into<Cow<'static, str>>>(value: V) -> Self {
288        let bytes = Bytes::from(value.into().into_owned().into_bytes());
289        let len = bytes.len() as u64;
290        Self {
291            body: PartBody::Bytes(bytes),
292            content_size: Some(len),
293            file_name: None,
294            mime: None,
295            headers: HeaderMap::new(),
296        }
297    }
298
299    /// Create a part from raw bytes.
300    pub fn bytes<B: Into<Bytes>>(value: B) -> Self {
301        let bytes: Bytes = value.into();
302        let len = bytes.len() as u64;
303        Self {
304            body: PartBody::Bytes(bytes),
305            content_size: Some(len),
306            file_name: None,
307            mime: None,
308            headers: HeaderMap::new(),
309        }
310    }
311
312    /// Create a streaming part. The content size is unknown unless set
313    /// explicitly via [`with_content_size`](Self::with_content_size).
314    pub fn stream<S, O, E>(stream: S) -> Self
315    where
316        S: rama_core::futures::Stream<Item = Result<O, E>> + Send + 'static,
317        O: Into<Bytes> + 'static,
318        E: Into<BoxError> + 'static,
319    {
320        let mapped = stream.map_ok(Into::into).map_err(Into::into);
321        Self {
322            body: PartBody::Stream(Box::pin(mapped)),
323            content_size: None,
324            file_name: None,
325            mime: None,
326            headers: HeaderMap::new(),
327        }
328    }
329
330    /// Create a part from a file. The filename is taken from the path's last
331    /// component, the MIME type is inferred from the extension (falling back
332    /// to `application/octet-stream`), and the content size is read from
333    /// filesystem metadata.
334    ///
335    /// Filenames are converted to UTF-8 using lossy replacement of any
336    /// non-UTF-8 bytes (relevant on Unix where filenames are arbitrary
337    /// byte sequences). If you need to preserve non-UTF-8 names verbatim,
338    /// build the [`Part`] yourself with [`Part::file`] and override the
339    /// name via [`Part::with_file_name`] from a known UTF-8 source.
340    ///
341    /// The reported `content_size` is taken from filesystem metadata at
342    /// the moment of the call. Concurrent writers that change the file
343    /// size between this call and the body being sent can desynchronise
344    /// the advertised `Content-Length` from the actually-streamed bytes;
345    /// avoid passing a path to a file that may be modified mid-flight.
346    pub async fn file<P: AsRef<Path>>(path: P) -> std::io::Result<Self> {
347        let path = path.as_ref();
348        let file_name: Option<Cow<'static, str>> = path
349            .file_name()
350            .map(|name| Cow::Owned(name.to_string_lossy().into_owned()));
351        let mime = path
352            .extension()
353            .and_then(std::ffi::OsStr::to_str)
354            .and_then(|ext| mime_guess::from_ext(ext).first())
355            .unwrap_or(mime::APPLICATION_OCTET_STREAM);
356
357        let file = rama_utils::fs::safe_open(path).await?;
358        let metadata = file.metadata().await?;
359        let len = metadata.len();
360
361        tracing::debug!(
362            path = %path.display(),
363            size = len,
364            mime = %mime,
365            "multipart::Part::file: opened file for streaming",
366        );
367
368        let stream = ReaderStream::new(file);
369        let mapped = stream.map_ok(Bytes::from).map_err(BoxError::from);
370
371        Ok(Self {
372            body: PartBody::Stream(Box::pin(mapped)),
373            content_size: Some(len),
374            file_name,
375            mime: Some(mime),
376            headers: HeaderMap::new(),
377        })
378    }
379
380    generate_set_and_with! {
381        /// Filename used in the part's `Content-Disposition` header.
382        ///
383        /// Accepts anything that converts into [`Cow<'static, str>`] —
384        /// `&'static str` literals, owned `String`, or an explicit
385        /// `Cow::Owned`/`Cow::Borrowed`.
386        pub fn file_name(mut self, file_name: impl Into<Cow<'static, str>>) -> Self {
387            self.file_name = Some(file_name.into());
388            self
389        }
390    }
391
392    generate_set_and_with! {
393        /// The part's `Content-Type`, as a parsed [`Mime`](mime::Mime).
394        ///
395        /// `with_*`/`set_*` set, `without_*`/`unset_*` clear any
396        /// previously-set value.
397        pub fn mime(mut self, mime: Option<mime::Mime>) -> Self {
398            self.mime = mime;
399            self
400        }
401    }
402
403    generate_set_and_with! {
404        /// The part's `Content-Type` parsed from a string such as
405        /// `"image/png"`. Generates `try_with_mime_str` /
406        /// `try_set_mime_str` companions returning `Result`.
407        pub fn mime_str(mut self, mime_str: &str) -> Result<Self, mime::FromStrError> {
408            self.mime = Some(mime_str.parse()?);
409            Ok(self)
410        }
411    }
412
413    generate_set_and_with! {
414        /// Known content size in bytes. For streaming parts this allows the
415        /// surrounding [`Form`] to advertise a `Content-Length`.
416        pub fn content_size(mut self, size: Option<u64>) -> Self {
417            self.content_size = size;
418            self
419        }
420    }
421
422    generate_set_and_with! {
423        /// Replace the part's headers (other than `Content-Disposition` and
424        /// `Content-Type`, which are derived from the part's metadata).
425        ///
426        /// RFC 7578 §4.8 states that headers other than `Content-Disposition`,
427        /// `Content-Type`, and (legacy) `Content-Transfer-Encoding` "MUST NOT be
428        /// included and MUST be ignored" by receivers. Custom headers are
429        /// allowed here for compatibility with non-standard receivers, but
430        /// strictly conforming peers will silently drop them.
431        pub fn headers(mut self, headers: HeaderMap) -> Self {
432            self.headers = headers;
433            self
434        }
435    }
436}
437
438/// Render the boundary delimiter plus part headers as a single chunk.
439///
440/// `with_leading_crlf` adds the delimiter's CRLF prefix used between parts;
441/// the very first part of a form has no preceding CRLF (its preamble is
442/// empty), per RFC 2046 §5.1.1.
443fn render_framing(boundary: &str, name: &str, part: &Part, with_leading_crlf: bool) -> Bytes {
444    let cap =
445        if with_leading_crlf { CRLF.len() } else { 0 } + part_headers_len(boundary, name, part);
446    let mut buf = BytesMut::with_capacity(cap);
447    if with_leading_crlf {
448        buf.put_slice(CRLF);
449    }
450    buf.put_slice(DASH_DASH);
451    buf.put_slice(boundary.as_bytes());
452    buf.put_slice(CRLF);
453    buf.put_slice(FIELD_DISPOSITION_PREFIX);
454    write_quoted(&mut buf, name);
455    buf.put_slice(QUOTE);
456    if let Some(file_name) = part.file_name.as_deref() {
457        buf.put_slice(FILENAME_PREFIX);
458        write_quoted(&mut buf, file_name);
459        buf.put_slice(QUOTE);
460    }
461    buf.put_slice(CRLF);
462    if let Some(mime) = &part.mime {
463        buf.put_slice(CONTENT_TYPE_PREFIX);
464        // `as_ref` returns the full mime string including parameters
465        // (e.g. `text/plain; charset=utf-8`); essence_str would drop them.
466        buf.put_slice(mime.as_ref().as_bytes());
467        buf.put_slice(CRLF);
468    }
469    for (name, value) in &part.headers {
470        if name == header::CONTENT_DISPOSITION || name == header::CONTENT_TYPE {
471            continue;
472        }
473        buf.put_slice(name.as_str().as_bytes());
474        buf.put_slice(HEADER_KV_SEP);
475        buf.put_slice(value.as_bytes());
476        buf.put_slice(CRLF);
477    }
478    buf.put_slice(CRLF);
479    buf.freeze()
480}
481
482/// Compute the byte length of the headers `render_part_headers` would emit,
483/// without doing any allocation. Must stay in lock-step with the rendering
484/// logic.
485fn part_headers_len(boundary: &str, name: &str, part: &Part) -> usize {
486    // "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\""
487    let mut len = DASH_DASH.len()
488        + boundary.len()
489        + CRLF.len()
490        + FIELD_DISPOSITION_PREFIX.len()
491        + quoted_len(name)
492        + QUOTE.len();
493    // "; filename=\"{file_name}\""
494    if let Some(file_name) = part.file_name.as_deref() {
495        len += FILENAME_PREFIX.len() + quoted_len(file_name) + QUOTE.len();
496    }
497    len += CRLF.len();
498    // "Content-Type: {mime}\r\n" — full mime string including any parameters
499    // (e.g. `text/plain; charset=utf-8`).
500    if let Some(mime) = &part.mime {
501        len += CONTENT_TYPE_PREFIX.len() + mime.as_ref().len() + CRLF.len();
502    }
503    // Custom headers (excluding the two we always derive ourselves).
504    for (h_name, h_value) in &part.headers {
505        if h_name == header::CONTENT_DISPOSITION || h_name == header::CONTENT_TYPE {
506            continue;
507        }
508        len += h_name.as_str().len() + HEADER_KV_SEP.len() + h_value.as_bytes().len() + CRLF.len();
509    }
510    // Blank line separating headers from body.
511    len += CRLF.len();
512    len
513}
514
515/// Counts the bytes `write_quoted` would emit.
516fn quoted_len(s: &str) -> usize {
517    s.bytes()
518        .map(|b| match b {
519            b'"' | b'\\' => 2,
520            // CR/LF replaced by a single space.
521            _ => 1,
522        })
523        .sum()
524}
525
526fn write_quoted(buf: &mut BytesMut, s: &str) {
527    for byte in s.as_bytes() {
528        match *byte {
529            b'"' | b'\\' => {
530                buf.put_u8(b'\\');
531                buf.put_u8(*byte);
532            }
533            b'\r' | b'\n' => {
534                // RFC 7578 forbids CR/LF in name/filename — replace with space.
535                buf.put_u8(b' ');
536            }
537            b => buf.put_u8(b),
538        }
539    }
540}
541
542/// A parsed `name=…` field spec for use with [`Form::with_field_spec`].
543///
544/// The same compact convention used by `curl -F` and friends:
545///
546/// | Spec | Meaning |
547/// |---|---|
548/// | `name=value` | text field |
549/// | `name=@path` | file upload (mime guessed from extension) |
550/// | `name=@-` | file upload from stdin |
551/// | `name=<path` | file content as a text field (not an upload) |
552/// | `name=<-` | text field content from stdin |
553///
554/// Modifiers may follow the source, separated by `;`:
555/// - `;type=mime/sub` overrides the part's `Content-Type`
556/// - `;filename=name` overrides the `filename` parameter
557///
558/// Example: `avatar=@./photo.png;type=image/png;filename=me.png`
559///
560/// # Limitations
561///
562/// Modifier splitting is naive: the first `;` after the value terminates the
563/// value. A literal `;` cannot appear inside a `name=value` text payload via
564/// this syntax. For text values containing `;`, build the [`Part`] directly
565/// with [`Part::text`] and add it via [`Form::part`].
566#[derive(Debug, Clone)]
567pub struct FieldSpec<'a> {
568    /// Field name (the part to the left of `=`).
569    pub name: &'a str,
570    /// Where the value comes from.
571    pub source: FieldSpecSource<'a>,
572    /// Optional `;type=…` override.
573    pub content_type: Option<&'a str>,
574    /// Optional `;filename=…` override.
575    pub filename: Option<&'a str>,
576}
577
578/// Source of a [`FieldSpec`] value.
579#[derive(Debug, Clone)]
580pub enum FieldSpecSource<'a> {
581    /// `name=value` — literal text.
582    Text(&'a str),
583    /// `name=@path` — upload the file's bytes; `path = "-"` reads stdin.
584    File(&'a str),
585    /// `name=<path` — read file content into a text field; `path = "-"` reads stdin.
586    FileText(&'a str),
587}
588
589/// Error type returned by [`FieldSpec::parse`].
590#[derive(Debug)]
591pub enum FieldSpecError {
592    /// The spec is missing a `=` separator between name and value.
593    MissingSeparator,
594    /// The field name (left of `=`) is empty.
595    EmptyName,
596    /// A `;…` modifier was malformed, or an I/O step (file open, stdin
597    /// read) failed during [`FieldSpec::into_part`].
598    InvalidModifier(BoxError),
599}
600
601impl std::fmt::Display for FieldSpecError {
602    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
603        match self {
604            Self::MissingSeparator => write!(f, "field spec is missing `=` separator"),
605            Self::EmptyName => write!(f, "field spec has empty name"),
606            Self::InvalidModifier(err) => write!(f, "invalid field spec: {err}"),
607        }
608    }
609}
610
611impl std::error::Error for FieldSpecError {
612    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
613        match self {
614            Self::InvalidModifier(err) => Some(&**err),
615            _ => None,
616        }
617    }
618}
619
620/// Small adapter to box a dynamic-string error message without going
621/// through `String` and the heavier error machinery.
622#[derive(Debug)]
623struct InlineErr(SmolStr);
624
625impl std::fmt::Display for InlineErr {
626    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
627        f.write_str(&self.0)
628    }
629}
630
631impl std::error::Error for InlineErr {}
632
633impl<'a> FieldSpec<'a> {
634    /// Parse a field spec string. Pure: does no I/O.
635    pub fn parse(spec: &'a str) -> Result<Self, FieldSpecError> {
636        let (name, rest) = spec
637            .split_once('=')
638            .ok_or(FieldSpecError::MissingSeparator)?;
639        if name.is_empty() {
640            return Err(FieldSpecError::EmptyName);
641        }
642
643        // Modifiers (;type=, ;filename=) are split off the right of the
644        // value. Multiple modifiers may follow.
645        let mut content_type: Option<&str> = None;
646        let mut filename: Option<&str> = None;
647        let value_part: &str;
648
649        if let Some((value, modifiers)) = split_modifiers(rest) {
650            value_part = value;
651            for modifier in modifiers.split(';') {
652                let modifier = modifier.trim();
653                if modifier.is_empty() {
654                    continue;
655                }
656                let (key, val) = modifier.split_once('=').ok_or_else(|| {
657                    FieldSpecError::InvalidModifier(
658                        InlineErr(format_smolstr!("missing `=` in modifier `{modifier}`")).into(),
659                    )
660                })?;
661                match key.trim() {
662                    "type" => content_type = Some(val),
663                    "filename" => filename = Some(val),
664                    other => {
665                        return Err(FieldSpecError::InvalidModifier(
666                            InlineErr(format_smolstr!("unknown modifier key `{other}`")).into(),
667                        ));
668                    }
669                }
670            }
671        } else {
672            value_part = rest;
673        }
674
675        let source = if let Some(path) = value_part.strip_prefix('@') {
676            FieldSpecSource::File(path)
677        } else if let Some(path) = value_part.strip_prefix('<') {
678            FieldSpecSource::FileText(path)
679        } else {
680            FieldSpecSource::Text(value_part)
681        };
682
683        Ok(Self {
684            name,
685            source,
686            content_type,
687            filename,
688        })
689    }
690
691    /// Resolve this spec into a [`Part`], performing any necessary I/O.
692    pub async fn into_part(self) -> Result<Part, FieldSpecError> {
693        let mut part = match self.source {
694            FieldSpecSource::Text(s) => Part::text(s.to_owned()),
695            FieldSpecSource::File("-") => Part::stream(read_stdin_stream()),
696            FieldSpecSource::File(path) => Part::file(path)
697                .await
698                .with_context(|| format_smolstr!("multipart field spec: open file `{path}`"))
699                .map_err(|e| FieldSpecError::InvalidModifier(e.into_box_error()))?,
700            FieldSpecSource::FileText("-") => {
701                let s = read_stdin_to_string()
702                    .await
703                    .context("multipart field spec: read stdin as text")
704                    .map_err(|e| FieldSpecError::InvalidModifier(e.into_box_error()))?;
705                Part::text(s)
706            }
707            FieldSpecSource::FileText(path) => {
708                let s = tokio::fs::read_to_string(path)
709                    .await
710                    .with_context(|| format_smolstr!("multipart field spec: read file `{path}`"))
711                    .map_err(|e| FieldSpecError::InvalidModifier(e.into_box_error()))?;
712                Part::text(s)
713            }
714        };
715        if let Some(ct) = self.content_type {
716            part.try_set_mime_str(ct)
717                .with_context(|| format_smolstr!("invalid `;type=` mime in field spec: {ct}"))
718                .map_err(|e| FieldSpecError::InvalidModifier(e.into_box_error()))?;
719        }
720        if let Some(fname) = self.filename {
721            part.set_file_name(fname.to_owned());
722        }
723        Ok(part)
724    }
725}
726
727/// Find the first un-quoted `;` that starts a modifier section, splitting
728/// the value from the modifier list. Returns `None` if there are no
729/// modifiers.
730fn split_modifiers(input: &str) -> Option<(&str, &str)> {
731    // Naive split on the first `;` is fine here — values in field specs
732    // do not have a quoted form, by convention.
733    input.split_once(';')
734}
735
736async fn read_stdin_to_string() -> Result<String, BoxError> {
737    let mut buf = String::new();
738    tokio::io::stdin()
739        .read_to_string(&mut buf)
740        .await
741        .context("read multipart field value from stdin")?;
742    Ok(buf)
743}
744
745fn read_stdin_stream() -> impl rama_core::futures::Stream<Item = Result<Bytes, BoxError>> + Send {
746    ReaderStream::new(tokio::io::stdin())
747        .map_ok(Bytes::from)
748        .map_err(BoxError::from)
749}
750
751fn gen_boundary() -> SmolStr {
752    let mut rng = rand::rng();
753    format_smolstr!(
754        "{:016x}-{:016x}-{:016x}-{:016x}",
755        rng.random::<u64>(),
756        rng.random::<u64>(),
757        rng.random::<u64>(),
758        rng.random::<u64>(),
759    )
760}
761
762#[cfg(test)]
763mod test {
764    use super::*;
765    use rama_core::futures::TryStreamExt;
766
767    async fn collect(form: Form) -> (HeaderValue, Option<u64>, Vec<u8>) {
768        let ct = form.content_type();
769        let len = form.content_length();
770        let bytes: Vec<u8> = form
771            .into_stream()
772            .map_ok(|chunk| chunk.to_vec())
773            .try_collect::<Vec<Vec<u8>>>()
774            .await
775            .unwrap()
776            .into_iter()
777            .flatten()
778            .collect();
779        (ct, len, bytes)
780    }
781
782    #[tokio::test]
783    async fn test_form_text_only() {
784        let form = Form::new().text("name", "glen").text("language", "rust");
785        let boundary = form.boundary().to_owned();
786        let (ct, len, bytes) = collect(form).await;
787        assert!(ct.to_str().unwrap().contains(&boundary));
788        assert_eq!(len.unwrap() as usize, bytes.len());
789        let s = std::str::from_utf8(&bytes).unwrap();
790        assert!(s.contains("name=\"name\""));
791        assert!(s.contains("name=\"language\""));
792        assert!(s.contains("\r\nglen\r\n"));
793        assert!(s.contains("\r\nrust\r\n"));
794        assert!(s.ends_with("--\r\n"));
795    }
796
797    #[tokio::test]
798    async fn test_form_bytes_with_filename_and_mime() {
799        let part = Part::bytes(b"\x00\x01\x02".as_slice())
800            .with_file_name("a.bin")
801            .with_mime(mime::APPLICATION_OCTET_STREAM);
802        let form = Form::new().part("avatar", part);
803        let (_, len, bytes) = collect(form).await;
804        assert!(len.is_some());
805        let s = std::str::from_utf8(&bytes[..bytes.iter().position(|&b| b == 0).unwrap()]).unwrap();
806        assert!(s.contains("filename=\"a.bin\""));
807        assert!(s.contains("Content-Type: application/octet-stream"));
808    }
809
810    #[tokio::test]
811    async fn test_form_unknown_length_when_streaming() {
812        let part = Part::stream(stream::iter([
813            Ok::<Bytes, BoxError>(Bytes::from_static(b"hello ")),
814            Ok::<Bytes, BoxError>(Bytes::from_static(b"world")),
815        ]));
816        let form = Form::new().part("payload", part);
817        assert!(form.content_length().is_none());
818        let (_, _len, bytes) = collect(form).await;
819        let s = std::str::from_utf8(&bytes).unwrap();
820        assert!(s.contains("hello world"));
821    }
822
823    #[tokio::test]
824    async fn test_form_known_length_when_streaming_with_content_size() {
825        let part = Part::stream(stream::iter([Ok::<Bytes, BoxError>(Bytes::from_static(
826            b"abcdef",
827        ))]))
828        .with_content_size(6);
829        let form = Form::new().part("payload", part);
830        let len = form.content_length().expect("length known");
831        let (_, _, bytes) = collect(form).await;
832        assert_eq!(len as usize, bytes.len());
833    }
834
835    #[tokio::test]
836    async fn test_form_quoting_escapes_quotes() {
837        let form = Form::new().text("we\"ird", "v");
838        let (_, _, bytes) = collect(form).await;
839        let s = std::str::from_utf8(&bytes).unwrap();
840        assert!(s.contains("name=\"we\\\"ird\""));
841    }
842
843    #[tokio::test]
844    async fn test_form_preserves_mime_parameters() {
845        // Regression: prior implementation used `mime.essence_str()` which
846        // dropped the charset parameter. Senders must emit the full mime
847        // including any params like `charset=utf-8`.
848        let part = Part::bytes(b"hi".as_slice())
849            .try_with_mime_str("text/plain; charset=utf-8")
850            .unwrap();
851        let form = Form::new().part("note", part);
852        let len = form.content_length().expect("length known");
853        let (_, _, bytes) = collect(form).await;
854        assert_eq!(len as usize, bytes.len());
855        let s = std::str::from_utf8(&bytes).unwrap();
856        assert!(
857            s.contains("Content-Type: text/plain; charset=utf-8"),
858            "rendered body: {s}"
859        );
860    }
861
862    #[test]
863    fn test_field_spec_text() {
864        let s = FieldSpec::parse("name=glen").unwrap();
865        assert_eq!(s.name, "name");
866        assert!(matches!(s.source, FieldSpecSource::Text("glen")));
867        assert!(s.content_type.is_none());
868        assert!(s.filename.is_none());
869    }
870
871    #[test]
872    fn test_field_spec_file_with_modifiers() {
873        let s = FieldSpec::parse("avatar=@./photo.png;type=image/png;filename=me.png").unwrap();
874        assert_eq!(s.name, "avatar");
875        assert!(matches!(s.source, FieldSpecSource::File("./photo.png")));
876        assert_eq!(s.content_type, Some("image/png"));
877        assert_eq!(s.filename, Some("me.png"));
878    }
879
880    #[test]
881    fn test_field_spec_file_text() {
882        let s = FieldSpec::parse("greeting=<hello.txt").unwrap();
883        assert_eq!(s.name, "greeting");
884        assert!(matches!(s.source, FieldSpecSource::FileText("hello.txt")));
885    }
886
887    #[test]
888    fn test_field_spec_stdin() {
889        let s = FieldSpec::parse("blob=@-").unwrap();
890        assert!(matches!(s.source, FieldSpecSource::File("-")));
891    }
892
893    #[test]
894    fn test_field_spec_errors() {
895        assert!(matches!(
896            FieldSpec::parse("noequal"),
897            Err(FieldSpecError::MissingSeparator)
898        ));
899        assert!(matches!(
900            FieldSpec::parse("=value"),
901            Err(FieldSpecError::EmptyName)
902        ));
903        assert!(matches!(
904            FieldSpec::parse("name=v;invalid"),
905            Err(FieldSpecError::InvalidModifier(_))
906        ));
907        assert!(matches!(
908            FieldSpec::parse("name=v;weird=val"),
909            Err(FieldSpecError::InvalidModifier(_))
910        ));
911    }
912
913    #[tokio::test]
914    async fn test_form_with_field_spec_text() {
915        let form = Form::new()
916            .with_field_spec("name=glen")
917            .await
918            .unwrap()
919            .with_field_spec("lang=rust")
920            .await
921            .unwrap();
922        let (_, _, bytes) = collect(form).await;
923        let s = std::str::from_utf8(&bytes).unwrap();
924        assert!(s.contains("name=\"name\""));
925        assert!(s.contains("\r\nglen\r\n"));
926        assert!(s.contains("name=\"lang\""));
927        assert!(s.contains("\r\nrust\r\n"));
928    }
929
930    #[tokio::test]
931    async fn test_form_with_field_spec_file() {
932        let dir = tempfile::tempdir().unwrap();
933        let path = dir.path().join("hello.txt");
934        tokio::fs::write(&path, b"hi from disk").await.unwrap();
935        let spec = format!("note=@{};type=text/plain", path.display());
936
937        let form = Form::new().with_field_spec(&spec).await.unwrap();
938        let (_, _, bytes) = collect(form).await;
939        let s = std::str::from_utf8(&bytes).unwrap();
940        assert!(s.contains("name=\"note\""));
941        assert!(s.contains("filename=\"hello.txt\""));
942        assert!(s.contains("Content-Type: text/plain"));
943        assert!(s.contains("hi from disk"));
944    }
945
946    #[tokio::test]
947    async fn test_field_spec_file_text_allows_parent_dir_paths() {
948        let dir = tempfile::tempdir().unwrap();
949        let child = dir.path().join("child");
950        tokio::fs::create_dir(&child).await.unwrap();
951        let payload = dir.path().join("payload.txt");
952        tokio::fs::write(&payload, b"hello parent").await.unwrap();
953        let spec = format!("greeting=<{}", child.join("../payload.txt").display());
954
955        let form = Form::new().with_field_spec(&spec).await.unwrap();
956        let (_, _, bytes) = collect(form).await;
957        let s = std::str::from_utf8(&bytes).unwrap();
958        assert!(s.contains("\r\nhello parent\r\n"));
959    }
960}