Skip to main content

umbral_core/web/
multipart.rs

1//! `multipart/form-data` parsing and the storage-merge upload helper.
2//!
3//! ## What this is
4//!
5//! A browser that POSTs a form containing a `<input type="file">` sends a
6//! `multipart/form-data` body, not the `application/x-www-form-urlencoded`
7//! body the rest of the form layer ([`crate::forms`], the admin's
8//! `serde_urlencoded` path) understands. This module turns that multipart
9//! body into the *same* flat `Vec<(String, String)>` shape the urlencoded
10//! path yields — text fields stay as `(name, value)` pairs, and each
11//! uploaded file is stored through the ambient [`Storage`] backend and
12//! reduced to a `(field_name, stored_key)` pair. A consumer (the admin's
13//! `create` / `update` handlers, wired in a later wave) can then feed the
14//! result to the ORM identically whether the body was urlencoded or
15//! multipart.
16//!
17//! ## Layering
18//!
19//! Two layers, so each is independently testable:
20//!
21//! 1. [`parse_multipart`] — pure parsing. No storage, no I/O beyond reading
22//!    the in-memory body. Returns a [`MultipartForm`] separating text
23//!    [`MultipartForm::fields`] from binary [`MultipartForm::files`].
24//! 2. [`parse_and_store_multipart`] — parse, then [`Storage::store`] every
25//!    non-empty file part and flatten everything to `Vec<(String, String)>`.
26//!
27//! [`Storage`]: crate::storage::Storage
28//! [`Storage::store`]: crate::storage::Storage::store
29
30use std::convert::Infallible;
31
32use crate::storage::StorageError;
33
34/// Default in-memory cap [`parse_multipart`] enforces on a multipart body, in
35/// bytes (**32 MiB**). Matches the framework-wide request-body limit
36/// `App::build` installs, so the multipart parser is a defence-in-depth
37/// backstop: even a caller that reads the raw body itself (the admin's upload
38/// handlers) and hands it here can't buffer an unbounded body. Call
39/// [`parse_multipart_capped`] to pick a different ceiling.
40pub const DEFAULT_MAX_MULTIPART_BYTES: usize = 32 * 1024 * 1024;
41
42/// One uploaded file part of a `multipart/form-data` body.
43///
44/// A multipart part is treated as a *file* iff multer reports a
45/// `Content-Disposition` `filename` for it; a part with no filename is a
46/// plain text field and lands in [`MultipartForm::fields`] instead. The
47/// raw [`bytes`](FilePart::bytes) are kept verbatim — never lossy-decoded —
48/// so binary uploads (images, PDFs) round-trip intact.
49#[derive(Clone, Debug)]
50pub struct FilePart {
51    /// The form field name (the `<input name="...">`).
52    pub field_name: String,
53    /// The client-supplied filename from the `Content-Disposition` header,
54    /// if any. Used to derive the storage key and as a content-type hint.
55    pub filename: Option<String>,
56    /// The part's declared `Content-Type`, if the client sent one.
57    pub content_type: Option<String>,
58    /// The raw file bytes, exactly as received.
59    pub bytes: Vec<u8>,
60}
61
62/// A parsed `multipart/form-data` body: text fields and file parts.
63///
64/// [`fields`](MultipartForm::fields) preserves both order and repeats — a
65/// multi-select / M2M widget sends the same field name multiple times and
66/// every value has to survive — so it is a `Vec`, not a map.
67#[derive(Debug, Default)]
68pub struct MultipartForm {
69    /// The non-file text parts, as `(name, value)` pairs, in body order,
70    /// with repeats preserved.
71    pub fields: Vec<(String, String)>,
72    /// The uploaded file parts (those with a `filename`), in body order.
73    pub files: Vec<FilePart>,
74}
75
76impl MultipartForm {
77    /// The value of the text field `name`, last-wins if it repeats.
78    ///
79    /// Returns `None` if no text field by that name was sent. (File parts
80    /// are not considered; look in [`files`](MultipartForm::files) for
81    /// those.)
82    pub fn field(&self, name: &str) -> Option<&str> {
83        self.fields
84            .iter()
85            .rev()
86            .find(|(k, _)| k == name)
87            .map(|(_, v)| v.as_str())
88    }
89
90    /// Iterate over every text field as `(&name, &value)`, in body order,
91    /// including repeats.
92    pub fn iter_fields(&self) -> impl Iterator<Item = (&str, &str)> {
93        self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
94    }
95}
96
97/// Errors [`parse_multipart`] can return.
98#[derive(Debug)]
99pub enum MultipartError {
100    /// The `Content-Type` header had no `boundary` parameter, so the body
101    /// can't be split into parts.
102    MissingBoundary,
103    /// The underlying multipart parser rejected the body (malformed part
104    /// headers, truncated body, etc.). Carries multer's message.
105    Parse(String),
106    /// A part (or the whole body) exceeded the configured size cap.
107    ///
108    /// Produced by [`parse_multipart`] (using [`DEFAULT_MAX_MULTIPART_BYTES`])
109    /// and [`parse_multipart_capped`] (using the caller's ceiling) when the
110    /// body up front, or the running total of decoded parts, exceeds the cap.
111    TooLarge {
112        /// The configured limit, in bytes.
113        limit: usize,
114        /// The actual size that was rejected, in bytes.
115        actual: usize,
116    },
117}
118
119impl std::fmt::Display for MultipartError {
120    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121        match self {
122            MultipartError::MissingBoundary => {
123                write!(f, "multipart: Content-Type has no boundary parameter")
124            }
125            MultipartError::Parse(s) => write!(f, "multipart: parse error: {s}"),
126            MultipartError::TooLarge { limit, actual } => write!(
127                f,
128                "multipart: body {actual}B exceeds configured cap of {limit}B"
129            ),
130        }
131    }
132}
133
134impl std::error::Error for MultipartError {}
135
136/// Errors [`parse_and_store_multipart`] can return: a parse failure, a
137/// storage failure, or the absence of a registered storage backend.
138#[derive(Debug)]
139pub enum MultipartUploadError {
140    /// Parsing the multipart body failed. See [`MultipartError`].
141    Multipart(MultipartError),
142    /// Storing an uploaded file through the [`Storage`] backend failed.
143    ///
144    /// [`Storage`]: crate::storage::Storage
145    Storage(StorageError),
146    /// No [`Storage`] backend was registered, but the body carried a file
147    /// part that needed storing.
148    ///
149    /// A stray multipart POST against a server with no media backend lands
150    /// here rather than panicking the worker; the boot-time system check
151    /// (Wave 2) is what guarantees a backend exists whenever a model
152    /// declares a file field.
153    ///
154    /// [`Storage`]: crate::storage::Storage
155    NoStorageBackend,
156}
157
158impl std::fmt::Display for MultipartUploadError {
159    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160        match self {
161            MultipartUploadError::Multipart(e) => write!(f, "{e}"),
162            MultipartUploadError::Storage(e) => write!(f, "{e}"),
163            MultipartUploadError::NoStorageBackend => write!(
164                f,
165                "multipart upload: no Storage backend registered; add StoragePlugin \
166                 or call umbral::storage::set_storage"
167            ),
168        }
169    }
170}
171
172impl std::error::Error for MultipartUploadError {
173    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
174        match self {
175            MultipartUploadError::Multipart(e) => Some(e),
176            MultipartUploadError::Storage(e) => Some(e),
177            MultipartUploadError::NoStorageBackend => None,
178        }
179    }
180}
181
182impl From<MultipartError> for MultipartUploadError {
183    fn from(e: MultipartError) -> Self {
184        MultipartUploadError::Multipart(e)
185    }
186}
187
188impl From<StorageError> for MultipartUploadError {
189    fn from(e: StorageError) -> Self {
190        MultipartUploadError::Storage(e)
191    }
192}
193
194/// Whether a `Content-Type` header value denotes a `multipart/form-data`
195/// body.
196///
197/// True when the header (ignoring leading whitespace) starts with
198/// `multipart/form-data`; the trailing `; boundary=...` parameter is
199/// ignored here and parsed later by [`parse_multipart`].
200pub fn is_multipart(content_type: &str) -> bool {
201    content_type
202        .trim_start()
203        .to_ascii_lowercase()
204        .starts_with("multipart/form-data")
205}
206
207/// Parse a `multipart/form-data` body into text fields and file parts.
208///
209/// `content_type_header` is the full `Content-Type` header value (it must
210/// carry the `boundary=...` parameter). `body` is the complete request body
211/// in memory.
212///
213/// Text parts (no `filename`) land in [`MultipartForm::fields`] preserving
214/// order and repeats; parts with a `filename` land in
215/// [`MultipartForm::files`] as [`FilePart`]s with their bytes kept verbatim.
216///
217/// # Errors
218///
219/// - [`MultipartError::MissingBoundary`] if the header has no boundary.
220/// - [`MultipartError::Parse`] on a malformed body.
221pub async fn parse_multipart(
222    content_type_header: &str,
223    body: impl Into<bytes::Bytes>,
224) -> Result<MultipartForm, MultipartError> {
225    parse_multipart_capped(content_type_header, body, DEFAULT_MAX_MULTIPART_BYTES).await
226}
227
228/// Like [`parse_multipart`], but with a caller-chosen in-memory size cap
229/// (`max_bytes`) instead of the [`DEFAULT_MAX_MULTIPART_BYTES`] default.
230///
231/// The cap is enforced two ways (audit_2 core-web H11 — wiring the previously
232/// dead [`MultipartError::TooLarge`]): the whole body is rejected up front if
233/// it already exceeds `max_bytes`, and the running total of decoded part bytes
234/// is checked as each part is read, so parsing stops the instant the sum
235/// crosses the ceiling instead of buffering an unbounded body. Pass
236/// `usize::MAX` to opt out of the cap.
237///
238/// # Errors
239///
240/// - [`MultipartError::MissingBoundary`] if the header has no boundary.
241/// - [`MultipartError::TooLarge`] if the body / accumulated parts exceed
242///   `max_bytes`.
243/// - [`MultipartError::Parse`] on a malformed body.
244pub async fn parse_multipart_capped(
245    content_type_header: &str,
246    body: impl Into<bytes::Bytes>,
247    max_bytes: usize,
248) -> Result<MultipartForm, MultipartError> {
249    let boundary =
250        multer::parse_boundary(content_type_header).map_err(|_| MultipartError::MissingBoundary)?;
251
252    let body: bytes::Bytes = body.into();
253    // Fast reject: the raw body is already in memory, and its length is an
254    // upper bound on the sum of all part payloads, so a body over the cap can
255    // never yield in-cap parts. Bail before spinning up the parser.
256    if body.len() > max_bytes {
257        return Err(MultipartError::TooLarge {
258            limit: max_bytes,
259            actual: body.len(),
260        });
261    }
262    // multer's constructor wants a Bytes stream; the whole body is already
263    // in memory, so a single-chunk, never-erroring stream is enough.
264    let stream = futures_util::stream::once(async move { Ok::<_, Infallible>(body) });
265    let mut multipart = multer::Multipart::new(stream, boundary);
266
267    let mut form = MultipartForm::default();
268    // Running total of decoded part payload bytes, checked against the cap as
269    // each part lands so buffering stops the moment the sum crosses it.
270    let mut accumulated: usize = 0;
271
272    while let Some(field) = multipart
273        .next_field()
274        .await
275        .map_err(|e| MultipartError::Parse(e.to_string()))?
276    {
277        // Capture all metadata BEFORE reading the body: multer's `bytes()`
278        // / `text()` consume the field handle, after which name/filename/
279        // content_type are gone.
280        let field_name = field.name().map(str::to_owned).unwrap_or_default();
281        let filename = field.file_name().map(str::to_owned);
282        let content_type = field.content_type().map(|m| m.to_string());
283
284        if filename.is_some() {
285            // A part with a filename is a file: keep raw bytes, never decode.
286            let bytes = field
287                .bytes()
288                .await
289                .map_err(|e| MultipartError::Parse(e.to_string()))?;
290            accumulated = accumulated.saturating_add(bytes.len());
291            if accumulated > max_bytes {
292                return Err(MultipartError::TooLarge {
293                    limit: max_bytes,
294                    actual: accumulated,
295                });
296            }
297            form.files.push(FilePart {
298                field_name,
299                filename,
300                content_type,
301                bytes: bytes.to_vec(),
302            });
303        } else {
304            // A part with no filename is a plain text field.
305            let value = field
306                .text()
307                .await
308                .map_err(|e| MultipartError::Parse(e.to_string()))?;
309            accumulated = accumulated.saturating_add(value.len());
310            if accumulated > max_bytes {
311                return Err(MultipartError::TooLarge {
312                    limit: max_bytes,
313                    actual: accumulated,
314                });
315            }
316            form.fields.push((field_name, value));
317        }
318    }
319
320    Ok(form)
321}
322
323/// Parse a `multipart/form-data` body, store its file parts, and return a
324/// flat `Vec<(String, String)>` of every field — text values plus the
325/// storage key of each uploaded file.
326///
327/// This is the upload entry point a handler calls instead of
328/// `serde_urlencoded::from_str::<Vec<(String, String)>>` when the body is
329/// multipart: the return shape is identical, so the rest of the form
330/// pipeline doesn't care which encoding arrived.
331///
332/// Each non-empty [`FilePart`] is stored via the ambient [`Storage`]
333/// backend and contributes one `(field_name, stored_key)` pair, using the
334/// part's `filename` (falling back to the field name) and its
335/// `content_type` (falling back to `application/octet-stream`).
336///
337/// ## Empty file parts are skipped — "keep current file on edit"
338///
339/// When a user edits a record with a file field but does *not* choose a new
340/// file, the browser still sends the file part — with an empty body. Such a
341/// part is **skipped entirely**: no pair is emitted for it. This is
342/// deliberate. Emitting `(field, "")` would overwrite the stored key with
343/// an empty string and lose the existing file; omitting the pair leaves the
344/// current value untouched downstream.
345///
346/// # Errors
347///
348/// - [`MultipartUploadError::Multipart`] on a parse failure.
349/// - [`MultipartUploadError::NoStorageBackend`] if a file needs storing but
350///   no backend is registered (returned, never panicked).
351/// - [`MultipartUploadError::Storage`] if the backend's `store` fails.
352///
353/// [`Storage`]: crate::storage::Storage
354pub async fn parse_and_store_multipart(
355    content_type_header: &str,
356    body: impl Into<bytes::Bytes>,
357) -> Result<Vec<(String, String)>, MultipartUploadError> {
358    let form = parse_multipart(content_type_header, body).await?;
359
360    let mut pairs: Vec<(String, String)> = Vec::new();
361
362    for file in &form.files {
363        // Skip empty file parts: the user submitted the edit form without
364        // choosing a new file, so leave the existing stored value alone.
365        if file.bytes.is_empty() {
366            continue;
367        }
368
369        // Resolve the backend lazily and only when a file actually needs
370        // storing, so a multipart POST with no file (or only empty parts)
371        // never trips on a missing backend.
372        let backend =
373            crate::storage::storage_opt().ok_or(MultipartUploadError::NoStorageBackend)?;
374
375        let filename = file
376            .filename
377            .as_deref()
378            .filter(|s| !s.is_empty())
379            .unwrap_or(&file.field_name);
380        let content_type = file
381            .content_type
382            .as_deref()
383            .unwrap_or("application/octet-stream");
384
385        let stored = backend.store(filename, content_type, &file.bytes).await?;
386        pairs.push((file.field_name.clone(), stored.key));
387    }
388
389    // Text fields always pass through, after the file keys.
390    pairs.extend(form.fields);
391
392    Ok(pairs)
393}
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    const BOUNDARY: &str = "X-UMBRAL-BOUNDARY";
400
401    /// One part spec for [`build_body`]: `(name, filename, content_type,
402    /// value)`. A `None` filename means a text field; `Some` means a file.
403    type PartSpec<'a> = (&'a str, Option<&'a str>, Option<&'a str>, &'a [u8]);
404
405    /// Build a real `multipart/form-data` body from part specs. A `None`
406    /// filename emits a plain text field; `Some(name)` emits a file part
407    /// with a `Content-Type` line.
408    fn build_body(parts: &[PartSpec<'_>]) -> Vec<u8> {
409        let mut out = Vec::new();
410        for (name, filename, content_type, value) in parts {
411            out.extend_from_slice(format!("--{BOUNDARY}\r\n").as_bytes());
412            match filename {
413                Some(fname) => {
414                    out.extend_from_slice(
415                        format!(
416                            "Content-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\n"
417                        )
418                        .as_bytes(),
419                    );
420                    if let Some(ct) = content_type {
421                        out.extend_from_slice(format!("Content-Type: {ct}\r\n").as_bytes());
422                    }
423                }
424                None => {
425                    out.extend_from_slice(
426                        format!("Content-Disposition: form-data; name=\"{name}\"\r\n").as_bytes(),
427                    );
428                }
429            }
430            out.extend_from_slice(b"\r\n");
431            out.extend_from_slice(value);
432            out.extend_from_slice(b"\r\n");
433        }
434        out.extend_from_slice(format!("--{BOUNDARY}--\r\n").as_bytes());
435        out
436    }
437
438    fn ct_header() -> String {
439        format!("multipart/form-data; boundary={BOUNDARY}")
440    }
441
442    #[test]
443    fn is_multipart_matches_form_data_content_types() {
444        assert!(is_multipart("multipart/form-data; boundary=abc"));
445        assert!(is_multipart("multipart/form-data"));
446        assert!(is_multipart("  Multipart/Form-Data; boundary=Z")); // case + leading ws
447        assert!(!is_multipart("application/x-www-form-urlencoded"));
448        assert!(!is_multipart("application/json"));
449        assert!(!is_multipart("multipart/mixed; boundary=abc"));
450    }
451
452    #[tokio::test]
453    async fn parse_separates_text_and_file_parts() {
454        let png = b"\x89PNG\r\n\x1a\nfake-image-bytes";
455        let body = build_body(&[
456            ("title", None, None, b"Hello"),
457            ("cover", Some("p.png"), Some("image/png"), png),
458        ]);
459
460        let form = parse_multipart(&ct_header(), body).await.unwrap();
461
462        assert_eq!(
463            form.fields,
464            vec![("title".to_string(), "Hello".to_string())]
465        );
466        assert_eq!(form.files.len(), 1);
467        let file = &form.files[0];
468        assert_eq!(file.field_name, "cover");
469        assert_eq!(file.filename.as_deref(), Some("p.png"));
470        assert_eq!(file.content_type.as_deref(), Some("image/png"));
471        assert_eq!(file.bytes, png);
472    }
473
474    #[tokio::test]
475    async fn parse_preserves_repeated_text_field_names() {
476        let body = build_body(&[
477            ("tags", None, None, b"red"),
478            ("tags", None, None, b"blue"),
479            ("name", None, None, b"shirt"),
480        ]);
481
482        let form = parse_multipart(&ct_header(), body).await.unwrap();
483
484        // Both `tags` survive, in order — M2M / multi-select correctness.
485        assert_eq!(
486            form.fields,
487            vec![
488                ("tags".to_string(), "red".to_string()),
489                ("tags".to_string(), "blue".to_string()),
490                ("name".to_string(), "shirt".to_string()),
491            ]
492        );
493        // field() is last-wins.
494        assert_eq!(form.field("tags"), Some("blue"));
495        assert_eq!(form.field("name"), Some("shirt"));
496        assert_eq!(form.field("missing"), None);
497        // iter_fields yields every entry including the repeat.
498        assert_eq!(form.iter_fields().filter(|(k, _)| *k == "tags").count(), 2);
499    }
500
501    #[tokio::test]
502    async fn parse_keeps_binary_bytes_intact() {
503        // Non-UTF8 bytes: 0xFF / 0x80 are invalid UTF-8 and must not be
504        // decoded. (Built at runtime, not a const literal — clippy
505        // const-folds a literal `from_utf8` and warns it always errors.)
506        let raw: Vec<u8> = vec![0x00, 0xFF, 0xFE, 0x80, 0x01, 0x7F];
507        assert!(std::str::from_utf8(&raw).is_err());
508        let body = build_body(&[(
509            "blob",
510            Some("data.bin"),
511            Some("application/octet-stream"),
512            &raw,
513        )]);
514
515        let form = parse_multipart(&ct_header(), body).await.unwrap();
516
517        assert_eq!(form.files.len(), 1);
518        assert_eq!(form.files[0].bytes, raw, "raw bytes must round-trip");
519    }
520
521    #[tokio::test]
522    async fn capped_rejects_body_over_the_limit() {
523        // A body whose parts sum past the cap must error with TooLarge instead
524        // of buffering unbounded (audit_2 core-web H11).
525        let big = vec![b'x'; 4096];
526        let body = build_body(&[(
527            "blob",
528            Some("big.bin"),
529            Some("application/octet-stream"),
530            &big,
531        )]);
532        let err = parse_multipart_capped(&ct_header(), body, 1024)
533            .await
534            .unwrap_err();
535        match err {
536            MultipartError::TooLarge { limit, actual } => {
537                assert_eq!(limit, 1024);
538                assert!(actual > 1024, "reports the offending size, got {actual}");
539            }
540            other => panic!("expected TooLarge, got {other:?}"),
541        }
542    }
543
544    #[tokio::test]
545    async fn capped_allows_body_under_the_limit() {
546        let small = b"hello";
547        let body = build_body(&[(
548            "blob",
549            Some("s.bin"),
550            Some("application/octet-stream"),
551            small,
552        )]);
553        let form = parse_multipart_capped(&ct_header(), body, 1024)
554            .await
555            .unwrap();
556        assert_eq!(form.files.len(), 1);
557        assert_eq!(form.files[0].bytes, small);
558    }
559
560    #[tokio::test]
561    async fn capped_counts_text_field_bytes_too() {
562        // The cap covers text parts, not just files, so a flood of oversized
563        // text fields is bounded as well.
564        let big = vec![b'a'; 4096];
565        let body = build_body(&[("notes", None, None, &big)]);
566        let err = parse_multipart_capped(&ct_header(), body, 512)
567            .await
568            .unwrap_err();
569        assert!(matches!(err, MultipartError::TooLarge { .. }));
570    }
571
572    #[tokio::test]
573    async fn parse_errors_on_missing_boundary() {
574        let body = build_body(&[("title", None, None, b"Hi")]);
575        let err = parse_multipart("multipart/form-data", body)
576            .await
577            .unwrap_err();
578        assert!(matches!(err, MultipartError::MissingBoundary));
579    }
580}