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}